diff --git a/.env.example b/.env.example index e8e314f..1b4564c 100644 --- a/.env.example +++ b/.env.example @@ -1,90 +1,77 @@ -# ZASCA 环境配置文件模板 -# 复制此文件为 .env 并填写实际配置值 - -# ========== 核心配置(影响功能或必须在初始化时定义) ========== - -# 调试模式(生产环境必须设置为 False) -DEBUG=True - -# Django 密钥(生产环境必须修改) -DJANGO_SECRET_KEY=your-secret-key-here-change-this-in-production - -# 允许访问的主机(生产环境必须配置) -ALLOWED_HOSTS=localhost,127.0.0.1 - -# CSRF 可信来源(生产环境必须配置) -CSRF_TRUSTED_ORIGINS=https://localhost,https://127.0.0.1 - -# ========== 数据库配置 ========== -# 数据库引擎: sqlite, mysql 或 postgresql -DB_ENGINE=sqlite -# MySQL 配置(DB_ENGINE=mysql 时生效) -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_NAME=zasca -DB_USER=root -DB_PASSWORD=your_database_password_here -# PostgreSQL 配置(DB_ENGINE=postgresql 时生效) -# 安装依赖: uv sync --extra postgresql -#DB_HOST=127.0.0.1 -#DB_PORT=5432 -#DB_NAME=zasca -#DB_USER=postgres -#DB_PASSWORD=your_database_password_here - -# ========== Redis 配置(可选增强) ========== -# Redis 是锦上添花的组件,不配置时程序使用本地替代方案: -# 缓存 -> LocMemCache(本地内存) -# 会话 -> 数据库存储 -# Celery -> SQLite broker -# 配置 REDIS_URL 且 Redis 服务可达时,自动切换到 Redis: -# 缓存 -> Redis(高性能,支持分布式) -# 会话 -> Redis 缓存(更快,支持多进程共享) -# Celery -> Redis broker(更稳定,支持结果过期清理) -#REDIS_URL=redis://localhost:6379/0 - -# ========== Celery 配置 ========== -# 未配置 Redis 时默认使用 SQLite broker,无需手动设置 -# 配置了 Redis 后默认自动使用 Redis broker(db1/db2),也可手动覆盖: -#CELERY_BROKER_URL=redis://localhost:6379/1 -#CELERY_RESULT_BACKEND=redis://localhost:6379/2 - -# ========== 演示模式 ========== -# 设置为 1 启用演示模式 -ZASCA_DEMO=0 - -# ========== 安全配置 ========== -# 生产环境必须设置为 True -SECURE_SSL_REDIRECT=False -SESSION_COOKIE_SECURE=False -CSRF_COOKIE_SECURE=False - -# 可信反向代理 IP(逗号分隔) -# 使用 nginx 反向代理时必须配置,否则所有用户共享同一 IP 导致限流误触发 -TRUSTED_PROXY_IPS=127.0.0.1,::1 - -# ========== 日志配置 ========== -LOG_LEVEL=DEBUG -LOG_FILE=/var/log/2c2a/application.log - -# ========== WinRM 配置 ========== +# 2c2a 异步架构 - 环境变量配置示例 +# 复制为 .env 并修改:cp .env.example .env + +# ── 运行环境 ── +ENV=production # production / staging / development +DEBUG=false +2C2A_DEMO=0 # 1=演示模式(自动生成密钥,仅本地) + +# ── Granian / FastAPI ── +HOST=0.0.0.0 +PORT=8000 +WORKERS=4 # 生产建议 CPU 核心数 + +# ── 密钥(生产必须显式配置)── +SECRET_KEY= # 生成:python -c "import secrets;print(secrets.token_urlsafe(48))" + +# Ed25519 密钥对(生成见下方说明) +ED25519_PRIVATE_KEY_PEM= +ED25519_PUBLIC_KEY_PEM= + +# AES-GCM 主密钥(32 字节 base64) +# 生成:python -c "import base64,os;print(base64.b64encode(os.urandom(32)).decode())" +CRYPTO_MASTER_KEY_B64= + +# keyed-BLAKE2b 缓存签名密钥 +CACHE_SIGNING_KEY= # 生成:python -c "import secrets;print(secrets.token_urlsafe(32))" + +# ── 数据库 ── +DB_ENGINE=sqlite # sqlite / postgresql / mysql +DB_NAME=2c2a +# PostgreSQL/MySQL 专用 +DB_HOST=localhost +DB_PORT=5432 +DB_USER=2c2a +DB_PASSWORD= +DB_POOL_SIZE=10 +DB_MAX_OVERFLOW=20 + +# ── Redis ── +REDIS_ENABLED=true +REDIS_URL=redis://localhost:6379/0 + +# ── 认证 ── +ACCESS_TOKEN_TTL_SECONDS=300 # Access Token 5 分钟 +REFRESH_TOKEN_TTL_DAYS=7 # Refresh Token 7 天 + +# ── Argon2id 参数 ── +ARGON2_TIME_COST=3 +ARGON2_MEMORY_COST=65536 # 64 MiB +ARGON2_PARALLELISM=2 + +# ── 缓存 ── +APP_SHELL_CACHE_TTL=300 # App Shell 边缘缓存 5 分钟 +TENANT_CACHE_TTL=300 + +# ── WinRM ── WINRM_TIMEOUT=30 -WINRM_RETRY_COUNT=3 - -# ========== Gateway 配置 ========== -GATEWAY_ENABLED=False -GATEWAY_CONTROL_SOCKET=/run/2c2a/control.sock - -# ========== Beta数据库配置(Beta推送插件) ========== -# 配置后可将生产数据推送到Beta版本数据库,仅支持PostgreSQL架构 -#BETA_DB_NAME=zasca_beta -#BETA_DB_USER=postgres -#BETA_DB_PASSWORD=your_beta_database_password_here -#BETA_DB_HOST=127.0.0.1 -#BETA_DB_PORT=5432 -# Beta环境的SECRET_KEY(用于重加密:生产密钥解密 → Beta密钥加密) -# 若不配置则直接复制密文,Beta端需使用与生产相同的SECRET_KEY才能解密 -#BETA_SECRET_KEY= - -# ========== Bootstrap 认证配置 ========== -BOOTSTRAP_SHARED_SALT= +WINRM_MAX_RETRIES=3 + +# ── 速率限制 ── +LOGIN_RATE_LIMIT=5 +API_RATE_LIMIT=100 + +# ── 可信代理 ── +TRUSTED_PROXY_IPS= +USE_X_FORWARDED_FOR=true + +# ────────────────────────────────────────────── +# Ed25519 密钥对生成方法: +# python -c " +# from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +# from cryptography.hazmat.primitives import serialization +# k = Ed25519PrivateKey.generate() +# print('ED25519_PRIVATE_KEY_PEM=' + k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()) +# print('ED25519_PUBLIC_KEY_PEM=' + k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo).decode()) +# " +# ────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index c382d13..4453502 100644 --- a/.gitignore +++ b/.gitignore @@ -136,9 +136,6 @@ templates/components/input_simple.html # Old project copies zacs/ -# trae -.trae/ - # Tailwind CSS standalone CLI executables static/vendor/tailwindcss tools/tailwindcss diff --git a/.trae/rules/00-overview.md b/.trae/rules/00-overview.md new file mode 100644 index 0000000..0f0a21f --- /dev/null +++ b/.trae/rules/00-overview.md @@ -0,0 +1,63 @@ +# 00 - 项目概览 + +## 项目定位 + +2c2a = **Zero Agent Security Control Architecture**。从 Django 同步架构全量重写为异步架构,核心目标:高性能、不阻塞前端、原生插件系统、原生站点隔离。 + +## 技术栈 + +| 层 | 技术 | 说明 | +| --- | --- | --- | +| ASGI 服务器 | Granian | 比 uvicorn 更高性能的 Rust 实现 | +| Web 框架 | FastAPI | 异步路由、依赖注入 | +| ORM | SQLAlchemy 2.0 Async | `Mapped`/`mapped_column` 新语法,全异步 | +| 迁移 | Alembic | 异步 env.py | +| 模板 | Jinja2 + JinjaX | 组件化模板 | +| 前端交互 | HTMX + HTMX OOB | 服务端渲染片段,无重前端框架 | +| 任务队列 | RedisHuey | 替代 Celery,更轻量 | +| Redis | redis[hiredis] | 缓存 + 队列 + 租户解析缓存 | +| 远程执行 | aiohttp | 替代同步 winrm,全异步 WS-Management | +| 安全 | argon2-cffi / pyjwt / cryptography | Argon2id / Ed25519 JWT / AES-256-GCM | +| CLI | Typer + Rich | `2c2a` 命令行工具 | +| 配置 | pydantic-settings | 环境变量 + `.env` | + +Python 版本:**>=3.12** + +## 目录结构 + +``` +app/ +├── main.py # FastAPI 应用工厂 + lifespan +├── core/ # 基础设施:config / db / redis / logging / exceptions +├── models/ # SQLAlchemy 模型(按领域分文件) +├── security/ # 加密原语 / JWT / 密码 / ban_version / 字段加密 +├── cache/ # App Shell 缓存 / HTMX 片段 / 缓存键 +├── tenant/ # 租户解析 / 中间件 / 依赖 +├── auth/ # 认证路由 / 依赖 / schemas +├── api/v1/ # REST API(hosts / operations / tickets / audit) +├── web/ # 页面骨架路由 + HTMX 片段路由 +├── winrm/ # 异步 WinRM 客户端(transport / client / commands) +├── tasks/ # RedisHuey 任务(hosts / operations) +├── plugins/ # 插件系统(base / loader / manager / registry)+ example/ +├── templates/ # Jinja2 模板(layouts / pages / fragments) +├── static/ # 应用静态资源(css / js / vendor) +└── cli/ # CLI 工具(main / db / account / server / plugins / tenant / static) +``` + +## 核心架构理念 + +1. **App Shell 边缘全量缓存 + HTMX 动态片段分离** + - 页面骨架(HTML 壳)仅依据域名解析租户配置渲染,绝不依赖用户状态 → 可被 CDN 全量缓存 + - 用户导航、统计等动态内容由 HTMX 在页面加载后独立请求获取 → 不可缓存 + +2. **站点隔离** + - 按域名解析 `SiteGroup`,所有业务数据按 `site_group_id` 过滤 + - 中间件层注入 `TenantContext`,依赖注入层强制过滤 + +3. **无状态秒级令牌撤销** + - JWT Payload 携带 `ban_version`,封禁时递增数据库版本号 + - 无需 Redis 黑名单,验签时比对版本号即可 + +4. **字段级加密** + - HKDF-SHA256 按字段名派生子密钥 + AES-256-GCM 加密 + - 每个敏感字段独立密钥,防篡改、防跨字段关联 \ No newline at end of file diff --git a/.trae/rules/01-iron-laws.md b/.trae/rules/01-iron-laws.md new file mode 100644 index 0000000..a141697 --- /dev/null +++ b/.trae/rules/01-iron-laws.md @@ -0,0 +1,95 @@ +# 01 - 铁律(违反 = 严重错误) + +这些规则**不可违反**。违反会导致性能退化、安全漏洞或架构腐化。 + +## 命令执行 + +### 1. 禁止同步阻塞调用出现在异步上下文 + +```python +# ✗ 禁止:在 async 函数中调用同步阻塞 IO +async def bad_handler(): + time.sleep(1) # 阻塞事件循环 + requests.get("https://...") # 同步 HTTP + +# ✓ 正确 +async def good_handler(): + await asyncio.sleep(1) + async with aiohttp.ClientSession() as s: + await s.get("https://...") +``` + +详见 `02-async-patterns.md`。 + +## 架构 + +### 2. 禁止重新引入 Django + +本项目已从 Django 全量重写为 FastAPI 异步架构。**禁止**: +- 引入 `django`、`django-admin`、`django-bootstrap5` 等 Django 包 +- 用 Django ORM 替代 SQLAlchemy +- 用 Django 模板替代 Jinja2/JinjaX + +### 3. 禁止用同步 WinRM 库 + +```python +# ✗ 禁止 +from winrm import Protocol + +# ✓ 正确 +from app.winrm.client import WinRMClient +``` + +### 4. 禁止移除或绕过站点隔离 + +所有业务数据查询**必须**按 `site_group_id` 过滤。详见 `05-tenant.md`。 + +## 安全 + +### 5. 禁止明文存储密码 + +密码哈希链路:**前端 BLAKE2b 预哈希 → 后端 Argon2id 加盐慢哈希**。 + +### 6. 禁止在 App Shell 缓存中包含用户状态 + +App Shell 缓存的 HTML **不得**包含:用户名、用户 ID、token、个性化数据、`Set-Cookie` 头。 +违反会导致跨用户数据泄漏。详见 `04-caching.md`。 + +### 7. 生产环境必须显式配置所有密钥 + +开发模式(`DEBUG=1` 或 `2C2A_DEMO=1`)允许从 `SECRET_KEY` 自动派生,生产**禁止**。 +生成密钥:`2c2a keys generate`。 + +## 前端 + +### 8. 禁止 CDN 链接 + +所有前端资源**必须**下载到 `app/static/vendor/` 本地服务。 + +### 9. 禁止内联样式 + +样式统一放 `app/static/css/base.css`。 + +## Git + +### 10. 禁止 force push 到 main/master + +功能分支可 force push,main/master 禁止。 + +### 11. feat/* 和 hotfix/* 分支合并后立即删除 + +## 迁移 + +### 12. 禁止 `SeparateDatabaseAndState` 空操作 + +```python +# ✗ 禁止 +def upgrade(): + pass +``` + +## 提交 + +### 13. 未经用户明确要求不得提交 + +**只有用户明确说"提交"/"commit"时才执行 git commit**。 \ No newline at end of file diff --git a/.trae/rules/02-async-patterns.md b/.trae/rules/02-async-patterns.md new file mode 100644 index 0000000..a30b05f --- /dev/null +++ b/.trae/rules/02-async-patterns.md @@ -0,0 +1,94 @@ +# 02 - 异步模式 + +本项目全异步。**任何阻塞事件循环的代码都是 bug**。 + +## 数据库访问 + +### 会话获取 + +```python +from app.core.db import get_db + +# ✓ Web 路由用依赖注入 +@router.get("/users/{id}") +async def get_user(id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(User).where(User.id == id)) + return result.scalar_one_or_none() +``` + +```python +# ✓ CLI / 后台任务用上下文管理器 +from app.cli.utils import db_session + +async def _do(): + async with db_session() as session: + result = await session.execute(select(User)) + return result.scalars().all() +``` + +### 查询必须 await + +```python +# ✗ 错误 +result = db.execute(select(User)) # 返回协程 + +# ✓ 正确 +result = await db.execute(select(User)) +users = (await db.execute(select(User))).scalars().all() +``` + +### 关系加载 + +异步会话关闭后不能懒加载关系,会触发 `DetachedInstanceError`。**必须**用 `selectinload` 预加载。 + +```python +from sqlalchemy.orm import selectinload + +async def get_user_ban(username: str): + async with db_session() as session: + result = await session.execute( + select(User).options(selectinload(User.active_ban)).where(User.username == username) + ) + user = result.scalar_one_or_none() + ban = user.active_ban # 已加载,安全 + return ban +``` + +## HTTP 调用 + +### 用 aiohttp,禁用 requests + +```python +# ✗ 禁止 +import requests + +# ✓ 正确 +import aiohttp +async with aiohttp.ClientSession() as s: + async with s.get("https://...") as resp: + return await resp.text() +``` + +### 连接池复用 + +不要在每次请求创建 `ClientSession`,在类中复用。 + +## 并发 + +### 并发执行多个独立 IO + +```python +import asyncio + +user, profile, settings = await asyncio.gather( + get_user(uid), + get_profile(uid), + get_settings(uid), +) +``` + +## 常见陷阱 + +1. 在同步函数中调用 async → 用 `run_async`(仅 CLI)/ 改为 async +2. 忘记 await 协程 → 有 RuntimeWarning +3. 在 async 函数中用同步 DB 驱动 → 必须用异步驱动 \ No newline at end of file diff --git a/.trae/rules/03-security.md b/.trae/rules/03-security.md new file mode 100644 index 0000000..21d26d2 --- /dev/null +++ b/.trae/rules/03-security.md @@ -0,0 +1,85 @@ +# 03 - 安全与加密 + +## 密钥体系 + +项目使用 5 类密钥,全部通过环境变量配置(`app/core/config.py`)。 + +| 密钥 | 环境变量 | 用途 | 生成命令 | +| --- | --- | --- | --- | +| SECRET_KEY | `SECRET_KEY` | 通用密钥 | `2c2a keys secret` | +| Ed25519 私钥 | `ED25519_PRIVATE_KEY_PEM` | JWT 签名 | `2c2a keys ed25519` | +| Ed25519 公钥 | `ED25519_PUBLIC_KEY_PEM` | JWT 验签 | 同上 | +| AES-GCM 主密钥 | `CRYPTO_MASTER_KEY_B64` | Refresh Token + 字段加密 | `2c2a keys aes` | +| BLAKE2b 签名密钥 | `CACHE_SIGNING_KEY` | 缓存键签名 | `2c2a keys blake2b` | + +一次性生成全部:`2c2a keys generate` + +## 密码哈希链路 + +``` +用户输入原始密码 → 前端 BLAKE2b(digest_size=64,输出 hex) + → BLAKE2b 预哈希(128 字符 hex) + → 后端 server_side_prehash 校验格式 + → Argon2id 加盐慢哈希 + → PHC 字符串(存数据库 password_hash 字段) +``` + +```python +from app.security.password import hash_password, verify_password + +# 创建 +phc = hash_password(blake2b_prehash_hex) +user.password_hash = phc + +# 验证 +if verify_password(blake2b_prehash_hex, user.password_hash): + # 登录成功 +``` + +CLI 中创建用户用 `blake2b_prehash_interactive` 模拟前端预哈希。 + +## JWT(Access Token) + +- 算法:Ed25519 非对称签名 +- 有效期:5 分钟 +- 存储:前端内存,`Authorization: Bearer ` 头发送 + +## Refresh Token + +- 算法:AES-256-GCM 加密 +- 有效期:7 天 +- 存储:HttpOnly Cookie(`2c2a_rt`),防 XSS + +## ban_version 无状态撤销 + +JWT Payload 携带 `ban_version`。封禁/改密码时递增 `user.ban_version += 1`。验签时比对,不等则令牌失效。 + +优点:无需 Redis 黑名单,秒级生效,无状态。 + +## 字段级加密 + +HKDF-SHA256 按字段名派生子密钥 → AES-256-GCM 加密。 + +```python +from app.security.field_cipher import encrypt_field, decrypt_field + +user.phone = encrypt_field("13800138000", field_name="phone") +phone = decrypt_field(user.phone, field_name="phone") +``` + +## keyed-BLAKE2b 缓存签名 + +```python +from app.security.crypto import keyed_blake2b_short + +cache_key = f"shell:{keyed_blake2b_short(domain, context='domain')}:{keyed_blake2b_short(path, context='path')}" +etag = f'W/"{keyed_blake2b_short(content, context="etag")}"' +``` + +## 禁止 + +1. 禁止把密钥写进代码或提交到 Git +2. 禁止在日志中打印密钥 +3. 禁止在生产用开发模式派生的密钥 +4. `.env` 必须在 `.gitignore` 中(已配置) +5. 密钥泄漏后必须立即轮换 \ No newline at end of file diff --git a/.trae/rules/04-caching.md b/.trae/rules/04-caching.md new file mode 100644 index 0000000..cec1489 --- /dev/null +++ b/.trae/rules/04-caching.md @@ -0,0 +1,56 @@ +# 04 - 缓存策略 + +## 核心理念:App Shell + HTMX 片段分离 + +``` +┌─────────────────────────────────────────────────────┐ +│ App Shell(可被 CDN 全量缓存) │ +│ ── 仅含租户级配置:站点名、主题、ICP │ +│ ── 绝不含用户状态 │ +│ ── 缓存键:keyed-BLAKE2b(domain + path) │ +├─────────────────────────────────────────────────────┤ +│ HTMX 动态片段(不可缓存) │ +│ ── 页面加载后 HTMX 独立请求 │ +│ ── 基于用户状态实时返回 │ +└─────────────────────────────────────────────────────┘ +``` + +## App Shell 缓存 + +可缓存条件: +1. 仅依据请求域名解析租户配置渲染 +2. 不依赖用户登录状态 +3. 不含 `Set-Cookie` 头 +4. 不含任何用户特定数据 + +```python +from app.cache.app_shell import get_app_shell_cache, set_app_shell_cache + +html = await get_app_shell_cache(domain, path) +if html: + return HTMLResponse(html, headers=edge_cache_headers()) + +html = render_template("layouts/app_shell.html", **ctx) +await set_app_shell_cache(domain, path, html) +return HTMLResponse(html, headers=edge_cache_headers()) +``` + +缓存键:`shell:{domain_hash}:{path_hash}`(keyed-BLAKE2b 短哈希) + +## HTMX 片段缓存 + +默认不缓存(`fragment_cache_ttl=0`)。仅当片段内容与用户无关时才可缓存。 + +## 缓存键命名规范 + +| 前缀 | 用途 | +| --- | --- | +| `shell:` | App Shell 边缘缓存 | +| `frag:` | HTMX 片段缓存 | +| `tenant:` | 租户配置缓存 | + +## 禁止 + +1. 禁止在 App Shell 缓存中包含用户状态 +2. 禁止用用户 ID 作为缓存键 +3. 禁止缓存含敏感数据的片段 \ No newline at end of file diff --git a/.trae/rules/05-tenant.md b/.trae/rules/05-tenant.md new file mode 100644 index 0000000..27a5b28 --- /dev/null +++ b/.trae/rules/05-tenant.md @@ -0,0 +1,73 @@ +# 05 - 租户隔离 + +## 核心模型 + +``` +SiteGroup(站点组) +├── id, slug, site_name, is_active +├── config (SiteGroupConfig) +├── hostnames (SiteGroupHostname[]) +└── admins (User[]) +``` + +所有业务模型混入 `SiteGroupMixin`,带 `site_group_id` 字段。 + +## 域名解析 + +``` +请求 → TenantMiddleware(提取 Host 头) + → resolve_tenant_by_hostname(db, hostname) + ├─ Redis 缓存命中 → 返回 SiteGroup + └─ 查 SiteGroupHostname 表 → 返回 SiteGroup + └─ 未匹配 → 回退默认租户 + → 注入 TenantContext 到 request.state +``` + +```python +from app.tenant.dependencies import get_tenant + +@router.get("/") +async def page(tenant: TenantContext = Depends(get_tenant)): + # tenant.site_group_id ← 当前租户 + ... +``` + +## 数据隔离规则 + +### 所有业务查询必须按 site_group_id 过滤 + +```python +# ✗ 禁止:跨租户查询 +hosts = await db.execute(select(Host)) + +# ✓ 正确 +hosts = await db.execute( + select(Host).where(Host.site_group_id == tenant.site_group_id) +) +``` + +### 新建记录必须带 site_group_id + +```python +host = Host(name="web-01", site_group_id=tenant.site_group_id, ...) +``` + +### slug 生成 + +非 ASCII 名称无法生成有效 slug 时用随机串兜底:`f"site-{secrets.token_hex(4)}"` + +## CLI 管理 + +```bash +2c2a tenant list +2c2a tenant create "我的站点" --slug my-site +2c2a tenant add-hostname my-site example.com +2c2a tenant add-admin my-site admin +2c2a tenant invalidate-cache +``` + +## 禁止 + +1. 禁止跨租户查询 +2. 禁止在 App Shell 渲染时依赖用户 +3. 禁止硬编码 site_group_id \ No newline at end of file diff --git a/.trae/rules/06-database.md b/.trae/rules/06-database.md new file mode 100644 index 0000000..4aa0d88 --- /dev/null +++ b/.trae/rules/06-database.md @@ -0,0 +1,72 @@ +# 06 - 数据库模型 + +## 技术栈 + +SQLAlchemy 2.0 异步,`Mapped`/`mapped_column` 新语法。 + +## 基类与 Mixin + +```python +from app.models.base import Base, TimestampMixin, SiteGroupMixin, UUIDPKMixin +``` + +| Mixin | 字段 | 用途 | +| --- | --- | --- | +| `Base` | - | 声明式基类 | +| `TimestampMixin` | `created_at`, `updated_at` | 时间戳(自动维护) | +| `SiteGroupMixin` | `site_group_id` | 租户隔离 | +| `UUIDPKMixin` | `id` (UUID) | 分布式场景 | + +## 模型定义规范 + +```python +from sqlalchemy import String, Integer, ForeignKey, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.models.base import Base, TimestampMixin + +class Host(Base, TimestampMixin): + __tablename__ = "hosts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + site_group_id: Mapped[int] = mapped_column(ForeignKey("site_groups.id"), nullable=False, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + site_group: Mapped["SiteGroup"] = relationship(back_populates="hosts") +``` + +### 规则 + +1. **必须**继承 `Base` +2. **必须**显式定义 `__tablename__` +3. 业务模型**必须**混入 `TimestampMixin` +4. 租户隔离模型**必须**加 `site_group_id` + ForeignKey + index +5. 用 `Mapped[type]` + `mapped_column()`,不用旧式 `Column` + +## 关系预加载 + +```python +from sqlalchemy.orm import selectinload, joinedload + +# selectinload:推荐,额外 IN 查询 +result = await db.execute( + select(User).options(selectinload(User.active_ban)).where(User.id == uid) +) + +# joinedload:JOIN 一次性加载 +result = await db.execute( + select(Host).options(joinedload(Host.site_group)) +) +``` + +**异步会话关闭后不能懒加载**,必须预加载。 + +## 新模型注册 + +新模型必须在 `app/models/__init__.py` 导入,否则 Alembic 无法发现。 + +## 禁止 + +1. 禁止用旧式 Column 语法 +2. 禁止在模型中写业务逻辑(放 service 或路由) +3. 禁止跨租户查询 \ No newline at end of file diff --git a/.trae/rules/07-migrations.md b/.trae/rules/07-migrations.md new file mode 100644 index 0000000..c612944 --- /dev/null +++ b/.trae/rules/07-migrations.md @@ -0,0 +1,81 @@ +# 07 - 数据库迁移 + +## 工具 + +Alembic(异步 env.py)。所有迁移操作通过 CLI 完成。 + +## 常用命令 + +```bash +2c2a db init # 初始化(create_all,开发用) +2c2a db migrate -m "描述" # 生成迁移脚本 +2c2a db upgrade # 升级到最新 +2c2a db downgrade -1 # 回滚一个版本 +2c2a db history # 查看历史 +2c2a db current # 当前版本 +2c2a db heads # 最新版本 +2c2a db reset # 危险:重置数据库 +``` + +快捷命令:`2c2a migrate -m "描述"`(生成 + 升级) + +## 迁移工作流 + +``` +1. 修改模型代码 +2. 确保模型在 app/models/__init__.py 导入 +3. 2c2a db migrate -m "描述变更" +4. 检查生成的迁移脚本(必须!autogenerate 不完美) +5. 2c2a db upgrade +6. 2c2a db current 验证 +``` + +### 必须检查生成的迁移脚本 + +Alembic autogenerate 常见遗漏: +- server_default 变更 +- 约束名变更 +- 关系变更 +- 枚举类型变更 + +## 迁移脚本规范 + +```python +def upgrade() -> None: + op.add_column("hosts", sa.Column("new_field", sa.String(100), nullable=True)) + +def downgrade() -> None: + op.drop_column("hosts", "new_field") +``` + +### 规则 + +1. **必须**实现 `downgrade()`,不能为空 +2. **禁止** `SeparateDatabaseAndState` 空操作 +3. 数据迁移用 `op.execute()` 或 `op.bulk_insert()` +4. 大表加列:先 nullable=True,回填数据,再改 NOT NULL + +## 多租户表迁移 + +新增租户隔离表必须包含 `site_group_id` + 外键 + 索引: + +```python +sa.Column("site_group_id", sa.Integer, sa.ForeignKey("site_groups.id"), nullable=False) +op.create_index("ix_new_table_site_group_id", "new_table", ["site_group_id"]) +``` + +## 故障排查 + +```bash +# "Target database is not up to date" +2c2a db current +2c2a db heads +2c2a db upgrade + +# "Multiple heads" +alembic merge -m "merge heads" head1 head2 + +# autogenerate 检测不到变更 +# 确认模型在 app/models/__init__.py 导入 +# 确认 Base.metadata 一致 +``` \ No newline at end of file diff --git a/.trae/rules/08-plugins.md b/.trae/rules/08-plugins.md new file mode 100644 index 0000000..40936be --- /dev/null +++ b/.trae/rules/08-plugins.md @@ -0,0 +1,81 @@ +# 08 - 插件系统 + +## 架构 + +``` +PluginInterface(抽象基类) +├── initialize() / shutdown() # 必须实现的异步生命周期 +│ +├── RouteProvider # 提供 FastAPI 路由 +├── ServiceProvider # 注册可复用服务 +├── UIExtensionProvider # 注册 JinjaX 组件扩展点 +└── EventHook # 事件钩子(async emit 并发执行) +``` + +## 插件目录结构 + +``` +app/plugins// +├── __init__.py # 导出插件类 +└── plugin.py # 插件实现 +``` + +## 创建插件 + +```python +# app/plugins/my_plugin/plugin.py +from app.plugins.base import PluginInterface, RouteProvider +import fastapi + +class MyPlugin(PluginInterface, RouteProvider): + def __init__(self): + super().__init__( + plugin_id="my_plugin", + name="我的插件", + version="0.1.0", + description="示例插件", + ) + + async def initialize(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + def get_routes(self) -> tuple[str, fastapi.APIRouter]: + router = fastapi.APIRouter() + @router.get("/hello") + async def hello(): + return {"message": "Hello"} + return ("/my_plugin", router) +``` + +## 生命周期 + +``` +应用启动 → PluginLoader.load_discovered() + → 遍历 app/plugins/*/plugin.py + → 实例化 → on_load() → initialize() + → 注册路由/服务/UI 扩展 +应用关闭 → shutdown() → on_unload() +``` + +## CLI 管理 + +```bash +2c2a plugin list # 列出插件 +2c2a plugin info # 详情 +2c2a plugin enable # 启用 +2c2a plugin disable # 禁用 +2c2a plugin reload # 重新加载 +2c2a plugin routes # 查看所有路由 +2c2a plugin scaffold # 生成骨架 +``` + +## 规范 + +1. plugin_id 小写 + 下划线,与目录名一致 +2. 所有生命周期方法必须是 async +3. 路由前缀以插件 ID 开头,避免冲突 +4. 插件中的数据查询必须遵守站点隔离 +5. 静态资源放 `app/plugins//static/`,`collectstatic` 收集 \ No newline at end of file diff --git a/.trae/rules/09-frontend.md b/.trae/rules/09-frontend.md new file mode 100644 index 0000000..b091d1e --- /dev/null +++ b/.trae/rules/09-frontend.md @@ -0,0 +1,80 @@ +# 09 - 前端与模板 + +## 技术栈 + +- **Jinja2**:页面模板 +- **JinjaX**:组件化模板 +- **HTMX + HTMX OOB**:服务端渲染片段 +- **CSS**:`app/static/css/base.css` + +## 模板目录 + +``` +app/templates/ +├── layouts/app_shell.html # App Shell 基础布局 +├── pages/ # 页面模板 +└── fragments/ # HTMX 动态片段 +``` + +## App Shell 布局原则 + +1. **仅含租户级配置**:站点名、主题、ICP +2. **绝不含用户状态**:无用户名、无个性化数据 +3. **动态内容用 HTMX 加载** + +```html + +``` + +## HTMX 模式 + +### 页面加载后请求片段 + +```html +
+ 加载中... +
+``` + +### OOB(Out of Band) + +```html +
主内容...
+ +
新统计...
+``` + +## 静态资源 + +``` +app/static/ +├── css/base.css # 全局样式 +├── js/auth.js # 认证 JS +└── vendor/htmx.min.js # 第三方库(本地化,禁 CDN) +``` + +### collectstatic + +```bash +2c2a collectstatic # 收集到 staticfiles/ +2c2a collectstatic /var/www/static --clear +2c2a collectstatic --dry-run # 仅预览 +``` + +收集后包含应用静态 + 插件静态(`staticfiles/plugins//`)。 + +## 样式规范 + +1. 禁止内联样式 +2. 禁止 CDN 链接 +3. 样式统一放 base.css,按模块注释分隔 \ No newline at end of file diff --git a/.trae/rules/10-cli.md b/.trae/rules/10-cli.md new file mode 100644 index 0000000..69fca9c --- /dev/null +++ b/.trae/rules/10-cli.md @@ -0,0 +1,91 @@ +# 10 - CLI 工具 + +## 入口 + +```bash +2c2a --help # 查看所有命令 +2c2a --help # 子命令帮助 +python -m app.cli # 备用入口 +``` + +## 命令结构 + +``` +2c2a +├── collectstatic [dest] [--clear] [--dry-run] # 静态资源收集 +├── keys (generate/secret/aes/blake2b/ed25519/show) # 密钥生成 +├── db (init/migrate/upgrade/downgrade/history/current/heads/reset) # 数据库 +├── account (createsuperuser/create/list/changepassword/activate/...) # 账户 +├── serve (serve/worker/shell/check) # 服务器 +├── plugin (list/info/enable/disable/reload/routes/services/scaffold) # 插件 +├── tenant (list/create/info/add-hostname/remove-hostname/...) # 租户 +├── migrate # 快捷 +├── createsuperuser # 快捷 +└── runserver # 快捷 +``` + +## 命令文件组织 + +``` +app/cli/ +├── main.py # 主入口 +├── utils.py # 共享工具(run_async / db_session / 输出) +├── db.py # 数据库 +├── account.py # 账户 +├── server.py # 服务器 +├── plugins.py # 插件 +├── tenant.py # 租户 +├── static.py # collectstatic + keys +└── __main__.py # python -m 入口 +``` + +## 异步命令 + +CLI 是同步入口,用 `run_async` 包装异步操作: + +```python +from app.cli.utils import run_async, db_session + +@account_app.command("create") +def create_user(username: str, ...): + async def _do(): + async with db_session() as session: + user = User(username=username, ...) + session.add(user) + run_async(_do()) +``` + +## 输出 + +用 `app/cli/utils.py` 的辅助函数: + +```python +from app.cli.utils import console, success, error, info, warn, print_table + +success("用户创建成功") +error("用户不存在") +print_table("用户列表", ["ID", "用户名", "邮箱"], rows) +``` + +## 密码交互 + +```python +from app.cli.utils import blake2b_prehash_interactive +from app.security.password import hash_password + +prehash = blake2b_prehash_interactive("密码") +phc = hash_password(prehash) +user.password_hash = phc +``` + +## 关系加载 + +CLI 中访问模型关系**必须**用 `selectinload` 预加载: + +```python +from sqlalchemy.orm import selectinload + +result = await session.execute( + select(User).options(selectinload(User.active_ban)).where(User.username == username) +) +``` \ No newline at end of file diff --git a/.trae/rules/11-winrm.md b/.trae/rules/11-winrm.md new file mode 100644 index 0000000..1c0a510 --- /dev/null +++ b/.trae/rules/11-winrm.md @@ -0,0 +1,62 @@ +# 11 - WinRM 异步客户端 + +## 架构 + +``` +app/winrm/ +├── transport.py # 底层 aiohttp WS-Management 传输 +├── client.py # 高层异步客户端 +└── commands.py # PowerShell 命令模板 + 转义 +``` + +基于 `aiohttp`,**全异步**。替代同步 `winrm` 库。 + +## 使用 + +```python +from app.winrm.client import WinRMClient + +client = WinRMClient( + host="192.168.1.100", + username="administrator", + password="...", + port=5985, + timeout=30, +) + +result = await client.execute_command("whoami") +await client.create_user("alice", "P@ssw0rd!") +users = await client.list_users() +``` + +## 命令注入防护 + +用 `commands.py` 中的模板 + `escape_ps_string`,禁止拼接: + +```python +# ✗ 禁止 +cmd = f"net user {username} {password} /add" + +# ✓ 正确 +from app.winrm.commands import CREATE_USER_PS, escape_ps_string +cmd = CREATE_USER_PS.format(name=escape_ps_string(username), password=escape_ps_string(password)) +``` + +## demo 模式 + +`2C2A_DEMO=1` 时所有 `execute_*` 返回模拟成功结果。 + +## 后台任务 + +长 WinRM 操作放 RedisHuey 任务: + +```bash +2c2a serve worker +``` + +## 禁止 + +1. 禁止用同步 winrm 库 +2. 禁止在 async 中阻塞执行 WinRM 操作 +3. 禁止拼接 PowerShell 命令 +4. 禁止在 HTTP 请求中直接执行长 WinRM 操作(用 Huey 任务) \ No newline at end of file diff --git a/.trae/rules/12-api.md b/.trae/rules/12-api.md new file mode 100644 index 0000000..c5ea56e --- /dev/null +++ b/.trae/rules/12-api.md @@ -0,0 +1,86 @@ +# 12 - API 路由 + +## 路由类型 + +| 类型 | 前缀 | 返回 | 用途 | +| --- | --- | --- | --- | +| REST API | `/api/v1/` | JSON | 前端/客户端 | +| 认证 API | `/auth/` | JSON | 登录/注册/刷新 | +| Web 页面 | `/` | HTML | App Shell(可缓存) | +| HTMX 片段 | `/fragments/` | HTML | 动态片段(不可缓存) | +| 插件路由 | `//` | JSON/HTML | 插件 | + +## 依赖注入 + +```python +from app.core.db import get_db +from app.tenant.dependencies import get_tenant +from app.auth.dependencies import get_current_user, require_staff + +@router.get("/hosts") +async def list_hosts( + tenant: TenantContext = Depends(get_tenant), + db: AsyncSession = Depends(get_db), +): + return await db.execute( + select(Host).where(Host.site_group_id == tenant.site_group_id) + ).scalars().all() + +@router.delete("/hosts/{id}") +async def delete_host( + id: int, + user: CurrentUser = Depends(require_staff), + db: AsyncSession = Depends(get_db), +): + ... +``` + +## REST API 规范 + +```python +router = APIRouter(prefix="/api/v1/hosts", tags=["hosts"]) + +@router.get("") # 列表 +@router.get("/{id}") # 详情 +@router.post("") # 创建 +@router.put("/{id}") # 全量更新 +@router.patch("/{id}") # 部分更新 +@router.delete("/{id}") # 删除 +``` + +### Pydantic Schema + +```python +class HostOut(BaseModel): + id: int + name: str + ip: str + model_config = {"from_attributes": True} +``` + +## 认证 API + +```python +@router.post("/login") # 登录 → 签发 JWT + Refresh Cookie +@router.post("/refresh") # 刷新 → 签发新 JWT +@router.post("/logout") # 登出 → 删除 Cookie +``` + +## 异常处理 + +```python +from app.core.exceptions import AuthError, NotFoundError + +@router.get("/hosts/{id}") +async def get_host(id: int, db = Depends(get_db)): + host = await db.get(Host, id) + if not host: + raise NotFoundError("主机不存在") + return host +``` + +## 禁止 + +1. 禁止跨租户查询 +2. 禁止在 App Shell 路由依赖用户 +3. 禁止返回明文密码或哈希 \ No newline at end of file diff --git a/.trae/rules/13-git.md b/.trae/rules/13-git.md new file mode 100644 index 0000000..1540d84 --- /dev/null +++ b/.trae/rules/13-git.md @@ -0,0 +1,58 @@ +# 13 - Git 工作流 + +## 分支模型 + +``` +main # 生产分支 +├── feat/* # 功能分支 +├── hotfix/* # 紧急修复 +└── fix/* # 普通 bug 修复 +``` + +## 工作流 + +```bash +# 新功能 +git checkout -b feat/my-feature +# 开发... +git add ... +git commit -m "feat: 添加批量主机操作" +git push -u origin feat/my-feature +# PR → 合并 → 删除分支 +git branch -d feat/my-feature +git push origin --delete feat/my-feature +``` + +## 提交规范 + +``` +: <描述> + +[可选正文] +``` + +| Type | 用途 | +| --- | --- | +| `feat` | 新功能 | +| `fix` | bug 修复 | +| `refactor` | 重构 | +| `docs` | 文档 | +| `test` | 测试 | +| `chore` | 构建/工具/依赖 | + +描述用中文,不超过 72 字符。 + +## 禁止 + +1. 禁止 force push 到 main/master +2. 禁止合并后保留 feat/* 和 hotfix/* 分支 +3. 禁止提交 `.env`、密钥、`__pycache__`、`staticfiles/` +4. 禁止未经用户同意提交 + +## 提交前检查 + +```bash +ruff check app/ +ruff format app/ +git status +``` \ No newline at end of file diff --git a/.trae/rules/14-troubleshooting.md b/.trae/rules/14-troubleshooting.md new file mode 100644 index 0000000..5f48625 --- /dev/null +++ b/.trae/rules/14-troubleshooting.md @@ -0,0 +1,93 @@ +# 14 - 故障排查 + +## 环境问题 + +### Python 版本 + +```bash +python --version # 需要 >= 3.12 +``` + +### 依赖 + +```bash +pip install -e . # 安装全部依赖 +pip install # 添加依赖 +``` + +## 数据库问题 + +### 连接失败 + +```bash +2c2a serve check +# 检查 DB_ENGINE/DB_HOST/DB_PORT/DB_USER/DB_PASSWORD +``` + +### DetachedInstanceError + +**原因**:异步会话关闭后访问懒加载关系。 + +**修复**:用 `selectinload` 预加载。 + +### 迁移失败 + +```bash +2c2a db current # 查看当前版本 +2c2a db history # 查看历史 +2c2a db downgrade -1 # 回滚一个版本 +2c2a db reset # 重置(危险!开发用) +``` + +## 启动问题 + +### 端口被占用 + +```bash +lsof -i :8000 # Linux/Mac +2c2a serve serve --port 8001 # 换端口 +``` + +### 密钥未配置 + +生产环境必须显式配置所有密钥。查看状态:`2c2a keys show` + +### Redis 连接失败 + +```bash +redis-cli ping +# 或禁用:REDIS_ENABLED=false +``` + +## 认证问题 + +```bash +2c2a account info # 查看用户状态 +2c2a account activate # 激活 +2c2a account changepassword # 重置密码(递增 ban_version) +``` + +### ban_version 不匹配 + +封禁或改密码后所有已签发令牌失效,用户需重新登录。 + +## 缓存问题 + +```bash +# 清除租户缓存 +2c2a tenant invalidate-cache + +# 清除所有 Redis 缓存 +redis-cli FLUSHDB +``` + +## 诊断命令 + +```bash +2c2a serve check # 配置检查 +2c2a keys show # 密钥状态 +2c2a db current # 数据库版本 +2c2a plugin list # 插件状态 +2c2a tenant list # 租户列表 +2c2a account list # 用户列表 +``` \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4784030..01f0ed0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,21 +1,38 @@ -<2c2a_iron_laws> -🚨 VIOLATION = SEVERE ERROR: -1. All Python cmds MUST use `uv run`. NO `pip`, NO bare `python`. -2. NO `django-admin` for provider/user dashboard. Build custom views. -3. NO raw ` - - -
-
-

2c2a 验证码服务

-
-
-

您好!

-
-

这是一封测试邮件,用于验证邮件配置是否正确。

-
-

系统配置的SMTP服务器可以正常发送邮件。

-

测试时间: {timezone.now().strftime("%Y-%m-%d %H:%M:%S")}

-
- -
- - - ''' - - _log('正在连接 SMTP 服务器...') - task_record.progress = 30 - task_record.save(update_fields=['progress']) - - email_service.send_email( - to_emails=[test_email], - subject=subject, - text_body=text_body, - html_body=html_body, - ) - - _log('邮件发送成功') - task_record.progress = 100 - task_record.save(update_fields=['progress']) - - task_record.complete_success( - result_data={'test_email': test_email} - ) - except Exception as exc: - logger.error(f'测试邮件发送失败: {exc}', exc_info=True) - _log(f'异常: {exc}') - task_record.complete_failure(str(exc)) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py deleted file mode 100755 index 2624f20..0000000 --- a/apps/accounts/urls.py +++ /dev/null @@ -1,79 +0,0 @@ -from django.urls import path -from django.views.decorators.cache import never_cache -from . import views - -app_name = 'accounts' - -urlpatterns = [ - path('register/', views.RegisterView.as_view(), name='register'), - path( - 'register//', - views.RegisterByLinkView.as_view(), - name='register_by_link', - ), - path( - 'login/', - never_cache(views.LoginView.as_view()), - name='login', - ), - path('profile/', views.ProfileView.as_view(), name='profile'), - path('migrate/', views.migrate_view, name='migrate'), - path('banned/', views.banned_view, name='banned'), - path('logout/', views.logout_view, name='logout'), - path( - 'email/send-code/', - views.send_register_email_code, - name='send_register_email_code', - ), - path( - 'forgot-password/', - views.ForgotPasswordView.as_view(), - name='forgot_password', - ), - path( - 'email/send-forgot-password-code/', - views.send_forgot_password_email_code, - name='send_forgot_password_email_code', - ), - path( - 'api/profile/avatar/', - views.upload_avatar, - name='upload_avatar', - ), - path( - 'api/password/change/', - views.password_change_api, - name='password_change_api', - ), - # 邮箱绑定 API - path( - 'api/emails/', - views.email_list_api, - name='email_list', - ), - path( - 'api/emails/bind/', - views.email_bind_api, - name='email_bind', - ), - path( - 'api/emails/send-bind-code/', - views.send_bind_email_code, - name='send_bind_email_code', - ), - path( - 'api/emails//set-primary/', - views.email_set_primary_api, - name='email_set_primary', - ), - path( - 'api/emails//unbind/', - views.email_unbind_api, - name='email_unbind', - ), - path( - 'api/emails/merge-confirm/', - views.email_merge_confirm_api, - name='email_merge_confirm', - ), -] diff --git a/apps/accounts/urls_admin.py b/apps/accounts/urls_admin.py deleted file mode 100644 index a662b8c..0000000 --- a/apps/accounts/urls_admin.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -超管后台 URL 配置 - -所有 URL 以 /admin/ 为前缀,命名空间为 'admin'。 -各子模块通过 include 引入,实现模块化路由。 -此模块完全替代 Django Admin。 -""" - -from django.urls import path, include - -from apps.accounts.views_admin import admin_dashboard -from plugins.dynamic_urls import get_plugin_admin_urls - -app_name = 'admin' - -urlpatterns = [ - # 仪表盘 - path('', admin_dashboard, name='dashboard'), - - # 用户与权限 - path('users/', include('apps.accounts.urls_admin_users')), - path('groups/', include('apps.accounts.urls_admin_groups')), - path('reglinks/', include('apps.accounts.urls_admin_reglinks')), - - # 提供商分配 - path('providers/', include('apps.accounts.urls_admin_providers')), - - # 主机与产品 - path('hosts/', include('apps.hosts.urls_admin')), - - # 运营管理 - path('operations/', include('apps.operations.urls_admin')), - - # 工单系统 - path('tickets/', include('apps.tickets.urls_admin')), - - # 插件配置(动态加载) - path('plugins/', include('plugins.urls_admin')), - path('plugins/', include(get_plugin_admin_urls())), - - # 审计日志 - path('audit/', include('apps.audit.urls_admin')), - - # 仪表盘组件配置 - path('dashboard/', include('apps.dashboard.urls_admin')), - - # 主题配置 - path('themes/', include('apps.themes.urls_admin')), -] diff --git a/apps/accounts/urls_admin_groups.py b/apps/accounts/urls_admin_groups.py deleted file mode 100644 index 1710088..0000000 --- a/apps/accounts/urls_admin_groups.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path - -from . import views_admin_groups as views - -app_name = 'admin_groups' - -urlpatterns = [ - path('', views.group_list, name='group_list'), - path('create/', views.group_create, name='group_create'), - path('/edit/', views.group_update, name='group_edit'), - path('/delete/', views.group_delete, name='group_delete'), -] diff --git a/apps/accounts/urls_admin_providers.py b/apps/accounts/urls_admin_providers.py deleted file mode 100644 index 5dccdda..0000000 --- a/apps/accounts/urls_admin_providers.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.urls import path - -from .views_superadmin import ( - superadmin_host_list, - superadmin_host_provider_assign, - superadmin_hostgroup_list, - superadmin_hostgroup_provider_assign, -) - -app_name = 'admin_providers' - -urlpatterns = [ - path( - 'hosts/', - superadmin_host_list, - name='provider_host_list', - ), - path( - 'hosts//providers/', - superadmin_host_provider_assign, - name='provider_host_assign', - ), - path( - 'host-groups/', - superadmin_hostgroup_list, - name='provider_hostgroup_list', - ), - path( - 'host-groups//providers/', - superadmin_hostgroup_provider_assign, - name='provider_hostgroup_assign', - ), -] diff --git a/apps/accounts/urls_admin_reglinks.py b/apps/accounts/urls_admin_reglinks.py deleted file mode 100644 index 902f4e7..0000000 --- a/apps/accounts/urls_admin_reglinks.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from . import views_admin_reglinks as views - -app_name = 'admin_reglinks' - -urlpatterns = [ - path('', views.reglink_list, name='reglink_list'), - path('create/', views.reglink_create, name='reglink_create'), - path('/delete/', views.reglink_delete, name='reglink_delete'), -] diff --git a/apps/accounts/urls_admin_users.py b/apps/accounts/urls_admin_users.py deleted file mode 100644 index 0e33668..0000000 --- a/apps/accounts/urls_admin_users.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -超管后台 - 用户管理 URL 配置 - -命名空间: admin_users (通过 admin: 命名空间访问) -""" - -from django.urls import path - -from . import views_admin_users as views - -app_name = 'admin_users' - -urlpatterns = [ - # 用户列表 - path('', views.user_list, name='user_list'), - - # 创建用户 - path('create/', views.user_create, name='user_create'), - - # 编辑用户 - path('/edit/', views.user_update, name='user_edit'), - - # 删除用户 - path('/delete/', views.user_delete, name='user_delete'), - - # 切换激活状态 - path('/toggle-active/', views.user_toggle_active, name='user_toggle_active'), - - # 重置密码 - path('/reset-password/', views.user_reset_password, name='user_reset_password'), -] diff --git a/apps/accounts/urls_provider.py b/apps/accounts/urls_provider.py deleted file mode 100644 index dd6769a..0000000 --- a/apps/accounts/urls_provider.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -提供商后台 URL 配置 - -所有 URL 以 /provider/ 为前缀,命名空间为 'provider'。 -各子模块通过 include 引入,实现模块化路由。 -""" - -from django.urls import path, include - -from apps.accounts.views_provider import provider_dashboard -from apps.accounts.views_superadmin import ( - superadmin_host_list, - superadmin_host_provider_assign, - superadmin_hostgroup_list, - superadmin_hostgroup_provider_assign, -) -from plugins.dynamic_urls import get_plugin_provider_urls - -app_name = 'provider' - -urlpatterns = [ - # 仪表盘 - path('', provider_dashboard, name='dashboard'), - - # 主机管理子模块 - path('hosts/', include('apps.hosts.urls_provider')), - - # 运营管理子模块 - path('operations/', include('apps.operations.urls_provider')), - - # 工单管理子模块 - path('tickets/', include('apps.tickets.urls_provider')), - - # 插件配置子模块(动态加载) - path('plugins/', include('plugins.urls_provider')), - path('plugins/', include(get_plugin_provider_urls())), - - # 提供商 API - path('api/', include('apps.provider_backend.api_urls')), - - # 超级管理员 - 提供商分配 - path('superadmin/hosts/', superadmin_host_list, name='superadmin_host_list'), - path('superadmin/hosts//providers/', superadmin_host_provider_assign, name='superadmin_host_provider_assign'), - path('superadmin/host-groups/', superadmin_hostgroup_list, name='superadmin_hostgroup_list'), - path('superadmin/host-groups//providers/', superadmin_hostgroup_provider_assign, name='superadmin_hostgroup_provider_assign'), -] diff --git a/apps/accounts/user_service.py b/apps/accounts/user_service.py deleted file mode 100644 index 11941ca..0000000 --- a/apps/accounts/user_service.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -用户服务模块 - -集中处理用户迁移、封禁同步、账户合并、邮箱绑定等核心业务逻辑。 -""" - -import logging - -from django.db import transaction -from django.contrib.auth import get_user_model - -from .models import UserEmail, UserBan, UserBanHistory - -User = get_user_model() -logger = logging.getLogger(__name__) - - -# ── 用户迁移 ────────────────────────────────────────── - - -def check_user_migration(user, site_group): - """ - 检查用户是否需要迁移到指定站点组。 - - Returns: - dict: { - 'needs_migration': bool, - 'already_member': bool, - 'user_banned': bool, - } - """ - if site_group is None: - return {"needs_migration": False, "already_member": True, "user_banned": False} - - # 超级管理员不需要迁移,相当于所有站点的管理员 - if user.is_superuser: - return {"needs_migration": False, "already_member": True, "user_banned": False} - - is_member = user.site_groups.filter(pk=site_group.pk).exists() - if is_member: - return {"needs_migration": False, "already_member": True, "user_banned": False} - - if UserBan.objects.filter(user=user).exists(): - return {"needs_migration": False, "already_member": False, "user_banned": True} - - return {"needs_migration": True, "already_member": False, "user_banned": False} - - -def migrate_user_to_site_group(user, site_group): - """ - 将用户迁移到指定站点组。 - - 仅添加 site_groups 关联,不改变用户数据。 - 迁移前需检查邮箱后缀合规性。 - - Returns: - dict: {'success': bool, 'reason': str} - """ - if site_group is None: - return {"success": False, "reason": "无效的站点组"} - - if UserBan.objects.filter(user=user).exists(): - return {"success": False, "reason": "用户已被封禁,无法迁移"} - - if user.site_groups.filter(pk=site_group.pk).exists(): - return {"success": False, "reason": "用户已在该站点组中"} - - # 检查邮箱后缀合规性 - from utils.site_group import get_effective_config - - ec = get_effective_config(site_group) - suffix_data = ec.get_email_suffix_lists() - has_whitelist = bool(suffix_data["whitelist"]) - has_blacklist = bool(suffix_data["blacklist"]) - - if has_whitelist or has_blacklist: - # 站点组配置了邮箱后缀限制,需要检查合规性 - user_emails = UserEmail.objects.filter(user=user) - email_list = list(user_emails.values_list("email", flat=True)) - # 兼容旧用户:如果没有 UserEmail 记录,回退到 User.email - if not email_list and user.email: - email_list = [user.email] - - has_compliant_email = any(ec.is_email_suffix_allowed(e) for e in email_list) - - if not has_compliant_email: - return { - "success": False, - "reason": "email_not_compliant", - "message": "您的邮箱不满足该站点的邮箱后缀要求,请先绑定符合条件的邮箱", - } - - user.site_groups.add(site_group) - logger.info( - f"用户 {user.username}(id={user.pk}) 已迁移到站点组 " - f"{site_group.name}(id={site_group.pk})" - ) - return {"success": True, "reason": "迁移成功"} - - -# ── 封禁同步 ────────────────────────────────────────── - - -def ban_user(user, reason="", banned_by=None): - """ - 全局封禁用户。 - - 使用自定义 UserBan 模型替代 Django 的 is_active 字段。 - 封禁是全局的,影响用户在所有站点组的访问。 - 封禁污染通过 bind_email 触发:当用户绑定被封禁用户的邮箱时, - 当前用户也会被封禁。 - """ - ban, created = UserBan.objects.get_or_create( - user=user, - defaults={"reason": reason, "banned_by": banned_by}, - ) - if not created: - # 已有封禁记录,更新理由 - if reason: - ban.reason = reason - if banned_by: - ban.banned_by = banned_by - ban.save() - - if created: - logger.info( - f"用户 {user.username}(id={user.pk}) 已被封禁。" - f"原因: {reason},操作者: {banned_by}" - ) - return ban - - -def unban_user(user, unbanned_by=None): - """ - 解封用户。 - - 将活跃封禁记录归档到 UserBanHistory,然后删除。 - """ - ban = UserBan.objects.filter(user=user).first() - if not ban: - return - - # 归档到历史记录 - UserBanHistory.objects.create( - user=user, - reason=ban.reason, - banned_by=ban.banned_by, - unbanned_by=unbanned_by, - banned_at=ban.created_at, - ) - ban.delete() - logger.info( - f"用户 {user.username}(id={user.pk}) 已解封。操作者: {unbanned_by}" - ) - - -def check_ban_status(email): - """ - 检查邮箱关联的账户是否被封禁。 - - 用于注册/忘记密码时检查,防止通过被封禁邮箱绕过。 - - Returns: - dict: {'is_banned': bool, 'user': User or None} - """ - # 先检查 UserEmail 表 - user_email = UserEmail.objects.filter(email=email).first() - if user_email and UserBan.objects.filter(user=user_email.user).exists(): - return {"is_banned": True, "user": user_email.user} - - # 再检查 User.email 字段(兼容旧数据) - user = User.objects.filter(email=email).first() - if user and UserBan.objects.filter(user=user).exists(): - return {"is_banned": True, "user": user} - - return {"is_banned": False, "user": user} - - -# ── 账户合并 ────────────────────────────────────────── - - -def merge_accounts(source_user, target_user, keep_newer=True): - """ - 合并两个账户。 - - 将 source_user 的数据迁移到 target_user,然后删除 source_user。 - 默认保留较新的账户(keep_newer=True),用户可选保留较旧的。 - - Args: - source_user: 被合并的账户(将被删除) - target_user: 保留的账户 - keep_newer: True=新到旧(source=旧,target=新), False=旧到新(source=新,target=旧) - - Returns: - dict: {'success': bool, 'reason': str} - """ - if source_user.pk == target_user.pk: - return {"success": False, "reason": "不能合并同一账户"} - - if not keep_newer: - # 旧到新:source 是较新的,target 是较旧的 - source_user, target_user = target_user, source_user - - with transaction.atomic(): - # 迁移 site_groups - for sg in source_user.site_groups.all(): - target_user.site_groups.add(sg) - - # 迁移 UserEmail - UserEmail.objects.filter(user=source_user).exclude( - email__in=UserEmail.objects.filter(user=target_user).values_list( - "email", flat=True - ) - ).update(user=target_user) - - # 迁移 groups - for group in source_user.groups.all(): - target_user.groups.add(group) - - # 迁移 UserProfile(如果 target 没有) - if not hasattr(target_user, "profile") and hasattr(source_user, "profile"): - source_user.profile.user = target_user - source_user.profile.save() - - # 如果 source 的主邮箱不在 target 的邮箱列表中,添加为子邮箱 - source_primary = UserEmail.objects.filter( - user=source_user, is_primary=True - ).first() - if source_primary: - if not UserEmail.objects.filter( - user=target_user, email=source_primary.email - ).exists(): - source_primary.user = target_user - source_primary.is_primary = False - source_primary.save() - - # 如果 target 没有任何邮箱,把 source 的邮箱都迁移过来 - if not UserEmail.objects.filter(user=target_user).exists(): - UserEmail.objects.filter(user=source_user).update(user=target_user) - - # 同步封禁状态 - source_banned = UserBan.objects.filter(user=source_user).first() - if source_banned and not UserBan.objects.filter(user=target_user).exists(): - UserBan.objects.create( - user=target_user, - reason=source_banned.reason, - banned_by=source_banned.banned_by, - ) - - # 删除被合并的账户 - source_user.delete() - - logger.info( - f"账户合并: 用户 {source_user.username}(id={source_user.pk}) " - f"已合并到 {target_user.username}(id={target_user.pk})" - ) - - return {"success": True, "reason": "合并成功", "kept_user": target_user} - - -# ── 邮箱绑定 ────────────────────────────────────────── - - -def bind_email(user, email, is_primary=False): - """ - 绑定邮箱到用户账户。 - - 如果该邮箱已被其他账户使用: - - 如果那个账户被封禁,同步封禁到当前用户(封禁污染) - - 如果那个账户正常,触发账户合并 - - Args: - user: 当前用户 - email: 要绑定的邮箱 - is_primary: 是否设为主邮箱 - - Returns: - dict: { - 'success': bool, - 'action': 'bound'|'banned'|'merge_required', - 'reason': str, - 'merge_info': dict or None, - } - """ - # 检查邮箱是否已被绑定 - existing = UserEmail.objects.filter(email=email).first() - if existing: - if existing.user.pk == user.pk: - return { - "success": False, - "action": "bound", - "reason": "该邮箱已绑定到当前账户", - "merge_info": None, - } - - other_user = existing.user - - # 封禁污染:如果邮箱关联的账户被封禁,同步封禁当前用户 - if UserBan.objects.filter(user=other_user).exists(): - ban_user( - user, reason=f"绑定了被封禁账户 {other_user.username} 的邮箱 {email}" - ) - return { - "success": False, - "action": "banned", - "reason": f"该邮箱关联的账户已被封禁,您的账户也已被同步封禁", - "merge_info": None, - } - - # 账户合并:需要用户确认 - return { - "success": False, - "action": "merge_required", - "reason": f"该邮箱已被账户 {other_user.username} 使用,需要进行账户合并", - "merge_info": { - "other_user_id": other_user.pk, - "other_username": other_user.username, - "other_created_at": str(other_user.created_at), - "current_user_id": user.pk, - "current_username": user.username, - "current_created_at": str(user.created_at), - }, - } - - # 正常绑定 - UserEmail.objects.create( - user=user, - email=email, - is_primary=is_primary, - is_verified=False, - ) - - # 如果设为主邮箱,同步更新 User.email - if is_primary: - user.email = email - user.save(update_fields=["email"]) - - return { - "success": True, - "action": "bound", - "reason": "邮箱绑定成功", - "merge_info": None, - } - - -def set_primary_email(user, email): - """设置主邮箱""" - ue = UserEmail.objects.filter(user=user, email=email).first() - if not ue: - return {"success": False, "reason": "该邮箱未绑定到当前账户"} - - ue.is_primary = True - ue.save() - - user.email = email - user.save(update_fields=["email"]) - - return {"success": True, "reason": "主邮箱设置成功"} - - -def unbind_email(user, email): - """解绑邮箱(不能解绑主邮箱)""" - ue = UserEmail.objects.filter(user=user, email=email).first() - if not ue: - return {"success": False, "reason": "该邮箱未绑定到当前账户"} - - if ue.is_primary: - return {"success": False, "reason": "不能解绑主邮箱,请先设置其他邮箱为主邮箱"} - - ue.delete() - return {"success": True, "reason": "邮箱解绑成功"} diff --git a/apps/accounts/views.py b/apps/accounts/views.py deleted file mode 100755 index adedd7e..0000000 --- a/apps/accounts/views.py +++ /dev/null @@ -1,1231 +0,0 @@ -""" -用户管理视图 -""" - -from django.shortcuts import redirect, render -from django.contrib.auth import login, logout -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from django.views.generic import CreateView, UpdateView, TemplateView -from django.urls import reverse_lazy -from django.utils.decorators import method_decorator -from django.views.decorators.http import require_http_methods -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_protect, csrf_exempt -from django.core.cache import cache -from django.utils.http import url_has_allowed_host_and_scheme -from PIL import Image -import os - -from .models import User, RegistrationLink -from .forms import UserRegistrationForm, UserUpdateForm, UserLoginForm -from . import rate_limit -from apps.themes.models import ThemeConfig, PageContent - - -def get_theme_context(): - """获取主题上下文,避免重复代码""" - theme_config = ThemeConfig.get_config() - return { - "theme_config": theme_config, - "theme_css_url": f"css/themes/{theme_config.active_theme}.css", - "custom_css_vars": theme_config.generate_css_variables(), - "page_contents": PageContent.get_all_enabled(), - } - - -def get_captcha_context(scene, request=None): - from utils.site_group import get_effective_config - - site_group = getattr(request, "site_group", None) if request else None - ec = get_effective_config(site_group) - captcha_provider, captcha_type = ec.get_captcha_config(scene=scene) - ctx = { - "CAPTCHA_PROVIDER": captcha_provider, - "CAPTCHA_TYPE": captcha_type, - } - if scene in ("register", "forgot_password"): - _, email_type = ec.get_captcha_config(scene="email") - ctx["CAPTCHA_TYPE_EMAIL"] = email_type - return ctx - - -@method_decorator(rate_limit.register_rate_limit, name="dispatch") -class RegisterView(CreateView): - """用户注册视图""" - - model = User - form_class = UserRegistrationForm - template_name = "accounts/register.html" - success_url = reverse_lazy("accounts:login") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update(get_captcha_context("register", self.request)) - context.update(get_theme_context()) - return context - - def form_valid(self, form): - """表单验证成功后的处理""" - request = self.request - email = form.cleaned_data.get("email") - email_code = request.POST.get("email_code") - if not (email and email_code): - form.add_error(None, "邮箱验证码缺失") - return self.form_invalid(form) - - # 检查该邮箱是否关联被封禁的账户 - from .user_service import check_ban_status - - ban = check_ban_status(email) - if ban["is_banned"]: - form.add_error("email", "该邮箱关联的账户已被封禁,无法注册") - return self.form_invalid(form) - - import hmac - - cache_key = f"register_email_code:{email}" - expected = cache.get(cache_key) - if expected is None: - form.add_error(None, "邮箱验证码已过期或不存在") - return self.form_invalid(form) - if not hmac.compare_digest(str(expected), str(email_code)): - form.add_error(None, "邮箱验证码错误") - return self.form_invalid(form) - - cache.delete(cache_key) - - response = super().form_valid(form) - - # 注册成功后创建 UserEmail 记录 - user = self.object - if user: - from .models import UserEmail - - UserEmail.objects.get_or_create( - email=email, - defaults={ - "user": user, - "is_primary": True, - "is_verified": True, - }, - ) - # 如果通过子站点注册,自动加入该站点组 - site_group = getattr(request, "site_group", None) - if site_group: - user.site_groups.add(site_group) - - messages.success(self.request, "注册成功!请登录您的账户。") - return response - - def form_invalid(self, form): - """表单验证失败后的处理""" - messages.error(self.request, "注册失败,请检查表单中的错误。") - return super().form_invalid(form) - - -@method_decorator(rate_limit.login_rate_limit, name="dispatch") -class LoginView(TemplateView): - """用户登录视图""" - - template_name = "accounts/login.html" - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context["form"] = UserLoginForm() - context.update(get_captcha_context("login", self.request)) - context["is_demo_mode"] = getattr(self.request, "is_demo_mode", False) - context["next"] = self.request.POST.get("next") or self.request.GET.get( - "next", "" - ) - context.update(get_theme_context()) - return context - - def post(self, request, *args, **kwargs): - """处理POST请求""" - # 处理迁移确认 - if request.POST.get("action") == "migrate_confirm": - return self._handle_migration_confirm(request) - - form = UserLoginForm(request.POST) - - if form.is_valid(): - from .captcha_service import validate_captcha - - is_valid, error_msg = validate_captcha(request, scene="login") - - if not is_valid: - form.add_error(None, error_msg) - context = self.get_context_data(**kwargs) - context["form"] = form - return self.render_to_response(context) - - username = form.cleaned_data["username"] - password = form.cleaned_data["password"] - remember = form.cleaned_data.get("remember", False) - - from django.contrib.auth import authenticate - - # 先检查封禁用户:使用自定义 UserBan 模型 - from .models import User as UserModel - from .models import UserBan - - try: - candidate = UserModel.objects.get(username=username) - if candidate.check_password(password) and UserBan.objects.filter(user=candidate).exists(): - login(request, candidate) - request.session["is_banned"] = True - return redirect("accounts:banned") - except UserModel.DoesNotExist: - pass - - user = authenticate(request, username=username, password=password) - - if user is not None: - # 检查是否需要迁移到当前站点组 - site_group = getattr(request, "site_group", None) - if site_group: - from .user_service import check_user_migration - - migration = check_user_migration(user, site_group) - if migration["user_banned"]: - login(request, user) - request.session["is_banned"] = True - return redirect("accounts:banned") - if migration["needs_migration"]: - # 将用户信息暂存到 session,重定向到迁移页 - login(request, user) - request.session["pending_migration_sg_id"] = site_group.pk - return redirect("accounts:migrate") - - # 更新最后登录IP - from django.utils import timezone - - user.last_login = timezone.now() - user.last_login_ip = self.get_client_ip(request) - user.save(update_fields=["last_login", "last_login_ip"]) - - # 登录用户 - if not hasattr(request, "user") or not request.user.is_authenticated: - login(request, user) - - # 设置会话过期时间 - if not remember: - request.session.set_expiry(0) - else: - request.session.set_expiry(60 * 60 * 24 * 7) - - messages.success(request, f"欢迎回来,{user.username}!") - next_url = request.POST.get("next") or request.GET.get("next") - if next_url and url_has_allowed_host_and_scheme( - next_url, - allowed_hosts=request.get_host(), - ): - return redirect(next_url) - if user.is_staff or user.is_superuser: - return redirect("/admin/") - return redirect("dashboard:index") - else: - messages.error(request, "用户名或密码错误") - - context = self.get_context_data(**kwargs) - context["form"] = form - return self.render_to_response(context) - - def _handle_migration_confirm(self, request): - """处理用户迁移确认(重定向到迁移页)""" - return redirect("accounts:migrate") - - def get_client_ip(self, request): - from utils.helpers import get_client_ip as _get_client_ip - - return _get_client_ip(request) - - -@method_decorator(login_required, name="dispatch") -class ProfileView(UpdateView): - """用户资料视图""" - - model = User - form_class = UserUpdateForm - template_name = "accounts/profile.html" - success_url = reverse_lazy("accounts:profile") - - def get_object(self, queryset=None): - """获取当前用户对象""" - return self.request.user - - def form_valid(self, form): - """表单验证成功后的处理""" - messages.success(self.request, "个人资料更新成功!") - return super().form_valid(form) - - def form_invalid(self, form): - """表单验证失败后的处理""" - messages.error(self.request, "个人资料更新失败,请检查表单中的错误。") - return super().form_invalid(form) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["is_demo_mode"] = getattr(self.request, "is_demo_mode", False) - return context - - def post(self, request, *args, **kwargs): - """处理POST请求,包括资料更新和密码修改""" - # 检查是否是密码修改请求 - current_password = request.POST.get("current_password") - new_password = request.POST.get("new_password") - confirm_password = request.POST.get("confirm_password") - - # 检查是否是密码修改请求 - if current_password or new_password or confirm_password: - # 检查是否在DEMO模式下 - if hasattr(request, "is_demo_mode") and request.is_demo_mode: - from django.contrib import messages - - messages.error(request, "DEMO模式下不允许修改密码") - # 返回GET请求以显示表单和错误消息 - return super().get(request, *args, **kwargs) - - # 验证密码字段 - if not current_password: - return JsonResponse({"status": "error", "message": "请输入当前密码"}) - if not new_password: - return JsonResponse({"status": "error", "message": "请输入新密码"}) - if new_password != confirm_password: - return JsonResponse( - {"status": "error", "message": "两次输入的新密码不一致"} - ) - - # 验证当前密码是否正确 - user = request.user - if not user.check_password(current_password): - return JsonResponse({"status": "error", "message": "当前密码错误"}) - - from django.contrib.auth.password_validation import validate_password - from django.core.exceptions import ValidationError as ValError - - try: - validate_password(new_password, user=user) - except ValError as e: - return JsonResponse({"status": "error", "message": e.messages[0]}) - - user.set_password(new_password) - user.save() - - return JsonResponse( - {"status": "success", "message": "密码修改成功,请重新登录"} - ) - - # 否则是资料更新请求 - return super().post(request, *args, **kwargs) - - -@login_required -def logout_view(request): - """用户登出视图""" - logout(request) - messages.success(request, "您已成功登出") - return redirect("accounts:login") - - -@login_required -@require_http_methods(["POST"]) -@rate_limit.general_api_rate_limit -def password_change_api(request): - """密码更改API端点""" - if hasattr(request, "is_demo_mode") and request.is_demo_mode: - return JsonResponse({"status": "error", "message": "DEMO模式下不允许修改密码"}) - - current_password = request.POST.get("current_password") - new_password = request.POST.get("new_password") - confirm_password = request.POST.get("confirm_password") - - # 验证密码字段 - if not current_password: - return JsonResponse({"status": "error", "message": "请输入当前密码"}) - if not new_password: - return JsonResponse({"status": "error", "message": "请输入新密码"}) - if new_password != confirm_password: - return JsonResponse({"status": "error", "message": "两次输入的新密码不一致"}) - - # 验证当前密码是否正确 - user = request.user - if not user.check_password(current_password): - return JsonResponse({"status": "error", "message": "当前密码错误"}) - - from django.contrib.auth.password_validation import validate_password - from django.core.exceptions import ValidationError as ValError - - try: - validate_password(new_password, user=user) - except ValError as e: - return JsonResponse({"status": "error", "message": e.messages[0]}) - - user.set_password(new_password) - user.save() - - return JsonResponse({"status": "success", "message": "密码修改成功,请重新登录"}) - - -import secrets as _secrets - - -def _gen_code(length=6): - return "".join([_secrets.choice("0123456789") for _ in range(length)]) - - -@require_http_methods(["POST"]) -@csrf_protect -@rate_limit.email_code_rate_limit -def send_register_email_code(request): - """Send a one-time code to the supplied email for registration.""" - reglink_token = request.POST.get("reglink_token", "").strip() - - if reglink_token: - try: - reglink = RegistrationLink.objects.get(token=reglink_token) - if not reglink.is_valid: - return JsonResponse( - {"status": "error", "message": "邀请链接无效或已失效"}, status=400 - ) - except RegistrationLink.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "邀请链接不存在"}, status=400 - ) - else: - from utils.site_group import get_effective_config - - site_group = getattr(request, "site_group", None) - ec = get_effective_config(site_group) - if not ec.enable_registration: - return JsonResponse( - {"status": "error", "message": "注册功能已被管理员禁用"}, status=400 - ) - - email = request.POST.get("email") - - # Validate email - if not email: - return JsonResponse({"status": "error", "message": "缺少email"}, status=400) - - # 验证邮箱后缀 - from utils.site_group import get_effective_config - - site_group = getattr(request, "site_group", None) - ec = get_effective_config(site_group) - - if not ec.is_email_suffix_allowed(email): - email_suffix = "@" + email.split("@")[1] if "@" in email else "" - suffix_data = ec.get_email_suffix_lists() - if suffix_data["whitelist"]: - msg = f"邮箱后缀 {email_suffix} 不在允许的列表中" - else: - msg = f"邮箱后缀 {email_suffix} 已被禁止使用" - return JsonResponse({"status": "error", "message": msg}, status=400) - - # 验证邮箱格式 - from django.core.validators import validate_email - from django.core.exceptions import ValidationError - - try: - validate_email(email) - except ValidationError: - return JsonResponse( - {"status": "error", "message": "请输入有效的邮箱地址"}, status=400 - ) - - from .captcha_service import validate_captcha - - is_valid, error_msg = validate_captcha(request, scene="email") - - if not is_valid: - return JsonResponse({"status": "error", "message": error_msg}, status=400) - - code = _gen_code(6) - cache_key = f"register_email_code:{email}" - cache.set(cache_key, code, timeout=10 * 60) - - subject = "2c2a 注册验证码" - message_body = f"您的注册验证码是: {code},有效期10分钟。" - html_body = f""" - - - - - {subject} - - - -
-
-

2c2a 验证码服务

-
-
-

您好!

-

感谢您注册2c2a账户。

-

您的验证码是:

-
{code}
-

此验证码将在10分钟后失效,请及时使用。

-

如果您没有进行相关操作,请忽略此邮件。

-
- -
- - - """ - - from .email_service import EmailService - - try: - EmailService.send_email_async( - to_emails=[email], - subject=subject, - text_body=message_body, - html_body=html_body, - ) - except Exception as e: - import logging as _logging - - _logging.getLogger(__name__).error( - f"发送注册验证码邮件任务派发失败: {str(e)}", exc_info=True - ) - return JsonResponse( - {"status": "error", "message": "SMTP配置不完整"}, status=500 - ) - - return JsonResponse({"status": "ok"}) - - -@method_decorator(rate_limit.register_rate_limit, name="dispatch") -class RegisterByLinkView(CreateView): - model = User - form_class = UserRegistrationForm - template_name = "accounts/register.html" - success_url = reverse_lazy("accounts:login") - - def dispatch(self, request, *args, **kwargs): - token = kwargs.get("token") - try: - self.reglink = RegistrationLink.objects.select_related("group").get( - token=token - ) - except RegistrationLink.DoesNotExist: - messages.error(request, "注册链接不存在") - return redirect("accounts:register") - - if self.reglink.is_exhausted: - messages.error(request, "此注册链接可用次数已用完") - return redirect("accounts:register") - - if self.reglink.is_expired: - messages.error(request, "此注册链接已过期") - return redirect("accounts:register") - - return super().dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["reglink"] = self.reglink - context["target_group"] = self.reglink.group - context.update(get_captcha_context("email", self.request)) - context.update(get_theme_context()) - return context - - def form_valid(self, form): - import hmac - - email = form.cleaned_data.get("email") - email_code = self.request.POST.get("email_code") - if not (email and email_code): - form.add_error(None, "邮箱验证码缺失") - return self.form_invalid(form) - - cache_key = f"register_email_code:{email}" - expected = cache.get(cache_key) - if expected is None: - form.add_error(None, "邮箱验证码已过期或不存在") - return self.form_invalid(form) - if not hmac.compare_digest(str(expected), str(email_code)): - form.add_error(None, "邮箱验证码错误") - return self.form_invalid(form) - - cache.delete(cache_key) - - user = form.save() - - user.groups.set([self.reglink.group]) - user.sync_staff_status() - - self.reglink.increment_usage(user) - - messages.success( - self.request, f"注册成功!您已加入「{self.reglink.group.name}」组,请登录。" - ) - return redirect(self.success_url) - - def form_invalid(self, form): - messages.error(self.request, "注册失败,请检查表单中的错误。") - return super().form_invalid(form) - - -@login_required -@require_http_methods(["POST"]) -@rate_limit.avatar_upload_rate_limit -def upload_avatar(request): - """上传头像""" - if request.method == "POST" and request.FILES.get("avatar"): - avatar_file = request.FILES["avatar"] - user = request.user - - # 验证文件扩展名 - allowed_extensions = [".jpg", ".jpeg", ".png", ".gif"] - ext = os.path.splitext(avatar_file.name)[1].lower() - if ext not in allowed_extensions: - return JsonResponse({"status": "error", "message": "不支持的图片格式"}) - - # 验证文件大小 (5MB) - if avatar_file.size > 5 * 1024 * 1024: - return JsonResponse({"status": "error", "message": "图片大小不能超过5MB"}) - - try: - # 验证文件确实是图像文件,并检查是否包含恶意内容 - image = Image.open(avatar_file) - image.verify() # 验证图像完整性 - - # 重新打开文件,因为verify()会将指针移到末尾 - avatar_file.seek(0) - - # 再次打开图像用于尺寸检查 - image = Image.open(avatar_file) - - # 检查图像尺寸是否合理(防止像素炸弹) - max_width, max_height = 5000, 5000 # 最大允许尺寸 - if image.width > max_width or image.height > max_height: - return JsonResponse({"status": "error", "message": "图片尺寸过大"}) - - # 限制最小图像尺寸 - min_width, min_height = 10, 10 - if image.width < min_width or image.height < min_height: - return JsonResponse({"status": "error", "message": "图片尺寸过小"}) - - except Exception: - return JsonResponse( - {"status": "error", "message": "上传的文件不是有效的图片"} - ) - - # 重置文件指针以供保存 - avatar_file.seek(0) - - # 保存头像 - user.avatar = avatar_file - user.save() - - return JsonResponse({"status": "success", "message": "头像上传成功"}) - - return JsonResponse({"status": "error", "message": "没有上传文件"}) - - -@method_decorator(rate_limit.register_rate_limit, name="dispatch") -class ForgotPasswordView(TemplateView): - """忘记密码视图""" - - template_name = "accounts/forgot_password.html" - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context.update(get_captcha_context("email", self.request)) - context.update(get_theme_context()) - return context - - def post(self, request, *args, **kwargs): - """处理POST请求""" - email = request.POST.get("email") - email_code = request.POST.get("email_code") - new_password1 = request.POST.get("new_password1") - new_password2 = request.POST.get("new_password2") - - # 验证输入 - if not (email and email_code and new_password1 and new_password2): - messages.error(request, "请填写所有必需字段") - return self.render_to_response(self.get_context_data()) - - # 1. 行为验证码 - from .captcha_service import validate_captcha - - is_valid, error_msg = validate_captcha(request, scene="email") - if not is_valid: - messages.error(request, error_msg) - return self.render_to_response(self.get_context_data()) - - # 2. 邮箱验证码 - import hmac - - cache_key = f"forgot_password_email_code:{email}" - expected = cache.get(cache_key) - if expected is None: - messages.error(request, "邮箱验证码已过期或不存在") - return self.render_to_response(self.get_context_data()) - if not hmac.compare_digest(str(expected), str(email_code)): - messages.error(request, "邮箱验证码错误") - return self.render_to_response(self.get_context_data()) - - # 3. 用户存在性检查 - user_exists = User.objects.filter(email=email).exists() - if not user_exists: - messages.success(request, "如果该邮箱已注册,密码重置邮件已发送") - return redirect("accounts:login") - - # 4. 封禁账户检查 - from .user_service import check_ban_status - - ban = check_ban_status(email) - if ban["is_banned"]: - messages.error(request, "该邮箱关联的账户已被封禁,无法重置密码") - return self.render_to_response(self.get_context_data()) - - # 5. 密码重置 - if new_password1 != new_password2: - messages.error(request, "两次输入的密码不一致") - return self.render_to_response(self.get_context_data()) - - from django.contrib.auth.password_validation import validate_password - from django.core.exceptions import ValidationError as ValError - - try: - validate_password(new_password1) - except ValError as e: - messages.error(request, e.messages[0]) - return self.render_to_response(self.get_context_data()) - - user = User.objects.get(email=email) - user.set_password(new_password1) - user.save() - - # 清除验证码缓存 - cache.delete(cache_key) - - messages.success(request, "密码重置成功,请使用新密码登录") - return redirect("accounts:login") - - -@require_http_methods(["POST"]) -@csrf_protect -@rate_limit.email_code_rate_limit -def send_forgot_password_email_code(request): - """Send a one-time code to the supplied email for password reset.""" - email = request.POST.get("email") - - if not email: - return JsonResponse({"status": "error", "message": "缺少email"}, status=400) - - from .captcha_service import validate_captcha - - is_valid, error_msg = validate_captcha(request, scene="email") - - if not is_valid: - return JsonResponse({"status": "error", "message": error_msg}, status=400) - - user_exists = User.objects.filter(email=email).exists() - - if not user_exists: - return JsonResponse({"status": "ok"}) - - code = _gen_code(6) - cache_key = f"forgot_password_email_code:{email}" - cache.set(cache_key, code, timeout=10 * 60) - - import os - - if os.environ.get("2C2A_DEMO", "").lower() == "1": - import logging as _logging - - _logging.getLogger(__name__).info( - f"DEMO模式: 模拟发送忘记密码验证码邮件至 {email}" - ) - return JsonResponse({"status": "ok"}) - - subject = "2c2a 重置密码验证码" - message_body = f"您的重置密码验证码是: {code},有效期10分钟。" - html_body = f""" - - - - - {subject} - - - -
-
-

2c2a 验证码服务

-
-
-

您好!

-

您正在重置2c2a账户的密码。

-

您的验证码是:

-
{code}
-

此验证码将在10分钟后失效,请及时使用。

-

如果您没有进行相关操作,请忽略此邮件。

-
- -
- - - """ - - from .email_service import EmailService - - try: - EmailService.send_email_async( - to_emails=[email], - subject=subject, - text_body=message_body, - html_body=html_body, - ) - except Exception as e: - import logging as _logging - - _logging.getLogger(__name__).error( - f"发送忘记密码验证码邮件任务派发失败: {str(e)}", exc_info=True - ) - return JsonResponse( - {"status": "error", "message": "SMTP配置不完整"}, status=500 - ) - - return JsonResponse({"status": "ok"}) - - -# ── 邮箱绑定 API ───────────────────────────────────── - - -@login_required -@require_http_methods(["GET"]) -def email_list_api(request): - """获取当前用户的所有绑定邮箱""" - from .models import UserEmail - - emails = UserEmail.objects.filter(user=request.user).order_by( - "-is_primary", "created_at" - ) - data = [ - { - "id": ue.pk, - "email": ue.email, - "is_primary": ue.is_primary, - "is_verified": ue.is_verified, - "created_at": ue.created_at.isoformat(), - } - for ue in emails - ] - return JsonResponse({"status": "ok", "emails": data}) - - -@login_required -@require_http_methods(["POST"]) -@csrf_protect -@rate_limit.email_code_rate_limit -def send_bind_email_code(request): - """发送邮箱绑定验证码""" - email = request.POST.get("email") - if not email: - return JsonResponse( - {"status": "error", "message": "缺少email"}, - status=400, - ) - - from django.core.validators import validate_email - from django.core.exceptions import ValidationError - - try: - validate_email(email) - except ValidationError: - return JsonResponse( - {"status": "error", "message": "请输入有效的邮箱地址"}, - status=400, - ) - - from utils.site_group import get_effective_config - - site_group = getattr(request, "site_group", None) - ec = get_effective_config(site_group) - if not ec.is_email_suffix_allowed(email): - return JsonResponse( - {"status": "error", "message": "该邮箱后缀不在允许的列表中"}, - status=400, - ) - - from .models import UserEmail - - if UserEmail.objects.filter(user=request.user, email=email).exists(): - return JsonResponse( - {"status": "error", "message": "该邮箱已绑定到当前账户"}, - status=400, - ) - - code = _gen_code(6) - cache_key = f"bind_email_code:{email}" - cache.set(cache_key, code, timeout=10 * 60) - - subject = "2c2a 邮箱绑定验证码" - message_body = f"您的邮箱绑定验证码是: {code},有效期10分钟。" - html_body = f""" - - - - - {subject} - - - -
-
-

2c2a 验证码服务

-
-
-

您好!

-

您正在绑定邮箱到2c2a账户。

-

您的验证码是:

-
{code}
-

此验证码将在10分钟后失效,请及时使用。

-

如果您没有进行相关操作,请忽略此邮件。

-
- -
- - - """ - - from .email_service import EmailService - - sg_id = site_group.pk if site_group else None - try: - EmailService.send_email_async( - to_emails=[email], - subject=subject, - text_body=message_body, - html_body=html_body, - site_group_id=sg_id, - ) - except Exception as e: - import logging as _logging - - _logging.getLogger(__name__).error( - f"发送绑定验证码邮件任务派发失败: {str(e)}", - exc_info=True, - ) - return JsonResponse( - {"status": "error", "message": "SMTP配置不完整"}, - status=500, - ) - - return JsonResponse({"status": "ok"}) - - -@login_required -@require_http_methods(["POST"]) -@csrf_protect -def email_bind_api(request): - """绑定邮箱""" - email = request.POST.get("email") - email_code = request.POST.get("email_code") - - if not (email and email_code): - return JsonResponse( - {"status": "error", "message": "缺少必要参数"}, - status=400, - ) - - import hmac - - cache_key = f"bind_email_code:{email}" - expected = cache.get(cache_key) - if expected is None: - return JsonResponse( - {"status": "error", "message": "验证码已过期或不存在"}, - status=400, - ) - if not hmac.compare_digest(str(expected), str(email_code)): - return JsonResponse( - {"status": "error", "message": "验证码错误"}, - status=400, - ) - cache.delete(cache_key) - - from .user_service import bind_email - - result = bind_email(request.user, email) - - if result["action"] == "banned": - return JsonResponse( - { - "status": "error", - "message": result["reason"], - "action": "banned", - }, - status=403, - ) - elif result["action"] == "merge_required": - return JsonResponse( - { - "status": "error", - "message": result["reason"], - "action": "merge_required", - "merge_info": result["merge_info"], - }, - status=409, - ) - elif result["success"]: - return JsonResponse({"status": "ok", "message": result["reason"]}) - else: - return JsonResponse( - {"status": "error", "message": result["reason"]}, - status=400, - ) - - -@login_required -@require_http_methods(["POST"]) -@csrf_protect -def email_set_primary_api(request, email_id): - """设置主邮箱""" - from .models import UserEmail - - try: - ue = UserEmail.objects.get(pk=email_id, user=request.user) - except UserEmail.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "邮箱记录不存在"}, - status=404, - ) - - if not ue.is_verified: - return JsonResponse( - {"status": "error", "message": "邮箱未验证,无法设为主邮箱"}, - status=400, - ) - - from .user_service import set_primary_email - - result = set_primary_email(request.user, ue.email) - if result["success"]: - return JsonResponse({"status": "ok", "message": result["reason"]}) - return JsonResponse( - {"status": "error", "message": result["reason"]}, - status=400, - ) - - -@login_required -@require_http_methods(["POST"]) -@csrf_protect -def email_unbind_api(request, email_id): - """解绑邮箱""" - from .models import UserEmail - - try: - ue = UserEmail.objects.get(pk=email_id, user=request.user) - except UserEmail.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "邮箱记录不存在"}, - status=404, - ) - - from .user_service import unbind_email - - result = unbind_email(request.user, ue.email) - if result["success"]: - return JsonResponse({"status": "ok", "message": result["reason"]}) - return JsonResponse( - {"status": "error", "message": result["reason"]}, - status=400, - ) - - -@login_required -@require_http_methods(["POST"]) -@csrf_protect -def email_merge_confirm_api(request): - """确认账户合并""" - other_user_id = request.POST.get("other_user_id") - keep_newer = request.POST.get("keep_newer", "true").lower() == "true" - - if not other_user_id: - return JsonResponse( - {"status": "error", "message": "缺少必要参数"}, - status=400, - ) - - try: - other_user = User.objects.get(pk=other_user_id) - except User.DoesNotExist: - return JsonResponse( - {"status": "error", "message": "目标用户不存在"}, - status=404, - ) - - from .user_service import merge_accounts - - result = merge_accounts( - source_user=other_user, - target_user=request.user, - keep_newer=keep_newer, - ) - - if result["success"]: - return JsonResponse({"status": "ok", "message": result["reason"]}) - return JsonResponse( - {"status": "error", "message": result["reason"]}, - status=400, - ) - - -# ── 站点迁移 ────────────────────────────────────────── - - -@login_required -def migrate_view(request): - """站点迁移页面""" - sg_id = request.session.get("pending_migration_sg_id") - if not sg_id: - return redirect("dashboard:index") - - from apps.dashboard.models import SiteGroup - - try: - site_group = SiteGroup.objects.get(pk=sg_id, is_active=True) - except SiteGroup.DoesNotExist: - request.session.pop("pending_migration_sg_id", None) - messages.error(request, "站点组不存在") - return redirect("dashboard:index") - - if request.method == "POST": - action = request.POST.get("action") - - if action == "confirm": - from .user_service import migrate_user_to_site_group - - result = migrate_user_to_site_group(request.user, site_group) - - if result["success"]: - request.session.pop("pending_migration_sg_id", None) - messages.success(request, f"已成功迁移到站点组「{site_group.name}」") - if request.user.is_staff or request.user.is_superuser: - return redirect("/admin/") - return redirect("dashboard:index") - elif result["reason"] == "email_not_compliant": - messages.warning(request, result["message"]) - else: - messages.error(request, result.get("reason", "迁移失败")) - return redirect("dashboard:index") - - elif action == "skip": - # 不迁移则退出登录 - request.session.pop("pending_migration_sg_id", None) - logout(request) - messages.info(request, "您已选择不迁移,已退出登录") - return redirect("accounts:login") - - # GET 或迁移失败后重新渲染 - # 只检查邮箱合规性,不执行迁移 - from utils.site_group import get_effective_config - from .models import UserEmail - - ec = get_effective_config(site_group) - suffix_data = ec.get_email_suffix_lists() - has_whitelist = bool(suffix_data["whitelist"]) - has_blacklist = bool(suffix_data["blacklist"]) - email_not_compliant = False - - if has_whitelist or has_blacklist: - user_emails = UserEmail.objects.filter(user=request.user) - email_list = list(user_emails.values_list("email", flat=True)) - if not email_list and request.user.email: - email_list = [request.user.email] - email_not_compliant = not any(ec.is_email_suffix_allowed(e) for e in email_list) - - context = { - "site_group_name": site_group.name, - "username": request.user.username, - "email_not_compliant": email_not_compliant, - } - context.update(get_theme_context()) - return render(request, "accounts/migrate.html", context) - - -def banned_view(request): - """封禁用户提示页面""" - if not request.user.is_authenticated: - return redirect("accounts:login") - - from .models import UserBan - - ban = UserBan.objects.filter(user=request.user).first() - if not ban: - request.session.pop("is_banned", None) - return redirect("dashboard:index") - - from apps.tickets.models import TicketCategory - - categories = TicketCategory.objects.filter( - is_active=True, allow_banned_users=True - ).order_by("display_order") - - context = { - "username": request.user.username, - "categories": categories, - "ban_reason": ban.reason, - "ban_time": ban.created_at, - } - context.update(get_theme_context()) - return render(request, "accounts/banned.html", context) diff --git a/apps/accounts/views_admin.py b/apps/accounts/views_admin.py deleted file mode 100644 index 3d09ea1..0000000 --- a/apps/accounts/views_admin.py +++ /dev/null @@ -1,380 +0,0 @@ -""" -超管后台视图 - -包含超管仪表盘视图,所有视图均使用 @admin_required 装饰器保护。 -超管可查看系统全局数据;站点组管理员可查看当前站点组数据; -提供商仅可查看自己相关的统计数据。 -""" - -from django.db.models import Q -from django.shortcuts import render -from django.contrib.auth import get_user_model - -from apps.accounts.provider_decorators import admin_required -from utils.provider import get_provider_hosts, get_provider_products - -User = get_user_model() - - -@admin_required -def admin_dashboard(request): - """ - 超管指挥中心视图 - - 渲染 admin_base/dashboard.html,传递任务导向的上下文数据。 - 设计理念:不是数据库 CRUD 界面,而是智能指挥中心。 - """ - from apps.hosts.models import Host, HostGroup - from apps.operations.models import ( - AccountOpeningRequest, - CloudComputerUser, - Product, - ProductGroup, - ProductInvitationToken, - ProductAccessGrant, - RdpDomainRoute, - ) - from apps.tickets.models import Ticket, TicketCategory - from apps.audit.models import AuditLog - - is_superuser = request.user.is_superuser - site_group = getattr(request, "site_group", None) - is_site_admin = ( - not is_superuser and site_group and request.user.is_site_group_admin(site_group) - ) - - provider_hosts = get_provider_hosts(request.user) - provider_products = get_provider_products(request.user) - - # === 基础统计(数据隔离) === - if is_superuser: - total_users = User.objects.count() - total_hosts = Host.objects.count() - total_hostgroups = HostGroup.objects.count() - total_products = Product.objects.count() - total_productgroups = ProductGroup.objects.count() - pending_requests = AccountOpeningRequest.objects.filter( - status="pending" - ).count() - total_cloud_users = CloudComputerUser.objects.filter(status="active").count() - active_tokens = ProductInvitationToken.objects.filter(is_active=True).count() - active_grants = ProductAccessGrant.objects.filter(is_revoked=False).count() - open_tickets = Ticket.objects.filter( - status__in=["pending", "processing", "waiting_feedback"] - ).count() - total_categories = TicketCategory.objects.count() - total_routes = RdpDomainRoute.objects.count() - total_audit_logs = AuditLog.objects.count() - elif is_site_admin: - sg = site_group - total_users = ( - User.objects.filter( - Q(provider_hosts__site_group=sg) | Q(created_products__site_group=sg) - ) - .distinct() - .count() - ) - total_hosts = Host.objects.filter(site_group=sg).count() - total_hostgroups = HostGroup.objects.filter(site_group=sg).count() - total_products = Product.objects.filter(site_group=sg).count() - total_productgroups = ProductGroup.objects.filter(site_group=sg).count() - pending_requests = AccountOpeningRequest.objects.filter( - status="pending", - target_product__in=Product.objects.filter(site_group=sg), - ).count() - total_cloud_users = CloudComputerUser.objects.filter( - status="active", - product__in=Product.objects.filter(site_group=sg), - ).count() - active_tokens = ProductInvitationToken.objects.filter( - is_active=True, - product__in=Product.objects.filter(site_group=sg), - ).count() - active_grants = ProductAccessGrant.objects.filter( - is_revoked=False, - product__in=Product.objects.filter(site_group=sg), - ).count() - open_tickets = ( - Ticket.objects.filter( - status__in=["pending", "processing", "waiting_feedback"], - ) - .filter(Q(related_cloud_computer__product__site_group=sg)) - .count() - ) - total_categories = TicketCategory.objects.count() - total_routes = RdpDomainRoute.objects.filter( - product__in=Product.objects.filter(site_group=sg), - ).count() - total_audit_logs = AuditLog.objects.filter( - Q(host__site_group=sg) | Q(host__isnull=True) - ).count() - else: - total_users = ( - User.objects.filter( - Q(cloud_users__product__in=provider_products) - | Q(provider_hosts__in=provider_hosts) - ) - .distinct() - .count() - ) - total_hosts = provider_hosts.count() - total_hostgroups = HostGroup.objects.filter(created_by=request.user).count() - total_products = provider_products.count() - total_productgroups = ProductGroup.objects.filter( - created_by=request.user - ).count() - pending_requests = AccountOpeningRequest.objects.filter( - status="pending", - target_product__in=provider_products, - ).count() - total_cloud_users = CloudComputerUser.objects.filter( - status="active", - product__in=provider_products, - ).count() - active_tokens = ProductInvitationToken.objects.filter( - is_active=True, - created_by=request.user, - ).count() - active_grants = ProductAccessGrant.objects.filter( - is_revoked=False, - product__in=provider_products, - ).count() - open_tickets = Ticket.objects.filter( - status__in=["pending", "processing", "waiting_feedback"], - related_cloud_computer__product__in=provider_products, - ).count() - total_categories = TicketCategory.objects.filter( - created_by=request.user - ).count() - total_routes = RdpDomainRoute.objects.filter( - product__in=provider_products, - ).count() - total_audit_logs = AuditLog.objects.filter(host__in=provider_hosts).count() - - # === 需要关注的事项 === - if is_superuser: - hosts_without_providers = Host.objects.filter(providers__isnull=True).count() - offline_hosts = Host.objects.filter(status="offline").count() - elif is_site_admin: - sg = site_group - sg_hosts = Host.objects.filter(site_group=sg) - hosts_without_providers = sg_hosts.filter(providers__isnull=True).count() - offline_hosts = sg_hosts.filter(status="offline").count() - else: - hosts_without_providers = provider_hosts.filter(providers__isnull=True).count() - offline_hosts = provider_hosts.filter(status="offline").count() - - attention_items = [] - if hosts_without_providers > 0: - attention_items.append( - { - "icon": "dns", - "description": f"{hosts_without_providers} 台主机未分配提供商", - "action_label": "分配", - "action_url": "admin:admin_providers:provider_host_list", - "severity": "warning", - } - ) - if pending_requests > 0: - attention_items.append( - { - "icon": "person_add", - "description": f"{pending_requests} 条开户申请待审批", - "action_label": "审批", - "action_url": "admin:admin_operations:request_list", - "severity": "warning", - } - ) - if open_tickets > 0: - attention_items.append( - { - "icon": "confirmation_number", - "description": f"{open_tickets} 个工单待处理", - "action_label": "处理", - "action_url": "admin:admin_tickets:ticket_list", - "severity": "warning", - } - ) - if offline_hosts > 0: - attention_items.append( - { - "icon": "cloud_off", - "description": f"{offline_hosts} 台主机离线", - "action_label": "查看", - "action_url": "admin:admin_hosts:host_list", - "severity": "error", - } - ) - - # === 快捷操作 === - quick_actions = [ - { - "label": "添加主机", - "icon": "dns", - "url": "admin:admin_hosts:host_create", - "variant": "filled", - }, - { - "label": "入驻提供商", - "icon": "person_add", - "url": "admin:admin_users:user_create", - "variant": "filled", - }, - { - "label": "审批申请", - "icon": "how_to_reg", - "url": "admin:admin_operations:request_list", - "variant": "filled", - "badge": pending_requests if pending_requests > 0 else None, - }, - { - "label": "查看工单", - "icon": "confirmation_number", - "url": "admin:admin_tickets:ticket_list", - "variant": "filled", - "badge": open_tickets if open_tickets > 0 else None, - }, - ] - - # === 系统健康状态 === - if is_superuser: - online_hosts = Host.objects.filter(status="online").count() - active_tunnels = Host.objects.filter(tunnel_status="online").count() - inactive_tunnels = ( - Host.objects.exclude(tunnel_status="no_tunnel") - .exclude(tunnel_status="online") - .count() - ) - elif is_site_admin: - sg = site_group - sg_hosts = Host.objects.filter(site_group=sg) - online_hosts = sg_hosts.filter(status="online").count() - active_tunnels = sg_hosts.filter(tunnel_status="online").count() - inactive_tunnels = ( - sg_hosts.exclude(tunnel_status="no_tunnel") - .exclude(tunnel_status="online") - .count() - ) - else: - online_hosts = provider_hosts.filter(status="online").count() - active_tunnels = provider_hosts.filter(tunnel_status="online").count() - inactive_tunnels = ( - provider_hosts.exclude(tunnel_status="no_tunnel") - .exclude(tunnel_status="online") - .count() - ) - - system_health = { - "online_hosts": online_hosts, - "offline_hosts": offline_hosts, - "total_hosts": total_hosts, - "active_tunnels": active_tunnels, - "inactive_tunnels": inactive_tunnels, - "active_users": total_cloud_users, - "active_products": total_products, - } - - # === 最近动态 === - if is_superuser: - recent_logs = AuditLog.objects.select_related("user", "host").order_by( - "-timestamp" - )[:10] - elif is_site_admin: - sg = site_group - recent_logs = ( - AuditLog.objects.filter(Q(host__site_group=sg) | Q(host__isnull=True)) - .select_related("user", "host") - .order_by("-timestamp")[:10] - ) - else: - recent_logs = ( - AuditLog.objects.filter(host__in=provider_hosts) - .select_related("user", "host") - .order_by("-timestamp")[:10] - ) - - # 为审计日志构建可读描述 - action_display_map = dict(AuditLog.ACTION_CHOICES) - recent_activities = [] - for log in recent_logs: - action_text = action_display_map.get(log.action, log.action) - description = action_text - if log.host: - description = f"{action_text} - {log.host.name}" - recent_activities.append( - { - "timestamp": log.timestamp, - "icon": _get_action_icon(log.action), - "description": description, - "actor": log.user.username if log.user else "系统", - "success": log.success, - } - ) - - context = { - "stats": { - "total_users": total_users, - "total_hosts": total_hosts, - "total_hostgroups": total_hostgroups, - "total_products": total_products, - "total_productgroups": total_productgroups, - "pending_requests": pending_requests, - "total_cloud_users": total_cloud_users, - "active_tokens": active_tokens, - "active_grants": active_grants, - "open_tickets": open_tickets, - "total_categories": total_categories, - "total_routes": total_routes, - "total_audit_logs": total_audit_logs, - }, - "attention_items": attention_items, - "quick_actions": quick_actions, - "recent_activities": recent_activities, - "system_health": system_health, - "page_title": "超管指挥中心", - "active_nav": "dashboard", - } - - return render(request, "admin_base/dashboard.html", context) - - -def _get_action_icon(action): - """根据审计操作类型返回对应的 Material Icon 名称""" - icon_map = { - "create_user": "person_add", - "delete_user": "person_remove", - "reset_password": "key", - "connect_host": "lan", - "modify_host": "edit", - "view_password": "visibility", - "approve_request": "check_circle", - "reject_request": "cancel", - "bootstrap_host": "rocket_launch", - "issue_cert": "verified", - "revoke_cert": "gpp_bad", - "create_host": "add_circle", - "delete_host": "remove_circle", - "update_host": "update", - "process_opening_request": "how_to_reg", - "batch_process_requests": "playlist_add_check", - "login": "login", - "logout": "logout", - "view_audit_log": "receipt_long", - "admin_action": "admin_panel_settings", - "tunnel_online": "link", - "tunnel_offline": "link_off", - "tunnel_heartbeat_timeout": "heart_broken", - "rdp_connect": "desktop_windows", - "rdp_disconnect": "desktop_access_disabled", - "remote_exec": "terminal", - "remote_exec_result": "terminal", - "domain_bind": "language", - "domain_unbind": "language", - "create_ticket": "add_task", - "update_ticket": "edit_note", - "assign_ticket": "assignment_ind", - "change_ticket_status": "swap_horiz", - "close_ticket": "task_alt", - "add_ticket_comment": "comment", - } - return icon_map.get(action, "circle") diff --git a/apps/accounts/views_admin_groups.py b/apps/accounts/views_admin_groups.py deleted file mode 100644 index 78ecc28..0000000 --- a/apps/accounts/views_admin_groups.py +++ /dev/null @@ -1,127 +0,0 @@ -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.contrib.auth.models import Group - -from apps.accounts.provider_decorators import superadmin_required -from apps.accounts.models import GroupProfile - - -@superadmin_required -def group_list(request): - group_profiles = GroupProfile.objects.select_related('group').order_by( - 'sort_order', 'group__name' - ) - unprofiled_groups = Group.objects.filter(profile__isnull=True).order_by( - 'name' - ) - - context = { - 'group_profiles': group_profiles, - 'unprofiled_groups': unprofiled_groups, - 'active_nav': 'groups', - } - return render(request, 'admin_base/groups/group_list.html', context) - - -@superadmin_required -def group_create(request): - if request.method == 'POST': - name = request.POST.get('name', '').strip() - description = request.POST.get('description', '').strip() - sort_order = request.POST.get('sort_order', 0) - auto_staff = request.POST.get('auto_staff') == 'on' - - if not name: - messages.error(request, '用户组名称不能为空') - return redirect('admin:admin_groups:group_create') - - if Group.objects.filter(name=name).exists(): - messages.error(request, f'用户组「{name}」已存在') - return redirect('admin:admin_groups:group_create') - - group = Group.objects.create(name=name) - GroupProfile.objects.create( - group=group, - is_default=False, - description=description, - auto_staff=auto_staff, - sort_order=int(sort_order), - ) - messages.success(request, f'用户组「{name}」创建成功') - return redirect('admin:admin_groups:group_list') - - context = { - 'is_create': True, - 'active_nav': 'groups', - } - return render(request, 'admin_base/groups/group_form.html', context) - - -@superadmin_required -def group_update(request, pk): - group_profile = get_object_or_404(GroupProfile, pk=pk) - - if request.method == 'POST': - name = request.POST.get('name', '').strip() - description = request.POST.get('description', '').strip() - sort_order = request.POST.get('sort_order', 0) - auto_staff = request.POST.get('auto_staff') == 'on' - - if not name: - messages.error(request, '用户组名称不能为空') - return redirect( - 'admin:admin_groups:group_edit', pk=group_profile.pk - ) - - if ( - Group.objects.filter(name=name) - .exclude(pk=group_profile.group.pk) - .exists() - ): - messages.error(request, f'用户组「{name}」已存在') - return redirect( - 'admin:admin_groups:group_edit', pk=group_profile.pk - ) - - group_profile.group.name = name - group_profile.group.save() - group_profile.description = description - group_profile.sort_order = int(sort_order) - group_profile.auto_staff = auto_staff - group_profile.save() - - for user in group_profile.group.user_set.all(): - user.sync_staff_status() - - messages.success(request, f'用户组「{name}」更新成功') - return redirect('admin:admin_groups:group_list') - - context = { - 'group_profile': group_profile, - 'is_create': False, - 'active_nav': 'groups', - } - return render(request, 'admin_base/groups/group_form.html', context) - - -@superadmin_required -def group_delete(request, pk): - group_profile = get_object_or_404(GroupProfile, pk=pk) - - if group_profile.is_default: - messages.error(request, '默认用户组不可删除') - return redirect('admin:admin_groups:group_list') - - if request.method == 'POST': - group_name = group_profile.group.name - group_profile.group.delete() - messages.success(request, f'用户组「{group_name}」已删除') - return redirect('admin:admin_groups:group_list') - - context = { - 'group_profile': group_profile, - 'active_nav': 'groups', - } - return render( - request, 'admin_base/groups/group_confirm_delete.html', context - ) diff --git a/apps/accounts/views_admin_reglinks.py b/apps/accounts/views_admin_reglinks.py deleted file mode 100644 index b1a4c4d..0000000 --- a/apps/accounts/views_admin_reglinks.py +++ /dev/null @@ -1,158 +0,0 @@ -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.contrib.auth.models import Group -from django.core.paginator import Paginator -from django.db.models import Q -from django.utils import timezone - -from apps.accounts.provider_decorators import superadmin_required -from apps.accounts.models import RegistrationLink - - -@superadmin_required -def reglink_list(request): - queryset = RegistrationLink.objects.select_related( - 'group', 'created_by', 'used_by' - ).order_by('-created_at') - - status_filter = request.GET.get('status', '').strip() - if status_filter == 'unused': - queryset = queryset.filter(used=False) - elif status_filter == 'used': - queryset = queryset.filter(used=True) - elif status_filter == 'expired': - queryset = queryset.filter(used=False, expires_at__lt=timezone.now()) - - group_filter = request.GET.get('group', '').strip() - if group_filter: - queryset = queryset.filter(group_id=group_filter) - - search = request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(note__icontains=search) | Q(token__icontains=search) - ) - - all_groups = Group.objects.select_related('profile').order_by('name') - - paginator = Paginator(queryset, 25) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'all_groups': all_groups, - 'status_filter': status_filter, - 'group_filter': group_filter, - 'search': search, - 'active_nav': 'reglinks', - } - return render(request, 'admin_base/reglinks/reglink_list.html', context) - - -@superadmin_required -def reglink_create(request): - all_groups = Group.objects.select_related('profile').order_by('name') - - if request.method == 'POST': - group_id = request.POST.get('group', '').strip() - expires_at_str = request.POST.get('expires_at', '').strip() - max_uses_str = request.POST.get('max_uses', '').strip() - note = request.POST.get('note', '').strip() - - if not group_id: - messages.error(request, '请选择注册后的用户组') - context = { - 'all_groups': all_groups, - 'active_nav': 'reglinks', - } - return render(request, 'admin_base/reglinks/reglink_form.html', context) - - try: - group = Group.objects.get(pk=group_id) - except Group.DoesNotExist: - messages.error(request, '所选用户组不存在') - context = { - 'all_groups': all_groups, - 'active_nav': 'reglinks', - } - return render(request, 'admin_base/reglinks/reglink_form.html', context) - - max_uses = 1 - if max_uses_str: - try: - max_uses = int(max_uses_str) - if max_uses < 0: - raise ValueError - except (ValueError, TypeError): - messages.error(request, '最大使用次数必须为非负整数') - context = { - 'all_groups': all_groups, - 'active_nav': 'reglinks', - } - return render( - request, 'admin_base/reglinks/reglink_form.html', context - ) - - expires_at = None - if expires_at_str: - try: - expires_at = timezone.datetime.fromisoformat(expires_at_str) - if timezone.is_naive(expires_at): - expires_at = timezone.make_aware(expires_at) - except (ValueError, TypeError): - messages.error(request, '过期时间格式不正确') - context = { - 'all_groups': all_groups, - 'active_nav': 'reglinks', - } - return render( - request, 'admin_base/reglinks/reglink_form.html', context - ) - - reglink = RegistrationLink.objects.create( - group=group, - created_by=request.user, - max_uses=max_uses, - expires_at=expires_at, - note=note, - ) - - messages.success(request, f'注册链接创建成功,用户将加入「{group.name}」组') - return redirect('admin:admin_reglinks:reglink_list') - - context = { - 'all_groups': all_groups, - 'active_nav': 'reglinks', - } - return render(request, 'admin_base/reglinks/reglink_form.html', context) - - -@superadmin_required -def reglink_delete(request, pk): - reglink = get_object_or_404(RegistrationLink, pk=pk) - - if reglink.is_exhausted: - messages.error(request, '已用完的注册链接不可删除') - return redirect('admin:admin_reglinks:reglink_list') - - if request.method == 'POST': - reglink.delete() - messages.success(request, '注册链接已删除') - return redirect('admin:admin_reglinks:reglink_list') - - context = { - 'reglink': reglink, - 'active_nav': 'reglinks', - } - return render(request, 'admin_base/reglinks/reglink_confirm_delete.html', context) - - -@superadmin_required -def reglink_copy_url(request, pk): - reglink = get_object_or_404(RegistrationLink, pk=pk) - from django.urls import reverse - url = request.build_absolute_uri( - reverse('accounts:register_by_link', kwargs={'token': reglink.token}) - ) - return {'url': url} diff --git a/apps/accounts/views_admin_users.py b/apps/accounts/views_admin_users.py deleted file mode 100644 index 6ebdfdc..0000000 --- a/apps/accounts/views_admin_users.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -超管后台 - 用户管理视图 - -所有视图均使用 @superadmin_required 装饰器保护。 -超管后台无数据隔离,可查看系统全局数据。 -""" - -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.core.paginator import Paginator -from django.db.models import Q - -from apps.accounts.provider_decorators import superadmin_required -from apps.accounts.models import UserBan -from apps.accounts.user_service import ban_user, unban_user -from .forms_admin import ( - AdminUserCreateForm, - AdminUserUpdateForm, - AdminPasswordResetForm, -) - -User = get_user_model() - - -@superadmin_required -def user_list(request): - """ - 用户列表视图 - - 支持按用户名/邮箱/姓名搜索,按 is_staff/is_active 筛选。 - 显示用户组信息,分页展示。 - """ - queryset = User.objects.prefetch_related( - 'groups' - ).select_related('active_ban').order_by('-created_at') - - search = request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(username__icontains=search) - | Q(email__icontains=search) - | Q(first_name__icontains=search) - | Q(last_name__icontains=search) - ) - - staff_filter = request.GET.get('is_staff', '').strip() - if staff_filter == '1': - queryset = queryset.filter(is_staff=True) - elif staff_filter == '0': - queryset = queryset.filter(is_staff=False) - - active_filter = request.GET.get('is_active', '').strip() - if active_filter == '1': - queryset = queryset.filter(is_active=True) - elif active_filter == '0': - queryset = queryset.filter(is_active=False) - - paginator = Paginator(queryset, 25) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'search': search, - 'staff_filter': staff_filter, - 'active_filter': active_filter, - 'active_nav': 'users', - } - - return render(request, 'admin_base/users/user_list.html', context) - - -@superadmin_required -def user_create(request): - """创建用户视图""" - if request.method == 'POST': - form = AdminUserCreateForm(request.POST) - if form.is_valid(): - user = form.save() - messages.success(request, f'用户「{user.username}」创建成功') - return redirect('admin:admin_users:user_list') - else: - form = AdminUserCreateForm() - - context = { - 'form': form, - 'is_create': True, - 'active_nav': 'users', - } - - return render(request, 'admin_base/users/user_form.html', context) - - -@superadmin_required -def user_update(request, pk): - """编辑用户视图""" - user = get_object_or_404(User, pk=pk) - - if request.method == 'POST': - form = AdminUserUpdateForm(request.POST, instance=user) - if form.is_valid(): - form.save() - messages.success( - request, f'用户「{user.username}」更新成功' - ) - return redirect('admin:admin_users:user_list') - else: - form = AdminUserUpdateForm(instance=user) - - context = { - 'form': form, - 'target_user': user, - 'is_create': False, - 'active_nav': 'users', - } - - return render(request, 'admin_base/users/user_form.html', context) - - -@superadmin_required -def user_delete(request, pk): - """删除用户视图(含自删除保护)""" - user = get_object_or_404(User, pk=pk) - - if user.pk == request.user.pk: - messages.error(request, '不能删除自己的账号') - return redirect('admin:admin_users:user_list') - - if request.method == 'POST': - username = user.username - user.delete() - messages.success(request, f'用户「{username}」已删除') - return redirect('admin:admin_users:user_list') - - context = { - 'target_user': user, - 'active_nav': 'users', - } - - return render( - request, 'admin_base/users/user_confirm_delete.html', context - ) - - -@superadmin_required -def user_toggle_active(request, pk): - """切换用户封禁状态(POST 操作,完成后重定向回列表)""" - user = get_object_or_404(User, pk=pk) - - if user.pk == request.user.pk: - messages.error(request, '不能封禁自己的账号') - return redirect('admin:admin_users:user_list') - - if request.method == 'POST': - is_banned = UserBan.objects.filter(user=user).exists() - if is_banned: - # 解封 - unban_user(user, unbanned_by=request.user) - messages.success(request, f'用户「{user.username}」已解封') - else: - # 封禁 - reason = request.POST.get('ban_reason', '').strip() - ban_user(user, reason=reason, banned_by=request.user) - messages.success(request, f'用户「{user.username}」已封禁') - - return redirect('admin:admin_users:user_list') - - -@superadmin_required -def user_reset_password(request, pk): - """重置用户密码视图""" - user = get_object_or_404(User, pk=pk) - - if request.method == 'POST': - form = AdminPasswordResetForm(request.POST) - if form.is_valid(): - user.set_password(form.cleaned_data['new_password1']) - user.save() - messages.success( - request, f'用户「{user.username}」密码已重置' - ) - return redirect('admin:admin_users:user_list') - else: - form = AdminPasswordResetForm() - - context = { - 'form': form, - 'target_user': user, - 'active_nav': 'users', - } - - return render( - request, 'admin_base/users/user_reset_password.html', context - ) diff --git a/apps/accounts/views_provider.py b/apps/accounts/views_provider.py deleted file mode 100644 index df18768..0000000 --- a/apps/accounts/views_provider.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -提供商后台视图 - -包含仪表盘视图,所有视图均使用 @provider_required 装饰器保护。 -统计数据通过 utils.provider 中的函数获取,确保数据隔离的一致性。 -""" - -from django.shortcuts import render - -from apps.accounts.provider_decorators import provider_required -from utils.provider import get_provider_hosts, get_provider_products - - -@provider_required -def provider_dashboard(request): - """ - 提供商仪表盘视图 - - 渲染 provider/dashboard.html,传递统计数据到模板。 - 所有统计均按 request.user 进行数据隔离。 - """ - user = request.user - - from apps.operations.models import AccountOpeningRequest, CloudComputerUser - - host_count = get_provider_hosts(user).count() - product_count = get_provider_products(user).count() - pending_request_count = AccountOpeningRequest.objects.filter( - target_product__created_by=user, status='pending' - ).count() - active_user_count = CloudComputerUser.objects.filter( - product__created_by=user, status='active' - ).count() - - context = { - 'host_count': host_count, - 'product_count': product_count, - 'pending_request_count': pending_request_count, - 'active_user_count': active_user_count, - 'stats': { - 'host_count': host_count, - 'product_count': product_count, - 'pending_request_count': pending_request_count, - 'active_user_count': active_user_count, - }, - 'page_title': '仪表盘', - 'active_nav': 'dashboard', - } - - return render(request, 'admin_base/provider/dashboard.html', context) diff --git a/apps/accounts/views_superadmin.py b/apps/accounts/views_superadmin.py deleted file mode 100644 index eb81c16..0000000 --- a/apps/accounts/views_superadmin.py +++ /dev/null @@ -1,175 +0,0 @@ -""" -超级管理员视图 - -用于超级管理员分配提供商给主机和主机组。 -所有视图均使用 @superadmin_required 装饰器保护。 -""" - -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.core.paginator import Paginator - -from apps.accounts.provider_decorators import superadmin_required -from apps.accounts.forms_superadmin import ( - HostProviderAssignForm, - HostGroupProviderAssignForm, -) -from apps.hosts.models import Host, HostGroup -from apps.hosts.views_admin import _get_permission_context - - -@superadmin_required -def superadmin_host_list(request): - """ - 超级管理员 - 主机列表视图 - - 显示所有主机及其已分配的提供商,支持搜索和筛选。 - """ - hosts = Host.objects.select_related('created_by').prefetch_related( - 'providers' - ).order_by('-created_at') - - # 搜索 - search = request.GET.get('search', '').strip() - if search: - hosts = hosts.filter( - name__icontains=search - ) | hosts.filter( - hostname__icontains=search - ) - # 重新排序,因为 OR 查询会丢失排序 - hosts = hosts.order_by('-created_at') - - # 状态筛选 - status_filter = request.GET.get('status', '').strip() - if status_filter: - hosts = hosts.filter(status=status_filter) - - # 连接类型筛选 - connection_type_filter = request.GET.get('connection_type', '').strip() - if connection_type_filter: - hosts = hosts.filter(connection_type=connection_type_filter) - - # 分页 - paginator = Paginator(hosts, 20) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'hosts': page_obj, - 'search': search, - 'status_filter': status_filter, - 'connection_type_filter': connection_type_filter, - 'status_choices': Host.STATUS_CHOICES, - 'connection_type_choices': Host.CONNECTION_TYPE_CHOICES, - 'active_nav': 'provider_hosts', - } - - return render(request, 'admin_base/providers/host_list.html', context) - - -@superadmin_required -def superadmin_host_provider_assign(request, pk): - """ - 超级管理员 - 分配提供商给主机 - - 允许超级管理员为主机分配或移除提供商。 - """ - host = get_object_or_404(Host, pk=pk) - - if request.method == 'POST': - form = HostProviderAssignForm(request.POST, host=host) - if form.is_valid(): - providers = form.cleaned_data['providers'] - host.providers.set(providers) - messages.success( - request, - f'已成功更新主机「{host.name}」的提供商分配,' - f'当前分配 {providers.count()} 个提供商。' - ) - return redirect('admin:admin_providers:provider_host_list') - else: - form = HostProviderAssignForm(host=host) - - context = { - 'host': host, - 'form': form, - 'current_providers': host.providers.all(), - 'active_nav': 'provider_hosts', - } - context.update(_get_permission_context(form, host)) - - return render( - request, 'admin_base/providers/host_provider_assign.html', context - ) - - -@superadmin_required -def superadmin_hostgroup_list(request): - """ - 超级管理员 - 主机组列表视图 - - 显示所有主机组及其已分配的提供商,支持搜索。 - """ - hostgroups = HostGroup.objects.select_related( - 'created_by' - ).prefetch_related('providers', 'hosts').order_by('-created_at') - - # 搜索 - search = request.GET.get('search', '').strip() - if search: - hostgroups = hostgroups.filter(name__icontains=search) - - # 分页 - paginator = Paginator(hostgroups, 20) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'hostgroups': page_obj, - 'search': search, - 'active_nav': 'provider_hosts', - } - - return render( - request, 'admin_base/providers/hostgroup_list.html', context - ) - - -@superadmin_required -def superadmin_hostgroup_provider_assign(request, pk): - """ - 超级管理员 - 分配提供商给主机组 - - 允许超级管理员为主机组分配或移除提供商。 - """ - hostgroup = get_object_or_404(HostGroup, pk=pk) - - if request.method == 'POST': - form = HostGroupProviderAssignForm(request.POST, hostgroup=hostgroup) - if form.is_valid(): - providers = form.cleaned_data['providers'] - hostgroup.providers.set(providers) - messages.success( - request, - f'已成功更新主机组「{hostgroup.name}」的提供商分配,' - f'当前分配 {providers.count()} 个提供商。' - ) - return redirect('admin:admin_providers:provider_hostgroup_list') - else: - form = HostGroupProviderAssignForm(hostgroup=hostgroup) - - context = { - 'hostgroup': hostgroup, - 'form': form, - 'current_providers': hostgroup.providers.all(), - 'active_nav': 'provider_hosts', - } - - return render( - request, - 'admin_base/providers/hostgroup_provider_assign.html', - context, - ) diff --git a/apps/audit/admin.py b/apps/audit/admin.py deleted file mode 100644 index adb01a3..0000000 --- a/apps/audit/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (apps.audit.views_admin) diff --git a/apps/audit/apps.py b/apps/audit/apps.py deleted file mode 100755 index 64c17b9..0000000 --- a/apps/audit/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.apps import AppConfig - - -class AuditConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.audit' - verbose_name = '审计日志系统' - - def ready(self): - # 导入信号处理器 - import apps.audit.signals \ No newline at end of file diff --git a/apps/audit/decorators.py b/apps/audit/decorators.py deleted file mode 100755 index 56f6399..0000000 --- a/apps/audit/decorators.py +++ /dev/null @@ -1,281 +0,0 @@ -from functools import wraps -from .models import AuditLog, SensitiveOperation, SecurityEvent -from django.contrib.auth.models import User -from apps.hosts.models import Host -from utils.helpers import get_client_ip -import json -import logging -from django.http import JsonResponse -from django.core.exceptions import PermissionDenied - -logger = logging.getLogger(__name__) - - -def audit_log(action, host_param=None, details_extractor=None, related_object_param=None): - """ - 审计日志装饰器 - :param action: 操作类型 - :param host_param: 从参数中提取主机对象的参数名 - :param details_extractor: 从参数中提取详细信息的函数 - :param related_object_param: 从参数中提取关联对象的参数名(用于通用外键) - """ - def decorator(view_func): - @wraps(view_func) - def wrapper(request, *args, **kwargs): - response = None - success = True - error_msg = None - - try: - response = view_func(request, *args, **kwargs) - except Exception as e: - success = False - error_msg = str(e) - raise - finally: - # 记录审计日志 - try: - user = request.user if request.user.is_authenticated else None - - # 获取主机对象 - host = None - if host_param and kwargs.get(host_param): - host_id = kwargs[host_param] - if isinstance(host_id, Host): - host = host_id - elif isinstance(host_id, int): - host = Host.objects.filter(id=host_id).first() - - # 获取关联对象(用于通用外键) - content_object = None - if related_object_param and kwargs.get(related_object_param): - obj_id = kwargs[related_object_param] - obj_type = None - if isinstance(obj_id, str) and '.' in obj_id: - app_label, model = obj_id.split('.') - from django.apps import apps - obj_type = apps.get_model(app_label, model) - # 这里可以根据实际需要扩展对象类型识别逻辑 - - # 提取操作详情 - details = {} - if details_extractor: - details = details_extractor(request, *args, **kwargs) - else: - # 默认提取一些基本信息 - details = { - 'method': request.method, - 'path': request.path, - 'user_agent': request.META.get('HTTP_USER_AGENT', ''), - } - - AuditLog.objects.create( - user=user, - host=host, - action=action, - ip_address=get_client_ip(request), - success=success, - details=details, - result=error_msg, - content_object=content_object - ) - except Exception as log_error: - # 审计日志记录失败不应该影响主业务 - logger.error(f"Audit logging failed: {log_error}", exc_info=True) - - return response - return wrapper - return decorator - - -def log_sensitive_operation(operation_type, justification_required=True, response_on_missing_justification=None): - """ - 敏感操作日志装饰器 - :param operation_type: 操作类型 - :param justification_required: 是否需要提供操作理由 - :param response_on_missing_justification: 缺少理由时的响应 - """ - def decorator(view_func): - @wraps(view_func) - def wrapper(request, *args, **kwargs): - if justification_required: - justification = ( - request.POST.get('justification') or - request.GET.get('justification') or - request.META.get('HTTP_X_JUSTIFICATION') or - getattr(request, 'data', {}).get('justification') # 对于DRF - ) - if not justification: - if response_on_missing_justification: - return response_on_missing_justification - else: - raise PermissionDenied("此操作需要提供操作理由") - - response = None - error_occurred = False - try: - response = view_func(request, *args, **kwargs) - except Exception as e: - error_occurred = True - raise - finally: - try: - # 记录敏感操作 - SensitiveOperation.objects.create( - operation_type=operation_type, - user=request.user if request.user.is_authenticated else None, - target=str(args) + str(kwargs), - ip_address=get_client_ip(request), - justification=justification or "N/A", - result=str(response) if response and hasattr(response, '__str__') else "Completed" if not error_occurred else "Failed" - ) - except Exception as log_error: - logger.error(f"Sensitive operation logging failed: {log_error}", exc_info=True) - - return response - return wrapper - return decorator - - -def security_event_logger(event_type, severity='medium', auto_resolve_threshold=5): - """ - 安全事件记录装饰器 - :param event_type: 事件类型 - :param severity: 严重程度 - :param auto_resolve_threshold: 自动解决阈值(相同IP同类型事件数量) - """ - def decorator(view_func): - @wraps(view_func) - def wrapper(request, *args, **kwargs): - ip_address = get_client_ip(request) - - # 检查是否有未解决的同类事件 - recent_events = SecurityEvent.objects.filter( - event_type=event_type, - ip_address=ip_address, - resolved=False - ).order_by('-timestamp') - - # 如果超过阈值,自动标记为已解决 - if recent_events.count() >= auto_resolve_threshold: - recent_events.update(resolved=True, resolved_at=timezone.now()) - - try: - response = view_func(request, *args, **kwargs) - except Exception as e: - # 记录安全事件 - SecurityEvent.objects.create( - event_type=event_type, - severity=severity, - user=request.user if request.user.is_authenticated else None, - ip_address=ip_address, - description=str(e) if str(e) != '' else f"Security event occurred during {view_func.__name__}", - ) - raise - - # 对于某些类型的事件,即使成功也要记录 - if event_type in ['failed_login']: # 这种情况不太可能,但我们保留这个逻辑 - pass # 不记录成功的登录为安全事件 - - return response - return wrapper - return decorator - - -def log_user_session_activity(view_func): - """ - 记录用户会话活动的装饰器 - """ - @wraps(view_func) - def wrapper(request, *args, **kwargs): - from .models import SessionActivity - from django.contrib.sessions.models import Session - - session_key = request.session.session_key - user = request.user if request.user.is_authenticated else None - - if user and session_key: - # 检查是否已有对应的会话活动记录 - session_activity, created = SessionActivity.objects.get_or_create( - session_key=session_key, - user=user, - is_active=True, - defaults={ - 'ip_address': get_client_ip(request), - 'user_agent': request.META.get('HTTP_USER_AGENT', '')[:500], # 限制长度 - } - ) - - response = view_func(request, *args, **kwargs) - return response - return wrapper - - -# 辅助函数:批量记录审计日志 -def bulk_audit_log(entries): - """ - 批量记录审计日志 - :param entries: 日志条目列表,每个条目是一个字典 - """ - audit_logs = [] - for entry in entries: - audit_logs.append(AuditLog( - user=entry.get('user'), - host=entry.get('host'), - action=entry['action'], - ip_address=entry.get('ip_address'), - success=entry.get('success', True), - details=entry.get('details', {}), - result=entry.get('result') - )) - - AuditLog.objects.bulk_create(audit_logs) - - -# Django信号处理器辅助函数 -def log_model_change(sender, instance, created, **kwargs): - """ - 通用模型变更日志记录函数 - 可以作为Django信号的处理器使用 - """ - from django.contrib.contenttypes.models import ContentType - - user = getattr(instance, '_audit_user', None) # 从实例获取操作用户(需要在视图中设置) - action = 'create' if created else 'update' - ip_address = getattr(instance, '_audit_ip', None) # 从实例获取IP地址 - - AuditLog.objects.create( - user=user, - action=action, - ip_address=ip_address, - details={ - 'model': sender._meta.label, - 'pk': instance.pk, - 'fields_changed': getattr(instance, '_fields_changed', []) - }, - content_type=ContentType.objects.get_for_model(sender), - object_id=instance.pk - ) - - -def log_model_deletion(sender, instance, **kwargs): - """ - 通用模型删除日志记录函数 - 可以作为Django信号的处理器使用 - """ - from django.contrib.contenttypes.models import ContentType - - user = getattr(instance, '_audit_user', None) # 从实例获取操作用户 - ip_address = getattr(instance, '_audit_ip', None) # 从实例获取IP地址 - - AuditLog.objects.create( - user=user, - action='delete', - ip_address=ip_address, - details={ - 'model': sender._meta.label, - 'pk': instance.pk, - }, - content_type=ContentType.objects.get_for_model(sender), - object_id=instance.pk - ) \ No newline at end of file diff --git a/apps/audit/forms_admin.py b/apps/audit/forms_admin.py deleted file mode 100644 index ef90566..0000000 --- a/apps/audit/forms_admin.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -审计日志超级管理员表单 - -审计日志为只读模型,无需创建/编辑表单。 -此文件保留用于未来可能的筛选表单扩展。 -""" - -# AuditLog 为只读模型,不需要表单 diff --git a/apps/audit/migrations/0001_initial.py b/apps/audit/migrations/0001_initial.py deleted file mode 100755 index 6885d1a..0000000 --- a/apps/audit/migrations/0001_initial.py +++ /dev/null @@ -1,104 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-01 11:58 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('hosts', '0005_host_connection_type_alter_host_port'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='SessionActivity', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('session_key', models.CharField(max_length=40, verbose_name='会话密钥')), - ('ip_address', models.GenericIPAddressField(verbose_name='IP地址')), - ('user_agent', models.TextField(verbose_name='用户代理')), - ('login_time', models.DateTimeField(auto_now_add=True, verbose_name='登录时间')), - ('logout_time', models.DateTimeField(blank=True, null=True, verbose_name='登出时间')), - ('is_active', models.BooleanField(default=True, verbose_name='是否活跃')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), - ], - options={ - 'verbose_name': '会话活动', - 'verbose_name_plural': '会话活动', - 'db_table': 'session_activity', - 'ordering': ['-login_time'], - }, - ), - migrations.CreateModel( - name='SensitiveOperation', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('operation_type', models.CharField(max_length=50, verbose_name='操作类型')), - ('target', models.CharField(max_length=255, verbose_name='操作目标')), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='操作时间')), - ('ip_address', models.GenericIPAddressField(verbose_name='操作IP')), - ('justification', models.TextField(verbose_name='操作理由')), - ('approved_at', models.DateTimeField(null=True, verbose_name='批准时间')), - ('result', models.TextField(blank=True, null=True, verbose_name='操作结果')), - ('approved_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_sensitive_ops', to=settings.AUTH_USER_MODEL, verbose_name='批准人')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='操作用户')), - ], - options={ - 'verbose_name': '敏感操作', - 'verbose_name_plural': '敏感操作', - 'db_table': 'sensitive_operation', - 'ordering': ['-timestamp'], - }, - ), - migrations.CreateModel( - name='SecurityEvent', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('event_type', models.CharField(choices=[('unauthorized_access', '未授权访问'), ('failed_login', '登录失败'), ('suspicious_activity', '可疑活动'), ('data_exposure', '数据暴露风险'), ('privilege_escalation', '权限提升尝试'), ('cert_compromise', '证书泄露'), ('brute_force', '暴力破解')], max_length=50, verbose_name='事件类型')), - ('severity', models.CharField(choices=[('low', '低'), ('medium', '中'), ('high', '高'), ('critical', '严重')], default='medium', max_length=10, verbose_name='严重程度')), - ('ip_address', models.GenericIPAddressField(verbose_name='事件IP')), - ('description', models.TextField(verbose_name='事件描述')), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='发生时间')), - ('resolved', models.BooleanField(default=False, verbose_name='已解决')), - ('resolved_at', models.DateTimeField(null=True, verbose_name='解决时间')), - ('resolution_notes', models.TextField(blank=True, null=True, verbose_name='解决备注')), - ('resolved_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_security_events', to=settings.AUTH_USER_MODEL, verbose_name='解决人')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='关联用户')), - ], - options={ - 'verbose_name': '安全事件', - 'verbose_name_plural': '安全事件', - 'db_table': 'security_event', - 'ordering': ['-timestamp'], - }, - ), - migrations.CreateModel( - name='AuditLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('action', models.CharField(choices=[('create_user', '创建用户'), ('delete_user', '删除用户'), ('reset_password', '重置密码'), ('connect_host', '连接主机'), ('modify_host', '修改主机'), ('view_password', '查看密码'), ('approve_request', '审批请求'), ('reject_request', '拒绝请求'), ('bootstrap_host', '初始化主机'), ('issue_cert', '签发证书'), ('revoke_cert', '吊销证书'), ('create_host', '创建主机'), ('delete_host', '删除主机'), ('update_host', '更新主机'), ('process_opening_request', '处理开户请求'), ('batch_process_requests', '批量处理请求'), ('login', '用户登录'), ('logout', '用户登出'), ('view_audit_log', '查看审计日志'), ('admin_action', '管理员操作')], max_length=50, verbose_name='操作类型')), - ('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='操作IP地址')), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='操作时间')), - ('success', models.BooleanField(default=True, verbose_name='操作成功')), - ('details', models.JSONField(default=dict, verbose_name='操作详情')), - ('result', models.TextField(blank=True, null=True, verbose_name='操作结果')), - ('object_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='关联对象ID')), - ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype', verbose_name='关联对象类型')), - ('host', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='hosts.host', verbose_name='操作主机')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='操作用户')), - ], - options={ - 'verbose_name': '审计日志', - 'verbose_name_plural': '审计日志', - 'db_table': 'audit_log', - 'ordering': ['-timestamp'], - 'indexes': [models.Index(fields=['user', 'timestamp'], name='audit_log_user_id_835db7_idx'), models.Index(fields=['host', 'timestamp'], name='audit_log_host_id_51b348_idx'), models.Index(fields=['action', 'timestamp'], name='audit_log_action_09d227_idx'), models.Index(fields=['timestamp'], name='audit_log_timesta_e8e14e_idx')], - }, - ), - ] diff --git a/apps/audit/migrations/0002_auditlog_tunnel_actions.py b/apps/audit/migrations/0002_auditlog_tunnel_actions.py deleted file mode 100644 index cfa7888..0000000 --- a/apps/audit/migrations/0002_auditlog_tunnel_actions.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('audit', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='auditlog', - name='action', - field=models.CharField( - choices=[ - ('create_user', '创建用户'), - ('delete_user', '删除用户'), - ('reset_password', '重置密码'), - ('connect_host', '连接主机'), - ('modify_host', '修改主机'), - ('view_password', '查看密码'), - ('approve_request', '审批请求'), - ('reject_request', '拒绝请求'), - ('bootstrap_host', '初始化主机'), - ('issue_cert', '签发证书'), - ('revoke_cert', '吊销证书'), - ('create_host', '创建主机'), - ('delete_host', '删除主机'), - ('update_host', '更新主机'), - ('process_opening_request', '处理开户请求'), - ('batch_process_requests', '批量处理请求'), - ('login', '用户登录'), - ('logout', '用户登出'), - ('view_audit_log', '查看审计日志'), - ('admin_action', '管理员操作'), - ('tunnel_online', '隧道上线'), - ('tunnel_offline', '隧道离线'), - ('tunnel_heartbeat_timeout', '隧道心跳超时'), - ('rdp_connect', 'RDP连接'), - ('rdp_disconnect', 'RDP断开'), - ('remote_exec', '远程执行命令'), - ('remote_exec_result', '远程执行结果'), - ('domain_bind', '域名绑定'), - ('domain_unbind', '域名解绑'), - ], - max_length=50, verbose_name='操作类型' - ), - ), - ] diff --git a/apps/audit/migrations/0003_alter_auditlog_action.py b/apps/audit/migrations/0003_alter_auditlog_action.py deleted file mode 100644 index d09dc53..0000000 --- a/apps/audit/migrations/0003_alter_auditlog_action.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-26 15:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('audit', '0002_auditlog_tunnel_actions'), - ] - - operations = [ - migrations.AlterField( - model_name='auditlog', - name='action', - field=models.CharField(choices=[('create_user', '创建用户'), ('delete_user', '删除用户'), ('reset_password', '重置密码'), ('connect_host', '连接主机'), ('modify_host', '修改主机'), ('view_password', '查看密码'), ('approve_request', '审批请求'), ('reject_request', '拒绝请求'), ('bootstrap_host', '初始化主机'), ('issue_cert', '签发证书'), ('revoke_cert', '吊销证书'), ('create_host', '创建主机'), ('delete_host', '删除主机'), ('update_host', '更新主机'), ('process_opening_request', '处理开户请求'), ('batch_process_requests', '批量处理请求'), ('login', '用户登录'), ('logout', '用户登出'), ('view_audit_log', '查看审计日志'), ('admin_action', '管理员操作'), ('tunnel_online', '隧道上线'), ('tunnel_offline', '隧道离线'), ('tunnel_heartbeat_timeout', '隧道心跳超时'), ('rdp_connect', 'RDP连接'), ('rdp_disconnect', 'RDP断开'), ('remote_exec', '远程执行命令'), ('remote_exec_result', '远程执行结果'), ('domain_bind', '域名绑定'), ('domain_unbind', '域名解绑'), ('create_ticket', '创建工单'), ('update_ticket', '更新工单'), ('assign_ticket', '分配工单'), ('change_ticket_status', '变更工单状态'), ('close_ticket', '关闭工单'), ('add_ticket_comment', '添加工单评论')], max_length=50, verbose_name='操作类型'), - ), - ] diff --git a/apps/audit/migrations/0004_auditlog_user_agent_alter_auditlog_action.py b/apps/audit/migrations/0004_auditlog_user_agent_alter_auditlog_action.py deleted file mode 100644 index e88ee86..0000000 --- a/apps/audit/migrations/0004_auditlog_user_agent_alter_auditlog_action.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-01 14:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('audit', '0003_alter_auditlog_action'), - ] - - operations = [ - migrations.AddField( - model_name='auditlog', - name='user_agent', - field=models.TextField(blank=True, verbose_name='用户代理'), - ), - migrations.AlterField( - model_name='auditlog', - name='action', - field=models.CharField(choices=[('create_user', '创建用户'), ('delete_user', '删除用户'), ('reset_password', '重置密码'), ('connect_host', '连接主机'), ('modify_host', '修改主机'), ('view_password', '查看密码'), ('approve_request', '审批请求'), ('reject_request', '拒绝请求'), ('bootstrap_host', '初始化主机'), ('issue_cert', '签发证书'), ('revoke_cert', '吊销证书'), ('create_host', '创建主机'), ('delete_host', '删除主机'), ('update_host', '更新主机'), ('process_opening_request', '处理开户请求'), ('batch_process_requests', '批量处理请求'), ('login', '用户登录'), ('logout', '用户登出'), ('view_audit_log', '查看审计日志'), ('admin_action', '管理员操作'), ('tunnel_online', '隧道上线'), ('tunnel_offline', '隧道离线'), ('tunnel_heartbeat_timeout', '隧道心跳超时'), ('rdp_connect', 'RDP连接'), ('rdp_disconnect', 'RDP断开'), ('remote_exec', '远程执行命令'), ('remote_exec_result', '远程执行结果'), ('domain_bind', '域名绑定'), ('domain_unbind', '域名解绑'), ('create_ticket', '创建工单'), ('update_ticket', '更新工单'), ('assign_ticket', '分配工单'), ('change_ticket_status', '变更工单状态'), ('close_ticket', '关闭工单'), ('add_ticket_comment', '添加工单评论'), ('dashboard_view', '访问仪表盘'), ('system_config_update', '更新系统配置')], max_length=50, verbose_name='操作类型'), - ), - ] diff --git a/apps/audit/migrations/__init__.py b/apps/audit/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/audit/models.py b/apps/audit/models.py deleted file mode 100755 index 9ddc99f..0000000 --- a/apps/audit/models.py +++ /dev/null @@ -1,222 +0,0 @@ -from django.db import models -from apps.hosts.models import Host -from apps.operations.models import AccountOpeningRequest, CloudComputerUser -from django.contrib.contenttypes.models import ContentType -from django.contrib.contenttypes.fields import GenericForeignKey - - -class AuditLog(models.Model): - """审计日志模型""" - ACTION_CHOICES = [ - ('create_user', '创建用户'), - ('delete_user', '删除用户'), - ('reset_password', '重置密码'), - ('connect_host', '连接主机'), - ('modify_host', '修改主机'), - ('view_password', '查看密码'), - ('approve_request', '审批请求'), - ('reject_request', '拒绝请求'), - ('bootstrap_host', '初始化主机'), - ('issue_cert', '签发证书'), - ('revoke_cert', '吊销证书'), - ('create_host', '创建主机'), - ('delete_host', '删除主机'), - ('update_host', '更新主机'), - ('process_opening_request', '处理开户请求'), - ('batch_process_requests', '批量处理请求'), - ('login', '用户登录'), - ('logout', '用户登出'), - ('view_audit_log', '查看审计日志'), - ('admin_action', '管理员操作'), - ('tunnel_online', '隧道上线'), - ('tunnel_offline', '隧道离线'), - ('tunnel_heartbeat_timeout', '隧道心跳超时'), - ('rdp_connect', 'RDP连接'), - ('rdp_disconnect', 'RDP断开'), - ('remote_exec', '远程执行命令'), - ('remote_exec_result', '远程执行结果'), - ('domain_bind', '域名绑定'), - ('domain_unbind', '域名解绑'), - ('create_ticket', '创建工单'), - ('update_ticket', '更新工单'), - ('assign_ticket', '分配工单'), - ('change_ticket_status', '变更工单状态'), - ('close_ticket', '关闭工单'), - ('add_ticket_comment', '添加工单评论'), - ('dashboard_view', '访问仪表盘'), - ('system_config_update', '更新系统配置'), - ] - - user = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="操作用户" - ) - host = models.ForeignKey( - Host, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="操作主机" - ) - action = models.CharField( - max_length=50, - choices=ACTION_CHOICES, - verbose_name="操作类型" - ) - ip_address = models.GenericIPAddressField( - null=True, - blank=True, - verbose_name="操作IP地址" - ) - user_agent = models.TextField( - blank=True, - verbose_name="用户代理" - ) - timestamp = models.DateTimeField(auto_now_add=True, verbose_name="操作时间") - success = models.BooleanField(default=True, verbose_name="操作成功") - details = models.JSONField(default=dict, verbose_name="操作详情") # 存储具体操作详情 - result = models.TextField(null=True, blank=True, verbose_name="操作结果") - - # 通用外键,用于关联各种模型 - content_type = models.ForeignKey( - ContentType, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="关联对象类型" - ) - object_id = models.PositiveIntegerField( - null=True, - blank=True, - verbose_name="关联对象ID" - ) - content_object = GenericForeignKey('content_type', 'object_id') - - class Meta: - verbose_name = "审计日志" - verbose_name_plural = "审计日志" - db_table = "audit_log" - ordering = ['-timestamp'] - indexes = [ - models.Index(fields=['user', 'timestamp']), - models.Index(fields=['host', 'timestamp']), - models.Index(fields=['action', 'timestamp']), - models.Index(fields=['timestamp']), - ] - - def __str__(self): - user_str = self.user.username if self.user else "Anonymous" - host_str = f" on {self.host.hostname}" if self.host else "" - return f"[{self.timestamp}] {user_str}{host_str} - {self.action}" - - -class SensitiveOperation(models.Model): - """敏感操作记录""" - operation_type = models.CharField(max_length=50, verbose_name="操作类型") - user = models.ForeignKey('accounts.User', on_delete=models.CASCADE, verbose_name="操作用户") - target = models.CharField(max_length=255, verbose_name="操作目标") # 目标对象描述 - timestamp = models.DateTimeField(auto_now_add=True, verbose_name="操作时间") - ip_address = models.GenericIPAddressField(verbose_name="操作IP") - justification = models.TextField(verbose_name="操作理由") # 必须提供操作理由 - approved_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - related_name='approved_sensitive_ops', - verbose_name="批准人" - ) - approved_at = models.DateTimeField(null=True, verbose_name="批准时间") - result = models.TextField(null=True, blank=True, verbose_name="操作结果") - - class Meta: - verbose_name = "敏感操作" - verbose_name_plural = "敏感操作" - db_table = "sensitive_operation" - ordering = ['-timestamp'] - - def __str__(self): - return f"[{self.timestamp}] {self.user.username} - {self.operation_type} on {self.target}" - - -class SecurityEvent(models.Model): - """安全事件模型""" - SEVERITY_CHOICES = [ - ('low', '低'), - ('medium', '中'), - ('high', '高'), - ('critical', '严重'), - ] - - EVENT_TYPE_CHOICES = [ - ('unauthorized_access', '未授权访问'), - ('failed_login', '登录失败'), - ('suspicious_activity', '可疑活动'), - ('data_exposure', '数据暴露风险'), - ('privilege_escalation', '权限提升尝试'), - ('cert_compromise', '证书泄露'), - ('brute_force', '暴力破解'), - ] - - event_type = models.CharField( - max_length=50, - choices=EVENT_TYPE_CHOICES, - verbose_name="事件类型" - ) - severity = models.CharField( - max_length=10, - choices=SEVERITY_CHOICES, - default='medium', - verbose_name="严重程度" - ) - user = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="关联用户" - ) - ip_address = models.GenericIPAddressField(verbose_name="事件IP") - description = models.TextField(verbose_name="事件描述") - timestamp = models.DateTimeField(auto_now_add=True, verbose_name="发生时间") - resolved = models.BooleanField(default=False, verbose_name="已解决") - resolved_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - related_name='resolved_security_events', - verbose_name="解决人" - ) - resolved_at = models.DateTimeField(null=True, verbose_name="解决时间") - resolution_notes = models.TextField(null=True, blank=True, verbose_name="解决备注") - - class Meta: - verbose_name = "安全事件" - verbose_name_plural = "安全事件" - db_table = "security_event" - ordering = ['-timestamp'] - - def __str__(self): - return f"[{self.severity.upper()}] {self.event_type} - {self.timestamp}" - - -class SessionActivity(models.Model): - """会话活动记录""" - user = models.ForeignKey('accounts.User', on_delete=models.CASCADE, verbose_name="用户") - session_key = models.CharField(max_length=40, verbose_name="会话密钥") - ip_address = models.GenericIPAddressField(verbose_name="IP地址") - user_agent = models.TextField(verbose_name="用户代理") - login_time = models.DateTimeField(auto_now_add=True, verbose_name="登录时间") - logout_time = models.DateTimeField(null=True, blank=True, verbose_name="登出时间") - is_active = models.BooleanField(default=True, verbose_name="是否活跃") - - class Meta: - verbose_name = "会话活动" - verbose_name_plural = "会话活动" - db_table = "session_activity" - ordering = ['-login_time'] - - def __str__(self): - return f"{self.user.username} - {self.session_key[:8]} - {self.login_time}" \ No newline at end of file diff --git a/apps/audit/signals.py b/apps/audit/signals.py deleted file mode 100755 index 47bce8c..0000000 --- a/apps/audit/signals.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -审计日志应用的信号处理器 -""" -from django.db.models.signals import post_save, pre_delete -from django.dispatch import receiver -from .models import AuditLog, SensitiveOperation, SecurityEvent, SessionActivity - - -# 可以在这里添加具体的信号处理器 -# 例如:在创建审计日志时触发某些操作 \ No newline at end of file diff --git a/apps/audit/tests/__init__.py b/apps/audit/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/audit/urls.py b/apps/audit/urls.py deleted file mode 100755 index cf73de1..0000000 --- a/apps/audit/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.urls import path -from . import views - -app_name = 'audit' - -urlpatterns = [ - # 审计日志API - path('logs/', views.get_audit_logs, name='get_audit_logs'), - path('sensitive-ops/', views.get_sensitive_operations, name='get_sensitive_operations'), - path('security-events/', views.get_security_events, name='get_security_events'), - path('mark-event-resolved/', views.mark_security_event_resolved, name='mark_security_event_resolved'), - path('session-activity/', views.get_user_session_activity, name='get_user_session_activity'), - path('stats/', views.AuditManagementView.as_view(), name='audit_stats'), - path('export/', views.export_audit_logs, name='export_audit_logs'), -] \ No newline at end of file diff --git a/apps/audit/urls_admin.py b/apps/audit/urls_admin.py deleted file mode 100644 index e40167a..0000000 --- a/apps/audit/urls_admin.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path - -from .views_admin import auditlog_list, auditlog_detail - -app_name = 'admin_audit' - -urlpatterns = [ - path('', auditlog_list, name='auditlog_list'), - path('/', auditlog_detail, name='auditlog_detail'), -] diff --git a/apps/audit/views.py b/apps/audit/views.py deleted file mode 100755 index 710e78b..0000000 --- a/apps/audit/views.py +++ /dev/null @@ -1,568 +0,0 @@ -from django.http import JsonResponse -from django.views.decorators.http import require_http_methods -from django.contrib.auth.decorators import login_required, permission_required -from django.utils.decorators import method_decorator -from django.views import View -from .models import AuditLog, SensitiveOperation, SecurityEvent, SessionActivity -from apps.hosts.models import Host -from django.contrib.auth.models import User -from django.shortcuts import get_object_or_404 -from django.core.paginator import Paginator -from django.utils import timezone -from datetime import datetime, timedelta -import json -import logging -import re - -logger = logging.getLogger(__name__) - -MAX_PAGE_SIZE = 100 -MAX_SEARCH_LENGTH = 200 -DATE_FORMAT = '%Y-%m-%d' - - -def _validate_int_param(value, default=1, min_val=1, max_val=None): - try: - result = int(value) - result = max(min_val, result) - if max_val: - result = min(max_val, result) - return result - except (ValueError, TypeError): - return default - - -def _validate_date_param(value): - if not value: - return None - try: - datetime.strptime(value, DATE_FORMAT) - return value - except ValueError: - return None - - -@require_http_methods(["GET"]) -@login_required -@permission_required('audit.view_auditlog', raise_exception=True) -def get_audit_logs(request): - """获取审计日志列表""" - try: - page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) - page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) - action = request.GET.get('action', '')[:50] if request.GET.get('action') else None - user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None - host_id = _validate_int_param(request.GET.get('host_id'), default=None, min_val=1) if request.GET.get('host_id') else None - start_date = _validate_date_param(request.GET.get('start_date')) - end_date = _validate_date_param(request.GET.get('end_date')) - success = request.GET.get('success') - search = request.GET.get('search', '')[:MAX_SEARCH_LENGTH] - - # 构建查询集 - queryset = AuditLog.objects.select_related('user', 'host').all() - - # 应用过滤器 - if action: - queryset = queryset.filter(action=action) - if user_id: - queryset = queryset.filter(user_id=user_id) - if host_id: - queryset = queryset.filter(host_id=host_id) - if success is not None: - queryset = queryset.filter(success=(success.lower() == 'true')) - if start_date: - queryset = queryset.filter(timestamp__gte=start_date) - if end_date: - queryset = queryset.filter(timestamp__lte=end_date) - if search: - # 搜索用户、主机或操作详情 - from django.db.models import Q - queryset = queryset.filter( - Q(user__username__icontains=search) | - Q(host__hostname__icontains=search) | - Q(details__icontains=search) | - Q(result__icontains=search) - ) - - # 按时间倒序排列 - queryset = queryset.order_by('-timestamp') - - # 分页 - paginator = Paginator(queryset, page_size) - logs_page = paginator.get_page(page) - - # 构造响应数据 - result = { - 'success': True, - 'data': { - 'logs': [ - { - 'id': log.id, - 'user': log.user.username if log.user else 'Anonymous', - 'user_id': log.user.id if log.user else None, - 'host': log.host.hostname if log.host else None, - 'host_id': log.host.id if log.host else None, - 'action': log.action, - 'ip_address': log.ip_address, - 'timestamp': log.timestamp.isoformat(), - 'success': log.success, - 'details': log.details, - 'result': log.result - } - for log in logs_page - ], - 'pagination': { - 'page': page, - 'page_size': page_size, - 'total_count': paginator.count, - 'total_pages': paginator.num_pages, - 'has_next': logs_page.has_next(), - 'has_previous': logs_page.has_previous() - } - } - } - - return JsonResponse(result) - - except ValueError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid parameter value' - }, status=400) - except Exception as e: - logger.error(f"Error getting audit logs: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve audit logs' - }, status=500) - - -@require_http_methods(["GET"]) -@login_required -@permission_required('audit.view_sensitiveoperation', raise_exception=True) -def get_sensitive_operations(request): - """获取敏感操作记录""" - try: - page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) - page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) - user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None - operation_type = request.GET.get('operation_type', '')[:50] if request.GET.get('operation_type') else None - start_date = _validate_date_param(request.GET.get('start_date')) - end_date = _validate_date_param(request.GET.get('end_date')) - - queryset = SensitiveOperation.objects.select_related('user', 'approved_by').all() - - if user_id: - queryset = queryset.filter(user_id=user_id) - if operation_type: - queryset = queryset.filter(operation_type=operation_type) - if start_date: - queryset = queryset.filter(timestamp__gte=start_date) - if end_date: - queryset = queryset.filter(timestamp__lte=end_date) - - queryset = queryset.order_by('-timestamp') - - # 分页 - paginator = Paginator(queryset, page_size) - ops_page = paginator.get_page(page) - - result = { - 'success': True, - 'data': { - 'operations': [ - { - 'id': op.id, - 'operation_type': op.operation_type, - 'user': op.user.username, - 'user_id': op.user.id, - 'target': op.target, - 'timestamp': op.timestamp.isoformat(), - 'ip_address': op.ip_address, - 'justification': op.justification, - 'approved_by': op.approved_by.username if op.approved_by else None, - 'approved_at': op.approved_at.isoformat() if op.approved_at else None, - 'result': op.result - } - for op in ops_page - ], - 'pagination': { - 'page': page, - 'page_size': page_size, - 'total_count': paginator.count, - 'total_pages': paginator.num_pages, - 'has_next': ops_page.has_next(), - 'has_previous': ops_page.has_previous() - } - } - } - - return JsonResponse(result) - - except ValueError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid parameter value' - }, status=400) - except Exception as e: - logger.error(f"Error getting sensitive operations: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve sensitive operations' - }, status=500) - - -@require_http_methods(["GET"]) -@login_required -@permission_required('audit.view_securityevent', raise_exception=True) -def get_security_events(request): - """获取安全事件记录""" - try: - page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) - page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) - event_type = request.GET.get('event_type', '')[:50] if request.GET.get('event_type') else None - severity = request.GET.get('severity', '')[:20] if request.GET.get('severity') else None - resolved = request.GET.get('resolved') - user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None - start_date = _validate_date_param(request.GET.get('start_date')) - end_date = _validate_date_param(request.GET.get('end_date')) - - queryset = SecurityEvent.objects.select_related('user', 'resolved_by').all() - - # 应用过滤器 - if event_type: - queryset = queryset.filter(event_type=event_type) - if severity: - queryset = queryset.filter(severity=severity) - if resolved is not None: - queryset = queryset.filter(resolved=(resolved.lower() == 'true')) - if user_id: - queryset = queryset.filter(user_id=user_id) - if start_date: - queryset = queryset.filter(timestamp__gte=start_date) - if end_date: - queryset = queryset.filter(timestamp__lte=end_date) - - queryset = queryset.order_by('-timestamp') - - # 分页 - paginator = Paginator(queryset, page_size) - events_page = paginator.get_page(page) - - result = { - 'success': True, - 'data': { - 'events': [ - { - 'id': event.id, - 'event_type': event.event_type, - 'severity': event.severity, - 'user': event.user.username if event.user else None, - 'user_id': event.user.id if event.user else None, - 'ip_address': event.ip_address, - 'description': event.description, - 'timestamp': event.timestamp.isoformat(), - 'resolved': event.resolved, - 'resolved_by': event.resolved_by.username if event.resolved_by else None, - 'resolved_at': event.resolved_at.isoformat() if event.resolved_at else None, - 'resolution_notes': event.resolution_notes - } - for event in events_page - ], - 'pagination': { - 'page': page, - 'page_size': page_size, - 'total_count': paginator.count, - 'total_pages': paginator.num_pages, - 'has_next': events_page.has_next(), - 'has_previous': events_page.has_previous() - } - } - } - - return JsonResponse(result) - - except ValueError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid parameter value' - }, status=400) - except Exception as e: - logger.error(f"Error getting security events: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve security events' - }, status=500) - - -@login_required -@permission_required('audit.change_securityevent', raise_exception=True) -@require_http_methods(["POST"]) -def mark_security_event_resolved(request): - """标记安全事件为已解决""" - try: - data = json.loads(request.body.decode('utf-8')) - event_id = data.get('event_id') - resolution_notes = data.get('resolution_notes', '') - - if not event_id: - return JsonResponse({ - 'success': False, - 'error': 'Event ID is required' - }, status=400) - - event = get_object_or_404(SecurityEvent, id=event_id) - - event.resolved = True - event.resolved_by = request.user if request.user.is_authenticated else None - event.resolved_at = timezone.now() - event.resolution_notes = resolution_notes[:1000] if resolution_notes else '' - event.save(update_fields=['resolved', 'resolved_by', 'resolved_at', 'resolution_notes']) - - return JsonResponse({ - 'success': True, - 'message': 'Security event marked as resolved' - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error marking security event as resolved: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to resolve security event' - }, status=500) - - -@require_http_methods(["GET"]) -@login_required -@permission_required('audit.view_sessionactivity', raise_exception=True) -def get_user_session_activity(request): - """获取用户会话活动记录""" - try: - user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None - page = _validate_int_param(request.GET.get('page', 1), default=1, min_val=1, max_val=10000) - page_size = _validate_int_param(request.GET.get('page_size', 20), default=20, min_val=1, max_val=MAX_PAGE_SIZE) - - queryset = SessionActivity.objects.select_related('user').all() - - if user_id: - queryset = queryset.filter(user_id=user_id) - - queryset = queryset.order_by('-login_time') - - # 分页 - paginator = Paginator(queryset, page_size) - sessions_page = paginator.get_page(page) - - result = { - 'success': True, - 'data': { - 'sessions': [ - { - 'id': session.id, - 'user': session.user.username, - 'user_id': session.user.id, - 'session_key': session.session_key[:8] + '...', - 'ip_address': session.ip_address, - 'user_agent': session.user_agent[:100], # 限制长度 - 'login_time': session.login_time.isoformat(), - 'logout_time': session.logout_time.isoformat() if session.logout_time else None, - 'is_active': session.is_active - } - for session in sessions_page - ], - 'pagination': { - 'page': page, - 'page_size': page_size, - 'total_count': paginator.count, - 'total_pages': paginator.num_pages, - 'has_next': sessions_page.has_next(), - 'has_previous': sessions_page.has_previous() - } - } - } - - return JsonResponse(result) - - except ValueError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid parameter value' - }, status=400) - except Exception as e: - logger.error(f"Error getting user session activity: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve session activity' - }, status=500) - - -class AuditManagementView(View): - """审计管理视图 - 需要审计权限""" - - @method_decorator(permission_required('audit.view_auditlog')) - def get(self, request): - """获取审计统计信息""" - try: - # 获取最近24小时的数据 - last_24h = timezone.now() - timedelta(hours=24) - - # 统计数据 - stats = { - 'total_logs': AuditLog.objects.count(), - 'recent_logs': AuditLog.objects.filter(timestamp__gte=last_24h).count(), - 'total_sensitive_ops': SensitiveOperation.objects.count(), - 'recent_sensitive_ops': SensitiveOperation.objects.filter(timestamp__gte=last_24h).count(), - 'total_security_events': SecurityEvent.objects.count(), - 'unresolved_security_events': SecurityEvent.objects.filter(resolved=False).count(), - 'recent_security_events': SecurityEvent.objects.filter(timestamp__gte=last_24h).count(), - } - - # 按操作类型统计 - from django.db.models import Count - action_stats = AuditLog.objects.values('action').annotate(count=Count('id')).order_by('-count')[:10] - stats['top_actions'] = list(action_stats) - - # 按用户统计 - user_stats = AuditLog.objects.values('user__username').annotate(count=Count('id')).exclude(user__isnull=True).order_by('-count')[:10] - stats['top_users'] = list(user_stats) - - # 按主机统计 - host_stats = AuditLog.objects.values('host__hostname').annotate(count=Count('id')).exclude(host__isnull=True).order_by('-count')[:10] - stats['top_hosts'] = list(host_stats) - - return JsonResponse({ - 'success': True, - 'data': stats - }) - - except Exception as e: - logger.error(f"Error getting audit statistics: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve audit statistics' - }, status=500) - - @method_decorator(permission_required('audit.delete_auditlog')) - def delete(self, request): - """清理审计日志""" - try: - data = json.loads(request.body.decode('utf-8')) - days_to_keep = data.get('days_to_keep', 90) # 默认保留90天 - - cutoff_date = timezone.now() - timedelta(days=days_to_keep) - - # 删除旧的审计日志 - deleted_count, _ = AuditLog.objects.filter(timestamp__lt=cutoff_date).delete() - - # 删除旧的敏感操作记录 - deleted_sensitive, _ = SensitiveOperation.objects.filter(timestamp__lt=cutoff_date).delete() - - # 删除旧的安全事件记录(已解决的) - deleted_events, _ = SecurityEvent.objects.filter( - timestamp__lt=cutoff_date, - resolved=True - ).delete() - - return JsonResponse({ - 'success': True, - 'data': { - 'deleted_logs': deleted_count, - 'deleted_sensitive_ops': deleted_sensitive, - 'deleted_security_events': deleted_events, - 'days_kept': days_to_keep - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error cleaning audit logs: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to clean audit logs' - }, status=500) - - -@require_http_methods(["GET"]) -@login_required -@permission_required('audit.view_auditlog', raise_exception=True) -def export_audit_logs(request): - """导出审计日志(CSV格式)""" - try: - # 获取查询参数 - action = request.GET.get('action', '')[:50] if request.GET.get('action') else None - user_id = _validate_int_param(request.GET.get('user_id'), default=None, min_val=1) if request.GET.get('user_id') else None - host_id = _validate_int_param(request.GET.get('host_id'), default=None, min_val=1) if request.GET.get('host_id') else None - start_date = _validate_date_param(request.GET.get('start_date')) - end_date = _validate_date_param(request.GET.get('end_date')) - - # 构建查询集 - queryset = AuditLog.objects.select_related('user', 'host').all() - - # 应用过滤器 - if action: - queryset = queryset.filter(action=action) - if user_id: - queryset = queryset.filter(user_id=user_id) - if host_id: - queryset = queryset.filter(host_id=host_id) - if start_date: - queryset = queryset.filter(timestamp__gte=start_date) - if end_date: - queryset = queryset.filter(timestamp__lte=end_date) - - # 按时间倒序排列 - queryset = queryset.order_by('-timestamp') - - # 生成CSV内容 - import csv - import io - - output = io.StringIO() - writer = csv.writer(output) - - # 写入标题行 - writer.writerow([ - 'ID', 'User', 'Host', 'Action', 'IP Address', 'Timestamp', - 'Success', 'Details', 'Result' - ]) - - # 写入数据行 - for log in queryset: - writer.writerow([ - log.id, - log.user.username if log.user else 'Anonymous', - log.host.hostname if log.host else '', - log.action, - log.ip_address, - log.timestamp.strftime('%Y-%m-%d %H:%M:%S'), - log.success, - json.dumps(log.details, ensure_ascii=False) if log.details else '', - log.result or '' - ]) - - # 获取CSV内容 - csv_content = output.getvalue() - output.close() - - # 返回CSV文件 - from django.http import HttpResponse - response = HttpResponse(csv_content, content_type='text/csv') - response['Content-Disposition'] = f'attachment; filename=audit_logs_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv' - - return response - - except Exception as e: - logger.error(f"Error exporting audit logs: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to export audit logs' - }, status=500) \ No newline at end of file diff --git a/apps/audit/views_admin.py b/apps/audit/views_admin.py deleted file mode 100644 index 25be78e..0000000 --- a/apps/audit/views_admin.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -审计日志超级管理员视图 - -审计日志为只读,不支持创建/编辑/删除操作。 -""" - -from django.shortcuts import render, get_object_or_404 -from django.core.paginator import Paginator - -from apps.accounts.provider_decorators import superadmin_required -from .models import AuditLog - - -@superadmin_required -def auditlog_list(request): - """ - 审计日志列表视图(只读) - - 支持按用户、操作类型、时间范围筛选,支持搜索。 - """ - queryset = AuditLog.objects.select_related( - 'user', 'host' - ).order_by('-timestamp') - - # 搜索 - search = request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - action__icontains=search - ) | queryset.filter( - user__username__icontains=search - ) | queryset.filter( - host__name__icontains=search - ) | queryset.filter( - ip_address__icontains=search - ) - - # 操作类型筛选 - action_filter = request.GET.get('action', '').strip() - if action_filter: - queryset = queryset.filter(action=action_filter) - - # 用户筛选 - user_filter = request.GET.get('user', '').strip() - if user_filter: - queryset = queryset.filter(user__username__icontains=user_filter) - - # 时间范围筛选 - timestamp_from = request.GET.get('timestamp_from', '').strip() - if timestamp_from: - queryset = queryset.filter(timestamp__gte=timestamp_from) - - timestamp_to = request.GET.get('timestamp_to', '').strip() - if timestamp_to: - queryset = queryset.filter(timestamp__lte=timestamp_to) - - # 分页 - paginator = Paginator(queryset, 25) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'search': search, - 'action_filter': action_filter, - 'user_filter': user_filter, - 'timestamp_from': timestamp_from, - 'timestamp_to': timestamp_to, - 'action_choices': AuditLog.ACTION_CHOICES, - 'active_nav': 'audit', - } - - return render(request, 'admin_base/audit/auditlog_list.html', context) - - -@superadmin_required -def auditlog_detail(request, pk): - """ - 审计日志详情视图(只读) - """ - log = get_object_or_404( - AuditLog.objects.select_related('user', 'host', 'content_type'), - pk=pk - ) - - context = { - 'log': log, - 'active_nav': 'audit', - } - - return render(request, 'admin_base/audit/auditlog_detail.html', context) diff --git a/apps/bootstrap/README.md b/apps/bootstrap/README.md deleted file mode 100644 index de237a3..0000000 --- a/apps/bootstrap/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# C端主机自动化初始化与安全认证系统 - -## 系统概述 - -本系统实现了符合共享技术契约的C端主机自动化初始化与安全认证功能,支持基于TOTP的双重验证机制,确保H端和C端之间的安全对接。 - -## 核心功能 - -### 1. 数据库设计 - -系统包含两个核心数据表: - -#### InitialToken(初始令牌表) -| 字段名 | 类型 | 说明 | -|--------|------|------| -| token | String (PK) | AccessToken | -| host | ForeignKey | 关联的主机 | -| expires_at | Datetime | AccessToken过期时间 | -| status | Enum | `ISSUED`(已签发), `TOTP_VERIFIED`(已验证), `CONSUMED`(已消耗) | -| created_at | Datetime | 创建时间 | - -#### ActiveSession(活动会话表) -| 字段名 | 类型 | 说明 | -|---------|------|------| -| session_token | String (PK) | 颁发给H端的临时凭证 | -| host | ForeignKey | 关联的主机 | -| bound_ip | String | **关键**:绑定的请求源IP | -| expires_at | Datetime | 24小时后的过期时间 | -| created_at | Datetime | 创建时间 | - -### 2. API接口 - -#### A. TOTP验证接口 -- **URL**: `POST /bootstrap/verify-totp/` -- **Request Body**: -```json -{ - "host_id": "Unique-Host-ID", - "totp_code": "123456" -} -``` - -#### B. Token交换接口 -- **URL**: `POST /bootstrap/exchange-token/` -- **Headers**: `Authorization: Bearer {AccessToken}` -- **Response**: -```json -{ - "success": true, - "session_token": "new-session-uuid", - "expires_in": 86400 -} -``` - -#### C. 会话吊销接口 -- **URL**: `DELETE /bootstrap/session/` -- **Headers**: `Authorization: Bearer {session_token}` - -### 3. 安全机制 - -#### 密钥派生算法 -严格按照共享技术契约实现: -1. 拼接字符串:`input_string = token + "|" + host_id + "|" + expires_at` -2. 哈希计算:`raw_hash = HMAC-SHA256(key="SHARED_STATIC_SALT", message=input_string)` -3. 截取与编码:取`raw_hash`的前20个字节,进行**Base32**编码 - -#### TOTP算法参数 -- **算法**: HMAC-SHA1 -- **时间步长**: 30秒 -- **位数**: 6位数字 -- **初始时间**: Unix Epoch (T0 = 0) - -## 管理界面 - -系统集成到Django Admin中,提供以下功能: -- 初始令牌管理 -- 活动会话监控 -- 一键生成令牌 -- 状态监控 - -## 自动化任务 - -- 定期清理过期的活动会话 -- 定期清理过期的初始令牌 - -## 配置要求 - -在`settings.py`中配置共享盐值: -```python -BOOTSTRAP_SHARED_SALT = os.environ.get('BOOTSTRAP_SHARED_SALT', 'MY_SECRET_2024') -``` - -## 使用流程 - -1. 管理员在Django Admin中生成初始令牌 -2. 系统生成包含C端URL、令牌、主机ID等信息的Base64配置字符串 -3. H端使用配置字符串进行初始化 -4. 用户在C端输入H端显示的TOTP码进行验证 -5. H端使用Access Token换取Session Token -6. 双方通过Session Token进行后续安全通信 \ No newline at end of file diff --git a/apps/bootstrap/admin.py b/apps/bootstrap/admin.py deleted file mode 100644 index 32abb64..0000000 --- a/apps/bootstrap/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 引导系统无需后台管理 diff --git a/apps/bootstrap/apps.py b/apps/bootstrap/apps.py deleted file mode 100755 index 9ef9793..0000000 --- a/apps/bootstrap/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.apps import AppConfig - - -class BootstrapConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.bootstrap' - verbose_name = '主机引导系统' - - def ready(self): - # 导入信号处理器 - import apps.bootstrap.signals \ No newline at end of file diff --git a/apps/bootstrap/management/commands/cleanup_expired_sessions.py b/apps/bootstrap/management/commands/cleanup_expired_sessions.py deleted file mode 100644 index 9b9c12c..0000000 --- a/apps/bootstrap/management/commands/cleanup_expired_sessions.py +++ /dev/null @@ -1,73 +0,0 @@ -from django.core.management.base import BaseCommand -from django.utils import timezone -from apps.bootstrap.models import ActiveSession, InitialToken - - -class Command(BaseCommand): - help = '清理过期的活动会话和初始令牌' - - def add_arguments(self, parser): - parser.add_argument( - '--dry-run', - action='store_true', - help='仅显示将要删除的记录,不实际删除', - ) - - def handle(self, *args, **options): - dry_run = options['dry_run'] - now = timezone.now() - - expired_sessions = ActiveSession.objects.filter( - expires_at__lt=now, - ) - if expired_sessions.exists(): - count = expired_sessions.count() - self.stdout.write( - f'找到 {count} 个过期的会话' - ) - if not dry_run: - expired_sessions.delete() - self.stdout.write( - self.style.SUCCESS( - f'已删除 {count} 个过期的会话' - ) - ) - - expired_tokens = InitialToken.objects.filter( - expires_at__lt=now, - ) - if expired_tokens.exists(): - count = expired_tokens.count() - self.stdout.write( - f'找到 {count} 个过期的初始令牌' - ) - if not dry_run: - expired_tokens.delete() - self.stdout.write( - self.style.SUCCESS( - f'已删除 {count} 个过期的初始令牌' - ) - ) - - orphan_tokens = InitialToken.objects.filter( - host=None, - status='ISSUED', - expires_at__gt=now, - ) - if orphan_tokens.exists(): - count = orphan_tokens.count() - self.stdout.write( - f'找到 {count} 个未关联主机的初始令牌' - ) - if not dry_run: - orphan_tokens.delete() - self.stdout.write( - self.style.SUCCESS( - f'已删除 {count} 个未关联主机的初始令牌' - ) - ) - - if not any([expired_sessions.exists(), expired_tokens.exists(), orphan_tokens.exists()]): - self.stdout.write( - self.style.SUCCESS('没有需要清理的记录') - ) \ No newline at end of file diff --git a/apps/bootstrap/middleware.py b/apps/bootstrap/middleware.py deleted file mode 100644 index e99c6ff..0000000 --- a/apps/bootstrap/middleware.py +++ /dev/null @@ -1,122 +0,0 @@ -import logging -from django.http import JsonResponse -from .models import ActiveSession -from django.utils import timezone -from django.urls import resolve - -logger = logging.getLogger(__name__) - - -class SessionValidationMiddleware: - """会话验证中间件 - 根据规范实现""" - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - # 记录请求信息用于调试 - client_ip = self.get_client_ip(request) - user_agent = request.META.get('HTTP_USER_AGENT', 'Unknown') - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - - logger.debug(f"Session validation middleware processing request: path={request.path}, method={request.method}") - - # 检查是否需要验证会话的API端点 - # 仅对需要认证的API端点进行验证 - # 排除不需要SessionToken验证的特殊端点 - excluded_paths = [ - '/api/exchange_token', - '/api/exchange_token/', - '/bootstrap/exchange-token/', - '/api/get_session_token', - '/api/get_session_token/', - '/bootstrap/api/get_session_token', - '/bootstrap/api/get_session_token/', - '/api/check_totp_status', - '/api/check_totp_status/', - '/bootstrap/api/check_totp_status', - '/bootstrap/api/check_totp_status/', - '/bootstrap/sse/init-status', - '/bootstrap/sse/init-status/', - '/bootstrap/api/upload_host_cert', - '/bootstrap/api/upload_host_cert/', - ] - - if (request.path.startswith('/api/') or - request.path.startswith('/bootstrap/')) and \ - request.path not in excluded_paths: - - logger.debug(f"Checking session for protected endpoint: {request.path}") - - # 检查Authorization头部 - if auth_header.startswith('Bearer '): - session_token = auth_header.split(' ')[1] - logger.debug("Found Bearer token, validating session") - - # 验证会话有效性 - is_valid, result = self.check_session_validity(request, session_token) - - if not is_valid: - logger.warning(f"Session validation failed for request {request.path}: {result}") - return JsonResponse({ - 'success': False, - 'error': 'Access denied', - }, status=403) - else: - logger.debug("Session validation successful") - else: - logger.debug("No valid Bearer authorization header found") - else: - logger.debug(f"Skipping session validation for path: {request.path}") - - response = self.get_response(request) - return response - - def check_session_validity(self, request, session_token): - """检查会话有效性""" - try: - logger.debug(f"Looking up session token: {session_token[:8]}...") - session = ActiveSession.objects.get( - session_token=session_token, - expires_at__gt=timezone.now() - ) - - logger.debug(f"Found active session for host: {session.host.name} (ID: {session.host.id})") - - # 获取真实客户端IP - current_ip = self.get_client_ip(request) - bound_ip = session.bound_ip - - logger.debug(f"Comparing IPs - Request IP: {current_ip}, Bound IP: {bound_ip}") - - # IP校验 - if session.bound_ip != current_ip: - error_msg = f"IP address mismatch - request from {current_ip}, session bound to {bound_ip}" - logger.warning(error_msg) - logger.warning(f"Session details: token={session_token[:8]}..., host={session.host.name}, created={session.created_at}") - return False, error_msg - - logger.debug(f"IP validation passed: {current_ip}") - return True, session - - except ActiveSession.DoesNotExist: - error_msg = f"Invalid or expired session token: {session_token[:8]}..." - logger.warning(error_msg) - return False, error_msg - except Exception as e: - error_msg = f"Error during session validation: {str(e)}" - logger.error(error_msg, exc_info=True) - return False, error_msg - - def get_client_ip(self, request): - """获取客户端真实IP地址""" - from django.conf import settings - if getattr(settings, 'USE_X_FORWARDED_FOR', False): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ip = x_forwarded_for.split(',')[0].strip() - logger.debug(f"Got IP from X-Forwarded-For: {ip}") - return ip - ip = request.META.get('REMOTE_ADDR', '127.0.0.1') - logger.debug(f"Got IP from REMOTE_ADDR: {ip}") - return ip \ No newline at end of file diff --git a/apps/bootstrap/migrations/0001_initial.py b/apps/bootstrap/migrations/0001_initial.py deleted file mode 100755 index e42f3d0..0000000 --- a/apps/bootstrap/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-01 11:58 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('hosts', '0005_host_connection_type_alter_host_port'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='BootstrapToken', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=255, unique=True, verbose_name='引导令牌')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('expires_at', models.DateTimeField(verbose_name='过期时间')), - ('is_used', models.BooleanField(default=False, verbose_name='是否已使用')), - ('used_at', models.DateTimeField(blank=True, null=True, verbose_name='使用时间')), - ('notes', models.TextField(blank=True, null=True, verbose_name='备注')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建者')), - ('host', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联主机')), - ('used_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='used_bootstrap_tokens', to=settings.AUTH_USER_MODEL, verbose_name='使用者')), - ], - options={ - 'verbose_name': '引导令牌', - 'verbose_name_plural': '引导令牌', - 'db_table': 'bootstrap_token', - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/apps/bootstrap/migrations/0002_bootstraptoken_is_paired_bootstraptoken_paired_at_and_more.py b/apps/bootstrap/migrations/0002_bootstraptoken_is_paired_bootstraptoken_paired_at_and_more.py deleted file mode 100644 index 971bd4f..0000000 --- a/apps/bootstrap/migrations/0002_bootstraptoken_is_paired_bootstraptoken_paired_at_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 08:54 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0005_host_connection_type_alter_host_port'), - ('bootstrap', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='bootstraptoken', - name='is_paired', - field=models.BooleanField(default=False, verbose_name='是否已配对'), - ), - migrations.AddField( - model_name='bootstraptoken', - name='paired_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='配对时间'), - ), - migrations.AddField( - model_name='bootstraptoken', - name='pairing_code', - field=models.CharField(blank=True, max_length=8, null=True, unique=True, verbose_name='配对码'), - ), - migrations.AddField( - model_name='bootstraptoken', - name='pairing_code_expires_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='配对码过期时间'), - ), - migrations.AlterField( - model_name='bootstraptoken', - name='host', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联主机'), - ), - ] diff --git a/apps/bootstrap/migrations/0003_bootstraptoken_totp_secret.py b/apps/bootstrap/migrations/0003_bootstraptoken_totp_secret.py deleted file mode 100644 index 3d4bf26..0000000 --- a/apps/bootstrap/migrations/0003_bootstraptoken_totp_secret.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 09:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0002_bootstraptoken_is_paired_bootstraptoken_paired_at_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='bootstraptoken', - name='totp_secret', - field=models.CharField(blank=True, max_length=255, null=True, unique=True, verbose_name='TOTP密钥'), - ), - ] diff --git a/apps/bootstrap/migrations/0004_remove_bootstraptoken_pairing_code_and_more.py b/apps/bootstrap/migrations/0004_remove_bootstraptoken_pairing_code_and_more.py deleted file mode 100644 index 9a684cd..0000000 --- a/apps/bootstrap/migrations/0004_remove_bootstraptoken_pairing_code_and_more.py +++ /dev/null @@ -1,59 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 11:48 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0005_host_connection_type_alter_host_port'), - ('bootstrap', '0003_bootstraptoken_totp_secret'), - ] - - operations = [ - migrations.RemoveField( - model_name='bootstraptoken', - name='pairing_code', - ), - migrations.RemoveField( - model_name='bootstraptoken', - name='pairing_code_expires_at', - ), - migrations.AlterField( - model_name='bootstraptoken', - name='host', - field=models.OneToOneField(default=1, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联主机'), - preserve_default=False, - ), - migrations.CreateModel( - name='InitialToken', - fields=[ - ('token', models.CharField(max_length=255, primary_key=True, serialize=False, verbose_name='AccessToken')), - ('expires_at', models.DateTimeField(verbose_name='AccessToken过期时间')), - ('status', models.CharField(choices=[('ISSUED', '已签发'), ('TOTP_VERIFIED', '已验证'), ('CONSUMED', '已消耗')], default='ISSUED', max_length=20, verbose_name='状态')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机')), - ], - options={ - 'verbose_name': '初始令牌', - 'verbose_name_plural': '初始令牌', - 'db_table': 'initial_token', - }, - ), - migrations.CreateModel( - name='ActiveSession', - fields=[ - ('session_token', models.CharField(max_length=255, primary_key=True, serialize=False, verbose_name='临时凭证')), - ('bound_ip', models.GenericIPAddressField(verbose_name='绑定的请求源IP')), - ('expires_at', models.DateTimeField(verbose_name='24小时后的过期时间')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机')), - ], - options={ - 'verbose_name': '活动会话', - 'verbose_name_plural': '活动会话', - 'db_table': 'active_session', - }, - ), - ] diff --git a/apps/bootstrap/migrations/0005_delete_bootstraptoken.py b/apps/bootstrap/migrations/0005_delete_bootstraptoken.py deleted file mode 100644 index 4ccf250..0000000 --- a/apps/bootstrap/migrations/0005_delete_bootstraptoken.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 11:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0004_remove_bootstraptoken_pairing_code_and_more'), - ] - - operations = [ - migrations.DeleteModel( - name='BootstrapToken', - ), - ] diff --git a/apps/bootstrap/migrations/0006_alter_activesession_expires_at.py b/apps/bootstrap/migrations/0006_alter_activesession_expires_at.py deleted file mode 100644 index 83516e7..0000000 --- a/apps/bootstrap/migrations/0006_alter_activesession_expires_at.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-03 03:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0005_delete_bootstraptoken'), - ] - - operations = [ - migrations.AlterField( - model_name='activesession', - name='expires_at', - field=models.DateTimeField(verbose_name='会话过期时间'), - ), - ] diff --git a/apps/bootstrap/migrations/0007_update_initialtoken_for_pairing.py b/apps/bootstrap/migrations/0007_update_initialtoken_for_pairing.py deleted file mode 100644 index 4b426fb..0000000 --- a/apps/bootstrap/migrations/0007_update_initialtoken_for_pairing.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0006_alter_activesession_expires_at'), - ] - - operations = [ - migrations.AddField( - model_name='initialtoken', - name='pairing_code', - field=models.CharField(blank=True, max_length=6, null=True, verbose_name='配对码'), - ), - migrations.AddField( - model_name='initialtoken', - name='pairing_code_expires_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='配对码过期时间'), - ), - migrations.AlterField( - model_name='initialtoken', - name='status', - field=models.CharField(choices=[('ISSUED', '已签发'), ('PAIRED', '已配对'), ('CONSUMED', '已消耗')], default='ISSUED', max_length=20, verbose_name='状态'), - ), - migrations.AlterField( - model_name='activesession', - name='expires_at', - field=models.DateTimeField(verbose_name='会话过期时间'), - ), - ] \ No newline at end of file diff --git a/apps/bootstrap/migrations/0008_add_pairing_attempts.py b/apps/bootstrap/migrations/0008_add_pairing_attempts.py deleted file mode 100644 index dd3ddc4..0000000 --- a/apps/bootstrap/migrations/0008_add_pairing_attempts.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-10 04:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0007_update_initialtoken_for_pairing'), - ] - - operations = [ - migrations.AddField( - model_name='initialtoken', - name='pairing_attempts', - field=models.IntegerField(default=0, verbose_name='配对码验证尝试次数'), - ), - ] diff --git a/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py b/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py deleted file mode 100644 index ca11b15..0000000 --- a/apps/bootstrap/migrations/0009_initialtoken_host_nullable.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-23 09:29 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0011_host_auth_method_host_cert_key_path_and_more'), - ('bootstrap', '0008_add_pairing_attempts'), - ] - - operations = [ - migrations.AlterField( - model_name='initialtoken', - name='host', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机'), - ), - ] diff --git a/apps/bootstrap/migrations/0010_add_cert_data.py b/apps/bootstrap/migrations/0010_add_cert_data.py deleted file mode 100644 index 27a0b8b..0000000 --- a/apps/bootstrap/migrations/0010_add_cert_data.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-23 14:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0009_initialtoken_host_nullable'), - ] - - operations = [ - migrations.AddField( - model_name='initialtoken', - name='cert_data', - field=models.JSONField(blank=True, default=None, null=True, verbose_name='暂存证书数据'), - ), - ] diff --git a/apps/bootstrap/migrations/0011_add_cert_provision_token.py b/apps/bootstrap/migrations/0011_add_cert_provision_token.py deleted file mode 100644 index e7e33e4..0000000 --- a/apps/bootstrap/migrations/0011_add_cert_provision_token.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-29 15:57 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0012_host_username_optional'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('bootstrap', '0010_add_cert_data'), - ] - - operations = [ - migrations.CreateModel( - name='CertProvisionToken', - fields=[ - ('token', models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='配置令牌')), - ('server_host', models.CharField(max_length=255, verbose_name='服务器地址')), - ('expires_at', models.DateTimeField(verbose_name='过期时间')), - ('status', models.CharField(choices=[('ISSUED', '已签发'), ('HOSTNAME_UPLOADED', '主机名已上传'), ('CERT_ISSUED', '证书已签发'), ('HOST_CONFIGURED', '主机已配置'), ('CONSUMED', '已消耗')], default='ISSUED', max_length=20, verbose_name='状态')), - ('consumed_at', models.DateTimeField(blank=True, null=True, verbose_name='消耗时间')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建者')), - ('host', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联的主机')), - ], - options={ - 'verbose_name': '证书配置令牌', - 'verbose_name_plural': '证书配置令牌', - 'db_table': 'cert_provision_token', - }, - ), - ] diff --git a/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py b/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py deleted file mode 100644 index 62d73eb..0000000 --- a/apps/bootstrap/migrations/0012_certprovisiontoken_cert_data_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-30 00:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0011_add_cert_provision_token'), - ] - - operations = [ - migrations.AddField( - model_name='certprovisiontoken', - name='cert_data', - field=models.JSONField(blank=True, default=None, null=True, verbose_name='暂存证书数据'), - ), - migrations.AddField( - model_name='certprovisiontoken', - name='hostname', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='主机名'), - ), - ] diff --git a/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py b/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py deleted file mode 100644 index de5339d..0000000 --- a/apps/bootstrap/migrations/0013_certprovisiontoken_ip_address.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-30 02:10 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bootstrap', '0012_certprovisiontoken_cert_data_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='certprovisiontoken', - name='ip_address', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='主机IP地址'), - ), - ] diff --git a/apps/bootstrap/migrations/__init__.py b/apps/bootstrap/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/bootstrap/models.py b/apps/bootstrap/models.py deleted file mode 100644 index aaa9636..0000000 --- a/apps/bootstrap/models.py +++ /dev/null @@ -1,119 +0,0 @@ -from django.db import models -from apps.hosts.models import Host -import uuid -from django.utils import timezone -from datetime import timedelta -import secrets as _secrets -from django.conf import settings - - -class InitialToken(models.Model): - """初始配置令牌表 - 基于配对码的简化认证机制""" - STATUS_CHOICES = [ - ('ISSUED', '已签发'), - ('PAIRED', '已配对'), - ('CONSUMED', '已消耗'), - ] - - MAX_PAIRING_ATTEMPTS = 5 - - token = models.CharField(max_length=255, primary_key=True, verbose_name="AccessToken") - host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机", null=True, blank=True) - expires_at = models.DateTimeField(verbose_name="AccessToken过期时间") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ISSUED', verbose_name="状态") - pairing_code = models.CharField(max_length=6, verbose_name="配对码", blank=True, null=True) - pairing_code_expires_at = models.DateTimeField(verbose_name="配对码过期时间", blank=True, null=True) - pairing_attempts = models.IntegerField(default=0, verbose_name="配对码验证尝试次数") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") - cert_data = models.JSONField(verbose_name="暂存证书数据", blank=True, null=True, default=None) - - class Meta: - verbose_name = "初始令牌" - verbose_name_plural = "初始令牌" - db_table = "initial_token" - - def generate_pairing_code(self): - """生成6位数字配对码""" - code = f"{_secrets.randbelow(1000000):06d}" - self.pairing_code = code - self.pairing_code_expires_at = timezone.now() + timedelta(minutes=5) - self.pairing_attempts = 0 - self.save(update_fields=['pairing_code', 'pairing_code_expires_at', 'pairing_attempts']) - return code - - def verify_pairing_code(self, input_code): - """验证配对码是否正确且未过期,含尝试次数限制""" - if not self.pairing_code or not self.pairing_code_expires_at: - return False - - if timezone.now() > self.pairing_code_expires_at: - return False - - from django.db.models import F - InitialToken.objects.filter(pk=self.pk).update( - pairing_attempts=F('pairing_attempts') + 1 - ) - self.refresh_from_db() - - if self.pairing_attempts >= self.MAX_PAIRING_ATTEMPTS: - self.pairing_code = None - self.pairing_code_expires_at = None - self.save(update_fields=['pairing_code', 'pairing_code_expires_at']) - return False - - if self.pairing_code != input_code: - return False - - self.status = 'PAIRED' - self.pairing_code = None - self.pairing_code_expires_at = None - self.pairing_attempts = 0 - self.save(update_fields=['status', 'pairing_code', 'pairing_code_expires_at', 'pairing_attempts']) - return True - - -class ActiveSession(models.Model): - """活动会话表 - 基于配对码认证的会话管理""" - session_token = models.CharField(max_length=255, primary_key=True, verbose_name="临时凭证") - host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机") - bound_ip = models.GenericIPAddressField(verbose_name="绑定的请求源IP") - expires_at = models.DateTimeField(verbose_name="会话过期时间") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") - - class Meta: - verbose_name = "活动会话" - verbose_name_plural = "活动会话" - db_table = "active_session" - - -class CertProvisionToken(models.Model): - STATUS_CHOICES = [ - ('ISSUED', '已签发'), - ('HOSTNAME_UPLOADED', '主机名已上传'), - ('CERT_ISSUED', '证书已签发'), - ('HOST_CONFIGURED', '主机已配置'), - ('CONSUMED', '已消耗'), - ] - - token = models.CharField(max_length=64, primary_key=True, verbose_name="配置令牌") - host = models.ForeignKey(Host, on_delete=models.CASCADE, verbose_name="关联的主机", null=True, blank=True) - server_host = models.CharField(max_length=255, verbose_name="服务器地址") - hostname = models.CharField(max_length=255, verbose_name="主机名", blank=True, default='') - ip_address = models.CharField(max_length=255, verbose_name="主机IP地址", blank=True, default='') - expires_at = models.DateTimeField(verbose_name="过期时间") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ISSUED', verbose_name="状态") - cert_data = models.JSONField(verbose_name="暂存证书数据", blank=True, null=True, default=None) - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="创建者") - consumed_at = models.DateTimeField(null=True, blank=True, verbose_name="消耗时间") - created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") - - class Meta: - verbose_name = "证书配置令牌" - verbose_name_plural = "证书配置令牌" - db_table = "cert_provision_token" - - def is_expired(self): - return timezone.now() > self.expires_at - - def is_valid(self): - return self.status == 'ISSUED' and not self.is_expired() \ No newline at end of file diff --git a/apps/bootstrap/signals.py b/apps/bootstrap/signals.py deleted file mode 100644 index 57ad72a..0000000 --- a/apps/bootstrap/signals.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -主机引导应用的信号处理器 -""" -from django.db.models.signals import post_save, pre_delete -from django.dispatch import receiver -from .models import InitialToken, ActiveSession - - -# 可以在这里添加具体的信号处理器 -# 例如:在引导令牌即将过期时发送通知 \ No newline at end of file diff --git a/apps/bootstrap/tasks.py b/apps/bootstrap/tasks.py deleted file mode 100644 index aff8b27..0000000 --- a/apps/bootstrap/tasks.py +++ /dev/null @@ -1,272 +0,0 @@ -import base64 -import datetime -import logging -from datetime import timedelta -from typing import cast - -from celery import shared_task -from cryptography import x509 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec -from django.utils import timezone - -from .models import ActiveSession, InitialToken - -logger = logging.getLogger(__name__) - - -@shared_task -def cleanup_expired_sessions(): - try: - expired_sessions = ActiveSession.objects.filter( - expires_at__lt=timezone.now() - ) - count = expired_sessions.count() - expired_sessions.delete() - logger.info(f"清理了 {count} 个过期的活动会话") - return f"清理了 {count} 个过期的活动会话" - except Exception as e: - logger.error(f"清理过期会话时出错: {str(e)}") - raise - - -@shared_task -def cleanup_expired_initial_tokens(): - try: - cutoff_time = timezone.now() - timedelta(days=7) - expired_tokens = InitialToken.objects.filter( - expires_at__lt=cutoff_time - ) - count = expired_tokens.count() - expired_tokens.delete() - logger.info(f"清理了 {count} 个过期的初始令牌") - return f"清理了 {count} 个过期的初始令牌" - except Exception as e: - logger.error(f"清理过期初始令牌时出错: {str(e)}") - raise - - -@shared_task -def generate_bootstrap_config(hostname, ip_address, operator_id): - try: - config = { - 'hostname': hostname, - 'ip_address': ip_address, - 'generated_at': timezone.now().isoformat(), - 'status': 'success', - } - return {'success': True, 'config': config} - except Exception as e: - logger.error(f"生成引导配置时出错: {str(e)}") - return {'success': False, 'error': str(e)} - - -@shared_task -def initialize_host_bootstrap(host_id, operator_id): - try: - from apps.hosts.models import Host - host = Host.objects.get(id=host_id) - return { - 'host_id': host_id, - 'hostname': host.hostname, - 'status': 'completed', - 'completed_at': timezone.now().isoformat(), - } - except Exception as e: - logger.error(f"初始化主机引导时出错: {str(e)}") - raise - - -@shared_task(bind=True, max_retries=1) -def cert_provision_issue_certs(self, token_str): - from apps.bootstrap.models import CertProvisionToken - from apps.certificates.models import CertificateAuthority - from utils.cert_service import ( - issue_server_cert, issue_client_cert, - generate_random_username, generate_random_password, - ) - from utils.cert_storage import generate_cert_paths, save_cert_files - - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - return - - if provision_token.status != 'HOSTNAME_UPLOADED': - return - - host = provision_token.host - hostname = host.hostname if host else provision_token.hostname - if not hostname: - return - - ip_address = provision_token.ip_address or '' - - ca_obj = CertificateAuthority.objects.filter(is_active=True).first() - if not ca_obj: - from utils.cert_service import generate_ca as _gen_ca - - ca_key, ca_cert = _gen_ca() - ca_obj = CertificateAuthority( - name='WinRM-CA', is_active=True, - ) - ca_key_pem = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - ca_cert_pem = ca_cert.public_bytes( - serialization.Encoding.PEM, - ) - ca_obj.save_ca_files(ca_key_pem, ca_cert_pem) - ca_obj.expires_at = ( - datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(days=3650) - ) - ca_obj.save() - - ca_key_pem = ca_obj.private_key - ca_cert_pem = ca_obj.certificate - if not ca_key_pem or not ca_cert_pem: - logger.error( - f"CA {ca_obj.name} key/cert files not found on disk" - ) - return - - ca_key = cast( - ec.EllipticCurvePrivateKey, - serialization.load_pem_private_key( - ca_key_pem.encode(), password=None, - ), - ) - ca_cert = x509.load_pem_x509_certificate(ca_cert_pem.encode()) - - ntlm_user = generate_random_username() - ntlm_password = generate_random_password() - upn_value = f"{ntlm_user}@localhost" - - server_result = issue_server_cert( - ca_key=ca_key, - ca_cert=ca_cert, - hostname=hostname, - ip_address=ip_address or None, - ) - - client_key, client_cert = issue_client_cert( - ca_key=ca_key, - ca_cert=ca_cert, - upn_value=upn_value, - ) - - cert_root, cert_sub = generate_cert_paths() - - ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM) - client_cert_pem = client_cert.public_bytes(serialization.Encoding.PEM) - client_key_pem = client_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - cert_dir = save_cert_files( - cert_root=cert_root, - cert_sub=cert_sub, - ca_cert_pem=ca_cert_pem, - client_cert_pem=client_cert_pem, - server_pfx_bytes=server_result['pfx_data'], - client_key_pem=client_key_pem, - ) - - if host: - host.cert_root = cert_root - host.cert_sub = cert_sub - host.pfx_password = server_result['pfx_password'] - host.ntlm_fallback_user = ntlm_user - host.ntlm_fallback_password = ntlm_password - host.cert_provision_status = 'ready' - host.cert_pem_path = str(cert_dir / 'client.crt') - host.cert_key_path = str(cert_dir / 'client.key') - host.auth_method = 'certificate' - host.use_ssl = True - if host.port == 5985: - host.port = 5986 - host.save() - - if not host: - provision_token.cert_data = { - 'cert_root': cert_root, - 'cert_sub': cert_sub, - 'pfx_password': server_result['pfx_password'], - 'ntlm_user': ntlm_user, - 'ntlm_password': ntlm_password, - 'ca_cert_b64': base64.b64encode( - ca_cert_pem - ).decode('utf-8'), - 'client_cert_b64': base64.b64encode( - client_cert_pem - ).decode('utf-8'), - 'server_pfx_b64': base64.b64encode( - server_result['pfx_data'] - ).decode('utf-8'), - } - - provision_token.status = 'CERT_ISSUED' - provision_token.save() - - return {'success': True, 'host_id': host.pk if host else None} - - -@shared_task -def cleanup_expired_provision_tokens(): - from apps.bootstrap.models import CertProvisionToken - now = timezone.now() - CertProvisionToken.objects.filter( - status='ISSUED', expires_at__lt=now, - ).delete() - week_ago = now - timedelta(days=7) - CertProvisionToken.objects.filter(expires_at__lt=week_ago).delete() - - -@shared_task -def cleanup_unactivated_certificates(): - from apps.hosts.models import Host - from utils.cert_storage import delete_cert_files - now = timezone.now() - cutoff = now - timedelta(minutes=60) - hosts = Host.objects.filter( - cert_provision_status__in=['pending', 'ready'], - created_at__lt=cutoff, - cert_activated_at__isnull=True, - ) - for host in hosts: - if host.cert_root and host.cert_sub: - delete_cert_files(host.cert_root, host.cert_sub) - host.cert_provision_status = 'failed' - host.cert_root = '' - host.cert_sub = '' - host.save() - - -@shared_task -def cleanup_orphan_cert_dirs(): - from apps.hosts.models import Host - from utils.cert_storage import get_cert_base_dir - import shutil - base_dir = get_cert_base_dir() - if not base_dir.exists(): - return - active_paths = set() - for host in Host.objects.filter(cert_root__gt='', cert_sub__gt=''): - active_paths.add((host.cert_root, host.cert_sub)) - for root_dir in base_dir.iterdir(): - if root_dir.is_dir() and len(root_dir.name) == 2: - for sub_dir in root_dir.iterdir(): - if sub_dir.is_dir() and len(sub_dir.name) == 2: - if (root_dir.name, sub_dir.name) not in active_paths: - shutil.rmtree(sub_dir, ignore_errors=True) - try: - root_dir.rmdir() - except OSError: - logger.debug( - "Skipping removal of non-empty or inaccessible orphan cert root dir: %s", - root_dir, - ) diff --git a/apps/bootstrap/tests/__init__.py b/apps/bootstrap/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/bootstrap/token_utils.py b/apps/bootstrap/token_utils.py deleted file mode 100644 index 6a967ff..0000000 --- a/apps/bootstrap/token_utils.py +++ /dev/null @@ -1,21 +0,0 @@ -import base64 -import json - - -def encode_provision_token(raw_token: str, scheme: str, host: str) -> str: - payload = json.dumps({"t": raw_token, "s": scheme, "h": host}, separators=(',', ':')) - return base64.urlsafe_b64encode(payload.encode('utf-8')).decode('ascii') - - -def decode_provision_token(encoded: str) -> dict | None: - try: - padding = 4 - len(encoded) % 4 - if padding != 4: - encoded += '=' * padding - payload = base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8') - data = json.loads(payload) - if 't' in data and 's' in data and 'h' in data: - return data - except Exception: - pass - return None diff --git a/apps/bootstrap/urls.py b/apps/bootstrap/urls.py deleted file mode 100644 index b18bd70..0000000 --- a/apps/bootstrap/urls.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.urls import path -from . import views - -app_name = 'bootstrap' - -urlpatterns = [ - # 引导配置API - path('config/', views.get_bootstrap_config, name='get_bootstrap_config'), - path('trigger/', views.trigger_host_bootstrap, name='trigger_host_bootstrap'), - path('create-initial-token/', views.create_initial_token, name='create_initial_token'), - path('status/', views.check_bootstrap_status, name='check_bootstrap_status'), - path('validate-token/', views.validate_bootstrap_token, name='validate_bootstrap_token'), - - # 引导管理API - path('manage/', views.BootstrapManagementView.as_view(), name='bootstrap_management'), - - # 新增API端点 - 基于配对码的认证机制 - path('verify-pairing-code/', views.verify_pairing_code, name='verify_pairing_code'), - path('api/verify-pairing-code/', views.verify_pairing_code, name='api_verify_pairing_code'), - path('exchange-token/', views.exchange_token, name='exchange_token'), - path('session/', views.revoke_session, name='revoke_session'), - - # API端点别名 - 为H端提供兼容路径 - path('api/verify_pairing_code/', views.verify_pairing_code, name='api_verify_pairing_code'), - path('api/exchange_token/', views.exchange_token, name='api_exchange_token'), - path('api/get_session_token', views.get_session_token, name='api_get_session_token_no_slash'), # 不带斜杠版本 - path('api/get_session_token/', views.get_session_token, name='api_get_session_token'), - path('api/upload_host_cert/', views.upload_host_cert, name='api_upload_host_cert'), - path('api/check_pairing_status', views.check_pairing_status, name='api_check_pairing_status'), # 新增:检查配对状态 - path('api/session/', views.revoke_session, name='api_revoke_session'), - - # 自动注册API - path('api/auto-register/', views.auto_register_host, name='auto_register_host'), - path('api/complete-auto-register/', views.complete_auto_register, name='complete_auto_register'), - path('api/pending-hosts/', views.get_pending_hosts, name='get_pending_hosts'), - path('api/revoke-pending-host/', views.revoke_pending_host, name='revoke_pending_host'), - - path('sse/init-status/', views.sse_init_status, name='sse_init_status'), - - path('api/cert-provision/validate/', views.cert_provision_validate, name='cert_provision_validate'), - path('api/cert-provision/upload-hostname/', views.cert_provision_upload_hostname, name='cert_provision_upload_hostname'), - path('api/cert-provision/download-certs/', views.cert_provision_download_certs, name='cert_provision_download_certs'), - path('api/cert-provision/notify-complete/', views.cert_provision_notify_complete, name='cert_provision_notify_complete'), - path('api/cert-provision/disable-password-auth/', views.cert_provision_disable_password_auth, name='cert_provision_disable_password_auth'), - path('api/cert-provision/test-result/', views.cert_provision_test_result, name='cert_provision_test_result'), - path('api/cert-provision/status-stream/', views.cert_provision_status_stream, name='cert_provision_status_stream'), - path('api/cert-provision/test-stream/', views.cert_provision_test_stream, name='cert_provision_test_stream'), -] \ No newline at end of file diff --git a/apps/bootstrap/views.py b/apps/bootstrap/views.py deleted file mode 100644 index 4323ba7..0000000 --- a/apps/bootstrap/views.py +++ /dev/null @@ -1,1444 +0,0 @@ -from django.http import JsonResponse, StreamingHttpResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods -from django.contrib.auth.decorators import login_required, permission_required -from django.utils.decorators import method_decorator -from django.views import View -from .models import InitialToken, ActiveSession, CertProvisionToken -from apps.hosts.models import Host -from apps.certificates.models import CertificateAuthority, ServerCertificate -from apps.tasks.models import AsyncTask -from apps.bootstrap.tasks import generate_bootstrap_config, initialize_host_bootstrap -from django.shortcuts import get_object_or_404 -import json -import logging -import base64 -from django.utils import timezone -from django.core.cache import cache -import secrets -import uuid -import time -import hmac - -from utils.helpers import get_client_ip - - -logger = logging.getLogger(__name__) - - -def _bootstrap_rate_limit(key_prefix, rate='10/m'): - limit, period = rate.lower().split('/') - limit = int(limit) - period_map = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400} - period_seconds = period_map.get(period, 60) - - def decorator(view_func): - def wrapper(request, *args, **kwargs): - ip = get_client_ip(request) - window = int(time.time() // period_seconds) - cache_key = f'rl:{key_prefix}:{ip}:{window}' - current = cache.get(cache_key, 0) - if current >= limit: - return JsonResponse( - {'success': False, 'error': 'Too many requests'}, - status=429, - ) - cache.set(cache_key, current + 1, timeout=period_seconds + 1) - return view_func(request, *args, **kwargs) - wrapper.__name__ = view_func.__name__ - return wrapper - return decorator - - -def _save_cert_to_host(host, pfx_b64, pfx_password, service_user, service_password): - import base64 - from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption - - try: - pfx_data = base64.b64decode(pfx_b64) - private_key, certificate, _ = pkcs12.load_key_and_certificates( - pfx_data, pfx_password.encode() - ) - except Exception as e: - logger.error(f"PFX decode failed for host {host.pk}: {e}") - return False - - if not private_key or not certificate: - return False - - import os - from django.conf import settings - cert_dir = os.path.join(settings.MEDIA_ROOT, 'certs', 'hosts', str(host.pk)) - os.makedirs(cert_dir, exist_ok=True) - - pem_path = os.path.join(cert_dir, 'client.pem') - key_path = os.path.join(cert_dir, 'client.key') - - with open(pem_path, 'wb') as f: - f.write(certificate.public_bytes(Encoding.PEM)) - with open(key_path, 'wb') as f: - f.write(private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())) - - os.chmod(pem_path, 0o600) - os.chmod(key_path, 0o600) - - update_fields = {'cert_pem_path': pem_path, 'cert_key_path': key_path} - if service_user and service_password: - update_fields['username'] = service_user - from utils.crypto import encrypt_value - update_fields['_password'] = encrypt_value(service_password) - - from apps.hosts.models import Host - Host.objects.filter(pk=host.pk).update(**update_fields) - - try: - host.refresh_from_db() - from apps.hosts.tasks import test_winrm_connection - test_winrm_connection.delay(host.pk) - except Exception as e: - logger.warning(f"Failed to dispatch connection test after cert upload: {e}") - - return True - - -@csrf_exempt -@require_http_methods(["POST"]) -def upload_host_cert(request): - try: - data = json.loads(request.body) - token_value = data.get('token', '') - pfx_b64 = data.get('pfx_b64', '') - pfx_password = data.get('pfx_password', '') - service_user = data.get('service_user', '') - service_password = data.get('service_password', '') - - if not token_value or not pfx_b64: - return JsonResponse({'success': False, 'error': 'Missing required fields'}, status=400) - - try: - token_obj = InitialToken.objects.get(token=token_value) - except InitialToken.DoesNotExist: - return JsonResponse({'success': False, 'error': 'Invalid token'}, status=401) - - if token_obj.host: - ok = _save_cert_to_host( - token_obj.host, pfx_b64, pfx_password, - service_user, service_password, - ) - if not ok: - return JsonResponse({'success': False, 'error': 'Invalid PFX data'}, status=400) - else: - token_obj.cert_data = { - 'pfx_b64': pfx_b64, - 'pfx_password': pfx_password, - 'service_user': service_user, - 'service_password': service_password, - } - token_obj.save(update_fields=['cert_data']) - logger.info(f"Cert data stored on token {token_obj.pk[:8]}, waiting for host association") - - logger.info(f"Cert uploaded for token {token_obj.pk[:8]}") - return JsonResponse({'success': True}) - - except Exception as e: - logger.error(f"upload_host_cert error: {e}", exc_info=True) - return JsonResponse({'success': False, 'error': 'Internal server error'}, status=500) - - -@login_required -@permission_required('hosts.delete_host', raise_exception=True) -def revoke_pending_host(request): - """ - 吊销待验证主机 - 删除InitialToken和关联的Host记录 - """ - try: - data = json.loads(request.body.decode('utf-8')) - token = data.get('token') - - if not token: - return JsonResponse({ - 'success': False, - 'error': 'Token is required' - }, status=400) - - try: - initial_token = InitialToken.objects.get(token=token) - host = initial_token.host - - initial_token.delete() - host.delete() - - return JsonResponse({ - 'success': True, - 'message': 'Host revoked successfully' - }) - - except InitialToken.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Token not found' - }, status=404) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error revoking pending host: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to revoke pending host' - }, status=500) - - -@require_http_methods(["GET"]) -@login_required -@permission_required('bootstrap.view_initialtoken', raise_exception=True) -def get_pending_hosts(request): - """ - 获取待验证的主机列表 - 返回所有状态为ISSUED的InitialToken - """ - try: - from apps.hosts.models import Host - - current_time = timezone.now() - pending_tokens = InitialToken.objects.filter( - status='ISSUED', - expires_at__gt=current_time - ).select_related('host').order_by('-created_at') - - hosts = [] - for token in pending_tokens: - if token.host: - hosts.append({ - 'token': token.token, - 'hostname': token.host.hostname, - 'host_id': token.host.id, - 'created_at': token.created_at.strftime('%Y-%m-%d %H:%M:%S'), - 'expires_at': token.expires_at.strftime('%Y-%m-%d %H:%M:%S') - }) - - return JsonResponse({ - 'success': True, - 'data': { - 'hosts': hosts - } - }) - - except Exception as e: - logger.error(f"Error in get_pending_hosts: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to get pending hosts' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -@permission_required('bootstrap.add_initialtoken', raise_exception=True) -def create_initial_token(request): - """创建初始令牌API - 基于配对码的简化认证机制""" - try: - data = json.loads(request.body.decode('utf-8')) - host_id = data.get('host_id') - operator_id = data.get('operator_id') - expire_hours = data.get('expire_hours', 24) - - if not host_id: - return JsonResponse({ - 'success': False, - 'error': 'Host ID is required' - }, status=400) - - if not operator_id: - return JsonResponse({ - 'success': False, - 'error': 'Operator ID is required' - }, status=400) - - try: - host = Host.objects.get(id=host_id) - except Host.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Host not found' - }, status=404) - - from datetime import timedelta - from django.utils import timezone - - token = secrets.token_urlsafe(32) - expires_at = timezone.now() + timedelta(hours=expire_hours) - - initial_token = InitialToken.objects.create( - token=token, - host=host, - expires_at=expires_at, - status='ISSUED' - ) - - pairing_code = initial_token.generate_pairing_code() - - import base64 - config_data = { - 'c_side_url': request.build_absolute_uri('/').rstrip('/'), - 'token': initial_token.token, - 'host_id': str(host.id), - 'expires_at': initial_token.expires_at.isoformat() - } - - config_json = json.dumps(config_data) - encoded_config = base64.b64encode(config_json.encode('utf-8')).decode('utf-8') - - return JsonResponse({ - 'success': True, - 'data': { - 'token': initial_token.token, - 'expires_at': initial_token.expires_at.isoformat(), - 'host_id': host.id, - 'hostname': host.hostname, - 'pairing_code': pairing_code, - 'encoded_config': encoded_config - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error creating initial token: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to create initial token' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -@_bootstrap_rate_limit('pairing_verify', '5/m') -def verify_pairing_code(request): - """配对码验证接口 - 简化的认证机制 - - 支持两种参数方式: - 1. 通过 host_id 和 pairing_code 验证 - 2. 通过 token 和 pairing_code 验证 - """ - try: - data = json.loads(request.body.decode('utf-8')) - host_id = data.get('host_id') - token = data.get('token') - pairing_code = data.get('pairing_code') - - if not pairing_code: - return JsonResponse({ - 'success': False, - 'error': 'Pairing code is required' - }, status=400) - - if not host_id and not token: - return JsonResponse({ - 'success': False, - 'error': 'Either host_id or token is required' - }, status=400) - - try: - if token: - try: - token_obj = InitialToken.objects.get( - token=token, - status='ISSUED', - expires_at__gt=timezone.now() - ) - - if token_obj.verify_pairing_code(pairing_code): - return JsonResponse({ - 'success': True, - 'message': 'Pairing code verification successful' - }) - else: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired pairing code' - }, status=400) - - except InitialToken.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid request' - }, status=400) - else: - initial_tokens = InitialToken.objects.filter( - host_id=host_id, - status='ISSUED', - expires_at__gt=timezone.now() - ) - - if not initial_tokens.exists(): - return JsonResponse({ - 'success': False, - 'error': 'Invalid request' - }, status=400) - - verified = False - for token_obj in initial_tokens: - if token_obj.verify_pairing_code(pairing_code): - verified = True - break - - if verified: - return JsonResponse({ - 'success': True, - 'message': 'Pairing code verification successful' - }) - else: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired pairing code' - }, status=400) - - except Exception as e: - logger.error(f"Error verifying pairing code: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Pairing code verification failed' - }, status=500) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error validating pairing code: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Pairing code validation failed' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -def get_bootstrap_config(request): - """获取主机引导配置API""" - try: - data = json.loads(request.body.decode('utf-8')) - hostname = data.get('hostname') - ip_address = data.get('ip_address') - auth_token = data.get('auth_token') # 认证令牌 - - if not hostname or not auth_token: - return JsonResponse({ - 'success': False, - 'error': 'Hostname and auth_token are required' - }, status=400) - - # 验证初始令牌 - try: - token_obj = InitialToken.objects.get( - token=auth_token, - status='PAIRED', # 确保已经配对验证 - expires_at__gt=timezone.now() - ) - except InitialToken.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or unauthorized bootstrap token' - }, status=401) - - # 验证主机是否匹配令牌 - if str(token_obj.host.id) != data.get('host_id', ''): - return JsonResponse({ - 'success': False, - 'error': 'Host ID does not match the token' - }, status=400) - - # 标记令牌为已使用 - token_obj.status = 'CONSUMED' - token_obj.save() - - # 生成活动会话 - session_token = str(uuid.uuid4()) - bound_ip = request.META.get('REMOTE_ADDR', '127.0.0.1') - - ActiveSession.objects.create( - session_token=session_token, - host=token_obj.host, - bound_ip=bound_ip, - expires_at=timezone.now() + timezone.timedelta(days=1) # 24小时有效期 - ) - - # 生成引导配置(异步任务) - from apps.accounts.models import User - admin_user = User.objects.filter(is_superuser=True).first() - operator_id = admin_user.id if admin_user else None - - task_result = generate_bootstrap_config.delay( - hostname=hostname, - ip_address=ip_address or token_obj.host.ip_address, - operator_id=operator_id - ) - - # 等待任务完成(最多等待30秒) - config_result = task_result.get(timeout=30) - - if config_result['success']: - return JsonResponse({ - 'success': True, - 'data': config_result['config'], - 'session_token': session_token # 返回新的会话令牌 - }) - else: - return JsonResponse({ - 'success': False, - 'error': config_result.get('error', 'Failed to generate bootstrap config') - }, status=500) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error getting bootstrap config: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to get bootstrap config' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -@permission_required('bootstrap.change_initialtoken', raise_exception=True) -def trigger_host_bootstrap(request): - """触发主机引导流程API""" - try: - data = json.loads(request.body.decode('utf-8')) - host_id = data.get('host_id') - operator_id = data.get('operator_id') - - if not host_id: - return JsonResponse({ - 'success': False, - 'error': 'Host ID is required' - }, status=400) - - if not operator_id: - return JsonResponse({ - 'success': False, - 'error': 'Operator ID is required' - }, status=400) - - try: - host = Host.objects.get(id=host_id) - except Host.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Host not found' - }, status=404) - - task_result = initialize_host_bootstrap.delay( - host_id=host_id, - operator_id=operator_id - ) - - return JsonResponse({ - 'success': True, - 'data': { - 'task_id': task_result.id, - 'host_id': host_id, - 'hostname': host.hostname, - 'status': 'started' - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error triggering host bootstrap: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to trigger host bootstrap' - }, status=500) - - -@csrf_exempt -@require_http_methods(["GET"]) -@_bootstrap_rate_limit('bootstrap_status', '10/m') -def check_bootstrap_status(request): - """检查引导状态API""" - try: - token = request.GET.get('token') - host_id = request.GET.get('host_id') - - if not token and not host_id: - return JsonResponse({ - 'success': False, - 'error': 'Either token or host_id is required' - }, status=400) - - if token: - try: - initial_token = InitialToken.objects.get(token=token) - host = initial_token.host - except InitialToken.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid token' - }, status=404) - else: - try: - host = Host.objects.get(id=host_id) - except Host.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Host not found' - }, status=404) - - return JsonResponse({ - 'success': True, - 'data': { - 'host_id': host.id, - 'hostname': host.hostname, - 'init_status': host.init_status if hasattr(host, 'init_status') else 'unknown', - 'initialized_at': getattr(host, 'initialized_at', None), - } - }) - - except Exception as e: - logger.error(f"Error checking bootstrap status: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to check bootstrap status' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -@_bootstrap_rate_limit('token_validate', '10/m') -def validate_bootstrap_token(request): - """验证引导令牌有效性""" - try: - data = json.loads(request.body.decode('utf-8')) - token = data.get('token') - - if not token: - return JsonResponse({ - 'success': False, - 'error': 'Token is required' - }, status=400) - - try: - token_obj = InitialToken.objects.get( - token=token, - status__in=['ISSUED', 'PAIRED'], - expires_at__gt=timezone.now() - ) - - return JsonResponse({ - 'success': True, - 'data': { - 'valid': True, - 'expires_at': token_obj.expires_at.isoformat(), - 'status': token_obj.status - } - }) - except InitialToken.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired token' - }, status=401) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error validating bootstrap token: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Token validation failed' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -@_bootstrap_rate_limit('session_token', '10/m') -def get_session_token(request): - """获取会话令牌接口 - H端初始化流程的第一步""" - try: - client_ip = get_client_ip(request) - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - - if not auth_header.startswith('Bearer '): - return JsonResponse({ - 'success': False, - 'error': 'Authorization header missing or invalid' - }, status=401) - - initial_token = auth_header.split(' ')[1] - - try: - token_obj = InitialToken.objects.get( - token=initial_token, - status__in=['ISSUED', 'PAIRED'], - expires_at__gt=timezone.now() - ) - except InitialToken.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired initial token' - }, status=401) - - # 获取真实客户端IP - ip = client_ip - - # 原子操作:生成新的session_token,创建ActiveSession记录 - from django.db import transaction - with transaction.atomic(): - session_token = str(uuid.uuid4()) - - if token_obj.host: - ActiveSession.objects.create( - session_token=session_token, - host=token_obj.host, - bound_ip=ip, - expires_at=timezone.now() + timezone.timedelta(hours=1) - ) - - token_obj.status = 'CONSUMED' - token_obj.save() - - return JsonResponse({ - 'success': True, - 'session_token': session_token, - 'expires_in': 3600, - }) - - except Exception as e: - logger.error(f"Error creating session token: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to create session token' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -@_bootstrap_rate_limit('exchange_token', '10/m') -def exchange_token(request): - """令牌交换接口 - 根据规范""" - try: - client_ip = get_client_ip(request) - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - - if not auth_header.startswith('Bearer '): - return JsonResponse({ - 'success': False, - 'error': 'Authorization header missing or invalid' - }, status=401) - - session_token = auth_header.split(' ')[1] - - try: - active_session = ActiveSession.objects.get( - session_token=session_token, - expires_at__gt=timezone.now() - ) - except ActiveSession.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired session token' - }, status=401) - - # 验证IP绑定 - current_ip = client_ip - if active_session.bound_ip != current_ip: - return JsonResponse({ - 'success': False, - 'error': 'IP address mismatch' - }, status=403) - - # 延长会话有效期 - from django.db import transaction - with transaction.atomic(): - active_session.expires_at = timezone.now() + timezone.timedelta(days=7) # 延长到7天 - active_session.save() - - return JsonResponse({ - 'success': True, - 'session_token': session_token, - 'expires_in': 604800, - }) - - except Exception as e: - logger.error(f"Error exchanging token: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Token exchange failed' - }, status=500) - - -@csrf_exempt -@require_http_methods(["GET"]) -@_bootstrap_rate_limit('pairing_status', '10/m') -def check_pairing_status(request): - """检查配对状态接口""" - try: - # 从Authorization头获取InitialToken - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - if not auth_header.startswith('Bearer '): - return JsonResponse({ - 'paired': False, - 'message': 'Invalid authorization header' - }, status=401) - - initial_token = auth_header.split(' ')[1] - - # 查找对应的初始令牌 - try: - token_obj = InitialToken.objects.get( - token=initial_token, - expires_at__gt=timezone.now() - ) - - if token_obj.status == 'PAIRED': - return JsonResponse({ - 'paired': True, - 'message': 'Pairing completed', - }) - elif token_obj.status == 'ISSUED': - return JsonResponse({ - 'paired': False, - 'message': 'Waiting for pairing code verification', - }) - elif token_obj.status == 'CONSUMED': - return JsonResponse({ - 'paired': True, - 'message': 'Token already consumed', - }) - else: - return JsonResponse({ - 'paired': False, - 'message': f'Token status: {token_obj.status}' - }) - - except InitialToken.DoesNotExist: - return JsonResponse({ - 'paired': False, - 'message': 'Invalid or expired token' - }, status=404) - - except Exception as e: - logger.error(f"Error checking pairing status: {str(e)}", exc_info=True) - return JsonResponse({ - 'paired': False, - 'message': 'Internal error' - }, status=500) - - -@csrf_exempt -@require_http_methods(["DELETE"]) -def revoke_session(request): - """吊销会话接口 - 根据规范""" - try: - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - if not auth_header.startswith('Bearer '): - return JsonResponse({ - 'success': False, - 'error': 'Authorization header missing or invalid' - }, status=401) - - session_token = auth_header.split(' ')[1] - - # 删除ActiveSession表中的对应记录 - try: - session = ActiveSession.objects.get(session_token=session_token) - session.delete() - - return JsonResponse({ - 'success': True, - 'message': 'Session revoked successfully' - }) - except ActiveSession.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid session token' - }, status=401) - - except Exception as e: - logger.error(f"Error revoking session: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to revoke session' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -@permission_required('hosts.add_host', raise_exception=True) -def complete_auto_register(request): - """ - 完成自动注册 - h_side_init.exe验证成功后调用此API创建主机记录 - """ - try: - import json - from apps.hosts.models import Host - - data = json.loads(request.body.decode('utf-8')) - - token = data.get('token', '') - hostname = data.get('hostname', '') - - if not token or not hostname: - return JsonResponse({ - 'success': False, - 'error': 'token and hostname are required' - }, status=400) - - # 检查是否已存在同名主机 - existing_host = Host.objects.filter(hostname=hostname).first() - if existing_host: - host = existing_host - host.status = 'offline' - host.save() - logger.info(f"主机 {hostname} 已存在,更新状态") - else: - # 创建新主机 - host = Host.objects.create( - name=hostname, - hostname=hostname, - connection_type='tunnel', - username='placeholder', - password='placeholder', - status='offline', - description='自动注册主机' - ) - logger.info(f"自动创建新主机: {hostname}") - - # 创建InitialToken - from datetime import timedelta - import secrets - - expires_at = timezone.now() + timedelta(hours=24) - initial_token = InitialToken.objects.create( - token=token, - host=host, - expires_at=expires_at, - status='ISSUED' - ) - - # 生成配对码 - pairing_code = initial_token.generate_pairing_code() - - return JsonResponse({ - 'success': True, - 'data': { - 'host_id': host.id, - 'hostname': host.hostname, - 'pairing_code': pairing_code, - 'token': initial_token.token - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error in complete_auto_register: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to complete auto register' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -@permission_required('hosts.add_host', raise_exception=True) -def auto_register_host(request): - """ - 自动注册主机接口 - 只生成预注册token,不创建主机记录 - 主机记录在h_side_init.exe完成验证后创建 - """ - try: - import json - import secrets - from datetime import timedelta - - data = json.loads(request.body.decode('utf-8')) - hostname = data.get('hostname', '') - - # 验证必填字段 - if not hostname: - return JsonResponse({ - 'success': False, - 'error': 'hostname is required' - }, status=400) - - # 生成预注册token(不创建主机记录) - token = secrets.token_urlsafe(32) - expires_at = timezone.now() + timedelta(hours=24) - - # 构建配置数据(供h_side_init.exe使用) - current_site = request.build_absolute_uri('/').rstrip('/') - secret_data = { - "c_side_url": current_site, - "token": token, - "hostname": hostname, - "generated_at": timezone.now().isoformat(), - "expires_at": expires_at.isoformat(), - "auto_register": True # 标记为自动注册模式 - } - - import base64 - json_str = json.dumps(secret_data, ensure_ascii=False) - encoded_bytes = base64.b64encode(json_str.encode('utf-8')) - encoded_str = encoded_bytes.decode('utf-8') - - # 生成 PowerShell 脚本(下载并运行) - download_url = "https://2c2a.cc.cd/2c2a/HostInitBash/releases/latest/download/h_side_init.exe" - script = f'''$exe = "$env:TEMP\\h_side_init.exe" -Invoke-WebRequest -Uri "{download_url}" -OutFile $exe -UseBasicParsing -& $exe "{encoded_str}"''' - - return JsonResponse({ - 'success': True, - 'data': { - 'script': script, - 'secret': encoded_str - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error in auto_register_host: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to generate register script' - }, status=500) - - -@login_required -def sse_init_status(request): - import json as _json - - token = request.GET.get('token', '') - if not token: - return JsonResponse( - {'error': 'token required'}, status=400, - ) - - def event_stream(): - for _ in range(120): - try: - token_obj = InitialToken.objects.get(token=token) - status = token_obj.status - data = { - 'status': status, - 'host_id': ( - token_obj.host_id - if token_obj.host_id else None - ), - 'cert_uploaded': bool(token_obj.cert_data), - } - if status == 'CONSUMED' and token_obj.host: - host_status = Host.objects.filter( - pk=token_obj.host_id - ).values_list('status', flat=True).first() - data['host_status'] = host_status - if host_status == 'online': - yield f"data: {_json.dumps(data)}\n\n" - return - if status == 'CONSUMED' and token_obj.cert_data and not token_obj.host: - yield f"data: {_json.dumps(data)}\n\n" - return - yield f"data: {_json.dumps(data)}\n\n" - if status == 'CONSUMED': - for _ in range(24): - time.sleep(5) - token_obj.refresh_from_db() - if token_obj.cert_data and not token_obj.host: - data['cert_uploaded'] = True - yield f"data: {_json.dumps(data)}\n\n" - return - if token_obj.host: - host_status = Host.objects.filter( - pk=token_obj.host_id - ).values_list('status', flat=True).first() - data['host_status'] = host_status - if host_status == 'online': - yield f"data: {_json.dumps(data)}\n\n" - return - return - except InitialToken.DoesNotExist: - yield f"data: {_json.dumps({'status': 'NOT_FOUND'})}\n\n" - return - time.sleep(5) - yield f"data: {_json.dumps({'status': 'TIMEOUT'})}\n\n" - - response = StreamingHttpResponse( - event_stream(), - content_type='text/event-stream', - ) - response['Cache-Control'] = 'no-cache' - response['X-Accel-Buffering'] = 'no' - return response - - -def get_client_ip(request): - """获取客户端真实IP地址""" - from django.conf import settings as django_settings - if getattr(django_settings, 'USE_X_FORWARDED_FOR', False): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - return x_forwarded_for.split(',')[0].strip() - return request.META.get('REMOTE_ADDR', '127.0.0.1') - - -class BootstrapManagementView(View): - """引导管理视图 - 需要管理员权限""" - - @method_decorator(permission_required('bootstrap.view_initialtoken')) - def get(self, request): - """获取引导令牌列表""" - try: - page = int(request.GET.get('page', 1)) - page_size = min(int(request.GET.get('page_size', 20)), 100) # 最大100条每页 - status_filter = request.GET.get('status') # issued, paired, consumed, all - - queryset = InitialToken.objects.select_related('host').all() - - # 状态过滤 - if status_filter == 'issued': - queryset = queryset.filter(status='ISSUED') - elif status_filter == 'paired': - queryset = queryset.filter(status='PAIRED') - elif status_filter == 'consumed': - queryset = queryset.filter(status='CONSUMED') - elif status_filter == 'expired': - queryset = queryset.filter(expires_at__lt=timezone.now()) - elif status_filter != 'all': - # 默认显示未过期的 - queryset = queryset.filter(expires_at__gt=timezone.now()) - - # 分页 - start_idx = (page - 1) * page_size - end_idx = start_idx + page_size - tokens = queryset[start_idx:end_idx] - - total_count = queryset.count() - - result = { - 'success': True, - 'data': { - 'tokens': [ - { - 'id': token.token, - 'token': token.token, - 'hostname': token.host.hostname, - 'host_id': token.host.id, - 'created_at': token.created_at.isoformat(), - 'expires_at': token.expires_at.isoformat(), - 'status': token.status, - 'is_expired': token.expires_at < timezone.now() - } - for token in tokens - ], - 'pagination': { - 'page': page, - 'page_size': page_size, - 'total_count': total_count, - 'total_pages': (total_count + page_size - 1) // page_size - } - } - } - - return JsonResponse(result) - - except ValueError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid page or page_size parameter' - }, status=400) - except Exception as e: - logger.error(f"Error getting bootstrap tokens: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve bootstrap tokens' - }, status=500) - - @method_decorator(permission_required('bootstrap.delete_initialtoken')) - def delete(self, request): - """删除引导令牌""" - try: - token_id = request.GET.get('id') - - if not token_id: - return JsonResponse({ - 'success': False, - 'error': 'Token ID is required' - }, status=400) - - token = get_object_or_404(InitialToken, token=token_id) - token.delete() - - return JsonResponse({ - 'success': True, - 'message': 'Initial token deleted successfully' - }) - - except Exception as e: - logger.error(f"Error deleting initial token: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to delete initial token' - }, status=500) - - -@csrf_exempt -@require_http_methods(["GET"]) -def cert_provision_validate(request): - token_str = request.GET.get('token', '') - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - return JsonResponse({ - 'valid': provision_token.is_valid(), - 'server_host': provision_token.server_host, - 'status': provision_token.status, - }) - except CertProvisionToken.DoesNotExist: - return JsonResponse({'valid': False, 'server_host': '', 'status': ''}) - - -@csrf_exempt -@require_http_methods(["POST"]) -def cert_provision_upload_hostname(request): - try: - data = json.loads(request.body) - token_str = data.get('token', '') - hostname = data.get('hostname', '') - except (json.JSONDecodeError, AttributeError): - return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400) - - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) - - if not provision_token.is_valid(): - return JsonResponse({'success': False, 'error': 'Token expired'}, status=403) - - host = provision_token.host - if host: - Host.objects.filter(pk=host.pk).update(hostname=hostname) - host.refresh_from_db() - else: - provision_token.hostname = hostname - - provision_token.status = 'HOSTNAME_UPLOADED' - provision_token.save() - - from apps.bootstrap.tasks import cert_provision_issue_certs - cert_provision_issue_certs.delay(token_str) - - return JsonResponse({'success': True}) - - -@csrf_exempt -@require_http_methods(["GET"]) -def cert_provision_download_certs(request): - token_str = request.GET.get('token', '') - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) - - if provision_token.status != 'CERT_ISSUED': - return JsonResponse({'success': False, 'error': 'Certificates not ready'}, status=400) - - host = provision_token.host - if host and host.cert_root and host.cert_sub: - from utils.cert_storage import get_cert_file_paths - paths = get_cert_file_paths(host.cert_root, host.cert_sub) - - ca_cert_b64 = base64.b64encode(paths['ca_cert'].read_bytes()).decode('utf-8') - client_cert_b64 = base64.b64encode(paths['client_cert'].read_bytes()).decode('utf-8') - server_pfx_b64 = base64.b64encode(paths['server_pfx'].read_bytes()).decode('utf-8') - - return JsonResponse({ - 'success': True, - 'ca_cert': ca_cert_b64, - 'client_cert': client_cert_b64, - 'server_pfx': server_pfx_b64, - 'pfx_password': host.pfx_password, - 'ntlm_user': host.ntlm_fallback_user, - 'ntlm_password': host.ntlm_fallback_password, - 'upn_value': f"{host.ntlm_fallback_user}@localhost", - }) - elif provision_token.cert_data: - cd = provision_token.cert_data - return JsonResponse({ - 'success': True, - 'ca_cert': cd.get('ca_cert_b64', ''), - 'client_cert': cd.get('client_cert_b64', ''), - 'server_pfx': cd.get('server_pfx_b64', ''), - 'pfx_password': cd.get('pfx_password', ''), - 'ntlm_user': cd.get('ntlm_user', ''), - 'ntlm_password': cd.get('ntlm_password', ''), - 'upn_value': f"{cd.get('ntlm_user', '')}@localhost", - }) - - return JsonResponse({'success': False, 'error': 'Host not configured'}, status=400) - - -@csrf_exempt -@require_http_methods(["POST"]) -def cert_provision_notify_complete(request): - try: - data = json.loads(request.body) - token_str = data.get('token', '') - except (json.JSONDecodeError, AttributeError): - return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400) - - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) - - provision_token.status = 'HOST_CONFIGURED' - provision_token.save() - - host = provision_token.host - if host: - from apps.hosts.tasks import test_winrm_connection - use_cert = host.auth_method == 'certificate' - test_winrm_connection.delay(host.pk, use_certificate_auth=use_cert) - return JsonResponse({'success': True, 'test': 'started'}) - else: - return JsonResponse({'success': True, 'test': 'deferred'}) - - -@csrf_exempt -@require_http_methods(["POST"]) -def cert_provision_disable_password_auth(request): - try: - data = json.loads(request.body) - token_str = data.get('token', '') - except (json.JSONDecodeError, AttributeError): - return JsonResponse({'success': False, 'error': 'Invalid request'}, status=400) - - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) - - provision_token.status = 'CONSUMED' - provision_token.consumed_at = timezone.now() - provision_token.save() - - return JsonResponse({'success': True}) - - -@csrf_exempt -@require_http_methods(["GET"]) -def cert_provision_test_result(request): - token_str = request.GET.get('token', '') - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - return JsonResponse({'success': False, 'error': 'Token not found'}, status=404) - - host = provision_token.host - if not host: - return JsonResponse({'status': 'testing'}) - - if host.cert_provision_status == 'configured': - return JsonResponse({'status': 'success'}) - elif host.cert_provision_status == 'failed': - return JsonResponse({'status': 'failed', 'error': 'Connection test failed'}) - else: - return JsonResponse({'status': 'testing'}) - - -@csrf_exempt -@require_http_methods(["GET"]) -def cert_provision_status_stream(request): - token_str = request.GET.get('token', '') - - def event_stream(): - for _ in range(120): - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - yield f"data: {json.dumps({'status': 'failed', 'error': 'Token not found'})}\n\n" - return - - if provision_token.status == 'CERT_ISSUED': - host = provision_token.host - yield f"data: {json.dumps({'status': 'ready'})}\n\n" - return - elif provision_token.status == 'HOST_CONFIGURED': - yield f"data: {json.dumps({'status': 'configured'})}\n\n" - return - elif provision_token.is_expired(): - yield f"data: {json.dumps({'status': 'failed', 'error': 'Token expired'})}\n\n" - return - - yield f"data: {json.dumps({'status': 'pending'})}\n\n" - time.sleep(5) - - yield f"data: {json.dumps({'status': 'failed', 'error': 'Timeout'})}\n\n" - - response = StreamingHttpResponse(event_stream(), content_type='text/event-stream') - response['Cache-Control'] = 'no-cache' - response['X-Accel-Buffering'] = 'no' - return response - - -@csrf_exempt -@require_http_methods(["GET"]) -def cert_provision_test_stream(request): - token_str = request.GET.get('token', '') - - def event_stream(): - for _ in range(60): - try: - provision_token = CertProvisionToken.objects.get(token=token_str) - except CertProvisionToken.DoesNotExist: - yield f"data: {json.dumps({'status': 'failed', 'error': 'Token not found'})}\n\n" - return - - host = provision_token.host - if host: - if host.cert_provision_status == 'configured': - yield f"data: {json.dumps({'status': 'success'})}\n\n" - return - elif host.cert_provision_status == 'failed': - yield f"data: {json.dumps({'status': 'failed', 'error': 'Connection test failed'})}\n\n" - return - - yield f"data: {json.dumps({'status': 'testing'})}\n\n" - time.sleep(5) - - yield f"data: {json.dumps({'status': 'failed', 'error': 'Timeout'})}\n\n" - - response = StreamingHttpResponse(event_stream(), content_type='text/event-stream') - response['Cache-Control'] = 'no-cache' - response['X-Accel-Buffering'] = 'no' - return response \ No newline at end of file diff --git a/apps/certificates/apps.py b/apps/certificates/apps.py deleted file mode 100755 index 8f4d391..0000000 --- a/apps/certificates/apps.py +++ /dev/null @@ -1,56 +0,0 @@ -import logging - -from django.apps import AppConfig - - -logger = logging.getLogger(__name__) - - -class CertificatesConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "apps.certificates" - verbose_name = "证书管理系统" - - def ready(self): - import apps.certificates.signals - - self._ensure_ca_exists() - - def _ensure_ca_exists(self): - import os - - if os.environ.get("RUN_MAIN") == "true": - return - if os.environ.get("DJANGO_AUTORELOAD") == "true": - return - try: - CertificateAuthority = self.get_model("CertificateAuthority") - if not CertificateAuthority.objects.filter(is_active=True).exists(): - from utils.cert_service import generate_ca - from cryptography.hazmat.primitives import serialization - import datetime - - ca_key, ca_cert = generate_ca() - ca_key_pem = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - ca_cert_pem = ca_cert.public_bytes( - serialization.Encoding.PEM, - ) - - ca, created = CertificateAuthority.objects.get_or_create( - name="WinRM-CA", - defaults={"is_active": True}, - ) - if created: - ca.save_ca_files(ca_key_pem, ca_cert_pem) - ca.expires_at = datetime.datetime.now( - datetime.timezone.utc - ) + datetime.timedelta(days=3650) - ca.save() - except Exception: - logger.exception( - "Failed to ensure default certificate authority exists during app startup." - ) diff --git a/apps/certificates/migrations/0001_initial.py b/apps/certificates/migrations/0001_initial.py deleted file mode 100755 index bb1244d..0000000 --- a/apps/certificates/migrations/0001_initial.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-01 11:58 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='CertificateAuthority', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True, verbose_name='CA名称')), - ('private_key', models.TextField(verbose_name='私钥(加密存储)')), - ('certificate', models.TextField(verbose_name='CA证书(PEM格式)')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('expires_at', models.DateTimeField(verbose_name='过期时间')), - ('is_active', models.BooleanField(default=True, verbose_name='是否激活')), - ('description', models.TextField(blank=True, null=True, verbose_name='描述')), - ], - options={ - 'verbose_name': '证书颁发机构', - 'verbose_name_plural': '证书颁发机构', - 'db_table': 'certificate_authority', - }, - ), - migrations.CreateModel( - name='ServerCertificate', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('hostname', models.CharField(max_length=255, unique=True, verbose_name='主机名')), - ('private_key', models.TextField(verbose_name='私钥(加密存储)')), - ('certificate', models.TextField(verbose_name='服务器证书(PEM格式)')), - ('pfx_data', models.TextField(verbose_name='PFX数据(Base64编码)')), - ('thumbprint', models.CharField(max_length=255, unique=True, verbose_name='证书指纹(SHA1)')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('expires_at', models.DateTimeField(verbose_name='过期时间')), - ('is_revoked', models.BooleanField(default=False, verbose_name='是否已吊销')), - ('revocation_reason', models.CharField(blank=True, max_length=255, null=True, verbose_name='吊销原因')), - ('revocation_date', models.DateTimeField(blank=True, null=True, verbose_name='吊销时间')), - ('ca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='certificates.certificateauthority', verbose_name='所属CA')), - ], - options={ - 'verbose_name': '服务器证书', - 'verbose_name_plural': '服务器证书', - 'db_table': 'server_certificate', - }, - ), - migrations.CreateModel( - name='ClientCertificate', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, verbose_name='证书名称')), - ('private_key', models.TextField(verbose_name='私钥(加密存储)')), - ('certificate', models.TextField(verbose_name='客户端证书(PEM格式)')), - ('thumbprint', models.CharField(max_length=255, unique=True, verbose_name='证书指纹(SHA1)')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('expires_at', models.DateTimeField(verbose_name='过期时间')), - ('is_active', models.BooleanField(default=True, verbose_name='是否激活')), - ('description', models.TextField(blank=True, null=True, verbose_name='描述')), - ('assigned_to_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='分配给用户')), - ('ca', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='certificates.certificateauthority', verbose_name='所属CA')), - ], - options={ - 'verbose_name': '客户端证书', - 'verbose_name_plural': '客户端证书', - 'db_table': 'client_certificate', - }, - ), - ] diff --git a/apps/certificates/migrations/0002_alter_certificateauthority_expires_at_and_more.py b/apps/certificates/migrations/0002_alter_certificateauthority_expires_at_and_more.py deleted file mode 100755 index 7f43963..0000000 --- a/apps/certificates/migrations/0002_alter_certificateauthority_expires_at_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-01 12:05 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('certificates', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='certificateauthority', - name='expires_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='过期时间'), - ), - migrations.AlterField( - model_name='clientcertificate', - name='expires_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='过期时间'), - ), - migrations.AlterField( - model_name='servercertificate', - name='expires_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='过期时间'), - ), - ] diff --git a/apps/certificates/migrations/0003_remove_certificateauthority_private_key_and_more.py b/apps/certificates/migrations/0003_remove_certificateauthority_private_key_and_more.py deleted file mode 100644 index 3e192ed..0000000 --- a/apps/certificates/migrations/0003_remove_certificateauthority_private_key_and_more.py +++ /dev/null @@ -1,161 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 15:15 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('certificates', '0002_alter_certificateauthority_expires_at_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='certificateauthority', - name='private_key', - ), - migrations.RemoveField( - model_name='clientcertificate', - name='private_key', - ), - migrations.RemoveField( - model_name='servercertificate', - name='private_key', - ), - migrations.AddField( - model_name='certificateauthority', - name='_private_key', - field=models.TextField(db_column='private_key', default=1, verbose_name='私钥(加密)'), - preserve_default=False, - ), - migrations.AddField( - model_name='clientcertificate', - name='_private_key', - field=models.TextField(db_column='private_key', default=1, verbose_name='私钥(加密)'), - preserve_default=False, - ), - migrations.AddField( - model_name='servercertificate', - name='_private_key', - field=models.TextField(db_column='private_key', default=1, verbose_name='私钥(加密)'), - preserve_default=False, - ), - migrations.AlterField( - model_name='certificateauthority', - name='certificate', - field=models.TextField(verbose_name='CA证书(PEM)'), - ), - migrations.AlterField( - model_name='certificateauthority', - name='created_at', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='certificateauthority', - name='description', - field=models.TextField(blank=True, null=True), - ), - migrations.AlterField( - model_name='certificateauthority', - name='expires_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='certificateauthority', - name='is_active', - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name='clientcertificate', - name='assigned_to_user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='clientcertificate', - name='ca', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='certificates.certificateauthority'), - ), - migrations.AlterField( - model_name='clientcertificate', - name='certificate', - field=models.TextField(verbose_name='证书(PEM)'), - ), - migrations.AlterField( - model_name='clientcertificate', - name='created_at', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='clientcertificate', - name='description', - field=models.TextField(blank=True, null=True), - ), - migrations.AlterField( - model_name='clientcertificate', - name='expires_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='clientcertificate', - name='is_active', - field=models.BooleanField(default=True), - ), - migrations.AlterField( - model_name='clientcertificate', - name='name', - field=models.CharField(max_length=255), - ), - migrations.AlterField( - model_name='clientcertificate', - name='thumbprint', - field=models.CharField(max_length=255, unique=True), - ), - migrations.AlterField( - model_name='servercertificate', - name='ca', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='certificates.certificateauthority'), - ), - migrations.AlterField( - model_name='servercertificate', - name='certificate', - field=models.TextField(verbose_name='证书(PEM)'), - ), - migrations.AlterField( - model_name='servercertificate', - name='created_at', - field=models.DateTimeField(auto_now_add=True), - ), - migrations.AlterField( - model_name='servercertificate', - name='expires_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='servercertificate', - name='is_revoked', - field=models.BooleanField(default=False), - ), - migrations.AlterField( - model_name='servercertificate', - name='pfx_data', - field=models.TextField(verbose_name='PFX(Base64)'), - ), - migrations.AlterField( - model_name='servercertificate', - name='revocation_date', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='servercertificate', - name='revocation_reason', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='servercertificate', - name='thumbprint', - field=models.CharField(max_length=255, unique=True), - ), - ] diff --git a/apps/certificates/migrations/0004_migrate_to_ecc_p256.py b/apps/certificates/migrations/0004_migrate_to_ecc_p256.py deleted file mode 100644 index 4b05436..0000000 --- a/apps/certificates/migrations/0004_migrate_to_ecc_p256.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-29 16:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('certificates', '0003_remove_certificateauthority_private_key_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='clientcertificate', - name='upn_value', - field=models.CharField(blank=True, default='', max_length=255, verbose_name='UPN值'), - ), - migrations.AddField( - model_name='servercertificate', - name='_pfx_password', - field=models.CharField(blank=True, db_column='pfx_password', default='', max_length=255, verbose_name='PFX密码(加密)'), - ), - migrations.AddField( - model_name='servercertificate', - name='ip_address', - field=models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址'), - ), - ] diff --git a/apps/certificates/migrations/0005_remove_cert_content_from_db.py b/apps/certificates/migrations/0005_remove_cert_content_from_db.py deleted file mode 100644 index 63cd317..0000000 --- a/apps/certificates/migrations/0005_remove_cert_content_from_db.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-30 07:54 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('certificates', '0004_migrate_to_ecc_p256'), - ] - - operations = [ - migrations.RemoveField( - model_name='clientcertificate', - name='_private_key', - ), - migrations.RemoveField( - model_name='clientcertificate', - name='certificate', - ), - migrations.RemoveField( - model_name='servercertificate', - name='_pfx_password', - ), - migrations.RemoveField( - model_name='servercertificate', - name='_private_key', - ), - migrations.RemoveField( - model_name='servercertificate', - name='certificate', - ), - migrations.RemoveField( - model_name='servercertificate', - name='pfx_data', - ), - ] diff --git a/apps/certificates/migrations/0006_remove_certificateauthority__private_key_and_more.py b/apps/certificates/migrations/0006_remove_certificateauthority__private_key_and_more.py deleted file mode 100644 index de31648..0000000 --- a/apps/certificates/migrations/0006_remove_certificateauthority__private_key_and_more.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging - -from django.db import migrations, models - -logger = logging.getLogger(__name__) - - -def migrate_ca_to_files(apps, schema_editor): - CertificateAuthority = apps.get_model('certificates', 'CertificateAuthority') - import secrets - import os - from django.conf import settings - from pathlib import Path - - ca_base_dir = Path(settings.MEDIA_ROOT) / 'certificates' / 'ca' - - for ca in CertificateAuthority.objects.all(): - raw_key = ca.__dict__.get('_private_key') or ca.__dict__.get('private_key') - raw_cert = ca.__dict__.get('certificate') - - if not raw_key or not raw_cert: - ca.is_active = False - ca.cert_dir = '' - ca.save() - logger.warning( - f"CA {ca.name} has no key/cert data, marked inactive" - ) - continue - - try: - import base64 - import hashlib - from cryptography.fernet import Fernet, InvalidToken - - key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() - fernet = Fernet(base64.urlsafe_b64encode(key)) - decrypted_key = fernet.decrypt(raw_key.encode()).decode() - - cert_dir_name = secrets.token_hex(8) - ca_dir = ca_base_dir / cert_dir_name - ca_dir.mkdir(parents=True, exist_ok=True) - - key_path = ca_dir / 'ca.key' - key_path.write_text(decrypted_key, encoding='utf-8') - os.chmod(key_path, 0o600) - - cert_path = ca_dir / 'ca.crt' - cert_path.write_text(raw_cert, encoding='utf-8') - os.chmod(cert_path, 0o600) - - ca.cert_dir = cert_dir_name - ca.save() - logger.info(f"CA {ca.name} migrated to files at {cert_dir_name}") - except (InvalidToken, Exception) as e: - ca.is_active = False - ca.cert_dir = '' - ca.save() - logger.warning( - f"CA {ca.name} key decryption failed ({e}), " - f"marked inactive for re-creation" - ) - - -def reverse_migrate(apps, schema_editor): - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ("certificates", "0005_remove_cert_content_from_db"), - ] - - operations = [ - migrations.AddField( - model_name="certificateauthority", - name="cert_dir", - field=models.CharField( - blank=True, default="", max_length=64, verbose_name="证书目录" - ), - ), - migrations.RunPython(migrate_ca_to_files, reverse_migrate), - migrations.RemoveField( - model_name="certificateauthority", - name="_private_key", - ), - migrations.RemoveField( - model_name="certificateauthority", - name="certificate", - ), - ] diff --git a/apps/certificates/migrations/0007_cert_dir_to_cert_root_sub.py b/apps/certificates/migrations/0007_cert_dir_to_cert_root_sub.py deleted file mode 100644 index 44e00b8..0000000 --- a/apps/certificates/migrations/0007_cert_dir_to_cert_root_sub.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -import os -import shutil -import secrets - -from django.db import migrations, models -from pathlib import Path -from django.conf import settings - -logger = logging.getLogger(__name__) - - -def migrate_cert_dir_to_root_sub(apps, schema_editor): - CertificateAuthority = apps.get_model( - 'certificates', 'CertificateAuthority' - ) - ca_base_dir = Path(settings.MEDIA_ROOT) / 'certificates' / 'ca' - - for ca in CertificateAuthority.objects.all(): - old_dir_name = ca.cert_dir - if not old_dir_name: - ca.cert_root = '' - ca.cert_sub = '' - ca.save() - continue - - old_dir = ca_base_dir / old_dir_name - if not old_dir.exists(): - logger.warning( - f"CA {ca.name}: old dir {old_dir} not found, " - f"generating new paths" - ) - ca.cert_root = '' - ca.cert_sub = '' - ca.save() - continue - - cert_root = secrets.token_hex(1) - cert_sub = secrets.token_hex(1) - new_dir = ca_base_dir / cert_root / cert_sub - new_dir.mkdir(parents=True, exist_ok=True) - - for fname in os.listdir(old_dir): - src = old_dir / fname - dst = new_dir / fname - shutil.move(str(src), str(dst)) - - shutil.rmtree(old_dir) - try: - old_dir.parent.rmdir() - except OSError as exc: - logger.debug( - f"CA {ca.name}: could not remove parent dir {old_dir.parent}: {exc}" - ) - - ca.cert_root = cert_root - ca.cert_sub = cert_sub - ca.save() - logger.info( - f"CA {ca.name}: moved from {old_dir_name} " - f"to {cert_root}/{cert_sub}" - ) - - -def reverse_migrate(apps, schema_editor): - CertificateAuthority = apps.get_model( - 'certificates', 'CertificateAuthority' - ) - ca_base_dir = Path(settings.MEDIA_ROOT) / 'certificates' / 'ca' - - for ca in CertificateAuthority.objects.all(): - if not ca.cert_root or not ca.cert_sub: - ca.cert_dir = '' - ca.save() - continue - - new_dir_name = secrets.token_hex(8) - new_dir = ca_base_dir / new_dir_name - old_dir = ca_base_dir / ca.cert_root / ca.cert_sub - - if old_dir.exists(): - new_dir.mkdir(parents=True, exist_ok=True) - for fname in os.listdir(old_dir): - src = old_dir / fname - dst = new_dir / fname - shutil.move(str(src), str(dst)) - shutil.rmtree(old_dir) - try: - old_dir.parent.rmdir() - except OSError as exc: - logger.debug( - f"CA {ca.name}: could not remove parent dir {old_dir.parent}: {exc}" - ) - - ca.cert_dir = new_dir_name - ca.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ( - "certificates", - "0006_remove_certificateauthority__private_key_and_more", - ), - ] - - operations = [ - migrations.AddField( - model_name="certificateauthority", - name="cert_root", - field=models.CharField( - blank=True, - default="", - max_length=2, - verbose_name="证书存储根路径", - ), - ), - migrations.AddField( - model_name="certificateauthority", - name="cert_sub", - field=models.CharField( - blank=True, - default="", - max_length=2, - verbose_name="证书存储子路径", - ), - ), - migrations.RunPython( - migrate_cert_dir_to_root_sub, reverse_migrate - ), - migrations.RemoveField( - model_name="certificateauthority", - name="cert_dir", - ), - ] diff --git a/apps/certificates/migrations/__init__.py b/apps/certificates/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/certificates/models.py b/apps/certificates/models.py deleted file mode 100755 index 7630d17..0000000 --- a/apps/certificates/models.py +++ /dev/null @@ -1,108 +0,0 @@ -from django.db import models -import datetime - -from utils.cert_storage import ( - get_ca_file_paths, - save_ca_files, - generate_ca_paths, -) - - -class CertificateAuthority(models.Model): - name = models.CharField(max_length=255, unique=True, verbose_name="CA名称") - cert_root = models.CharField( - max_length=2, default="", blank=True, verbose_name="证书存储根路径" - ) - cert_sub = models.CharField( - max_length=2, default="", blank=True, verbose_name="证书存储子路径" - ) - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField(null=True, blank=True) - is_active = models.BooleanField(default=True) - description = models.TextField(blank=True, null=True) - - class Meta: - verbose_name = "证书颁发机构" - verbose_name_plural = "证书颁发机构" - db_table = "certificate_authority" - - @property - def private_key(self): - paths = get_ca_file_paths(self.cert_root, self.cert_sub) - key_path = paths["key"] - if not key_path.exists(): - return None - return key_path.read_text(encoding="utf-8") - - @property - def certificate(self): - paths = get_ca_file_paths(self.cert_root, self.cert_sub) - cert_path = paths["cert"] - if not cert_path.exists(): - return None - return cert_path.read_text(encoding="utf-8") - - def save_ca_files(self, ca_key_pem: bytes, ca_cert_pem: bytes): - if not self.cert_root or not self.cert_sub: - self.cert_root, self.cert_sub = generate_ca_paths() - save_ca_files(self.cert_root, self.cert_sub, ca_key_pem, ca_cert_pem) - - def __str__(self): - return f"CA: {self.name}" - - -class ServerCertificate(models.Model): - hostname = models.CharField(max_length=255, unique=True, verbose_name="主机名") - ip_address = models.GenericIPAddressField( - null=True, blank=True, verbose_name="IP地址" - ) - ca = models.ForeignKey(CertificateAuthority, on_delete=models.CASCADE) - thumbprint = models.CharField(max_length=255, unique=True) - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField(null=True, blank=True) - is_revoked = models.BooleanField(default=False) - revocation_reason = models.CharField(max_length=255, blank=True, null=True) - revocation_date = models.DateTimeField(blank=True, null=True) - - class Meta: - verbose_name = "服务器证书" - verbose_name_plural = "服务器证书" - db_table = "server_certificate" - - def revoke(self, reason=""): - self.is_revoked = True - self.revocation_reason = reason - self.revocation_date = datetime.datetime.utcnow() - self.save() - - def __str__(self): - return f"Server Cert: {self.hostname}" - - -class ClientCertificate(models.Model): - name = models.CharField(max_length=255) - upn_value = models.CharField( - max_length=255, blank=True, default="", verbose_name="UPN值" - ) - ca = models.ForeignKey(CertificateAuthority, on_delete=models.CASCADE) - thumbprint = models.CharField(max_length=255, unique=True) - created_at = models.DateTimeField(auto_now_add=True) - expires_at = models.DateTimeField(null=True, blank=True) - assigned_to_user = models.ForeignKey( - "accounts.User", on_delete=models.SET_NULL, null=True, blank=True - ) - is_active = models.BooleanField(default=True) - description = models.TextField(blank=True, null=True) - - class Meta: - verbose_name = "客户端证书" - verbose_name_plural = "客户端证书" - db_table = "client_certificate" - - def __str__(self): - user_info = ( - f" (User: {self.assigned_to_user.username})" - if self.assigned_to_user - else "" - ) - return f"Client Cert: {self.name}{user_info}" diff --git a/apps/certificates/signals.py b/apps/certificates/signals.py deleted file mode 100755 index 64e89b3..0000000 --- a/apps/certificates/signals.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -证书管理应用的信号处理器 -""" -from django.db.models.signals import post_save, pre_delete -from django.dispatch import receiver -from .models import CertificateAuthority, ServerCertificate, ClientCertificate - - -# 可以在这里添加具体的信号处理器 -# 例如:在证书即将过期时发送通知 \ No newline at end of file diff --git a/apps/certificates/urls.py b/apps/certificates/urls.py deleted file mode 100755 index 7fecb9d..0000000 --- a/apps/certificates/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.urls import path -from . import views - -app_name = 'certificates' - -urlpatterns = [ - # 证书签发API - path('issue-server-cert/', views.issue_server_certificate, name='issue_server_certificate'), - path('issue-client-cert/', views.issue_client_certificate, name='issue_client_certificate'), - path('validate-request/', views.validate_certificate_request, name='validate_certificate_request'), - path('get-ca-cert/', views.get_ca_certificate, name='get_ca_certificate'), - - # 证书管理API - path('manage/', views.CertificateManagementView.as_view(), name='certificate_management'), - path('renew/', views.renew_certificate, name='renew_certificate'), -] \ No newline at end of file diff --git a/apps/certificates/views.py b/apps/certificates/views.py deleted file mode 100755 index c1af73e..0000000 --- a/apps/certificates/views.py +++ /dev/null @@ -1,492 +0,0 @@ -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods -from django.contrib.auth.decorators import login_required, permission_required -from .models import ServerCertificate, CertificateAuthority, ClientCertificate -from apps.hosts.models import Host -from django.utils.decorators import method_decorator -from django.views import View -from django.shortcuts import get_object_or_404 -import json -import logging -from datetime import datetime - -logger = logging.getLogger(__name__) - - -def _get_or_create_ca(ca_name='default-ca'): - ca, created = CertificateAuthority.objects.get_or_create( - name=ca_name, - defaults={ - 'name': ca_name, - 'description': f'Default CA for {ca_name}', - }, - ) - if created: - from utils.cert_service import generate_ca - from cryptography.hazmat.primitives import serialization - import datetime - - ca_key, ca_cert = generate_ca() - ca_key_pem = ca_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - ca_cert_pem = ca_cert.public_bytes(serialization.Encoding.PEM) - ca.save_ca_files(ca_key_pem, ca_cert_pem) - ca.expires_at = ( - datetime.datetime.now(datetime.timezone.utc) - + datetime.timedelta(days=3650) - ) - ca.save() - logger.info(f"Created new CA: {ca_name}") - - return ca, created - - -def _load_ca_crypto(ca): - from cryptography.hazmat.primitives import serialization - from cryptography import x509 as x509_mod - from typing import cast - from cryptography.hazmat.primitives.asymmetric import ec - - private_key_pem = ca.private_key - certificate_pem = ca.certificate - if not private_key_pem or not certificate_pem: - raise ValueError(f"CA {ca.name} key/cert files not found on disk") - - ca_key = cast( - ec.EllipticCurvePrivateKey, - serialization.load_pem_private_key( - private_key_pem.encode(), password=None, - ), - ) - ca_cert = x509_mod.load_pem_x509_certificate(certificate_pem.encode()) - return ca_key, ca_cert - - -@require_http_methods(["POST"]) -@login_required -@permission_required('certificates.add_servercertificate', raise_exception=True) -def issue_server_certificate(request): - try: - data = json.loads(request.body.decode('utf-8')) - hostname = data.get('hostname') - san_names = data.get('san_names', []) - ca_name = data.get('ca_name', 'default-ca') - - if not hostname: - return JsonResponse({ - 'success': False, - 'error': 'Hostname is required' - }, status=400) - - ca, created = _get_or_create_ca(ca_name) - - cert, created = ServerCertificate.objects.get_or_create( - hostname=hostname, - defaults={ - 'hostname': hostname, - 'ca': ca - } - ) - - if created or cert.is_revoked: - from utils.cert_service import issue_server_cert - from cryptography.hazmat.primitives import hashes - - ca_key, ca_cert = _load_ca_crypto(ca) - result = issue_server_cert( - ca_key=ca_key, - ca_cert=ca_cert, - hostname=hostname, - ip_address=None, - ) - fingerprint = result['server_cert'].fingerprint( - hashes.SHA1() - ) - cert.thumbprint = ":".join( - f"{byte:02X}" for byte in fingerprint - ) - cert.is_revoked = False - cert.expires_at = ( - datetime.utcnow() + __import__('datetime').timedelta( - days=3650 - ) - ) - cert.save() - logger.info(f"Issued new server certificate for {hostname}") - elif cert.expires_at and cert.expires_at < datetime.utcnow(): - cert.is_revoked = True - cert.save() - logger.info(f"Marked expired server cert for {hostname}") - - return JsonResponse({ - 'success': True, - 'data': { - 'thumbprint': cert.thumbprint, - 'expires_at': ( - cert.expires_at.isoformat() if cert.expires_at else None - ) - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error issuing server certificate: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to issue server certificate' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -@permission_required('certificates.add_clientcertificate', raise_exception=True) -def issue_client_certificate(request): - try: - data = json.loads(request.body.decode('utf-8')) - name = data.get('name') - user_id = data.get('user_id') - description = data.get('description', '') - ca_name = data.get('ca_name', 'default-ca') - - if not name: - return JsonResponse({ - 'success': False, - 'error': 'Certificate name is required' - }, status=400) - - ca, created = _get_or_create_ca(ca_name) - - user = None - if user_id: - from django.contrib.auth.models import User - try: - user = User.objects.get(id=user_id) - except User.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'User not found' - }, status=404) - - cert, created = ClientCertificate.objects.get_or_create( - name=name, - defaults={ - 'name': name, - 'ca': ca, - 'assigned_to_user': user, - 'description': description - } - ) - - if created: - from utils.cert_service import issue_client_cert - from cryptography.hazmat.primitives import hashes - - ca_key, ca_cert = _load_ca_crypto(ca) - upn_value = f"{name}@localhost" - cert.upn_value = upn_value - client_key, client_cert = issue_client_cert( - ca_key=ca_key, - ca_cert=ca_cert, - upn_value=upn_value, - ) - fingerprint = client_cert.fingerprint(hashes.SHA1()) - cert.thumbprint = ":".join( - f"{byte:02X}" for byte in fingerprint - ) - cert.expires_at = ( - datetime.utcnow() + __import__('datetime').timedelta( - days=3650 - ) - ) - cert.save() - logger.info(f"Issued new client certificate for {name}") - - return JsonResponse({ - 'success': True, - 'data': { - 'thumbprint': cert.thumbprint, - 'expires_at': ( - cert.expires_at.isoformat() if cert.expires_at else None - ) - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error( - f"Error issuing client certificate: {str(e)}", exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': 'Failed to issue client certificate' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -def validate_certificate_request(request): - try: - data = json.loads(request.body.decode('utf-8')) - hostname = data.get('hostname') - token = data.get('token') - - if not hostname or not token: - return JsonResponse({ - 'success': False, - 'error': 'Hostname and token are required' - }, status=400) - - host = Host.objects.filter( - hostname=hostname, - ).first() - - if not host: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired token' - }, status=401) - - return JsonResponse({ - 'success': True, - 'data': { - 'hostname': hostname, - 'host_id': host.id, - 'valid': True - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error( - f"Error validating certificate request: {str(e)}", exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': 'Certificate validation failed' - }, status=500) - - -@require_http_methods(["GET"]) -@login_required -def get_ca_certificate(request): - try: - ca_name = request.GET.get('ca_name', 'default-ca') - - try: - ca = CertificateAuthority.objects.get( - name=ca_name, is_active=True - ) - ca_cert_pem = ca.certificate - if not ca_cert_pem: - return JsonResponse({ - 'success': False, - 'error': 'CA certificate file not found on disk' - }, status=404) - return JsonResponse({ - 'success': True, - 'data': { - 'ca_cert': ca_cert_pem, - 'expires_at': ca.expires_at.isoformat() - } - }) - except CertificateAuthority.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'CA not found or not active' - }, status=404) - - except Exception as e: - logger.error( - f"Error getting CA certificate: {str(e)}", exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve CA certificate' - }, status=500) - - -class CertificateManagementView(View): - - @method_decorator( - permission_required('certificates.view_certificateauthority') - ) - def get(self, request): - try: - cert_type = request.GET.get('type', 'all') - - result = {'success': True, 'data': {}} - - if cert_type in ['all', 'ca']: - cas = CertificateAuthority.objects.all() - result['data']['cas'] = [ - { - 'id': ca.id, - 'name': ca.name, - 'created_at': ca.created_at.isoformat(), - 'expires_at': ca.expires_at.isoformat(), - 'is_active': ca.is_active - } - for ca in cas - ] - - if cert_type in ['all', 'server']: - servers = ServerCertificate.objects.select_related('ca').all() - result['data']['servers'] = [ - { - 'id': cert.id, - 'hostname': cert.hostname, - 'ca_name': cert.ca.name, - 'thumbprint': cert.thumbprint, - 'created_at': cert.created_at.isoformat(), - 'expires_at': cert.expires_at.isoformat(), - 'is_revoked': cert.is_revoked - } - for cert in servers - ] - - if cert_type in ['all', 'client']: - clients = ClientCertificate.objects.select_related( - 'ca', 'assigned_to_user' - ).all() - result['data']['clients'] = [ - { - 'id': cert.id, - 'name': cert.name, - 'ca_name': cert.ca.name, - 'assigned_to_user': ( - cert.assigned_to_user.username - if cert.assigned_to_user else None - ), - 'thumbprint': cert.thumbprint, - 'created_at': cert.created_at.isoformat(), - 'expires_at': cert.expires_at.isoformat(), - 'is_active': cert.is_active - } - for cert in clients - ] - - return JsonResponse(result) - - except Exception as e: - logger.error( - f"Error getting certificates: {str(e)}", exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': 'Failed to retrieve certificates' - }, status=500) - - @method_decorator( - permission_required('certificates.delete_servercertificate') - ) - def delete(self, request): - try: - cert_id = request.GET.get('id') - cert_type = request.GET.get('type', 'server') - - if not cert_id: - return JsonResponse({ - 'success': False, - 'error': 'Certificate ID is required' - }, status=400) - - if cert_type == 'server': - cert = get_object_or_404(ServerCertificate, id=cert_id) - cert.revoke("Revoked by admin") - elif cert_type == 'client': - cert = get_object_or_404(ClientCertificate, id=cert_id) - cert.is_active = False - cert.save() - else: - return JsonResponse({ - 'success': False, - 'error': 'Invalid certificate type' - }, status=400) - - return JsonResponse({ - 'success': True, - 'message': ( - f'{cert_type.title()} certificate revoked successfully' - ) - }) - - except Exception as e: - logger.error( - f"Error revoking certificate: {str(e)}", exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': 'Failed to revoke certificate' - }, status=500) - - -@require_http_methods(["POST"]) -@login_required -@permission_required( - 'certificates.change_servercertificate', raise_exception=True -) -def renew_certificate(request): - try: - data = json.loads(request.body.decode('utf-8')) - cert_id = data.get('cert_id') - cert_type = data.get('type', 'server') - - if not cert_id: - return JsonResponse({ - 'success': False, - 'error': 'Certificate ID is required' - }, status=400) - - if cert_type == 'server': - cert = get_object_or_404(ServerCertificate, id=cert_id) - cert.is_revoked = False - cert.save() - elif cert_type == 'client': - cert = get_object_or_404(ClientCertificate, id=cert_id) - cert.is_active = True - cert.save() - else: - return JsonResponse({ - 'success': False, - 'error': 'Invalid certificate type' - }, status=400) - - return JsonResponse({ - 'success': True, - 'message': f'{cert_type.title()} certificate renewed successfully', - 'data': { - 'expires_at': ( - cert.expires_at.isoformat() if cert.expires_at else None - ) - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error( - f"Error renewing certificate: {str(e)}", exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': 'Failed to renew certificate' - }, status=500) diff --git a/apps/dashboard/__init__.py b/apps/dashboard/__init__.py deleted file mode 100755 index bdc2535..0000000 --- a/apps/dashboard/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -2c2a仪表盘应用 -""" -default_app_config = 'apps.dashboard.apps.DashboardConfig' diff --git a/apps/dashboard/admin.py b/apps/dashboard/admin.py deleted file mode 100755 index 4c53a6a..0000000 --- a/apps/dashboard/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (apps.dashboard.views_admin) diff --git a/apps/dashboard/apps.py b/apps/dashboard/apps.py deleted file mode 100755 index 7a2152a..0000000 --- a/apps/dashboard/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -仪表盘应用配置 -""" -from django.apps import AppConfig - - -class DashboardConfig(AppConfig): - """仪表盘应用配置类""" - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.dashboard' - verbose_name = '仪表盘' diff --git a/apps/dashboard/context_processors.py b/apps/dashboard/context_processors.py deleted file mode 100644 index 050dca2..0000000 --- a/apps/dashboard/context_processors.py +++ /dev/null @@ -1,43 +0,0 @@ -from .models import SystemConfig -from utils.site_group import get_effective_config - - -def system_config(request): - try: - config = SystemConfig.get_config() - except Exception: - config = None - - site_group = getattr(request, "site_group", None) - effective_config = get_effective_config(site_group) if config else None - - if effective_config and effective_config.site_name: - site_name = effective_config.site_name - elif site_group and site_group.site_name: - site_name = site_group.site_name - else: - site_name = "2c2a" - - site_icon = None - if effective_config and effective_config.site_icon: - site_icon = effective_config.site_icon - elif site_group and site_group.site_icon: - site_icon = site_group.site_icon - if not site_icon and effective_config: - hostname = request.get_host().split(":")[0] if request else "" - site_icon = effective_config.get_site_icon_for_hostname(hostname) - if not site_icon: - site_icon = "/static/img/favicon.svg" - - is_site_group_admin = False - if request.user.is_authenticated: - is_site_group_admin = request.user.is_site_group_admin(site_group) - - return { - "system_config": config, - "effective_config": effective_config, - "site_name": site_name, - "site_icon": site_icon, - "site_group": site_group, - "is_site_group_admin": is_site_group_admin, - } diff --git a/apps/dashboard/forms.py b/apps/dashboard/forms.py deleted file mode 100755 index b9557e9..0000000 --- a/apps/dashboard/forms.py +++ /dev/null @@ -1,249 +0,0 @@ -""" -仪表盘表单 -""" -from django import forms -from django.core.mail import send_mail -from django.core.exceptions import ValidationError -from .models import DashboardWidget, SystemConfig - -MD_INPUT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition' -) -MD_SELECT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface appearance-none focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition cursor-pointer' -) -MD_CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 text-md-primary ' - 'focus:ring-md-primary focus:ring-2 transition cursor-pointer accent-md-primary' -) -MD_TEXTAREA_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition resize-y' -) - - -class DashboardWidgetForm(forms.ModelForm): - """ - 仪表盘组件表单 - 用于创建和编辑仪表盘组件 - """ - class Meta: - model = DashboardWidget - fields = [ - 'widget_type', 'title', 'display_order', - 'is_enabled', 'widget_config' - ] - widgets = { - 'widget_type': forms.Select(attrs={ - 'class': MD_SELECT_CLASS - }), - 'title': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入组件标题' - }), - 'display_order': forms.NumberInput(attrs={ - 'class': MD_INPUT_CLASS, - 'min': 0 - }), - 'is_enabled': forms.CheckboxInput(attrs={ - 'class': MD_CHECKBOX_CLASS - }), - 'widget_config': forms.Textarea(attrs={ - 'class': MD_TEXTAREA_CLASS, - 'rows': 5, - 'placeholder': '请输入JSON格式的配置参数' - }) - } - - def clean_widget_config(self): - """ - 验证widget_config字段 - 确保是有效的JSON格式 - """ - import json - config = self.cleaned_data.get('widget_config') - - if config: - try: - json.loads(config) - except json.JSONDecodeError: - raise forms.ValidationError('配置参数必须是有效的JSON格式') - - return config - - -class WidgetConfigForm(forms.Form): - """ - 组件配置表单 - 用于快速配置仪表盘组件 - """ - widget_id = forms.IntegerField( - widget=forms.HiddenInput(), - required=True - ) - is_enabled = forms.BooleanField( - label='启用组件', - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': MD_CHECKBOX_CLASS - }) - ) - display_order = forms.IntegerField( - label='显示顺序', - required=True, - min_value=0, - widget=forms.NumberInput(attrs={ - 'class': MD_INPUT_CLASS - }) - ) - - -class SystemConfigForm(forms.ModelForm): - """系统配置表单""" - - _PRESERVE_IF_EMPTY = [ - 'smtp_password', - ] - - class Meta: - model = SystemConfig - fields = [ - 'site_name', - 'icp_number', - 'police_number', - 'smtp_host', - 'smtp_port', - 'smtp_encryption', - 'smtp_username', - 'smtp_password', - 'smtp_from_email', - 'captcha_provider', - 'captcha_type', - 'login_captcha_type', - 'register_captcha_type', - 'email_captcha_type', - 'email_suffix_whitelist', - 'email_suffix_blacklist', - ] - widgets = { - 'site_name': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入站点名称' - }), - 'icp_number': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '例如:京ICP备12345678号' - }), - 'police_number': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '例如:京公网安备 11010502000000号' - }), - 'enable_registration': forms.CheckboxInput(attrs={ - 'class': MD_CHECKBOX_CLASS - }), - 'smtp_host': forms.TextInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入SMTP服务器地址' - }), - 'smtp_port': forms.NumberInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入SMTP端口' - }), - 'smtp_encryption': forms.Select(attrs={ - 'class': MD_INPUT_CLASS - }), - 'smtp_username': forms.EmailInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入SMTP用户名' - }), - 'smtp_password': forms.PasswordInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入SMTP密码', - 'render_value': True - }), - 'smtp_from_email': forms.EmailInput(attrs={ - 'class': MD_INPUT_CLASS, - 'placeholder': '请输入发件人邮箱' - }), - 'captcha_provider': forms.Select(attrs={ - 'class': MD_SELECT_CLASS - }), - 'email_suffix_whitelist': forms.Textarea(attrs={ - 'class': MD_TEXTAREA_CLASS, - 'rows': 5, - 'placeholder': ( - '每行一个允许的邮箱后缀,例如:\n' - '@example.com\n@gmail.com\n@company.com' - ) - }), - 'email_suffix_blacklist': forms.Textarea(attrs={ - 'class': MD_TEXTAREA_CLASS, - 'rows': 5, - 'placeholder': ( - '每行一个禁止的邮箱后缀,例如:\n' - '@tempmail.com\n@spam.com' - ) - }), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._original_values = {} - if self.instance and self.instance.pk: - for field in self._PRESERVE_IF_EMPTY: - self._original_values[field] = getattr( - self.instance, field - ) - for field in self._PRESERVE_IF_EMPTY: - self.fields[field].required = False - - def clean_smtp_port(self): - """验证SMTP端口""" - port = self.cleaned_data.get('smtp_port') - if port and (port < 1 or port > 65535): - raise forms.ValidationError('端口号必须在1-65535之间') - return port - - def clean(self): - cleaned = super().clean() - if cleaned is None: - cleaned = {} - return cleaned - - def save(self, commit=True): - config = super().save(commit=False) - - if self.instance and self.instance.pk: - for field in self._PRESERVE_IF_EMPTY: - if not getattr(config, field): - original = self._original_values.get(field) - if original: - setattr(config, field, original) - - smtp_configured = ( - config.smtp_host and config.smtp_port and - config.smtp_username and config.smtp_password and - config.smtp_from_email - ) - if smtp_configured: - try: - send_mail( - subject='系统配置测试邮件', - message='这是一封测试邮件,用于验证系统邮件配置是否正确。', - from_email=config.smtp_from_email, - recipient_list=[config.smtp_username], - fail_silently=False, - ) - except Exception as e: - raise ValidationError( - f'邮件配置测试失败: {str(e)}' - ) - - if commit: - config.save() - return config diff --git a/apps/dashboard/forms_admin.py b/apps/dashboard/forms_admin.py deleted file mode 100644 index d49a3e9..0000000 --- a/apps/dashboard/forms_admin.py +++ /dev/null @@ -1,158 +0,0 @@ -from django import forms -from .models import DashboardWidget, SystemConfig - - -class DashboardWidgetForm(forms.ModelForm): - - class Meta: - model = DashboardWidget - fields = [ - "widget_type", - "title", - "display_order", - "is_enabled", - "widget_config", - ] - - def clean_widget_config(self): - import json - - config = self.cleaned_data.get("widget_config") - if config: - if isinstance(config, str): - try: - json.loads(config) - except json.JSONDecodeError: - raise forms.ValidationError("配置参数必须是有效的 JSON 格式") - return config - - -class HostnameBrandingWidget(forms.Textarea): - def __init__(self, *args, **kwargs): - kwargs.setdefault("attrs", {}) - kwargs["attrs"]["rows"] = 6 - kwargs["attrs"]["class"] = ( - "w-full bg-white/5 backdrop-blur-xl border border-white/10 " - "rounded-md px-4 py-3 text-white placeholder-slate-500 " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 " - "focus:border-cyan-500 transition resize-y font-mono text-sm" - ) - kwargs["attrs"]["placeholder"] = ( - "{\n" - ' "site-a.example.com": {\n' - ' "site_name": "站点A",\n' - ' "site_icon": "/media/branding/site-a-icon.svg"\n' - " },\n" - ' "site-b.example.com": {\n' - ' "site_name": "站点B",\n' - ' "site_icon": "/media/branding/site-b-icon.svg"\n' - " }\n" - "}" - ) - super().__init__(*args, **kwargs) - - def format_value(self, value): - import json - - if value and isinstance(value, dict): - return json.dumps(value, indent=2, ensure_ascii=False) - return super().format_value(value) - - -class SystemConfigForm(forms.ModelForm): - - _PRESERVE_IF_EMPTY = [ - "smtp_password", - ] - - hostname_branding = forms.CharField( - widget=HostnameBrandingWidget(), - required=False, - label="主机名品牌绑定", - help_text=( - "按主机名绑定专用站点名和图标,格式:\n" - '{"host.example.com": {"site_name": "站点名", "site_icon": "/media/branding/icon.svg"}}\n' - "未配置的主机名使用全局默认值\n\n" - "⚠️ 此功能已由「站点组管理」替代,建议运行 python manage.py migrate_hostname_branding 迁移数据" - ), - ) - - class Meta: - model = SystemConfig - fields = [ - "site_name", - "enable_registration", - "icp_number", - "police_number", - "smtp_host", - "smtp_port", - "smtp_encryption", - "smtp_username", - "smtp_password", - "smtp_from_email", - "smtp_from_name", - "captcha_provider", - "captcha_type", - "login_captcha_type", - "register_captcha_type", - "email_captcha_type", - "email_suffix_whitelist", - "email_suffix_blacklist", - "local_access_locked", - "hostname_branding", - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._original_values = {} - if self.instance and self.instance.pk: - for field in self._PRESERVE_IF_EMPTY: - self._original_values[field] = getattr(self.instance, field) - for field in self._PRESERVE_IF_EMPTY: - self.fields[field].required = False - - def clean_hostname_branding(self): - import json - - value = self.cleaned_data.get("hostname_branding") - if not value: - return {} - if isinstance(value, str): - try: - value = json.loads(value) - except json.JSONDecodeError: - raise forms.ValidationError("必须是有效的 JSON 格式") - if not isinstance(value, dict): - raise forms.ValidationError("必须是 JSON 对象格式") - for hostname, config in value.items(): - if not isinstance(hostname, str) or not hostname.strip(): - raise forms.ValidationError(f'主机名 "{hostname}" 无效') - if not isinstance(config, dict): - raise forms.ValidationError(f'主机名 "{hostname}" 的配置必须是对象') - allowed_keys = {"site_name", "site_icon"} - invalid_keys = set(config.keys()) - allowed_keys - if invalid_keys: - raise forms.ValidationError( - f'主机名 "{hostname}" 包含无效字段: ' - f'{", ".join(invalid_keys)},' - f'仅支持: {", ".join(allowed_keys)}' - ) - return value - - def clean_smtp_port(self): - port = self.cleaned_data.get("smtp_port") - if port and (port < 1 or port > 65535): - raise forms.ValidationError("端口号必须在 1-65535 之间") - return port - - def save(self, commit=True): - instance = super().save(commit=False) - if self.instance and self.instance.pk: - for field in self._PRESERVE_IF_EMPTY: - if not getattr(instance, field): - original = self._original_values.get(field) - if original: - setattr(instance, field, original) - if commit: - instance.save() - return instance diff --git a/apps/dashboard/forms_sitegroup.py b/apps/dashboard/forms_sitegroup.py deleted file mode 100644 index ee63a98..0000000 --- a/apps/dashboard/forms_sitegroup.py +++ /dev/null @@ -1,199 +0,0 @@ -from django import forms -from .models import SiteGroup, SiteGroupHostname, SiteGroupConfig - - -GLOW_INPUT = ( - "w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 " - "rounded px-3 py-2 text-slate-200 placeholder-slate-500 " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 " - "focus:border-cyan-500 transition" -) -GLOW_SELECT = ( - "w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 " - "rounded px-3 py-2 text-slate-200 appearance-none " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 " - "focus:border-cyan-500 transition cursor-pointer" -) -GLOW_TEXTAREA = ( - "w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 " - "rounded px-3 py-2 text-slate-200 placeholder-slate-500 " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 " - "focus:border-cyan-500 transition font-mono text-sm" -) -GLOW_CHECKBOX = ( - "w-4 h-4 bg-slate-900/50 border-slate-700/50 rounded " - "focus:ring-cyan-500/50 text-cyan-500" -) - - -class SiteGroupForm(forms.ModelForm): - class Meta: - model = SiteGroup - fields = ["name", "slug", "description", "site_name", "site_icon", "is_active"] - widgets = { - "name": forms.TextInput(attrs={"class": GLOW_INPUT}), - "slug": forms.TextInput(attrs={"class": GLOW_INPUT}), - "description": forms.Textarea( - attrs={ - "class": GLOW_INPUT, - "rows": 3, - } - ), - "site_name": forms.TextInput(attrs={"class": GLOW_INPUT}), - "site_icon": forms.TextInput(attrs={"class": GLOW_INPUT}), - "is_active": forms.CheckboxInput(attrs={"class": GLOW_CHECKBOX}), - } - - -class SiteGroupHostnameForm(forms.ModelForm): - class Meta: - model = SiteGroupHostname - fields = ["hostname"] - widgets = { - "hostname": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "demo.example.com", - } - ), - } - - -class SiteGroupConfigForm(forms.ModelForm): - """站点组配置覆盖表单""" - - class Meta: - model = SiteGroupConfig - fields = [ - "site_name", - "site_icon", - "icp_number", - "police_number", - "smtp_host", - "smtp_port", - "smtp_encryption", - "smtp_username", - "smtp_password", - "smtp_from_email", - "smtp_from_name", - "captcha_provider", - "captcha_type", - "login_captcha_type", - "register_captcha_type", - "email_captcha_type", - "enable_registration", - "email_suffix_whitelist", - "email_suffix_blacklist", - ] - widgets = { - "site_name": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "site_icon": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置,如 /media/branding/icon.svg", - } - ), - "icp_number": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "police_number": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "smtp_host": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "smtp_port": forms.NumberInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "smtp_encryption": forms.Select(attrs={"class": GLOW_SELECT}), - "smtp_username": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "smtp_password": forms.PasswordInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空保持原值", - } - ), - "smtp_from_email": forms.EmailInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "smtp_from_name": forms.TextInput( - attrs={ - "class": GLOW_INPUT, - "placeholder": "留空使用全局配置", - } - ), - "captcha_provider": forms.Select(attrs={"class": GLOW_SELECT}), - "captcha_type": forms.Select(attrs={"class": GLOW_SELECT}), - "login_captcha_type": forms.Select(attrs={"class": GLOW_SELECT}), - "register_captcha_type": forms.Select(attrs={"class": GLOW_SELECT}), - "email_captcha_type": forms.Select(attrs={"class": GLOW_SELECT}), - "enable_registration": forms.Select( - attrs={ - "class": GLOW_SELECT, - }, - choices=[ - ("", "--- 使用全局配置 ---"), - ("true", "启用"), - ("false", "禁用"), - ], - ), - "email_suffix_whitelist": forms.Textarea( - attrs={ - "class": GLOW_TEXTAREA, - "rows": 4, - "placeholder": "留空使用全局配置\n@example.com\n@gmail.com", - } - ), - "email_suffix_blacklist": forms.Textarea( - attrs={ - "class": GLOW_TEXTAREA, - "rows": 4, - "placeholder": "留空使用全局配置\n@tempmail.com\n@spam.com", - } - ), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # 为验证码类型字段添加"使用全局配置"空选项 - for field_name in [ - "captcha_type", - "login_captcha_type", - "register_captcha_type", - "email_captcha_type", - ]: - self.fields[field_name].widget.choices = [ - ("", "--- 使用全局配置 ---"), - ] + list(self.fields[field_name].widget.choices) - - def clean_smtp_password(self): - """密码字段留空时保留原值""" - password = self.cleaned_data.get("smtp_password") - if not password and self.instance and self.instance.pk: - return self.instance.smtp_password - return password diff --git a/apps/dashboard/management/__init__.py b/apps/dashboard/management/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/dashboard/management/commands/__init__.py b/apps/dashboard/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/dashboard/management/commands/locallock.py b/apps/dashboard/management/commands/locallock.py deleted file mode 100644 index cd2bbb4..0000000 --- a/apps/dashboard/management/commands/locallock.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.core.management.base import BaseCommand -from apps.dashboard.models import SystemConfig - - -class Command(BaseCommand): - help = '禁止本地访问(localhost/127.0.0.1)' - - def handle(self, *args, **options): - config = SystemConfig.get_config() - if config.local_access_locked: - self.stdout.write( - self.style.WARNING('本地访问限制已经处于启用状态') - ) - return - - config.local_access_locked = True - config.save(update_fields=['local_access_locked', 'updated_at']) - self.stdout.write( - self.style.SUCCESS( - '已启用本地访问限制,' - '来自 localhost/127.0.0.1 的请求将被拒绝' - ) - ) diff --git a/apps/dashboard/management/commands/localunlock.py b/apps/dashboard/management/commands/localunlock.py deleted file mode 100644 index 9db2144..0000000 --- a/apps/dashboard/management/commands/localunlock.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.core.management.base import BaseCommand -from apps.dashboard.models import SystemConfig - - -class Command(BaseCommand): - help = '解除本地访问限制(localhost/127.0.0.1)' - - def handle(self, *args, **options): - config = SystemConfig.get_config() - if not config.local_access_locked: - self.stdout.write( - self.style.WARNING('本地访问限制已经处于关闭状态') - ) - return - - config.local_access_locked = False - config.save(update_fields=['local_access_locked', 'updated_at']) - self.stdout.write( - self.style.SUCCESS( - '已解除本地访问限制,' - '来自 localhost/127.0.0.1 的请求将被允许' - ) - ) diff --git a/apps/dashboard/management/commands/migrate_hostname_branding.py b/apps/dashboard/management/commands/migrate_hostname_branding.py deleted file mode 100644 index d7d138b..0000000 --- a/apps/dashboard/management/commands/migrate_hostname_branding.py +++ /dev/null @@ -1,67 +0,0 @@ -from django.core.management.base import BaseCommand -from django.core.cache import cache -from apps.dashboard.models import SystemConfig, SiteGroup, SiteGroupHostname - - -class Command(BaseCommand): - help = '将 SystemConfig.hostname_branding 数据迁移到 SiteGroup 模型' - - def handle(self, *args, **options): - try: - config = SystemConfig.get_config() - except SystemConfig.DoesNotExist: - self.stdout.write(self.style.ERROR('SystemConfig 不存在')) - return - - if not config.hostname_branding: - self.stdout.write(self.style.WARNING('hostname_branding 为空,无需迁移')) - return - - created_count = 0 - hostname_count = 0 - - for hostname, branding in config.hostname_branding.items(): - site_name = branding.get('site_name', '') - site_icon = branding.get('site_icon', '') - - if not site_name and not site_icon: - continue - - slug = hostname.replace('.', '-').replace(':', '-') - site_group, created = SiteGroup.objects.get_or_create( - slug=slug, - defaults={ - 'name': site_name or hostname, - 'site_name': site_name, - 'site_icon': site_icon, - } - ) - - if created: - created_count += 1 - self.stdout.write(self.style.SUCCESS(f'创建站点组: {site_group.name} (slug={slug})')) - else: - updated = False - if site_name and not site_group.site_name: - site_group.site_name = site_name - updated = True - if site_icon and not site_group.site_icon: - site_group.site_icon = site_icon - updated = True - if updated: - site_group.save() - self.stdout.write(f'更新站点组: {site_group.name}') - - _, hn_created = SiteGroupHostname.objects.get_or_create( - hostname=hostname, - defaults={'site_group': site_group} - ) - if hn_created: - hostname_count += 1 - self.stdout.write(f'绑定主机名: {hostname} -> {site_group.name}') - - cache.delete_pattern('site_group:hostname:*') if hasattr(cache, 'delete_pattern') else None - - self.stdout.write(self.style.SUCCESS( - f'\n迁移完成: 创建 {created_count} 个站点组, 绑定 {hostname_count} 个主机名' - )) diff --git a/apps/dashboard/middleware.py b/apps/dashboard/middleware.py deleted file mode 100644 index 89997fc..0000000 --- a/apps/dashboard/middleware.py +++ /dev/null @@ -1,121 +0,0 @@ -from django.core.cache import cache -from django.shortcuts import redirect -from django.urls import resolve - - -class SiteGroupMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - request.site_group = self._resolve_site_group(request) - response = self._check_pending_migration(request) - if response: - return response - response = self._check_banned_user(request) - if response: - return response - return self.get_response(request) - - def _check_pending_migration(self, request): - """已登录用户有 pending_migration_sg_id 时,强制跳转迁移页""" - if not request.user.is_authenticated: - return None - sg_id = request.session.get("pending_migration_sg_id") - if not sg_id: - return None - # 跳过静态文件和媒体文件 - if request.path.startswith("/static/") or request.path.startswith("/media/"): - return None - try: - match = resolve(request.path) - # 允许访问迁移页、登出、邮箱绑定相关 API - allowed_names = ( - "migrate", - "logout", - "email_bind", - "send_bind_email_code", - "email_list", - "email_set_primary", - "email_unbind", - "email_merge_confirm", - ) - if match.url_name in allowed_names: - return None - except Exception: - pass - return redirect("accounts:migrate") - - def _check_banned_user(self, request): - """封禁用户只能访问封禁提示页和工单相关页面""" - if not request.user.is_authenticated: - return None - # 使用自定义 UserBan 模型判断封禁状态 - from apps.accounts.models import UserBan - if not UserBan.objects.filter(user=request.user).exists(): - return None - # 静态文件和媒体文件放行 - if request.path.startswith("/static/") or request.path.startswith("/media/"): - return None - try: - match = resolve(request.path) - # 允许访问封禁提示页、登出、工单相关页面 - allowed_names = ( - "banned", - "logout", - "ticket_list", - "ticket_create", - "ticket_detail", - "my_tickets", - "ticket_comment", - ) - if match.url_name in allowed_names: - return None - # 允许工单 app 的所有视图 - if match.app_name == "tickets": - return None - except Exception: - pass - return redirect("accounts:banned") - - def _resolve_site_group(self, request): - try: - hostname = request.get_host().split(":")[0] - except Exception: - return None - - if not hostname: - return None - - cache_key = f"site_group:hostname:{hostname}" - site_group_id = cache.get(cache_key) - - if site_group_id is not None: - if site_group_id == 0: - return None - from apps.dashboard.models import SiteGroup - - try: - site_group = SiteGroup.objects.get(pk=site_group_id, is_active=True) - return site_group - except SiteGroup.DoesNotExist: - cache.delete(cache_key) - return None - - from apps.dashboard.models import SiteGroupHostname - - try: - mapping = SiteGroupHostname.objects.select_related("site_group").get( - hostname=hostname - ) - except SiteGroupHostname.DoesNotExist: - cache.set(cache_key, 0, timeout=300) - return None - - site_group = mapping.site_group - if not site_group.is_active: - cache.set(cache_key, 0, timeout=300) - return None - - cache.set(cache_key, site_group.pk, timeout=300) - return site_group diff --git a/apps/dashboard/migrations/0001_initial.py b/apps/dashboard/migrations/0001_initial.py deleted file mode 100755 index aea5ccf..0000000 --- a/apps/dashboard/migrations/0001_initial.py +++ /dev/null @@ -1,101 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 03:24 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='SystemConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('smtp_host', models.CharField(blank=True, help_text='SMTP服务器地址,如smtp.gmail.com', max_length=255, null=True, verbose_name='SMTP服务器')), - ('smtp_port', models.IntegerField(blank=True, help_text='SMTP服务器端口,通常为587或465', null=True, verbose_name='SMTP端口')), - ('smtp_use_tls', models.BooleanField(default=True, help_text='是否使用TLS加密连接', verbose_name='使用TLS')), - ('smtp_username', models.CharField(blank=True, help_text='SMTP登录用户名,通常是邮箱地址', max_length=255, null=True, verbose_name='SMTP用户名')), - ('smtp_password', models.CharField(blank=True, help_text='SMTP登录密码或应用专用密码', max_length=255, null=True, verbose_name='SMTP密码')), - ('smtp_from_email', models.EmailField(blank=True, help_text='系统发送邮件时使用的发件人地址', max_length=254, null=True, verbose_name='发件人邮箱')), - ('captcha_id', models.CharField(blank=True, help_text='验证码服务的公共ID(Geetest的captcha_id 或 Turnstile的site key)', max_length=255, null=True, verbose_name='验证码 ID')), - ('captcha_key', models.CharField(blank=True, help_text='验证码服务的密钥(Geetest的private_key 或 Turnstile的secret key)', max_length=255, null=True, verbose_name='验证码密钥')), - ('captcha_provider', models.CharField(choices=[('none', '无'), ('geetest', 'Geetest (极验 v4)'), ('turnstile', 'Cloudflare Turnstile'), ('local', '本地图片验证码')], default='none', help_text='选择要启用的验证码提供器(只能选择其一)', max_length=32, verbose_name='验证码提供器')), - ('email_captcha_provider', models.CharField(blank=True, choices=[('none', '无'), ('geetest', 'Geetest (极验 v4)'), ('turnstile', 'Cloudflare Turnstile'), ('local', '本地图片验证码')], help_text='邮箱场景的验证码提供器(留空则使用全局配置)', max_length=32, null=True, verbose_name='邮箱验证码提供器')), - ('email_captcha_id', models.CharField(blank=True, help_text='邮箱场景验证码服务的公共ID(如果为空,则使用全局配置)', max_length=255, null=True, verbose_name='邮箱验证码 ID')), - ('email_captcha_key', models.CharField(blank=True, help_text='邮箱场景验证码服务的密钥(如果为空,则使用全局配置)', max_length=255, null=True, verbose_name='邮箱验证码密钥')), - ('login_captcha_provider', models.CharField(blank=True, choices=[('none', '无'), ('geetest', 'Geetest (极验 v4)'), ('turnstile', 'Cloudflare Turnstile'), ('local', '本地图片验证码')], help_text='登录场景的验证码提供器(留空则使用全局配置)', max_length=32, null=True, verbose_name='登录验证码提供器')), - ('login_captcha_id', models.CharField(blank=True, help_text='登录场景验证码服务的公共ID(如果为空,则使用全局配置)', max_length=255, null=True, verbose_name='登录验证码 ID')), - ('login_captcha_key', models.CharField(blank=True, help_text='登录场景验证码服务的密钥(如果为空,则使用全局配置)', max_length=255, null=True, verbose_name='登录验证码密钥')), - ('register_captcha_provider', models.CharField(blank=True, choices=[('none', '无'), ('geetest', 'Geetest (极验 v4)'), ('turnstile', 'Cloudflare Turnstile'), ('local', '本地图片验证码')], help_text='注册场景的验证码提供器(留空则使用全局配置)', max_length=32, null=True, verbose_name='注册验证码提供器')), - ('register_captcha_id', models.CharField(blank=True, help_text='注册场景验证码服务的公共ID(如果为空,则使用全局配置)', max_length=255, null=True, verbose_name='注册验证码 ID')), - ('register_captcha_key', models.CharField(blank=True, help_text='注册场景验证码服务的密钥(如果为空,则使用全局配置)', max_length=255, null=True, verbose_name='注册验证码密钥')), - ('site_name', models.CharField(default='2c2a', help_text='系统显示的站点名称', max_length=100, verbose_name='站点名称')), - ('enable_registration', models.BooleanField(default=False, help_text='是否开启用户注册功能,默认为关闭', verbose_name='启用用户注册')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ], - options={ - 'verbose_name': '系统配置', - 'verbose_name_plural': '系统配置', - }, - ), - migrations.CreateModel( - name='SystemStats', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('stats_type', models.CharField(choices=[('user_count', '用户数量'), ('host_count', '主机数量'), ('operation_count', '操作数量'), ('active_host_count', '活跃主机数'), ('error_count', '错误数量')], help_text='统计数据的类型', max_length=50, verbose_name='统计类型')), - ('stats_value', models.IntegerField(help_text='统计数据的值', verbose_name='统计值')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='统计数据创建时间', verbose_name='创建时间')), - ], - options={ - 'verbose_name': '系统统计', - 'verbose_name_plural': '系统统计', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['stats_type'], name='dashboard_s_stats_t_d000e0_idx'), models.Index(fields=['created_at'], name='dashboard_s_created_068de5_idx')], - }, - ), - migrations.CreateModel( - name='DashboardWidget', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('widget_type', models.CharField(choices=[('stat_card', '统计卡片'), ('chart', '图表'), ('recent_operations', '最近操作'), ('host_status', '主机状态'), ('system_alerts', '系统告警')], help_text='组件的类型', max_length=50, verbose_name='组件类型')), - ('title', models.CharField(help_text='组件显示的标题', max_length=200, verbose_name='标题')), - ('display_order', models.IntegerField(default=0, help_text='组件在仪表盘上的显示顺序', verbose_name='显示顺序')), - ('is_enabled', models.BooleanField(default=True, help_text='组件是否在仪表盘上显示', verbose_name='是否启用')), - ('widget_config', models.JSONField(blank=True, default=dict, help_text='组件的配置参数', verbose_name='组件配置')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='组件创建时间', verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, help_text='组件更新时间', verbose_name='更新时间')), - ], - options={ - 'verbose_name': '仪表盘组件', - 'verbose_name_plural': '仪表盘组件', - 'ordering': ['display_order'], - 'indexes': [models.Index(fields=['widget_type'], name='dashboard_d_widget__357338_idx'), models.Index(fields=['is_enabled'], name='dashboard_d_is_enab_79f78f_idx'), models.Index(fields=['display_order'], name='dashboard_d_display_5437b7_idx')], - }, - ), - migrations.CreateModel( - name='UserActivity', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('activity_type', models.CharField(help_text='用户活动的类型', max_length=100, verbose_name='活动类型')), - ('description', models.TextField(blank=True, help_text='活动描述', verbose_name='描述')), - ('ip_address', models.GenericIPAddressField(blank=True, help_text='用户操作的IP地址', null=True, verbose_name='IP地址')), - ('user_agent', models.TextField(blank=True, help_text='用户浏览器的User-Agent信息', verbose_name='用户代理')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='活动记录创建时间', verbose_name='创建时间')), - ('user', models.ForeignKey(help_text='关联的用户', on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL, verbose_name='用户')), - ], - options={ - 'verbose_name': '用户活动', - 'verbose_name_plural': '用户活动', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['user'], name='dashboard_u_user_id_00b481_idx'), models.Index(fields=['activity_type'], name='dashboard_u_activit_a0fab2_idx'), models.Index(fields=['ip_address'], name='dashboard_u_ip_addr_76ce0f_idx'), models.Index(fields=['created_at'], name='dashboard_u_created_8f7e1e_idx')], - }, - ), - ] diff --git a/apps/dashboard/migrations/0002_systemconfig_email_suffix_list_and_more.py b/apps/dashboard/migrations/0002_systemconfig_email_suffix_list_and_more.py deleted file mode 100755 index a4a3d1d..0000000 --- a/apps/dashboard/migrations/0002_systemconfig_email_suffix_list_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-28 03:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='systemconfig', - name='email_suffix_list', - field=models.TextField(blank=True, help_text='允许或禁止的邮箱后缀列表,每行一个后缀,例如:\n@example.com\n@gmail.com\n@company.com', null=True, verbose_name='邮箱后缀列表'), - ), - migrations.AddField( - model_name='systemconfig', - name='email_suffix_mode', - field=models.CharField(choices=[('allow_all', '全部允许'), ('whitelist', '白名单'), ('blacklist', '黑名单')], default='allow_all', help_text='邮箱后缀验证模式:全部允许、白名单或黑名单', max_length=20, verbose_name='邮箱后缀模式'), - ), - ] diff --git a/apps/dashboard/migrations/0003_systemconfig_icp_number_systemconfig_police_number.py b/apps/dashboard/migrations/0003_systemconfig_icp_number_systemconfig_police_number.py deleted file mode 100644 index 1f70b06..0000000 --- a/apps/dashboard/migrations/0003_systemconfig_icp_number_systemconfig_police_number.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-06 16:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0002_systemconfig_email_suffix_list_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="systemconfig", - name="icp_number", - field=models.CharField( - blank=True, - help_text="ICP备案号,例如:京ICP备12345678号", - max_length=100, - null=True, - verbose_name="ICP备案号", - ), - ), - migrations.AddField( - model_name="systemconfig", - name="police_number", - field=models.CharField( - blank=True, - help_text="公安备案号,例如:京公网安备 11010502000000号", - max_length=100, - null=True, - verbose_name="公安备案号", - ), - ), - ] diff --git a/apps/dashboard/migrations/0004_remove_system_stats.py b/apps/dashboard/migrations/0004_remove_system_stats.py deleted file mode 100644 index 29ac643..0000000 --- a/apps/dashboard/migrations/0004_remove_system_stats.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-07 13:52 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0003_systemconfig_icp_number_systemconfig_police_number"), - ] - - operations = [ - migrations.DeleteModel( - name="SystemStats", - ), - ] diff --git a/apps/dashboard/migrations/0005_add_local_access_locked.py b/apps/dashboard/migrations/0005_add_local_access_locked.py deleted file mode 100644 index 917daef..0000000 --- a/apps/dashboard/migrations/0005_add_local_access_locked.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-10 19:46 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0004_remove_system_stats"), - ] - - operations = [ - migrations.AddField( - model_name="systemconfig", - name="local_access_locked", - field=models.BooleanField( - default=False, - help_text="启用后将禁止来自 localhost/127.0.0.1 的访问", - verbose_name="禁止本地访问", - ), - ), - ] diff --git a/apps/dashboard/migrations/0006_delete_useractivity.py b/apps/dashboard/migrations/0006_delete_useractivity.py deleted file mode 100644 index efcd6d5..0000000 --- a/apps/dashboard/migrations/0006_delete_useractivity.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-01 14:24 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0005_add_local_access_locked'), - ] - - operations = [ - migrations.DeleteModel( - name='UserActivity', - ), - ] diff --git a/apps/dashboard/migrations/0007_systemconfig_email_suffix_whitelist_blacklist.py b/apps/dashboard/migrations/0007_systemconfig_email_suffix_whitelist_blacklist.py deleted file mode 100644 index 6df0a01..0000000 --- a/apps/dashboard/migrations/0007_systemconfig_email_suffix_whitelist_blacklist.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-02 09:05 - -from django.db import migrations, models - - -def migrate_email_suffix_data(apps, schema_editor): - SystemConfig = apps.get_model('dashboard', 'SystemConfig') - for config in SystemConfig.objects.all(): - old_mode = getattr(config, 'email_suffix_mode', None) - old_list = getattr(config, 'email_suffix_list', None) or '' - if old_mode == 'whitelist' and old_list.strip(): - config.email_suffix_whitelist = old_list - config.email_suffix_blacklist = '' - elif old_mode == 'blacklist' and old_list.strip(): - config.email_suffix_whitelist = '' - config.email_suffix_blacklist = old_list - else: - config.email_suffix_whitelist = '' - config.email_suffix_blacklist = '' - config.save(update_fields=['email_suffix_whitelist', 'email_suffix_blacklist']) - - -def reverse_migrate_email_suffix_data(apps, schema_editor): - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0006_delete_useractivity'), - ] - - operations = [ - migrations.AddField( - model_name='systemconfig', - name='email_suffix_blacklist', - field=models.TextField(blank=True, help_text='禁止注册的邮箱后缀列表,每行一个后缀,例如:\n@tempmail.com\n@spam.com\n留空表示不限制', null=True, verbose_name='邮箱后缀黑名单'), - ), - migrations.AddField( - model_name='systemconfig', - name='email_suffix_whitelist', - field=models.TextField(blank=True, help_text='允许注册的邮箱后缀列表,每行一个后缀,例如:\n@example.com\n@gmail.com\n@company.com\n留空表示不限制', null=True, verbose_name='邮箱后缀白名单'), - ), - migrations.RunPython(migrate_email_suffix_data, reverse_migrate_email_suffix_data), - migrations.RemoveField( - model_name='systemconfig', - name='email_suffix_list', - ), - migrations.RemoveField( - model_name='systemconfig', - name='email_suffix_mode', - ), - ] diff --git a/apps/dashboard/migrations/0008_add_qq_bot_default_config.py b/apps/dashboard/migrations/0008_add_qq_bot_default_config.py deleted file mode 100644 index 8b42e97..0000000 --- a/apps/dashboard/migrations/0008_add_qq_bot_default_config.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-02 09:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0007_systemconfig_email_suffix_whitelist_blacklist'), - ] - - operations = [ - migrations.AddField( - model_name='systemconfig', - name='qq_bot_host', - field=models.CharField(blank=True, help_text='QQ机器人服务器的主机地址(系统默认配置)', max_length=255, null=True, verbose_name='QQ机器人服务器地址'), - ), - migrations.AddField( - model_name='systemconfig', - name='qq_bot_port', - field=models.CharField(blank=True, help_text='QQ机器人服务器的端口号(系统默认配置)', max_length=20, null=True, verbose_name='QQ机器人服务器端口'), - ), - migrations.AddField( - model_name='systemconfig', - name='qq_bot_token', - field=models.CharField(blank=True, help_text='用于认证的访问令牌(系统默认配置)', max_length=255, null=True, verbose_name='QQ机器人访问令牌'), - ), - ] diff --git a/apps/dashboard/migrations/0009_remove_qq_bot_fields.py b/apps/dashboard/migrations/0009_remove_qq_bot_fields.py deleted file mode 100644 index c1d17b3..0000000 --- a/apps/dashboard/migrations/0009_remove_qq_bot_fields.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-03 12:32 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0008_add_qq_bot_default_config'), - ] - - operations = [ - migrations.RemoveField( - model_name='systemconfig', - name='qq_bot_host', - ), - migrations.RemoveField( - model_name='systemconfig', - name='qq_bot_port', - ), - migrations.RemoveField( - model_name='systemconfig', - name='qq_bot_token', - ), - ] diff --git a/apps/dashboard/migrations/0010_migrate_qq_bot_to_plugin_config.py b/apps/dashboard/migrations/0010_migrate_qq_bot_to_plugin_config.py deleted file mode 100644 index badc01b..0000000 --- a/apps/dashboard/migrations/0010_migrate_qq_bot_to_plugin_config.py +++ /dev/null @@ -1,83 +0,0 @@ -from django.db import migrations - - -def migrate_qq_bot_config_to_plugin(apps, schema_editor): - SystemConfig = apps.get_model('dashboard', 'SystemConfig') - PluginRecord = apps.get_model('plugins', 'PluginRecord') - PluginConfiguration = apps.get_model( - 'plugins', 'PluginConfiguration' - ) - - try: - config = SystemConfig.objects.first() - if not config: - return - except Exception: - return - - record, _ = PluginRecord.objects.get_or_create( - plugin_id='qq_verification', - defaults={ - 'name': 'QQ Verification Plugin', - 'version': '1.0.0', - 'description': 'QQ验证插件', - 'is_active': True, - }, - ) - - for field_name in ( - 'qq_bot_host', 'qq_bot_port', 'qq_bot_token' - ): - value = getattr(config, field_name, None) or '' - PluginConfiguration.objects.update_or_create( - plugin=record, - key=field_name, - defaults={'value': value}, - ) - - -def reverse_migrate(apps, schema_editor): - SystemConfig = apps.get_model('dashboard', 'SystemConfig') - PluginRecord = apps.get_model('plugins', 'PluginRecord') - PluginConfiguration = apps.get_model( - 'plugins', 'PluginConfiguration' - ) - - try: - record = PluginRecord.objects.get( - plugin_id='qq_verification' - ) - except PluginRecord.DoesNotExist: - return - - config = SystemConfig.objects.first() - if not config: - return - - for field_name in ( - 'qq_bot_host', 'qq_bot_port', 'qq_bot_token' - ): - try: - pc = PluginConfiguration.objects.get( - plugin=record, key=field_name - ) - setattr(config, field_name, pc.value) - except PluginConfiguration.DoesNotExist: - pass - - config.save() - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0009_remove_qq_bot_fields'), - ('plugins', '0007_add_use_default_bot_and_group_ids'), - ] - - operations = [ - migrations.RunPython( - migrate_qq_bot_config_to_plugin, - reverse_migrate, - ), - ] diff --git a/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py b/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py deleted file mode 100644 index b014c58..0000000 --- a/apps/dashboard/migrations/0011_remove_systemconfig_captcha_id_and_more.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-29 15:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0010_migrate_qq_bot_to_plugin_config'), - ] - - operations = [ - migrations.RemoveField( - model_name='systemconfig', - name='captcha_id', - ), - migrations.RemoveField( - model_name='systemconfig', - name='captcha_key', - ), - migrations.RemoveField( - model_name='systemconfig', - name='email_captcha_id', - ), - migrations.RemoveField( - model_name='systemconfig', - name='email_captcha_key', - ), - migrations.RemoveField( - model_name='systemconfig', - name='email_captcha_provider', - ), - migrations.RemoveField( - model_name='systemconfig', - name='login_captcha_id', - ), - migrations.RemoveField( - model_name='systemconfig', - name='login_captcha_key', - ), - migrations.RemoveField( - model_name='systemconfig', - name='login_captcha_provider', - ), - migrations.RemoveField( - model_name='systemconfig', - name='register_captcha_id', - ), - migrations.RemoveField( - model_name='systemconfig', - name='register_captcha_key', - ), - migrations.RemoveField( - model_name='systemconfig', - name='register_captcha_provider', - ), - migrations.AlterField( - model_name='systemconfig', - name='captcha_provider', - field=models.CharField(choices=[('none', '无'), ('tianai', '天爱验证码')], default='none', help_text='选择要启用的验证码提供器', max_length=32, verbose_name='验证码提供器'), - ), - ] diff --git a/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py b/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py deleted file mode 100644 index 3f31402..0000000 --- a/apps/dashboard/migrations/0012_systemconfig_captcha_type_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-29 15:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dashboard', '0011_remove_systemconfig_captcha_id_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='systemconfig', - name='captcha_type', - field=models.CharField(choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], default='SLIDER', help_text='全局默认的验证码类型', max_length=32, verbose_name='默认验证码类型'), - ), - migrations.AddField( - model_name='systemconfig', - name='email_captcha_type', - field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='邮箱发送验证码场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='邮箱验证码类型'), - ), - migrations.AddField( - model_name='systemconfig', - name='login_captcha_type', - field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='登录场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='登录验证码类型'), - ), - migrations.AddField( - model_name='systemconfig', - name='register_captcha_type', - field=models.CharField(blank=True, choices=[('SLIDER', '滑块验证'), ('ROTATE', '旋转验证'), ('CONCAT', '滑动还原'), ('WORD_IMAGE_CLICK', '文字点选')], help_text='注册场景的验证码类型(留空则使用默认类型)', max_length=32, null=True, verbose_name='注册验证码类型'), - ), - ] diff --git a/apps/dashboard/migrations/0013_systemconfig_hostname_branding.py b/apps/dashboard/migrations/0013_systemconfig_hostname_branding.py deleted file mode 100644 index 831f2e4..0000000 --- a/apps/dashboard/migrations/0013_systemconfig_hostname_branding.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-31 05:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0012_systemconfig_captcha_type_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="systemconfig", - name="hostname_branding", - field=models.JSONField( - blank=True, - default=dict, - help_text='按主机名绑定专用站点名和图标,格式:\n{"host.example.com": {"site_name": "站点名", "site_icon": "/media/branding/icon.svg"}}\n未配置的主机名使用全局默认值', - verbose_name="主机名品牌绑定", - ), - ), - ] diff --git a/apps/dashboard/migrations/0014_sitegroup_sitegrouphostname_and_more.py b/apps/dashboard/migrations/0014_sitegroup_sitegrouphostname_and_more.py deleted file mode 100644 index f50e22a..0000000 --- a/apps/dashboard/migrations/0014_sitegroup_sitegrouphostname_and_more.py +++ /dev/null @@ -1,148 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-31 13:27 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("dashboard", "0013_systemconfig_hostname_branding"), - ] - - operations = [ - migrations.CreateModel( - name="SiteGroup", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField( - help_text="站点组的显示名称", - max_length=100, - verbose_name="站点组名称", - ), - ), - ( - "slug", - models.SlugField( - help_text="唯一标识符,用于URL和内部引用", - max_length=100, - unique=True, - verbose_name="标识符", - ), - ), - ( - "description", - models.TextField( - blank=True, help_text="站点组的描述信息", verbose_name="描述" - ), - ), - ( - "site_name", - models.CharField( - blank=True, - help_text="该站点组的站点名称,留空则使用全局默认值", - max_length=100, - verbose_name="站点名称", - ), - ), - ( - "site_icon", - models.CharField( - blank=True, - help_text="该站点组的站点图标路径,留空则使用全局默认值", - max_length=500, - verbose_name="站点图标", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="禁用后该站点组的所有功能将不可用", - verbose_name="是否启用", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ( - "admins", - models.ManyToManyField( - blank=True, - help_text="该站点组的管理员,在当前站点组内拥有类似超级管理员的权限", - related_name="admin_site_groups", - to=settings.AUTH_USER_MODEL, - verbose_name="站点组管理员", - ), - ), - ], - options={ - "verbose_name": "站点组", - "verbose_name_plural": "站点组", - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="SiteGroupHostname", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "hostname", - models.CharField( - help_text="HTTP Host头中的主机名(不含端口),如 demo.example.com", - max_length=255, - unique=True, - verbose_name="主机名", - ), - ), - ( - "site_group", - models.ForeignKey( - help_text="该主机名所属的站点组", - on_delete=django.db.models.deletion.CASCADE, - related_name="hostnames", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - ], - options={ - "verbose_name": "站点组主机名", - "verbose_name_plural": "站点组主机名", - "indexes": [ - models.Index( - fields=["hostname"], name="dashboard_s_hostnam_daee7a_idx" - ) - ], - }, - ), - migrations.AddIndex( - model_name="sitegroup", - index=models.Index(fields=["slug"], name="dashboard_s_slug_5cb7f5_idx"), - ), - ] diff --git a/apps/dashboard/migrations/0015_remove_sitegroup_dashboard_s_slug_5cb7f5_idx_and_more.py b/apps/dashboard/migrations/0015_remove_sitegroup_dashboard_s_slug_5cb7f5_idx_and_more.py deleted file mode 100644 index e70c3bc..0000000 --- a/apps/dashboard/migrations/0015_remove_sitegroup_dashboard_s_slug_5cb7f5_idx_and_more.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-01 13:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0014_sitegroup_sitegrouphostname_and_more"), - ] - - operations = [ - migrations.RemoveIndex( - model_name="sitegroup", - name="dashboard_s_slug_5cb7f5_idx", - ), - migrations.RemoveIndex( - model_name="sitegrouphostname", - name="dashboard_s_hostnam_daee7a_idx", - ), - ] diff --git a/apps/dashboard/migrations/0016_systemconfig_smtp_from_name.py b/apps/dashboard/migrations/0016_systemconfig_smtp_from_name.py deleted file mode 100644 index 3c09949..0000000 --- a/apps/dashboard/migrations/0016_systemconfig_smtp_from_name.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-06 17:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0015_remove_sitegroup_dashboard_s_slug_5cb7f5_idx_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="systemconfig", - name="smtp_from_name", - field=models.CharField( - blank=True, - help_text='系统发送邮件时显示的发件人名称,如"XX云服务"', - max_length=255, - null=True, - verbose_name="发件人名称", - ), - ), - ] diff --git a/apps/dashboard/migrations/0017_remove_systemconfig_smtp_use_tls_and_more.py b/apps/dashboard/migrations/0017_remove_systemconfig_smtp_use_tls_and_more.py deleted file mode 100644 index 4bcf781..0000000 --- a/apps/dashboard/migrations/0017_remove_systemconfig_smtp_use_tls_and_more.py +++ /dev/null @@ -1,46 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-06 17:56 - -from django.db import migrations, models - - -def migrate_smtp_use_tls_to_encryption(apps, schema_editor): - SystemConfig = apps.get_model('dashboard', 'SystemConfig') - for config in SystemConfig.objects.all(): - if config.smtp_use_tls: - config.smtp_encryption = 'TLS' - else: - config.smtp_encryption = 'NONE' - config.save(update_fields=['smtp_encryption']) - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0016_systemconfig_smtp_from_name"), - ] - - operations = [ - migrations.AddField( - model_name="systemconfig", - name="smtp_encryption", - field=models.CharField( - choices=[ - ("NONE", "无加密"), - ("TLS", "TLS (STARTTLS)"), - ("SSL", "SSL (SMTPS)"), - ], - default="TLS", - help_text="TLS: 端口通常为587;SSL: 端口通常为465", - max_length=8, - verbose_name="加密方式", - ), - ), - migrations.RunPython( - migrate_smtp_use_tls_to_encryption, - migrations.RunPython.noop, - ), - migrations.RemoveField( - model_name="systemconfig", - name="smtp_use_tls", - ), - ] diff --git a/apps/dashboard/migrations/0018_sitegroupconfig.py b/apps/dashboard/migrations/0018_sitegroupconfig.py deleted file mode 100644 index 4a04657..0000000 --- a/apps/dashboard/migrations/0018_sitegroupconfig.py +++ /dev/null @@ -1,226 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-07 06:25 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0017_remove_systemconfig_smtp_use_tls_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="SiteGroupConfig", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "smtp_host", - models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=255, - null=True, - verbose_name="SMTP服务器", - ), - ), - ( - "smtp_port", - models.IntegerField( - blank=True, - help_text="留空使用全局配置", - null=True, - verbose_name="SMTP端口", - ), - ), - ( - "smtp_encryption", - models.CharField( - blank=True, - choices=[ - ("NONE", "无加密"), - ("TLS", "TLS (STARTTLS)"), - ("SSL", "SSL (SMTPS)"), - ], - help_text="留空使用全局配置", - max_length=8, - null=True, - verbose_name="加密方式", - ), - ), - ( - "smtp_username", - models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=255, - null=True, - verbose_name="SMTP用户名", - ), - ), - ( - "smtp_password", - models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=255, - null=True, - verbose_name="SMTP密码", - ), - ), - ( - "smtp_from_email", - models.EmailField( - blank=True, - help_text="留空使用全局配置", - max_length=254, - null=True, - verbose_name="发件人邮箱", - ), - ), - ( - "smtp_from_name", - models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=255, - null=True, - verbose_name="发件人名称", - ), - ), - ( - "captcha_provider", - models.CharField( - blank=True, - choices=[("none", "无"), ("tianai", "天爱验证码")], - help_text="留空使用全局配置", - max_length=32, - null=True, - verbose_name="验证码提供器", - ), - ), - ( - "captcha_type", - models.CharField( - blank=True, - choices=[ - ("SLIDER", "滑块验证"), - ("ROTATE", "旋转验证"), - ("CONCAT", "滑动还原"), - ("WORD_IMAGE_CLICK", "文字点选"), - ], - help_text="留空使用全局配置", - max_length=32, - null=True, - verbose_name="默认验证码类型", - ), - ), - ( - "login_captcha_type", - models.CharField( - blank=True, - choices=[ - ("SLIDER", "滑块验证"), - ("ROTATE", "旋转验证"), - ("CONCAT", "滑动还原"), - ("WORD_IMAGE_CLICK", "文字点选"), - ], - help_text="留空使用全局配置", - max_length=32, - null=True, - verbose_name="登录验证码类型", - ), - ), - ( - "register_captcha_type", - models.CharField( - blank=True, - choices=[ - ("SLIDER", "滑块验证"), - ("ROTATE", "旋转验证"), - ("CONCAT", "滑动还原"), - ("WORD_IMAGE_CLICK", "文字点选"), - ], - help_text="留空使用全局配置", - max_length=32, - null=True, - verbose_name="注册验证码类型", - ), - ), - ( - "email_captcha_type", - models.CharField( - blank=True, - choices=[ - ("SLIDER", "滑块验证"), - ("ROTATE", "旋转验证"), - ("CONCAT", "滑动还原"), - ("WORD_IMAGE_CLICK", "文字点选"), - ], - help_text="留空使用全局配置", - max_length=32, - null=True, - verbose_name="邮箱验证码类型", - ), - ), - ( - "enable_registration", - models.BooleanField( - blank=True, - help_text="留空使用全局配置", - null=True, - verbose_name="启用用户注册", - ), - ), - ( - "email_suffix_whitelist", - models.TextField( - blank=True, - help_text="留空使用全局配置。每行一个后缀", - null=True, - verbose_name="邮箱后缀白名单", - ), - ), - ( - "email_suffix_blacklist", - models.TextField( - blank=True, - help_text="留空使用全局配置。每行一个后缀", - null=True, - verbose_name="邮箱后缀黑名单", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ( - "site_group", - models.OneToOneField( - help_text="关联的站点组", - on_delete=django.db.models.deletion.CASCADE, - related_name="config", - to="dashboard.sitegroup", - verbose_name="站点组", - ), - ), - ], - options={ - "verbose_name": "站点组配置", - "verbose_name_plural": "站点组配置", - }, - ), - ] diff --git a/apps/dashboard/migrations/0019_sitegroupconfig_icp_number_and_more.py b/apps/dashboard/migrations/0019_sitegroupconfig_icp_number_and_more.py deleted file mode 100644 index a6921e7..0000000 --- a/apps/dashboard/migrations/0019_sitegroupconfig_icp_number_and_more.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-07 10:11 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0018_sitegroupconfig"), - ] - - operations = [ - migrations.AddField( - model_name="sitegroupconfig", - name="icp_number", - field=models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=100, - null=True, - verbose_name="ICP备案号", - ), - ), - migrations.AddField( - model_name="sitegroupconfig", - name="police_number", - field=models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=100, - null=True, - verbose_name="公安备案号", - ), - ), - migrations.AddField( - model_name="sitegroupconfig", - name="site_icon", - field=models.CharField( - blank=True, - help_text="留空使用全局配置。图标路径,如 /media/branding/icon.svg", - max_length=500, - null=True, - verbose_name="站点图标", - ), - ), - migrations.AddField( - model_name="sitegroupconfig", - name="site_name", - field=models.CharField( - blank=True, - help_text="留空使用全局配置", - max_length=100, - null=True, - verbose_name="站点名称", - ), - ), - ] diff --git a/apps/dashboard/migrations/0020_systemconfig_site_icon.py b/apps/dashboard/migrations/0020_systemconfig_site_icon.py deleted file mode 100644 index 0af5959..0000000 --- a/apps/dashboard/migrations/0020_systemconfig_site_icon.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-07 10:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0019_sitegroupconfig_icp_number_and_more"), - ] - - operations = [ - migrations.AddField( - model_name="systemconfig", - name="site_icon", - field=models.CharField( - blank=True, - default="", - help_text="站点图标路径,如 /media/branding/icon.svg,留空使用默认图标", - max_length=500, - verbose_name="站点图标", - ), - ), - ] diff --git a/apps/dashboard/migrations/__init__.py b/apps/dashboard/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/dashboard/models.py b/apps/dashboard/models.py deleted file mode 100755 index 99d3b50..0000000 --- a/apps/dashboard/models.py +++ /dev/null @@ -1,540 +0,0 @@ -""" -仪表盘数据模型 -""" -from django.db import models -from django.conf import settings -from django.contrib.auth import get_user_model - -User = get_user_model() - - -class DashboardWidget(models.Model): - """ - 仪表盘组件模型 - 用于配置仪表盘上的各种组件 - """ - class Meta: - verbose_name = '仪表盘组件' - verbose_name_plural = verbose_name - ordering = ['display_order'] - indexes = [ - models.Index(fields=['widget_type']), - models.Index(fields=['is_enabled']), - models.Index(fields=['display_order']), - ] - - WIDGET_TYPES = ( - ('stat_card', '统计卡片'), - ('chart', '图表'), - ('recent_operations', '最近操作'), - ('host_status', '主机状态'), - ('system_alerts', '系统告警'), - ) - - widget_type = models.CharField( - '组件类型', - max_length=50, - choices=WIDGET_TYPES, - help_text='组件的类型' - ) - title = models.CharField( - '标题', - max_length=200, - help_text='组件显示的标题' - ) - display_order = models.IntegerField( - '显示顺序', - default=0, - help_text='组件在仪表盘上的显示顺序' - ) - is_enabled = models.BooleanField( - '是否启用', - default=True, - help_text='组件是否在仪表盘上显示' - ) - widget_config = models.JSONField( - '组件配置', - default=dict, - blank=True, - help_text='组件的配置参数' - ) - created_at = models.DateTimeField( - '创建时间', - auto_now_add=True, - help_text='组件创建时间' - ) - updated_at = models.DateTimeField( - '更新时间', - auto_now=True, - help_text='组件更新时间' - ) - - def __str__(self): - return self.title - - -class SystemConfig(models.Model): - """ - 系统配置模型 - - 用于存储系统的全局配置,如SMTP服务器、验证码服务等 - """ - # SMTP配置 - smtp_host = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='SMTP服务器', - help_text='SMTP服务器地址,如smtp.gmail.com' - ) - smtp_port = models.IntegerField( - blank=True, - null=True, - verbose_name='SMTP端口', - help_text='SMTP服务器端口,通常为587或465' - ) - SMTP_ENCRYPTION_TYPES = ( - ('NONE', '无加密'), - ('TLS', 'TLS (STARTTLS)'), - ('SSL', 'SSL (SMTPS)'), - ) - - smtp_encryption = models.CharField( - max_length=8, - choices=SMTP_ENCRYPTION_TYPES, - default='TLS', - verbose_name='加密方式', - help_text='TLS: 端口通常为587;SSL: 端口通常为465' - ) - smtp_username = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='SMTP用户名', - help_text='SMTP登录用户名,通常是邮箱地址' - ) - smtp_password = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='SMTP密码', - help_text='SMTP登录密码或应用专用密码' - ) - smtp_from_email = models.EmailField( - blank=True, - null=True, - verbose_name='发件人邮箱', - help_text='系统发送邮件时使用的发件人地址' - ) - smtp_from_name = models.CharField( - max_length=255, - blank=True, - null=True, - verbose_name='发件人名称', - help_text='系统发送邮件时显示的发件人名称,如"XX云服务"' - ) - - CAPTCHA_TYPES = ( - ('SLIDER', '滑块验证'), - ('ROTATE', '旋转验证'), - ('CONCAT', '滑动还原'), - ('WORD_IMAGE_CLICK', '文字点选'), - ) - - captcha_provider = models.CharField( - max_length=32, - choices=( - ('none', '无'), - ('tianai', '天爱验证码'), - ), - default='none', - verbose_name='验证码提供器', - help_text='选择要启用的验证码提供器' - ) - captcha_type = models.CharField( - max_length=32, - choices=CAPTCHA_TYPES, - default='SLIDER', - verbose_name='默认验证码类型', - help_text='全局默认的验证码类型' - ) - login_captcha_type = models.CharField( - max_length=32, - choices=CAPTCHA_TYPES, - blank=True, - null=True, - verbose_name='登录验证码类型', - help_text='登录场景的验证码类型(留空则使用默认类型)' - ) - register_captcha_type = models.CharField( - max_length=32, - choices=CAPTCHA_TYPES, - blank=True, - null=True, - verbose_name='注册验证码类型', - help_text='注册场景的验证码类型(留空则使用默认类型)' - ) - email_captcha_type = models.CharField( - max_length=32, - choices=CAPTCHA_TYPES, - blank=True, - null=True, - verbose_name='邮箱验证码类型', - help_text='邮箱发送验证码场景的验证码类型(留空则使用默认类型)' - ) - - # 其他配置 - site_name = models.CharField( - max_length=100, - default='2c2a', - verbose_name='站点名称', - help_text='系统显示的站点名称' - ) - site_icon = models.CharField( - max_length=500, - blank=True, - default='', - verbose_name='站点图标', - help_text='站点图标路径,如 /media/branding/icon.svg,留空使用默认图标' - ) - - # 注册开关 - enable_registration = models.BooleanField( - default=False, - verbose_name='启用用户注册', - help_text='是否开启用户注册功能,默认为关闭' - ) - - # ICP备案号配置 - icp_number = models.CharField( - max_length=100, - blank=True, - null=True, - verbose_name='ICP备案号', - help_text='ICP备案号,例如:京ICP备12345678号' - ) - - # 公安备案号配置 - police_number = models.CharField( - max_length=100, - blank=True, - null=True, - verbose_name='公安备案号', - help_text='公安备案号,例如:京公网安备 11010502000000号' - ) - - email_suffix_whitelist = models.TextField( - blank=True, - null=True, - verbose_name='邮箱后缀白名单', - help_text=( - '允许注册的邮箱后缀列表,每行一个后缀,' - '例如:\n@example.com\n@gmail.com\n@company.com\n' - '留空表示不限制' - ) - ) - email_suffix_blacklist = models.TextField( - blank=True, - null=True, - verbose_name='邮箱后缀黑名单', - help_text=( - '禁止注册的邮箱后缀列表,每行一个后缀,' - '例如:\n@tempmail.com\n@spam.com\n' - '留空表示不限制' - ) - ) - - local_access_locked = models.BooleanField( - default=False, - verbose_name='禁止本地访问', - help_text='启用后将禁止来自 localhost/127.0.0.1 的访问' - ) - - hostname_branding = models.JSONField( - default=dict, - blank=True, - verbose_name='主机名品牌绑定', - help_text=( - '按主机名绑定专用站点名和图标,格式:\n' - '{"host.example.com": {"site_name": "站点名", "site_icon": "/media/branding/icon.svg"}}\n' - '未配置的主机名使用全局默认值' - ) - ) - - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name='创建时间' - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name='更新时间' - ) - - class Meta: - verbose_name = '系统配置' - verbose_name_plural = '系统配置' - - def __str__(self): - return f'{self.site_name} 配置' - - def clean(self): - pass - - @classmethod - def get_config(cls): - """获取当前系统配置(带缓存)""" - from django.core.cache import cache - cache_key = 'system_config:singleton' - config = cache.get(cache_key) - if config is not None: - return config - config, created = cls.objects.get_or_create(pk=1) - cache.set(cache_key, config, timeout=300) - return config - - def save(self, *args, **kwargs): - from django.core.cache import cache - result = super().save(*args, **kwargs) - cache.delete('system_config:singleton') - return result - - def delete(self, *args, **kwargs): - from django.core.cache import cache - result = super().delete(*args, **kwargs) - cache.delete('system_config:singleton') - return result - - def get_captcha_config(self, scene=None): - provider = self.captcha_provider - if scene == 'login': - captcha_type = self.login_captcha_type or self.captcha_type - elif scene == 'register': - captcha_type = self.register_captcha_type or self.captcha_type - elif scene == 'email': - captcha_type = self.email_captcha_type or self.captcha_type - else: - captcha_type = self.captcha_type - return provider, captcha_type - - def get_branding_for_hostname(self, hostname): - if self.hostname_branding and hostname in self.hostname_branding: - return self.hostname_branding[hostname] - return {} - - def get_site_name_for_hostname(self, hostname): - branding = self.get_branding_for_hostname(hostname) - return branding.get('site_name') or self.site_name - - def get_site_icon_for_hostname(self, hostname): - branding = self.get_branding_for_hostname(hostname) - return branding.get('site_icon') or '/static/img/favicon.svg' - - -class SiteGroup(models.Model): - name = models.CharField('站点组名称', max_length=100, help_text='站点组的显示名称') - slug = models.SlugField('标识符', max_length=100, unique=True, help_text='唯一标识符,用于URL和内部引用') - description = models.TextField('描述', blank=True, help_text='站点组的描述信息') - site_name = models.CharField('站点名称', max_length=100, blank=True, help_text='该站点组的站点名称,留空则使用全局默认值') - site_icon = models.CharField('站点图标', max_length=500, blank=True, help_text='该站点组的站点图标路径,留空则使用全局默认值') - is_active = models.BooleanField('是否启用', default=True, help_text='禁用后该站点组的所有功能将不可用') - admins = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - related_name='admin_site_groups', - verbose_name='站点组管理员', - help_text='该站点组的管理员,在当前站点组内拥有类似超级管理员的权限', - ) - created_at = models.DateTimeField('创建时间', auto_now_add=True) - updated_at = models.DateTimeField('更新时间', auto_now=True) - - class Meta: - verbose_name = '站点组' - verbose_name_plural = '站点组' - ordering = ['name'] - - def __str__(self): - return self.name - - -class SiteGroupConfig(models.Model): - """ - 站点组配置覆盖模型 - - 允许每个站点组覆盖 SystemConfig 中的配置项。 - 字段留空(null)表示使用 SystemConfig 的全局默认值。 - """ - site_group = models.OneToOneField( - SiteGroup, - on_delete=models.CASCADE, - related_name='config', - verbose_name='站点组', - help_text='关联的站点组', - ) - - # SMTP 配置覆盖 - smtp_host = models.CharField( - max_length=255, blank=True, null=True, - verbose_name='SMTP服务器', - help_text='留空使用全局配置', - ) - smtp_port = models.IntegerField( - blank=True, null=True, - verbose_name='SMTP端口', - help_text='留空使用全局配置', - ) - smtp_encryption = models.CharField( - max_length=8, - choices=SystemConfig.SMTP_ENCRYPTION_TYPES, - blank=True, null=True, - verbose_name='加密方式', - help_text='留空使用全局配置', - ) - smtp_username = models.CharField( - max_length=255, blank=True, null=True, - verbose_name='SMTP用户名', - help_text='留空使用全局配置', - ) - smtp_password = models.CharField( - max_length=255, blank=True, null=True, - verbose_name='SMTP密码', - help_text='留空使用全局配置', - ) - smtp_from_email = models.EmailField( - blank=True, null=True, - verbose_name='发件人邮箱', - help_text='留空使用全局配置', - ) - smtp_from_name = models.CharField( - max_length=255, blank=True, null=True, - verbose_name='发件人名称', - help_text='留空使用全局配置', - ) - - # 验证码配置覆盖 - captcha_provider = models.CharField( - max_length=32, - choices=(('none', '无'), ('tianai', '天爱验证码')), - blank=True, null=True, - verbose_name='验证码提供器', - help_text='留空使用全局配置', - ) - captcha_type = models.CharField( - max_length=32, - choices=SystemConfig.CAPTCHA_TYPES, - blank=True, null=True, - verbose_name='默认验证码类型', - help_text='留空使用全局配置', - ) - login_captcha_type = models.CharField( - max_length=32, - choices=SystemConfig.CAPTCHA_TYPES, - blank=True, null=True, - verbose_name='登录验证码类型', - help_text='留空使用全局配置', - ) - register_captcha_type = models.CharField( - max_length=32, - choices=SystemConfig.CAPTCHA_TYPES, - blank=True, null=True, - verbose_name='注册验证码类型', - help_text='留空使用全局配置', - ) - email_captcha_type = models.CharField( - max_length=32, - choices=SystemConfig.CAPTCHA_TYPES, - blank=True, null=True, - verbose_name='邮箱验证码类型', - help_text='留空使用全局配置', - ) - - # 注册与邮箱配置覆盖 - enable_registration = models.BooleanField( - blank=True, null=True, - verbose_name='启用用户注册', - help_text='留空使用全局配置', - ) - email_suffix_whitelist = models.TextField( - blank=True, null=True, - verbose_name='邮箱后缀白名单', - help_text='留空使用全局配置。每行一个后缀', - ) - email_suffix_blacklist = models.TextField( - blank=True, null=True, - verbose_name='邮箱后缀黑名单', - help_text='留空使用全局配置。每行一个后缀', - ) - - # 站点外观配置覆盖 - site_name = models.CharField( - max_length=100, blank=True, null=True, - verbose_name='站点名称', - help_text='留空使用全局配置', - ) - site_icon = models.CharField( - max_length=500, blank=True, null=True, - verbose_name='站点图标', - help_text='留空使用全局配置。图标路径,如 /media/branding/icon.svg', - ) - icp_number = models.CharField( - max_length=100, blank=True, null=True, - verbose_name='ICP备案号', - help_text='留空使用全局配置', - ) - police_number = models.CharField( - max_length=100, blank=True, null=True, - verbose_name='公安备案号', - help_text='留空使用全局配置', - ) - - created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') - updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') - - class Meta: - verbose_name = '站点组配置' - verbose_name_plural = '站点组配置' - - def __str__(self): - return f'{self.site_group.name} 配置' - - def save(self, *args, **kwargs): - from django.core.cache import cache - result = super().save(*args, **kwargs) - cache.delete(f'site_group_config:{self.site_group_id}') - return result - - def delete(self, *args, **kwargs): - from django.core.cache import cache - cache.delete(f'site_group_config:{self.site_group_id}') - return super().delete(*args, **kwargs) - - @classmethod - def get_config(cls, site_group): - """获取站点组配置(带缓存)""" - if site_group is None: - return None - from django.core.cache import cache - cache_key = f'site_group_config:{site_group.pk}' - config = cache.get(cache_key) - if config is not None: - return config - config, _ = cls.objects.get_or_create(site_group=site_group) - cache.set(cache_key, config, timeout=300) - return config - - -class SiteGroupHostname(models.Model): - hostname = models.CharField('主机名', max_length=255, unique=True, help_text='HTTP Host头中的主机名(不含端口),如 demo.example.com') - site_group = models.ForeignKey( - SiteGroup, - on_delete=models.CASCADE, - related_name='hostnames', - verbose_name='所属站点组', - help_text='该主机名所属的站点组', - ) - - class Meta: - verbose_name = '站点组主机名' - verbose_name_plural = '站点组主机名' - - def __str__(self): - return f'{self.hostname} -> {self.site_group.name}' diff --git a/apps/dashboard/signals.py b/apps/dashboard/signals.py deleted file mode 100644 index c7dca0c..0000000 --- a/apps/dashboard/signals.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.dispatch import Signal - -system_config_saved = Signal() diff --git a/apps/dashboard/templatetags/__init__.py b/apps/dashboard/templatetags/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/dashboard/templatetags/markdown_extras.py b/apps/dashboard/templatetags/markdown_extras.py deleted file mode 100755 index 92a8f0f..0000000 --- a/apps/dashboard/templatetags/markdown_extras.py +++ /dev/null @@ -1,48 +0,0 @@ -import markdown -from django import template -from django.utils.safestring import mark_safe - -register = template.Library() - - -@register.filter -def get_item(dictionary, key): - if dictionary is None: - return None - return dictionary.get(key) - - -@register.filter -def markdown_filter(value): - """ - 将 Markdown 文本转换为 HTML - """ - if not value: - return value - - md = markdown.Markdown(extensions=[ - 'extra', - 'codehilite', - 'tables', - 'toc', - ]) - html = md.convert(value) - return mark_safe(html) - - -@register.simple_tag -def markdown_render(text): - """ - 渲染 Markdown 文本的简单标签 - """ - if not text: - return "" - - md = markdown.Markdown(extensions=[ - 'extra', - 'codehilite', - 'tables', - 'toc', - ]) - html = md.convert(text) - return mark_safe(html) \ No newline at end of file diff --git a/apps/dashboard/urls.py b/apps/dashboard/urls.py deleted file mode 100755 index 7e79e09..0000000 --- a/apps/dashboard/urls.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -仪表盘URL配置 -""" - -from django.urls import path -from . import views -from . import views_sitegroup -from . import views_sitegroup_users - -app_name = "dashboard" - -urlpatterns = [ - path("", views.DashboardView.as_view(), name="index"), - path("widget-config/", views.WidgetConfigView.as_view(), name="widget_config"), - path("api/stats/", views.StatsAPIView.as_view(), name="stats_api"), - path( - "api/widget-config/", views.WidgetConfigView.as_view(), name="widget_config_api" - ), - path("sitegroup/", views_sitegroup.sitegroup_list, name="sitegroup_list"), - path( - "sitegroup/create/", views_sitegroup.sitegroup_create, name="sitegroup_create" - ), - path( - "sitegroup//", views_sitegroup.sitegroup_detail, name="sitegroup_detail" - ), - path( - "sitegroup//update/", - views_sitegroup.sitegroup_update, - name="sitegroup_update", - ), - path( - "sitegroup//delete/", - views_sitegroup.sitegroup_delete, - name="sitegroup_delete", - ), - path( - "sitegroup//add-hostname/", - views_sitegroup.sitegroup_add_hostname, - name="sitegroup_add_hostname", - ), - path( - "sitegroup//remove-hostname//", - views_sitegroup.sitegroup_remove_hostname, - name="sitegroup_remove_hostname", - ), - path( - "sitegroup//add-admin/", - views_sitegroup.sitegroup_add_admin, - name="sitegroup_add_admin", - ), - path( - "sitegroup//remove-admin//", - views_sitegroup.sitegroup_remove_admin, - name="sitegroup_remove_admin", - ), - path( - "sitegroup//config/", - views_sitegroup.sitegroup_config, - name="sitegroup_config", - ), - # 站点组用户管理 - path( - "sitegroup/users/", - views_sitegroup_users.sitegroup_user_list, - name="sitegroup_user_list", - ), - path( - "sitegroup/users//toggle-active/", - views_sitegroup_users.sitegroup_user_toggle_active, - name="sitegroup_user_toggle_active", - ), - path( - "sitegroup/users//reset-password/", - views_sitegroup_users.sitegroup_user_reset_password, - name="sitegroup_user_reset_password", - ), - path( - "sitegroup/users//remove/", - views_sitegroup_users.sitegroup_user_remove, - name="sitegroup_user_remove", - ), - # 站点组管理员配置(无需 pk,使用 request.site_group) - path( - "sitegroup/my-config/", - views_sitegroup.sitegroup_my_config, - name="sitegroup_my_config", - ), -] diff --git a/apps/dashboard/urls_admin.py b/apps/dashboard/urls_admin.py deleted file mode 100644 index a4b515a..0000000 --- a/apps/dashboard/urls_admin.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.urls import path - -from .views_admin import ( - widget_list, - widget_create, - widget_edit, - widget_delete, - systemconfig_edit, - systemconfig_send_test_email, - test_email_progress, - test_email_sse, -) - -app_name = 'admin_dashboard_config' - -urlpatterns = [ - path('widgets/', widget_list, name='widget_list'), - path('widgets/create/', widget_create, name='widget_create'), - path('widgets//edit/', widget_edit, name='widget_edit'), - path('widgets//delete/', widget_delete, name='widget_delete'), - path('config/', systemconfig_edit, name='systemconfig_edit'), - path( - 'config/send-test-email/', - systemconfig_send_test_email, - name='systemconfig_send_test_email', - ), - path( - 'config/test-email//', - test_email_progress, - name='test_email_progress', - ), - path( - 'config/test-email//sse/', - test_email_sse, - name='test_email_sse', - ), -] diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py deleted file mode 100755 index 18a372e..0000000 --- a/apps/dashboard/views.py +++ /dev/null @@ -1,433 +0,0 @@ -""" -仪表盘视图 -""" - -from typing import Any -from django.shortcuts import render, redirect -from django.views import View -from django.views.generic import TemplateView -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.http import JsonResponse -from django.db.models import Count, Q -from django.utils import timezone -from datetime import timedelta -from django.contrib.auth import get_user_model -from django.contrib import messages - -from apps.hosts.models import Host -from apps.operations.models import ( - AccountOpeningRequest, - CloudComputerUser, - Product, - ProductGroup, - ProductAccessGrant, -) -from apps.audit.models import AuditLog -from .models import DashboardWidget, SystemConfig -from .forms import SystemConfigForm -from utils.helpers import get_client_ip - -User = get_user_model() - - -class DashboardView(LoginRequiredMixin, TemplateView): - """ - 仪表盘主视图 - 展示机器一览和注册主机入口 - """ - - template_name = "dashboard/index.html" - - def get_context_data(self, **kwargs): - """获取仪表盘上下文数据""" - context = super().get_context_data(**kwargs) - - site_group = getattr(self.request, 'site_group', None) - if site_group: - product_groups = ProductGroup.objects.filter( - is_active=True, site_group=site_group - ).order_by( - "display_order", "name" - ) - else: - product_groups = ProductGroup.objects.filter(is_active=True, site_group__isnull=True).order_by( - "display_order", "name" - ) - - if site_group: - products_qs = Product.objects.filter( - is_available=True, site_group=site_group - ).select_related( - "host", "product_group" - ) - else: - products_qs = Product.objects.filter(is_available=True, site_group__isnull=True).select_related( - "host", "product_group" - ) - - search = self.request.GET.get("search", "") - if search: - products_qs = products_qs.filter( - Q(display_name__icontains=search) - | Q(display_description__icontains=search) - | Q(name__icontains=search) - ) - - status_filter = self.request.GET.get("status", "") - if status_filter: - products_qs = products_qs.filter(host__status=status_filter) - - group_filter = self.request.GET.get("group", "") - if group_filter: - products_qs = products_qs.filter(product_group_id=group_filter) - - auto_approval_filter = self.request.GET.get("auto_approval", "") - if auto_approval_filter == "true": - products_qs = products_qs.filter(auto_approval=True) - elif auto_approval_filter == "false": - products_qs = products_qs.filter(auto_approval=False) - - # 邀请访问权限过滤 - user = self.request.user - if not user.is_staff and not user.is_superuser: - # 获取用户有效的产品授权 - granted_product_ids = set( - ProductAccessGrant.objects.filter( - user=user, - product__isnull=False, - is_revoked=False, - ).exclude( - expires_at__lt=timezone.now() - ).values_list('product_id', flat=True) - ) - # 获取用户有效的产品组授权 - granted_group_ids = set( - ProductAccessGrant.objects.filter( - user=user, - product_group__isnull=False, - is_revoked=False, - ).exclude( - expires_at__lt=timezone.now() - ).values_list('product_group_id', flat=True) - ) - # 提供商可以看到自己创建的所有产品 - provider_created_ids = set() - if hasattr(user, 'created_products'): - provider_created_ids = set( - Product.objects.filter(created_by=user).values_list('id', flat=True) - ) - - # 过滤:公开产品 或 已授权产品 或 已授权产品组下的产品 或 提供商自己创建的产品 - products_qs = products_qs.filter( - Q(visibility='public') | - Q(id__in=granted_product_ids) | - Q(product_group_id__in=granted_group_ids) | - Q(id__in=provider_created_ids) - ) - - all_products = list(products_qs.order_by("-created_at")) - - user = self.request.user - existing_cloud_users = {} - if not (user.is_staff or user.is_superuser): - cloud_user_qs = CloudComputerUser.objects.filter( - Q(owner=user) | Q(created_from_request__applicant=user), - status__in=['active', 'inactive', 'disabled'], - ).values_list('product_id', 'pk') - for product_id, cloud_user_pk in cloud_user_qs: - if product_id not in existing_cloud_users: - existing_cloud_users[product_id] = cloud_user_pk - - pending_request_ids = {} - if not (user.is_staff or user.is_superuser): - request_qs = AccountOpeningRequest.objects.filter( - applicant=user, - status__in=['pending', 'approved', 'processing'], - ).values_list('target_product_id', 'pk') - for product_id, request_pk in request_qs: - if product_id not in pending_request_ids: - pending_request_ids[product_id] = request_pk - - grouped_products: list[dict[str, Any]] = [] - for group in product_groups: - products = [p for p in all_products if p.product_group_id == group.id] - if products: - grouped_products.append({"group": group, "products": products}) - - ungrouped = [p for p in all_products if p.product_group_id is None] - if ungrouped: - grouped_products.append({"group": None, "products": ungrouped}) - - context["existing_cloud_users"] = existing_cloud_users - context["pending_request_ids"] = pending_request_ids - - context["grouped_products"] = grouped_products - - context["products"] = all_products - - context["public_hosts"] = all_products - - context["product_groups"] = product_groups - context["status_choices"] = Host._meta.get_field("status").choices - context["search"] = search - context["status_filter"] = status_filter - context["group_filter"] = group_filter - context["auto_approval_filter"] = auto_approval_filter - - if site_group: - stats = AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ).aggregate( - pending_count=Count("id", filter=Q(status="pending")), - ) - context["cloud_users_total"] = CloudComputerUser.objects.filter( - product__site_group=site_group - ).count() - else: - stats = AccountOpeningRequest.objects.filter(target_product__site_group__isnull=True).aggregate( - pending_count=Count("id", filter=Q(status="pending")), - ) - context["cloud_users_total"] = CloudComputerUser.objects.filter(product__site_group__isnull=True).count() - context["account_requests_pending"] = stats["pending_count"] - - if self.request.user.is_staff or self.request.user.is_superuser: - if site_group: - context["account_requests_recent"] = ( - AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ).select_related( - "applicant", "target_product", "target_product__host" - ).order_by("-created_at")[:5] - ) - else: - context["account_requests_recent"] = ( - AccountOpeningRequest.objects.filter(target_product__site_group__isnull=True).select_related( - "applicant", "target_product", "target_product__host" - ).order_by("-created_at")[:5] - ) - else: - if site_group: - context["account_requests_recent"] = AccountOpeningRequest.objects.filter( - applicant=self.request.user, - ).filter( - target_product__site_group=site_group - ).select_related( - "applicant", "target_product", "target_product__host" - ).order_by("-created_at")[:5] - else: - context["account_requests_recent"] = AccountOpeningRequest.objects.filter( - applicant=self.request.user, target_product__site_group__isnull=True - ).select_related( - "applicant", "target_product", "target_product__host" - ).order_by("-created_at")[:5] - - try: - AuditLog.objects.create( - user=self.request.user, - action="dashboard_view", - description="访问仪表盘", - ip_address=get_client_ip(self.request), - user_agent=self.request.META.get("HTTP_USER_AGENT", ""), - ) - except Exception: - pass - - return context - - -class StatsAPIView(LoginRequiredMixin, View): - """提供JSON格式的统计数据""" - - def get(self, request, *args, **kwargs): - """获取统计数据""" - stats_type = request.GET.get("type", "all") - site_group = getattr(request, 'site_group', None) - - if stats_type == "all": - data = self._get_all_stats(site_group) - elif stats_type == "hosts": - data = self._get_host_stats(site_group) - elif stats_type == "operations": - data = self._get_operation_stats() - elif stats_type == "users": - data = self._get_user_stats() - elif stats_type == "account_opening": - data = self._get_account_opening_stats(site_group) - else: - data = {"error": "Invalid stats type"} - - return JsonResponse(data) - - def _get_all_stats(self, site_group): - """获取所有统计数据""" - return { - "hosts": self._get_host_stats(site_group), - "operations": self._get_operation_stats(), - "users": self._get_user_stats(), - "account_opening": self._get_account_opening_stats(site_group), - } - - def _get_host_stats(self, site_group): - """获取主机统计""" - from django.db.models import Count, Q - if site_group: - host_qs = Host.objects.filter(site_group=site_group) - else: - host_qs = Host.objects.filter(site_group__isnull=True) - stats = host_qs.aggregate( - total=Count('id'), - online=Count('id', filter=Q(status='online')), - offline=Count('id', filter=Q(status='offline')), - error=Count('id', filter=Q(status='error')), - ) - by_type = dict( - host_qs.values("connection_type") - .annotate(count=Count("id")) - .values_list("connection_type", "count") - ) - stats["by_type"] = by_type - return stats - - def _get_operation_stats(self): - """获取操作统计""" - # 由于已移除 OperationLog,返回空统计 - return { - "total": 0, - "success": 0, - "failed": 0, - "recent_7_days": 0, - "by_type": {}, - } - - def _get_user_stats(self): - """获取用户统计""" - from django.db.models import Count, Q - seven_days_ago = timezone.now() - timedelta(days=7) - - return User.objects.aggregate( - total=Count('id'), - active=Count('id', filter=Q(is_active=True)), - recent_7_days=Count('id', filter=Q(date_joined__gte=seven_days_ago)), - ) - - def _get_account_opening_stats(self, site_group): - """获取开户统计""" - from django.db.models import Count, Q - - if site_group: - request_qs = AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ) - cloud_qs = CloudComputerUser.objects.filter( - product__site_group=site_group - ) - else: - request_qs = AccountOpeningRequest.objects.filter(target_product__site_group__isnull=True) - cloud_qs = CloudComputerUser.objects.filter(product__site_group__isnull=True) - request_stats = request_qs.aggregate( - requests_total=Count('id'), - requests_pending=Count('id', filter=Q(status='pending')), - requests_approved=Count('id', filter=Q(status='approved')), - requests_completed=Count('id', filter=Q(status='completed')), - requests_failed=Count('id', filter=Q(status='failed')), - ) - cloud_user_stats = cloud_qs.aggregate( - cloud_users_total=Count('id'), - cloud_users_active=Count('id', filter=Q(status='active')), - ) - return {**request_stats, **cloud_user_stats} - - -class SystemConfigView(LoginRequiredMixin, UserPassesTestMixin, TemplateView): - """ - 系统配置视图 - 仅限管理员访问 - """ - - template_name = "dashboard/system_config.html" - - def test_func(self): - """检查用户是否为管理员""" - return ( - self.request.user.is_staff or self.request.user.is_superuser - ) # type: ignore - - def handle_no_permission(self): - """处理无权限访问的情况""" - messages.error(self.request, "您没有权限访问系统配置页面") - return redirect("dashboard:index") - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - # 获取或创建系统配置 - config = SystemConfig.get_config() - context["form"] = SystemConfigForm(instance=config) - return context - - def post(self, request, *args, **kwargs): - """处理系统配置更新""" - config = SystemConfig.get_config() - form = SystemConfigForm(request.POST, instance=config) - - if form.is_valid(): - form.save() - messages.success(request, "系统配置已更新") - - AuditLog.objects.create( - user=request.user, - action="system_config_update", - description="更新系统配置", - ip_address=get_client_ip(request), - user_agent=request.META.get("HTTP_USER_AGENT", ""), - ) - - return redirect("dashboard:index") - else: - messages.error(request, "系统配置更新失败,请检查表单中的错误") - context = self.get_context_data() - context["form"] = form - return self.render_to_response(context) - - -class WidgetConfigView(LoginRequiredMixin, View): - """ - 仪表盘组件配置视图 - 用于管理仪表盘组件的显示和配置 - """ - - def get(self, request, *args, **kwargs): - """渲染组件配置页面""" - widgets = DashboardWidget.objects.all() - context = {"widgets": widgets} - return render(request, "dashboard/widget_config.html", context) - - def post(self, request, *args, **kwargs): - """更新组件配置""" - import json - - try: - data = json.loads(request.body) - widgets_data = data.get("widgets", []) - - for widget_data in widgets_data: - widget_id = widget_data.get("widget_id") - is_enabled = widget_data.get("is_enabled", False) - display_order = widget_data.get("display_order", 0) - - try: - widget = DashboardWidget.objects.get(id=widget_id) - widget.is_enabled = is_enabled - widget.display_order = display_order - widget.save() - except DashboardWidget.DoesNotExist: - return JsonResponse( - {"status": "error", "message": f"Widget {widget_id} not found"}, - status=404, - ) - - return JsonResponse({"status": "success"}) - except json.JSONDecodeError: - return JsonResponse( - {"status": "error", "message": "Invalid JSON data"}, status=400 - ) diff --git a/apps/dashboard/views_admin.py b/apps/dashboard/views_admin.py deleted file mode 100644 index c44ef73..0000000 --- a/apps/dashboard/views_admin.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -仪表盘超级管理员视图 - -包含: -- DashboardWidget CRUD -- SystemConfig 单例编辑 + 发送测试邮件 -""" - -import json -import logging - -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.core.paginator import Paginator -from django.http import StreamingHttpResponse -from django.utils import timezone -from django.views.decorators.http import require_POST - -from apps.accounts.provider_decorators import superadmin_required -from .models import DashboardWidget, SystemConfig -from .forms_admin import DashboardWidgetForm, SystemConfigForm - -logger = logging.getLogger('2c2a') - - -# ============================================================ -# DashboardWidget CRUD -# ============================================================ - -@superadmin_required -def widget_list(request): - """仪表盘组件列表""" - queryset = DashboardWidget.objects.order_by('display_order') - - search = request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - title__icontains=search - ) | queryset.filter( - widget_type__icontains=search - ) - - paginator = Paginator(queryset, 25) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'search': search, - 'active_nav': 'dashboard_widgets', - } - return render(request, 'admin_base/dashboard/widget_list.html', context) - - -@superadmin_required -def widget_create(request): - """创建仪表盘组件""" - if request.method == 'POST': - form = DashboardWidgetForm(request.POST) - if form.is_valid(): - widget = form.save() - messages.success( - request, f'仪表盘组件「{widget.title}」创建成功。' - ) - return redirect('admin:admin_dashboard_config:widget_list') - else: - form = DashboardWidgetForm() - - context = { - 'form': form, - 'active_nav': 'dashboard_widgets', - 'is_create': True, - } - return render(request, 'admin_base/dashboard/widget_form.html', context) - - -@superadmin_required -def widget_edit(request, pk): - """编辑仪表盘组件""" - widget = get_object_or_404(DashboardWidget, pk=pk) - - if request.method == 'POST': - form = DashboardWidgetForm(request.POST, instance=widget) - if form.is_valid(): - widget = form.save() - messages.success( - request, f'仪表盘组件「{widget.title}」更新成功。' - ) - return redirect('admin:admin_dashboard_config:widget_list') - else: - form = DashboardWidgetForm(instance=widget) - - context = { - 'form': form, - 'widget': widget, - 'active_nav': 'dashboard_widgets', - 'is_create': False, - } - return render(request, 'admin_base/dashboard/widget_form.html', context) - - -@superadmin_required -def widget_delete(request, pk): - """删除仪表盘组件""" - widget = get_object_or_404(DashboardWidget, pk=pk) - - if request.method == 'POST': - title = widget.title - widget.delete() - messages.success( - request, f'仪表盘组件「{title}」已删除。' - ) - return redirect('admin:admin_dashboard_config:widget_list') - - context = { - 'widget': widget, - 'active_nav': 'dashboard_widgets', - } - return render( - request, 'admin_base/dashboard/widget_confirm_delete.html', context - ) - - -# ============================================================ -# SystemConfig 单例编辑 + 发送测试邮件 -# ============================================================ - -@superadmin_required -def systemconfig_edit(request): - """系统配置编辑(单例,自动 get_or_create)""" - config, _ = SystemConfig.objects.get_or_create(pk=1) - - if request.method == 'POST': - form = SystemConfigForm(request.POST, instance=config) - if form.is_valid(): - form.save() - from .signals import system_config_saved - system_config_saved.send( - sender=SystemConfig, - request=request, - ) - messages.success(request, '系统配置已更新。') - return redirect('admin:admin_dashboard_config:systemconfig_edit') - else: - form = SystemConfigForm(instance=config) - - context = { - 'form': form, - 'config': config, - 'active_nav': 'dashboard_config', - } - return render( - request, 'admin_base/dashboard/systemconfig_edit.html', context - ) - - -@superadmin_required -@require_POST -def systemconfig_send_test_email(request): - """发送测试邮件(异步,跳转中间页通过 SSE 追踪状态)""" - config = get_object_or_404(SystemConfig, pk=1) - - test_email = ( - request.POST.get('test_email') - or request.user.email - or config.smtp_from_email - ) - - if not test_email: - messages.error(request, '未提供测试邮箱地址。') - return redirect('admin:admin_dashboard_config:systemconfig_edit') - - # 创建 AsyncTask 追踪记录 - from apps.tasks.models import AsyncTask - import uuid - task_record = AsyncTask.objects.create( - task_id=str(uuid.uuid4()), - name='测试邮件发送', - created_by=request.user, - status='pending', - result={'test_email': test_email}, - ) - - # 派发 Celery 任务 - from apps.accounts.tasks import send_test_email_task - result = send_test_email_task.delay(task_record.pk) - - # 回填 Celery task ID - task_record.task_id = result.id - task_record.save(update_fields=['task_id']) - - # 跳转到中间页 - return redirect( - 'admin:admin_dashboard_config:test_email_progress', - task_pk=task_record.pk - ) - - -@superadmin_required -def test_email_progress(request, task_pk): - """测试邮件发送进度中间页""" - from apps.tasks.models import AsyncTask - task_record = get_object_or_404(AsyncTask, pk=task_pk) - - # 安全检查:只允许创建者或超管查看 - if ( - task_record.created_by != request.user - and not request.user.is_superuser - ): - messages.error(request, '无权查看此任务。') - return redirect('admin:admin_dashboard_config:systemconfig_edit') - - context = { - 'task_record': task_record, - 'test_email': ( - task_record.result.get('test_email', '') - if task_record.result else '' - ), - 'active_nav': 'dashboard_config', - } - return render( - request, - 'admin_base/dashboard/test_email_progress.html', - context, - ) - - -@superadmin_required -def test_email_sse(request, task_pk): - """测试邮件发送状态 SSE 端点""" - from apps.tasks.models import AsyncTask - import time - - def event_stream(): - for _ in range(120): # 最多等待 60 秒 - try: - task_record = AsyncTask.objects.get(pk=task_pk) - except AsyncTask.DoesNotExist: - yield f"data: {json.dumps({'status': 'failed', 'error': '任务不存在'})}\n\n" - return - - result = task_record.result or {} - data = { - 'status': task_record.status, - 'progress': task_record.progress, - 'error': task_record.error_message, - 'logs': result.get('logs', []), - } - yield f"data: {json.dumps(data)}\n\n" - - if task_record.status in ('success', 'failed', 'cancelled'): - return - - time.sleep(0.5) - - # 超时 - yield f"data: {json.dumps({'status': 'timeout'})}\n\n" - - response = StreamingHttpResponse( - event_stream(), content_type='text/event-stream' - ) - response['Cache-Control'] = 'no-cache' - response['X-Accel-Buffering'] = 'no' - return response diff --git a/apps/dashboard/views_sitegroup.py b/apps/dashboard/views_sitegroup.py deleted file mode 100644 index 188956e..0000000 --- a/apps/dashboard/views_sitegroup.py +++ /dev/null @@ -1,225 +0,0 @@ -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from apps.accounts.provider_decorators import superadmin_required, site_group_admin_required -from .models import SiteGroup, SiteGroupHostname, SiteGroupConfig -from .forms_sitegroup import SiteGroupForm, SiteGroupHostnameForm, SiteGroupConfigForm - - -@superadmin_required -def sitegroup_list(request): - sitegroups = SiteGroup.objects.all() - return render( - request, - "dashboard/sitegroup_list.html", - { - "sitegroups": sitegroups, - "active_nav": "sitegroups", - }, - ) - - -@superadmin_required -def sitegroup_create(request): - if request.method == "POST": - form = SiteGroupForm(request.POST) - if form.is_valid(): - form.save() - messages.success(request, "站点组创建成功") - return redirect("dashboard:sitegroup_list") - else: - form = SiteGroupForm() - return render( - request, - "dashboard/sitegroup_form.html", - { - "form": form, - "title": "创建站点组", - "active_nav": "sitegroups", - }, - ) - - -@superadmin_required -def sitegroup_update(request, pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - if request.method == "POST": - form = SiteGroupForm(request.POST, instance=sitegroup) - if form.is_valid(): - form.save() - messages.success(request, "站点组更新成功") - return redirect("dashboard:sitegroup_list") - else: - form = SiteGroupForm(instance=sitegroup) - return render( - request, - "dashboard/sitegroup_form.html", - { - "form": form, - "title": "编辑站点组", - "sitegroup": sitegroup, - "active_nav": "sitegroups", - }, - ) - - -@superadmin_required -def sitegroup_delete(request, pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - if request.method == "POST": - sitegroup.delete() - messages.success(request, "站点组已删除") - return redirect("dashboard:sitegroup_list") - return render( - request, - "dashboard/sitegroup_detail.html", - { - "sitegroup": sitegroup, - "active_nav": "sitegroups", - }, - ) - - -@superadmin_required -def sitegroup_detail(request, pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - hostnames = sitegroup.hostnames.all() - admins = sitegroup.admins.all() - return render( - request, - "dashboard/sitegroup_detail.html", - { - "sitegroup": sitegroup, - "hostnames": hostnames, - "admins": admins, - "active_nav": "sitegroups", - }, - ) - - -@superadmin_required -def sitegroup_add_hostname(request, pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - if request.method == "POST": - form = SiteGroupHostnameForm(request.POST) - if form.is_valid(): - hostname = form.save(commit=False) - hostname.site_group = sitegroup - hostname.save() - messages.success(request, f"主机名 {hostname.hostname} 已绑定") - else: - for error in form.errors.get_json_data().values(): - for e in error: - messages.error(request, e["message"]) - return redirect("dashboard:sitegroup_detail", pk=pk) - - -@superadmin_required -def sitegroup_remove_hostname(request, pk, hostname_pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - hostname = get_object_or_404( - SiteGroupHostname, pk=hostname_pk, site_group=sitegroup - ) - if request.method == "POST": - hostname.delete() - messages.success(request, f"主机名 {hostname.hostname} 已解绑") - return redirect("dashboard:sitegroup_detail", pk=pk) - - -@superadmin_required -def sitegroup_add_admin(request, pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - if request.method == "POST": - username = request.POST.get("username", "").strip() - if username: - from django.contrib.auth import get_user_model - - User = get_user_model() - try: - user = User.objects.get(username=username) - if user.is_superuser: - messages.warning( - request, f"用户 {username} 已是超级管理员,无需添加" - ) - elif sitegroup.admins.filter(pk=user.pk).exists(): - messages.warning(request, f"用户 {username} 已是该站点组管理员") - else: - sitegroup.admins.add(user) - messages.success(request, f"已将 {username} 添加为站点组管理员") - except User.DoesNotExist: - messages.error(request, f"用户 {username} 不存在") - return redirect("dashboard:sitegroup_detail", pk=pk) - - -@superadmin_required -def sitegroup_remove_admin(request, pk, user_pk): - sitegroup = get_object_or_404(SiteGroup, pk=pk) - from django.contrib.auth import get_user_model - - User = get_user_model() - user = get_object_or_404(User, pk=user_pk) - if request.method == "POST": - sitegroup.admins.remove(user) - messages.success(request, f"已移除 {user.username} 的站点组管理员权限") - return redirect("dashboard:sitegroup_detail", pk=pk) - - -@superadmin_required -def sitegroup_config(request, pk): - """站点组配置覆盖编辑""" - sitegroup = get_object_or_404(SiteGroup, pk=pk) - config, _ = SiteGroupConfig.objects.get_or_create(site_group=sitegroup) - - # 获取全局配置用于显示默认值 - from .models import SystemConfig - - global_config = SystemConfig.get_config() - - if request.method == "POST": - form = SiteGroupConfigForm(request.POST, instance=config) - if form.is_valid(): - form.save() - messages.success(request, f"站点组「{sitegroup.name}」配置已更新") - return redirect("dashboard:sitegroup_config", pk=pk) - else: - form = SiteGroupConfigForm(instance=config) - - return render( - request, - "dashboard/sitegroup_config.html", - { - "sitegroup": sitegroup, - "form": form, - "global_config": global_config, - "active_nav": "sitegroups", - }, - ) - - -@site_group_admin_required -def sitegroup_my_config(request): - """站点组管理员编辑自己站点组的配置覆盖""" - site_group = request.site_group - config, _ = SiteGroupConfig.objects.get_or_create(site_group=site_group) - - from .models import SystemConfig - global_config = SystemConfig.get_config() - - if request.method == "POST": - form = SiteGroupConfigForm(request.POST, instance=config) - if form.is_valid(): - form.save() - messages.success(request, f"站点组「{site_group.name}」配置已更新") - return redirect("dashboard:sitegroup_my_config") - else: - form = SiteGroupConfigForm(instance=config) - - return render( - request, - "dashboard/sitegroup_config.html", - { - "sitegroup": site_group, - "form": form, - "global_config": global_config, - "active_nav": "sitegroup_config", - }, - ) diff --git a/apps/dashboard/views_sitegroup_users.py b/apps/dashboard/views_sitegroup_users.py deleted file mode 100644 index 697c919..0000000 --- a/apps/dashboard/views_sitegroup_users.py +++ /dev/null @@ -1,182 +0,0 @@ -""" -站点组管理员 - 用户管理视图 - -站点组管理员只能管理本站点组内的用户。 -支持:用户列表、封禁/解封、重置密码。 -""" - -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.core.paginator import Paginator -from django.db.models import Q - -from apps.accounts.provider_decorators import site_group_admin_required -from apps.accounts.forms_admin import AdminPasswordResetForm -from apps.accounts.models import UserBan -from apps.accounts.user_service import ban_user, unban_user - -User = get_user_model() - - -@site_group_admin_required -def sitegroup_user_list(request): - """站点组用户列表""" - site_group = request.site_group - if not site_group: - messages.error(request, "未识别到站点组") - return redirect("dashboard:index") - - queryset = ( - User.objects.filter(site_groups=site_group) - .prefetch_related("groups") - .select_related("active_ban") - .order_by("-created_at") - ) - - search = request.GET.get("search", "").strip() - if search: - queryset = queryset.filter( - Q(username__icontains=search) - | Q(email__icontains=search) - | Q(first_name__icontains=search) - | Q(last_name__icontains=search) - ) - - active_filter = request.GET.get("is_active", "").strip() - if active_filter == "1": - queryset = queryset.filter(is_active=True) - elif active_filter == "0": - queryset = queryset.filter(is_active=False) - - paginator = Paginator(queryset, 25) - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - admin_ids = set(site_group.admins.values_list("pk", flat=True)) - - context = { - "site_group": site_group, - "admin_ids": admin_ids, - "page_obj": page_obj, - "search": search, - "active_filter": active_filter, - "active_nav": "sitegroup_users", - } - return render(request, "dashboard/sitegroup_user_list.html", context) - - -@site_group_admin_required -def sitegroup_user_toggle_active(request, user_pk): - """站点组管理员封禁/解封用户""" - site_group = request.site_group - if not site_group: - messages.error(request, "未识别到站点组") - return redirect("dashboard:index") - - user = get_object_or_404( - User, pk=user_pk, site_groups=site_group - ) - - # 不能封禁自己 - if user.pk == request.user.pk: - messages.error(request, "不能封禁自己的账号") - return redirect("dashboard:sitegroup_user_list") - - # 不能封禁超管 - if user.is_superuser: - messages.error(request, "不能封禁超级管理员") - return redirect("dashboard:sitegroup_user_list") - - if request.method == "POST": - is_banned = UserBan.objects.filter(user=user).exists() - if is_banned: - # 解封 - unban_user(user, unbanned_by=request.user) - status_text = "解封" - else: - # 封禁 - reason = request.POST.get("ban_reason", "").strip() - if not reason: - reason = f"站点组 {site_group.name} 管理员封禁" - ban_user(user, reason=reason, banned_by=request.user) - status_text = "封禁" - - messages.success( - request, f"用户「{user.username}」已{status_text}" - ) - - return redirect("dashboard:sitegroup_user_list") - - -@site_group_admin_required -def sitegroup_user_reset_password(request, user_pk): - """站点组管理员重置用户密码""" - site_group = request.site_group - if not site_group: - messages.error(request, "未识别到站点组") - return redirect("dashboard:index") - - user = get_object_or_404( - User, pk=user_pk, site_groups=site_group - ) - - if user.is_superuser: - messages.error(request, "不能重置超级管理员密码") - return redirect("dashboard:sitegroup_user_list") - - if request.method == "POST": - form = AdminPasswordResetForm(request.POST) - if form.is_valid(): - user.set_password(form.cleaned_data["new_password1"]) - user.save() - messages.success( - request, f"用户「{user.username}」密码已重置" - ) - return redirect("dashboard:sitegroup_user_list") - else: - form = AdminPasswordResetForm() - - context = { - "site_group": site_group, - "form": form, - "target_user": user, - "active_nav": "sitegroup_users", - } - return render( - request, "dashboard/sitegroup_user_reset_password.html", context - ) - - -@site_group_admin_required -def sitegroup_user_remove(request, user_pk): - """站点组管理员将用户移出本站点组""" - site_group = request.site_group - if not site_group: - messages.error(request, "未识别到站点组") - return redirect("dashboard:index") - - user = get_object_or_404( - User, pk=user_pk, site_groups=site_group - ) - - if user.is_superuser: - messages.error(request, "不能移出超级管理员") - return redirect("dashboard:sitegroup_user_list") - - if request.method == "POST": - user.site_groups.remove(site_group) - messages.success( - request, - f"用户「{user.username}」已从站点组「{site_group.name}」移出", - ) - return redirect("dashboard:sitegroup_user_list") - - context = { - "site_group": site_group, - "target_user": user, - "active_nav": "sitegroup_users", - } - return render( - request, "dashboard/sitegroup_user_remove.html", context - ) diff --git a/apps/errors/__init__.py b/apps/errors/__init__.py deleted file mode 100755 index 13eca5e..0000000 --- a/apps/errors/__init__.py +++ /dev/null @@ -1,52 +0,0 @@ -""" -自定义错误页面视图 -""" -from django.shortcuts import render -from django.http import HttpResponseServerError, HttpResponseNotFound, HttpResponseForbidden -import logging - -logger = logging.getLogger('2c2a') - - -def handler403(request, exception=None): - """403 错误处理""" - logger.warning(f"403 Forbidden access from {request.META.get('REMOTE_ADDR')} to {request.path}") - return render(request, 'errors/403.html', { - 'error_title': '访问被拒绝', - 'error_message': '您没有权限访问此页面。', - 'request_id': getattr(request, 'request_id', None) - }, status=403) - - -def handler404(request, exception=None): - """404 错误处理""" - logger.info(f"404 Not found: {request.path}") - return render(request, 'errors/404.html', { - 'error_title': '页面未找到', - 'error_message': '您请求的页面不存在或已被移动。', - 'request_path': request.path, - 'request_id': getattr(request, 'request_id', None) - }, status=404) - - -def handler500(request): - """500 错误处理""" - logger.error(f"500 Server error at {request.path}", exc_info=True) - # 使用通用错误消息,不暴露技术细节 - return render(request, 'errors/500.html', { - 'error_title': '服务器错误', - 'error_message': '服务器遇到了意外情况,我们正在努力修复此问题。', - 'request_id': getattr(request, 'request_id', None), - 'support_message': '如果问题持续存在,请联系技术支持团队', - 'trace_id': '请联系技术支持人员并提供错误ID' - }, status=500) - - -def handler400(request, exception=None): - """400 错误处理""" - logger.warning(f"400 Bad request from {request.META.get('REMOTE_ADDR')}: {request.path}") - return render(request, 'errors/400.html', { - 'error_title': '错误的请求', - 'error_message': '您的请求格式不正确或包含无效数据。', - 'request_id': getattr(request, 'request_id', None) - }, status=400) \ No newline at end of file diff --git a/apps/errors/urls.py b/apps/errors/urls.py deleted file mode 100755 index def489a..0000000 --- a/apps/errors/urls.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -错误处理 URL 配置 -""" -from django.urls import path -from . import views - -# 错误处理器 -handler400 = 'apps.errors.handler400' -handler403 = 'apps.errors.handler403' -handler404 = 'apps.errors.handler404' -handler500 = 'apps.errors.handler500' - -urlpatterns = [ - # 可以添加错误页面测试路由 - # path('403/', views.handler_test403, name='test_403'), - # path('404/', views.handler_test404, name='test_404'), - # path('500/', views.handler_test500, name='test_500'), -] \ No newline at end of file diff --git a/apps/errors/views.py b/apps/errors/views.py deleted file mode 100755 index 4dc55a6..0000000 --- a/apps/errors/views.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -错误处理视图 -""" -from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponseServerError, HttpResponseForbidden -from django.shortcuts import render -import logging - -logger = logging.getLogger('2c2a') - - -def handler400(request, exception=None): - """400 错误处理""" - logger.warning(f"400 Bad request from {request.META.get('REMOTE_ADDR')} to {request.path}") - return render(request, 'errors/400.html', { - 'error_title': '错误的请求', - 'error_message': '您的请求格式不正确或包含无效数据。', - 'request_id': getattr(request, 'request_id', None) - }, status=400) - - -def handler403(request, exception=None): - """403 错误处理""" - logger.warning(f"403 Forbidden access from {request.META.get('REMOTE_ADDR')} to {request.path}") - return render(request, 'errors/403.html', { - 'error_title': '访问被拒绝', - 'error_message': '您没有权限访问此页面。', - 'request_id': getattr(request, 'request_id', None) - }, status=403) - - -def handler404(request, exception=None): - """404 错误处理""" - logger.info(f"404 Not found: {request.path}") - return render(request, 'errors/404.html', { - 'error_title': '页面未找到', - 'error_message': '您请求的页面不存在或已被移动。', - 'request_path': request.path, - 'request_id': getattr(request, 'request_id', None) - }, status=404) - - -def handler500(request): - """500 错误处理""" - logger.error(f"500 Server error at {request.path}", exc_info=True) - # 获取追踪 ID - import uuid - trace_id = str(uuid.uuid4()) - - # 使用通用错误消息,不暴露技术细节 - return render(request, 'errors/500.html', { - 'error_title': '服务器错误', - 'error_message': '服务器遇到了意外情况,我们正在努力修复此问题。', - 'request_id': getattr(request, 'request_id', None), - 'trace_id': trace_id, - 'support_message': '如果问题持续存在,请联系技术支持团队' - }, status=500) \ No newline at end of file diff --git a/apps/hosts/__init__.py b/apps/hosts/__init__.py deleted file mode 100755 index 81716a5..0000000 --- a/apps/hosts/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Hosts 应用初始化 -""" -default_app_config = 'apps.hosts.apps.HostsConfig' \ No newline at end of file diff --git a/apps/hosts/admin.py b/apps/hosts/admin.py deleted file mode 100644 index 0baebbe..0000000 --- a/apps/hosts/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (apps.hosts.views_admin) 和提供商后台 (apps.hosts.views_provider) diff --git a/apps/hosts/apps.py b/apps/hosts/apps.py deleted file mode 100755 index b76792a..0000000 --- a/apps/hosts/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Hosts 应用配置 -""" -from django.apps import AppConfig - - -class HostsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.hosts' - verbose_name = '主机管理' \ No newline at end of file diff --git a/apps/hosts/forms_admin.py b/apps/hosts/forms_admin.py deleted file mode 100644 index c82ff2f..0000000 --- a/apps/hosts/forms_admin.py +++ /dev/null @@ -1,330 +0,0 @@ -""" -主机管理 - 超管后台表单 - -超管可操作所有字段,无提供商数据隔离。 -包含主机创建/编辑表单和主机组表单。 -""" - -import os - -from django import forms -from django.contrib.auth import get_user_model -from django.conf import settings - -from utils.provider import PROVIDER_GROUP_NAME -from .models import Host, HostGroup -from .forms_wizard import ( - validate_certificate_pem, - validate_private_key_pem, - _ensure_cert_dir, -) - -User = get_user_model() - -INPUT_CLASS = ( - 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' - 'rounded px-3 py-2 text-slate-200 placeholder-slate-500 ' - 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' - 'focus:border-cyan-500 transition' -) -SELECT_CLASS = ( - 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' - 'rounded px-3 py-2 text-slate-200 appearance-none ' - 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' - 'focus:border-cyan-500 transition cursor-pointer' -) -CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 ' - 'text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition ' - 'accent-cyan-500 cursor-pointer' -) -MULTI_SELECT_CLASS = ( - 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' - 'rounded px-3 py-2 text-slate-200 ' - 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' - 'focus:border-cyan-500 transition min-h-[120px]' -) -FILE_INPUT_CLASS = ( - 'w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 ' - 'file:rounded file:border-0 file:text-sm file:font-medium ' - 'file:bg-cyan-600/20 file:text-cyan-400 hover:file:bg-cyan-600/30 ' - 'file:cursor-pointer cursor-pointer' -) - - -class AdminHostForm(forms.ModelForm): - """ - 超管主机表单 - - 包含所有主机字段,无提供商过滤。 - 密码字段可选,留空则自动生成(创建时)或不修改(编辑时)。 - """ - - password = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入远程主机登录密码', - 'autocomplete': 'new-password', - }), - required=False, - label='密码', - ) - - cert_pem = forms.FileField( - label='客户端证书(公钥)', - required=False, - widget=forms.ClearableFileInput(attrs={ - 'class': FILE_INPUT_CLASS, - 'accept': '.pem,.cer,.crt,.cert', - }), - help_text='PEM格式的客户端证书文件', - ) - - cert_key = forms.FileField( - label='客户端私钥', - required=False, - widget=forms.ClearableFileInput(attrs={ - 'class': FILE_INPUT_CLASS, - 'accept': '.pem,.key', - }), - help_text='PEM格式的客户端私钥文件', - ) - - class Meta: - model = Host - fields = [ - 'name', 'os_type', 'hostname', 'connection_type', - 'auth_method', 'port', 'rdp_port', - 'use_ssl', 'username', - 'providers', - ] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入主机名称', - }), - 'os_type': forms.Select(attrs={ - 'class': SELECT_CLASS, - }), - 'hostname': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入主机地址', - }), - 'connection_type': forms.Select(attrs={ - 'class': SELECT_CLASS, - }), - 'auth_method': forms.Select(attrs={ - 'class': SELECT_CLASS, - }), - 'port': forms.NumberInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '5985', - }), - 'rdp_port': forms.NumberInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '3389', - }), - 'username': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入连接用户名', - }), - 'use_ssl': forms.CheckboxInput(attrs={ - 'class': CHECKBOX_CLASS, - }), - 'providers': forms.SelectMultiple(attrs={ - 'class': MULTI_SELECT_CLASS, - 'size': '6', - }), - } - labels = { - 'name': '主机名称', - 'os_type': '主机系统', - 'hostname': '主机地址', - 'connection_type': '连接类型', - 'auth_method': '连接方式', - 'port': 'WinRM端口', - 'rdp_port': 'RDP端口', - 'use_ssl': '使用SSL', - 'username': '用户名', - 'providers': '管理提供商', - } - help_texts = { - 'providers': '按住 Ctrl / Cmd 可多选提供商', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - provider_users = User.objects.filter( - groups__name=PROVIDER_GROUP_NAME, - is_staff=True, - is_superuser=False, - ).order_by('username') - self.fields['providers'].queryset = provider_users - - self.fields['os_type'].choices = Host.OS_TYPE_CHOICES - self.fields['connection_type'].choices = [ - ('winrm', 'WinRM'), - ('localwinserver', '本地WinServer'), - ] - self.fields['auth_method'].choices = Host.AUTH_METHOD_CHOICES - - if self.instance.pk: - self.fields['password'].help_text = ( - '留空则不修改密码。为安全起见,此处不显示原密码。' - ) - self.fields['password'].required = False - - def clean(self): - cleaned_data = super().clean() - connection_type = cleaned_data.get('connection_type') - auth_method = cleaned_data.get('auth_method') - - if connection_type == 'winrm' and auth_method == 'certificate': - cert_pem = self.files.get('cert_pem') - cert_key = self.files.get('cert_key') - has_existing = ( - self.instance.pk - and self.instance.cert_pem_path - and os.path.exists(self.instance.cert_pem_path) - ) - if not cert_pem and not has_existing: - self.add_error('cert_pem', '证书认证方式必须上传客户端证书') - if not cert_key and not has_existing: - self.add_error('cert_key', '证书认证方式必须上传客户端私钥') - if cert_pem: - try: - validate_certificate_pem(cert_pem.read()) - except forms.ValidationError as e: - self.add_error('cert_pem', e) - finally: - cert_pem.seek(0) - if cert_key: - try: - validate_private_key_pem(cert_key.read()) - except forms.ValidationError as e: - self.add_error('cert_key', e) - finally: - cert_key.seek(0) - - if connection_type == 'winrm' and auth_method == 'ntlm': - if not cleaned_data.get('username'): - self.add_error('username', 'NTLM认证方式必须填写用户名') - if not self.instance.pk and not cleaned_data.get('password'): - self.add_error('password', 'NTLM认证方式必须填写密码') - - if connection_type == 'localwinserver': - if not cleaned_data.get('username'): - self.add_error('username', '必须填写用户名') - if not self.instance.pk and not cleaned_data.get('password'): - self.add_error('password', '必须填写密码') - - return cleaned_data - - def save(self, commit=True): - instance = super().save(commit=False) - auth_method = self.cleaned_data.get('auth_method') - connection_type = self.cleaned_data.get('connection_type') - password = self.cleaned_data.get('password') - - if self.instance.pk: - if password: - instance.password = password - else: - if connection_type == 'winrm' and auth_method == 'ntlm': - instance.password = password - elif connection_type == 'winrm' and auth_method == 'certificate': - instance.cert_pem_path = instance.cert_pem_path or '' - instance.cert_key_path = instance.cert_key_path or '' - elif connection_type == 'localwinserver': - instance.password = password - - if commit: - instance.save() - self.save_m2m() - if connection_type == 'winrm' and auth_method == 'certificate': - self._save_cert_files(instance) - - return instance - - def _save_cert_files(self, host): - cert_pem = self.files.get('cert_pem') - cert_key = self.files.get('cert_key') - if not cert_pem or not cert_key: - return - - cert_dir = _ensure_cert_dir(host.pk) - pem_path = os.path.join(cert_dir, 'client.pem') - key_path = os.path.join(cert_dir, 'client.key') - - with open(pem_path, 'wb') as f: - for chunk in cert_pem.chunks(): - f.write(chunk) - - with open(key_path, 'wb') as f: - for chunk in cert_key.chunks(): - f.write(chunk) - - os.chmod(pem_path, 0o600) - os.chmod(key_path, 0o600) - - host.cert_pem_path = pem_path - host.cert_key_path = key_path - Host.objects.filter(pk=host.pk).update( - cert_pem_path=pem_path, - cert_key_path=key_path, - ) - - -class AdminHostGroupForm(forms.ModelForm): - """ - 超管主机组表单 - - 包含所有主机组字段,无提供商过滤。 - providers 字段显示所有提供商组用户。 - """ - - class Meta: - model = HostGroup - fields = ['name', 'description', 'hosts', 'providers'] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入主机组名称', - }), - 'description': forms.Textarea(attrs={ - 'class': INPUT_CLASS + ' resize-y', - 'rows': 3, - 'placeholder': '输入主机组描述(可选)', - }), - 'hosts': forms.SelectMultiple(attrs={ - 'class': MULTI_SELECT_CLASS, - 'size': '8', - }), - 'providers': forms.SelectMultiple(attrs={ - 'class': MULTI_SELECT_CLASS, - 'size': '6', - }), - } - labels = { - 'name': '组名称', - 'description': '描述', - 'hosts': '主机', - 'providers': '管理提供商', - } - help_texts = { - 'hosts': '按住 Ctrl / Cmd 可选主机', - 'providers': '按住 Ctrl / Cmd 可多选提供商', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['hosts'].queryset = Host.objects.order_by('name') - - provider_users = User.objects.filter( - groups__name=PROVIDER_GROUP_NAME, - is_staff=True, - is_superuser=False, - ).order_by('username') - self.fields['providers'].queryset = provider_users diff --git a/apps/hosts/forms_provider.py b/apps/hosts/forms_provider.py deleted file mode 100644 index 000084e..0000000 --- a/apps/hosts/forms_provider.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -主机管理 - 提供商后台表单 - -包含主机创建和编辑表单,复用 Admin 中的密码处理逻辑。 -""" - -import secrets -import string - -from django import forms - -from .models import Host, HostGroup - - -def generate_random_password(length=16): - """ - 生成随机复杂密码 - - 包含大写字母、小写字母、数字和特殊字符,确保密码强度。 - """ - alphabet = string.ascii_letters + string.digits + '!@#$%^&*()_+-=[]{}|;:,.<>?' - while True: - password = ''.join(secrets.choice(alphabet) for _ in range(length)) - has_upper = any(c.isupper() for c in password) - has_lower = any(c.islower() for c in password) - has_digit = any(c.isdigit() for c in password) - has_special = any(c in '!@#$%^&*()_+-=[]{}|;:,.<>?' for c in password) - if has_upper and has_lower and has_digit and has_special: - return password - - -class HostCreateForm(forms.ModelForm): - """ - 主机创建表单 - - 密码字段为必填,可自动生成随机密码。 - 创建时自动设置 created_by 为当前用户。 - """ - - password = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入密码或留空自动生成', - 'autocomplete': 'new-password', - }), - required=False, - help_text='留空将自动生成随机密码', - label='密码', - ) - - class Meta: - model = Host - fields = [ - 'name', 'hostname', 'connection_type', 'port', 'rdp_port', - 'use_ssl', 'username', 'os_version', 'description', - ] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入主机名称', - }), - 'hostname': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入主机地址', - }), - 'connection_type': forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface appearance-none focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer', - }), - 'port': forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '5985', - }), - 'rdp_port': forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '3389', - }), - 'username': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入连接用户名', - }), - 'os_version': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '例如: Windows Server 2022', - }), - 'description': forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '输入主机描述(可选)', - }), - 'use_ssl': forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 text-md-primary focus:ring-md-primary focus:ring-2 transition', - }), - } - - def __init__(self, *args, **kwargs): - self.generated_password = None - super().__init__(*args, **kwargs) - # 创建时密码可选(自动生成) - self.fields['password'].required = False - - def save(self, commit=True): - instance = super().save(commit=False) - password = self.cleaned_data.get('password') - if password: - instance.password = password - else: - # 自动生成随机密码 - self.generated_password = generate_random_password() - instance.password = self.generated_password - if commit: - instance.save() - return instance - - -class HostUpdateForm(forms.ModelForm): - """ - 主机编辑表单 - - 密码字段可选,留空则不修改。 - 不允许修改 created_by 和 providers 字段。 - """ - - password = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '留空则不修改密码', - 'autocomplete': 'new-password', - }), - required=False, - help_text='留空则不修改密码。为安全起见,此处不显示原密码。', - label='密码', - ) - - class Meta: - model = Host - fields = [ - 'name', 'hostname', 'connection_type', 'port', 'rdp_port', - 'use_ssl', 'username', 'os_version', 'status', 'description', - ] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入主机名称', - }), - 'hostname': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入主机地址', - }), - 'connection_type': forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface appearance-none focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer', - }), - 'port': forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '5985', - }), - 'rdp_port': forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '3389', - }), - 'username': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入连接用户名', - }), - 'os_version': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '例如: Windows Server 2022', - }), - 'status': forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface appearance-none focus:outline-none focus:ring-2 focus:ring-md-primary transition cursor-pointer', - }), - 'description': forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '输入主机描述(可选)', - }), - 'use_ssl': forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 text-md-primary focus:ring-md-primary focus:ring-2 transition', - }), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if self.instance.pk: - self.fields['password'].help_text = '留空则不修改密码。为安全起见,此处不显示原密码。' - - def save(self, commit=True): - instance = super().save(commit=False) - if self.cleaned_data.get('password'): - instance.password = self.cleaned_data['password'] - if commit: - instance.save() - return instance - - -class HostGroupForm(forms.ModelForm): - """ - 主机组表单 - - 提供商只能选择自己可见的主机和提供商。 - hosts 和 providers 字段按当前提供商过滤。 - """ - - class Meta: - model = HostGroup - fields = ['name', 'description', 'hosts', 'providers'] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '输入主机组名称', - }), - 'description': forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '输入主机组描述(可选)', - }), - 'hosts': forms.SelectMultiple(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface focus:outline-none focus:ring-2 focus:ring-md-primary transition min-h-[120px]', - 'size': '8', - }), - 'providers': forms.SelectMultiple(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface focus:outline-none focus:ring-2 focus:ring-md-primary transition min-h-[80px]', - 'size': '5', - }), - } - labels = { - 'name': '组名称', - 'description': '描述', - 'hosts': '主机', - 'providers': '管理提供商', - } - help_texts = { - 'hosts': '按住 Ctrl / Cmd 可多选主机', - 'providers': '按住 Ctrl / Cmd 可多选提供商', - } - - def __init__(self, *args, **kwargs): - self.provider_user = kwargs.pop('provider_user', None) - super().__init__(*args, **kwargs) - - if self.provider_user: - # 过滤 hosts:只显示当前提供商可见的主机 - from utils.provider import get_provider_hosts - self.fields['hosts'].queryset = get_provider_hosts( - self.provider_user - ).order_by('name') - - # 过滤 providers:只显示提供商组的用户 - from django.contrib.auth.models import User - from utils.provider import is_provider, PROVIDER_GROUP_NAME - provider_users = User.objects.filter( - groups__name=PROVIDER_GROUP_NAME, - is_staff=True, - is_superuser=False, - ).order_by('username') - self.fields['providers'].queryset = provider_users diff --git a/apps/hosts/forms_wizard.py b/apps/hosts/forms_wizard.py deleted file mode 100644 index 4c5d1ff..0000000 --- a/apps/hosts/forms_wizard.py +++ /dev/null @@ -1,372 +0,0 @@ -""" -主机管理 - 向导式创建表单 - -分步引导超管添加主机,提供智能默认值和逐步验证。 -与 AdminHostForm 不同,此表单专注于创建流程的简化和引导。 -""" - -import os - -from django import forms -from django.contrib.auth import get_user_model -from django.conf import settings - -from utils.provider import PROVIDER_GROUP_NAME -from .models import Host - -User = get_user_model() - -INPUT_CLASS = ( - 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' - 'rounded px-3 py-2 text-slate-200 placeholder-slate-500 ' - 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' - 'focus:border-cyan-500 transition' -) -SELECT_CLASS = ( - 'w-full bg-slate-900/50 backdrop-blur-sm border border-slate-700/50 ' - 'rounded px-3 py-2 text-slate-200 appearance-none ' - 'focus:outline-none focus:ring-1 focus:ring-cyan-500/50 ' - 'focus:border-cyan-500 transition cursor-pointer' -) -CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 ' - 'text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition ' - 'accent-cyan-500 cursor-pointer' -) -FILE_INPUT_CLASS = ( - 'w-full text-sm text-slate-200 file:mr-4 file:py-2 file:px-4 ' - 'file:rounded file:border-0 file:text-sm file:font-medium ' - 'file:bg-cyan-600/20 file:text-cyan-400 hover:file:bg-cyan-600/30 ' - 'file:cursor-pointer cursor-pointer' -) - -CONNECTION_DEFAULT_PORTS = { - 'winrm': 5985, - 'localwinserver': 5985, - 'ssh': 22, - 'tunnel': 5985, -} - -CONNECTION_DEFAULT_SSL = { - 'winrm': False, - 'localwinserver': False, - 'ssh': False, - 'tunnel': False, -} - -CERT_STORAGE_DIR = os.path.join(settings.MEDIA_ROOT, 'certs', 'hosts') - - -def _ensure_cert_dir(host_pk): - d = os.path.join(CERT_STORAGE_DIR, str(host_pk)) - os.makedirs(d, exist_ok=True) - return d - - -def validate_certificate_pem(content: bytes, field_name: str = '证书') -> None: - try: - text = content.decode('utf-8') - except UnicodeDecodeError: - raise forms.ValidationError(f'{field_name}文件编码无效,必须为UTF-8文本格式') - if '-----BEGIN' not in text: - raise forms.ValidationError(f'{field_name}文件格式无效,不是合法的PEM格式') - if '-----END' not in text: - raise forms.ValidationError(f'{field_name}文件格式无效,不是合法的PEM格式') - - -def validate_private_key_pem(content: bytes) -> None: - try: - text = content.decode('utf-8') - except UnicodeDecodeError: - raise forms.ValidationError('私钥文件编码无效,必须为UTF-8文本格式') - if '-----BEGIN' not in text: - raise forms.ValidationError('私钥文件格式无效,不是合法的PEM格式') - if 'PRIVATE KEY' not in text: - raise forms.ValidationError('私钥文件格式无效,未包含私钥标识') - if '-----END' not in text: - raise forms.ValidationError('私钥文件格式无效,不是合法的PEM格式') - try: - from cryptography.hazmat.primitives.serialization import load_pem_private_key - from cryptography.hazmat.backends import default_backend - load_pem_private_key(content, password=None, backend=default_backend()) - except ImportError: - # cryptography 为可选依赖:缺失时仅执行基础 PEM 文本校验, - # 不阻断表单流程,以保持兼容现有部署环境。 - pass - except Exception as e: - raise forms.ValidationError(f'私钥文件无效: {str(e)}') - - -class HostWizardForm(forms.ModelForm): - """ - 主机创建向导表单 - - 分为三步: - - Step 1: 基本信息 (name, os_type, hostname, connection_type) - - Step 2: 连接配置 (port, auth_method, username/password 或 证书) - - Step 3: 分配提供商 (providers, description) - """ - - password = forms.CharField( - widget=forms.PasswordInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入远程主机登录密码', - 'autocomplete': 'new-password', - 'x-model': 'password', - }), - required=False, - label='密码', - ) - - tunnel_token = forms.CharField( - widget=forms.HiddenInput(attrs={ - 'x-model': 'tunnelToken', - }), - required=False, - ) - - init_token = forms.CharField( - widget=forms.HiddenInput(attrs={ - 'x-model': 'initToken', - }), - required=False, - ) - - cert_config_method = forms.CharField( - widget=forms.HiddenInput(attrs={ - 'x-model': 'certConfigMethod', - }), - required=False, - initial='quick', - ) - - cert_pem = forms.FileField( - label='客户端证书(公钥)', - required=False, - widget=forms.ClearableFileInput(attrs={ - 'class': FILE_INPUT_CLASS, - 'accept': '.pem,.cer,.crt,.cert', - }), - help_text='PEM格式的客户端证书文件', - ) - - cert_key = forms.FileField( - label='客户端私钥', - required=False, - widget=forms.ClearableFileInput(attrs={ - 'class': FILE_INPUT_CLASS, - 'accept': '.pem,.key', - }), - help_text='PEM格式的客户端私钥文件', - ) - - class Meta: - model = Host - fields = [ - 'name', 'os_type', 'hostname', 'connection_type', - 'auth_method', 'port', 'rdp_port', 'use_ssl', - 'username', 'password', - 'providers', 'description', - 'tunnel_token', - ] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入主机名称,如: 北京服务器-01', - 'x-model': 'name', - }), - 'os_type': forms.Select(attrs={ - 'class': SELECT_CLASS, - 'x-model': 'osType', - }), - 'hostname': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入主机地址,如: 192.168.1.100', - 'x-model': 'hostname', - }), - 'connection_type': forms.Select(attrs={ - 'class': SELECT_CLASS, - 'x-model': 'connectionType', - 'x-on:change': 'onConnectionTypeChange()', - }), - 'auth_method': forms.Select(attrs={ - 'class': SELECT_CLASS, - 'x-model': 'authMethod', - 'x-on:change': 'onAuthMethodChange()', - }), - 'port': forms.NumberInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '5985', - 'x-model': 'port', - }), - 'rdp_port': forms.NumberInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '3389', - 'x-model.number': 'rdpPort', - }), - 'use_ssl': forms.CheckboxInput(attrs={ - 'class': CHECKBOX_CLASS, - 'x-model': 'useSsl', - }), - 'username': forms.TextInput(attrs={ - 'class': INPUT_CLASS, - 'placeholder': '输入连接用户名,如: Administrator', - 'x-model': 'username', - }), - 'description': forms.Textarea(attrs={ - 'class': INPUT_CLASS + ' resize-y', - 'rows': 3, - 'placeholder': '输入主机描述(可选)', - 'x-model': 'description', - }), - 'providers': forms.CheckboxSelectMultiple(), - } - labels = { - 'name': '主机名称', - 'os_type': '主机系统', - 'hostname': '主机地址', - 'connection_type': '连接类型', - 'auth_method': '连接方式', - 'port': 'WinRM端口', - 'rdp_port': 'RDP端口', - 'use_ssl': '使用SSL', - 'username': '用户名', - 'description': '描述', - 'providers': '管理提供商', - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - provider_users = User.objects.filter( - groups__name=PROVIDER_GROUP_NAME, - is_staff=True, - is_superuser=False, - ).order_by('username') - self.fields['providers'].queryset = provider_users - - if not self.initial.get('port'): - self.initial['port'] = 5985 - if not self.initial.get('rdp_port'): - self.initial['rdp_port'] = 3389 - - self.fields['os_type'].choices = Host.OS_TYPE_CHOICES - - self.fields['connection_type'].choices = [ - ('winrm', 'WinRM'), - ('localwinserver', '本地WinServer'), - ] - - self.fields['auth_method'].choices = Host.AUTH_METHOD_CHOICES - - def clean(self): - cleaned_data = super().clean() - connection_type = cleaned_data.get('connection_type') - hostname = cleaned_data.get('hostname') - auth_method = cleaned_data.get('auth_method') - - if connection_type == 'tunnel' and not hostname: - cleaned_data['hostname'] = 'tunnel-pending' - - tunnel_token = cleaned_data.get('tunnel_token') - if tunnel_token == '': - cleaned_data['tunnel_token'] = None - - if connection_type == 'winrm' and auth_method == 'certificate': - cert_config_method = cleaned_data.get('cert_config_method', 'quick') - cert_pem = self.files.get('cert_pem') - cert_key = self.files.get('cert_key') - if cert_config_method == 'manual': - if not cert_pem: - self.add_error('cert_pem', '证书认证方式必须上传客户端证书') - if not cert_key: - self.add_error('cert_key', '证书认证方式必须上传客户端私钥') - if cert_pem: - try: - validate_certificate_pem(cert_pem.read()) - except forms.ValidationError as e: - self.add_error('cert_pem', e) - finally: - cert_pem.seek(0) - if cert_key: - try: - validate_private_key_pem(cert_key.read()) - except forms.ValidationError as e: - self.add_error('cert_key', e) - finally: - cert_key.seek(0) - - if connection_type == 'winrm' and auth_method == 'ntlm': - if not cleaned_data.get('username'): - self.add_error('username', 'NTLM认证方式必须填写用户名') - if not cleaned_data.get('password'): - self.add_error('password', 'NTLM认证方式必须填写密码') - - if connection_type == 'localwinserver': - if not cleaned_data.get('username'): - self.add_error('username', '必须填写用户名') - if not cleaned_data.get('password'): - self.add_error('password', '必须填写密码') - - return cleaned_data - - def save(self, commit=True): - instance = super().save(commit=False) - auth_method = self.cleaned_data.get('auth_method') - connection_type = self.cleaned_data.get('connection_type') - - if connection_type == 'winrm' and auth_method == 'ntlm': - instance.password = self.cleaned_data.get('password') - elif connection_type == 'winrm' and auth_method == 'certificate': - instance.cert_pem_path = '' - instance.cert_key_path = '' - elif connection_type == 'localwinserver': - instance.password = self.cleaned_data.get('password') - - if commit: - instance.save() - self.save_m2m() - if connection_type == 'winrm' and auth_method == 'certificate': - self._save_cert_files(instance) - - return instance - - def _save_cert_files(self, host): - cert_pem = self.files.get('cert_pem') - cert_key = self.files.get('cert_key') - if not cert_pem or not cert_key: - return - - cert_dir = _ensure_cert_dir(host.pk) - pem_path = os.path.join(cert_dir, 'client.pem') - key_path = os.path.join(cert_dir, 'client.key') - - with open(pem_path, 'wb') as f: - for chunk in cert_pem.chunks(): - f.write(chunk) - - with open(key_path, 'wb') as f: - for chunk in cert_key.chunks(): - f.write(chunk) - - os.chmod(pem_path, 0o600) - os.chmod(key_path, 0o600) - - host.cert_pem_path = pem_path - host.cert_key_path = key_path - Host.objects.filter(pk=host.pk).update( - cert_pem_path=pem_path, - cert_key_path=key_path, - ) - - def get_providers_with_host_count(self): - providers = self.fields['providers'].queryset - result = [] - for provider in providers: - host_count = provider.provider_hosts.count() - result.append({ - 'id': provider.pk, - 'username': provider.username, - 'host_count': host_count, - }) - return result diff --git a/apps/hosts/management/__init__.py b/apps/hosts/management/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/hosts/management/commands/__init__.py b/apps/hosts/management/commands/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/hosts/management/commands/gateway_listener.py b/apps/hosts/management/commands/gateway_listener.py deleted file mode 100644 index c0eaacf..0000000 --- a/apps/hosts/management/commands/gateway_listener.py +++ /dev/null @@ -1,249 +0,0 @@ -import logging -import signal -import sys - -from django.core.management.base import BaseCommand -from django.conf import settings - -logger = logging.getLogger('2c2a') - - -class Command(BaseCommand): - help = 'Listen for Gateway events via Unix Domain Socket' - - def add_arguments(self, parser): - parser.add_argument( - '--socket', - type=str, - default=None, - help='Unix Domain Socket path (default: from settings)', - ) - - def handle(self, *args, **options): - from utils.gateway_client import is_gateway_enabled - - if not is_gateway_enabled(): - self.stdout.write( - self.style.WARNING( - 'Gateway is not enabled. ' - 'Set GATEWAY_ENABLED=True in environment to enable. ' - 'Exiting.' - ) - ) - return - - from utils.gateway_client import GatewayEventListener - - socket_path = options.get('socket') or getattr( - settings, 'GATEWAY_CONTROL_SOCKET', - '/run/2c2a/control.sock' - ) - - self.stdout.write( - f'Starting Gateway event listener on {socket_path}' - ) - - listener = GatewayEventListener(socket_path) - - listener.register_handler( - 'tunnel_online', self._handle_tunnel_online - ) - listener.register_handler( - 'tunnel_offline', self._handle_tunnel_offline - ) - listener.register_handler( - 'rdp_gateway_connect', self._handle_rdp_gateway_connect - ) - listener.register_handler( - 'rdp_gateway_disconnect', self._handle_rdp_gateway_disconnect - ) - listener.register_handler( - 'remote_exec_result', self._handle_remote_exec_result - ) - - def signal_handler(signum, frame): - self.stdout.write('Shutting down listener...') - listener.stop() - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - try: - listener.start() - except KeyboardInterrupt: - listener.stop() - - def _handle_tunnel_online(self, event_type, payload): - from django.utils import timezone - from apps.hosts.models import Host - from apps.audit.models import AuditLog - - token = payload.get('token', '') - client_ip = payload.get('client_ip', '') - client_ver = payload.get('client_ver', '') - public_key = payload.get('public_key', b'') - - try: - host = Host.objects.get(tunnel_token=token) - now = timezone.now() - host.tunnel_status = 'online' - host.tunnel_connected_at = now - host.tunnel_last_seen_at = now - host.tunnel_client_ip = client_ip - host.tunnel_client_version = client_ver - if public_key: - host.tunnel_public_key = public_key - host.save(update_fields=[ - 'tunnel_status', 'tunnel_connected_at', - 'tunnel_last_seen_at', 'tunnel_client_ip', - 'tunnel_client_version', 'tunnel_public_key', - ]) - - AuditLog.objects.create( - host=host, - action='tunnel_online', - details={ - 'token': token, - 'client_ip': client_ip, - 'client_ver': client_ver, - } - ) - - logger.info( - f'Tunnel online: host={host.name}, ' - f'token={token}, ip={client_ip}' - ) - - except Host.DoesNotExist: - logger.warning( - f'Tunnel online event for unknown token: {token}' - ) - - def _handle_tunnel_offline(self, event_type, payload): - from apps.hosts.models import Host - from apps.audit.models import AuditLog - - token = payload.get('token', '') - - try: - host = Host.objects.get(tunnel_token=token) - host.tunnel_status = 'offline' - host.save(update_fields=['tunnel_status']) - - AuditLog.objects.create( - host=host, - action='tunnel_offline', - details={'token': token} - ) - - logger.info( - f'Tunnel offline: host={host.name}, token={token}' - ) - - except Host.DoesNotExist: - logger.warning( - f'Tunnel offline event for unknown token: {token}' - ) - - def _handle_rdp_gateway_connect(self, event_type, payload): - from apps.audit.models import AuditLog - - token = payload.get('token', '') - session_id = payload.get('session_id', '') - target_host = payload.get('target_host', '') - user = payload.get('user', '') - client_ip = payload.get('client_ip', '') - - try: - from apps.hosts.models import Host - host = Host.objects.get(tunnel_token=token) - - AuditLog.objects.create( - host=host, - action='rdp_gateway_connect', - ip_address=client_ip, - details={ - 'session_id': session_id, - 'token': token, - 'target_host': target_host, - 'user': user, - } - ) - - logger.info( - f'RDP gateway connect: host={host.name}, ' - f'session_id={session_id}, user={user}, ip={client_ip}' - ) - - except Host.DoesNotExist: - logger.warning( - f'RDP gateway connect event for unknown token: {token}' - ) - - def _handle_rdp_gateway_disconnect(self, event_type, payload): - from apps.audit.models import AuditLog - - token = payload.get('token', '') - session_id = payload.get('session_id', '') - target_host = payload.get('target_host', '') - user = payload.get('user', '') - client_ip = payload.get('client_ip', '') - duration = payload.get('duration', 0) - - try: - from apps.hosts.models import Host - host = Host.objects.get(tunnel_token=token) - - AuditLog.objects.create( - host=host, - action='rdp_gateway_disconnect', - ip_address=client_ip, - details={ - 'session_id': session_id, - 'token': token, - 'target_host': target_host, - 'user': user, - 'duration': duration, - } - ) - - logger.info( - f'RDP gateway disconnect: host={host.name}, ' - f'session_id={session_id}, user={user}, duration={duration}' - ) - - except Host.DoesNotExist: - logger.warning( - f'RDP gateway disconnect event for unknown token: {token}' - ) - - def _handle_remote_exec_result(self, event_type, payload): - from apps.audit.models import AuditLog - - token = payload.get('token', '') - req_id = payload.get('req_id', '') - exit_code = payload.get('exit_code', -1) - - try: - from apps.hosts.models import Host - host = Host.objects.get(tunnel_token=token) - - AuditLog.objects.create( - host=host, - action='remote_exec_result', - details={ - 'req_id': req_id, - 'exit_code': exit_code, - } - ) - - logger.info( - f'Remote exec result: host={host.name}, ' - f'req_id={req_id}, exit_code={exit_code}' - ) - - except Host.DoesNotExist: - logger.warning( - f'Remote exec result event for unknown token: {token}' - ) diff --git a/apps/hosts/management/commands/generate_tunnel_token.py b/apps/hosts/management/commands/generate_tunnel_token.py deleted file mode 100644 index 209a332..0000000 --- a/apps/hosts/management/commands/generate_tunnel_token.py +++ /dev/null @@ -1,60 +0,0 @@ -import secrets -from django.core.management.base import BaseCommand -from apps.hosts.models import Host - - -class Command(BaseCommand): - help = '为隧道模式主机生成隧道Token' - - def add_arguments(self, parser): - parser.add_argument( - 'host_id', - type=int, - help='主机ID', - ) - parser.add_argument( - '--force', - action='store_true', - default=False, - help='强制重新生成Token(即使已存在)', - ) - - def handle(self, *args, **options): - host_id = options['host_id'] - force = options['force'] - - try: - host = Host.objects.get(id=host_id) - except Host.DoesNotExist: - self.stderr.write( - self.style.ERROR(f'主机ID {host_id} 不存在') - ) - return - - if host.tunnel_token and not force: - self.stderr.write( - self.style.WARNING( - f'主机 {host.name} 已有Token: {host.tunnel_token}\n' - f'使用 --force 强制重新生成' - ) - ) - return - - token = secrets.token_urlsafe(32) - host.tunnel_token = token - host.connection_type = 'tunnel' - host.tunnel_status = 'offline' - host.save(update_fields=[ - 'tunnel_token', 'connection_type', 'tunnel_status', - ]) - - self.stdout.write( - self.style.SUCCESS( - f'主机 {host.name} 的隧道Token已生成:\n' - f' Token: {token}\n' - f' 连接类型已设置为: tunnel\n' - f'\n' - f'请将此Token配置到边缘端 2c2a-tunnel:\n' - f' 2c2a-tunnel.exe install -token {token} -server wss://:9000' - ) - ) diff --git a/apps/hosts/migrations/0001_initial.py b/apps/hosts/migrations/0001_initial.py deleted file mode 100755 index cc9d7f0..0000000 --- a/apps/hosts/migrations/0001_initial.py +++ /dev/null @@ -1,58 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 03:24 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Host', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='主机名称')), - ('hostname', models.CharField(max_length=255, verbose_name='主机地址')), - ('port', models.IntegerField(default=5985, verbose_name='WinRM端口')), - ('rdp_port', models.IntegerField(default=3389, verbose_name='RDP端口')), - ('use_ssl', models.BooleanField(default=False, verbose_name='使用SSL')), - ('username', models.CharField(max_length=100, verbose_name='用户名')), - ('password', models.CharField(max_length=255, verbose_name='密码')), - ('host_type', models.CharField(choices=[('server', '服务器'), ('workstation', '工作站'), ('laptop', '笔记本'), ('desktop', '台式机')], max_length=20, verbose_name='主机类型')), - ('os_version', models.CharField(blank=True, max_length=100, verbose_name='操作系统版本')), - ('status', models.CharField(choices=[('online', '在线'), ('offline', '离线'), ('error', '错误')], default='offline', max_length=20, verbose_name='状态')), - ('description', models.TextField(blank=True, verbose_name='描述')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建者')), - ], - options={ - 'verbose_name': '主机', - 'verbose_name_plural': '主机', - 'db_table': 'hosts_host', - }, - ), - migrations.CreateModel( - name='HostGroup', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, verbose_name='组名称')), - ('description', models.TextField(blank=True, verbose_name='描述')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('hosts', models.ManyToManyField(blank=True, to='hosts.host', verbose_name='包含主机')), - ], - options={ - 'verbose_name': '主机组', - 'verbose_name_plural': '主机组', - 'db_table': 'hosts_hostgroup', - }, - ), - ] diff --git a/apps/hosts/migrations/0002_alter_hostgroup_hosts.py b/apps/hosts/migrations/0002_alter_hostgroup_hosts.py deleted file mode 100755 index 5fd2eca..0000000 --- a/apps/hosts/migrations/0002_alter_hostgroup_hosts.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 03:35 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='hostgroup', - name='hosts', - field=models.ManyToManyField(blank=True, to='hosts.host', verbose_name='主机'), - ), - ] diff --git a/apps/hosts/migrations/0003_alter_host_password_rename_password_host__password_and_more.py b/apps/hosts/migrations/0003_alter_host_password_rename_password_host__password_and_more.py deleted file mode 100755 index 169eed2..0000000 --- a/apps/hosts/migrations/0003_alter_host_password_rename_password_host__password_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 10:36 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('hosts', '0002_alter_hostgroup_hosts'), - ] - - operations = [ - migrations.AlterField( - model_name='host', - name='password', - field=models.CharField(db_column='password', max_length=255, verbose_name='密码'), - ), - migrations.RenameField( - model_name='host', - old_name='password', - new_name='_password', - ), - migrations.AlterField( - model_name='host', - name='port', - field=models.IntegerField(default=5985, verbose_name='WinRM端Port'), - ), - migrations.CreateModel( - name='HostPermission', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('can_edit', models.BooleanField(default=False, help_text='是否可以编辑主机信息', verbose_name='编辑权限')), - ('can_manage_products', models.BooleanField(default=False, help_text='是否可以在该主机上管理产品', verbose_name='产品上架权限')), - ('can_review_requests', models.BooleanField(default=False, help_text='是否可以审核该主机相关的开户申请', verbose_name='审核权限')), - ('can_manage_cloud_users', models.BooleanField(default=False, help_text='是否可以管理该主机上的云电脑用户', verbose_name='云电脑用户管理权限')), - ('granted_at', models.DateTimeField(auto_now_add=True, help_text='权限授予的时间', verbose_name='授权时间')), - ('expires_at', models.DateTimeField(blank=True, help_text='权限过期时间,留空表示永久有效', null=True, verbose_name='过期时间')), - ('granted_by', models.ForeignKey(blank=True, help_text='授予此权限的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_permissions', to=settings.AUTH_USER_MODEL, verbose_name='授权人')), - ('host', models.ForeignKey(help_text='被授权的主机', on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='主机')), - ('user', models.ForeignKey(help_text='拥有权限的用户', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='用户')), - ], - options={ - 'verbose_name': '主机权限', - 'verbose_name_plural': '主机权限', - 'indexes': [models.Index(fields=['user'], name='hosts_hostp_user_id_580592_idx'), models.Index(fields=['host'], name='hosts_hostp_host_id_085956_idx'), models.Index(fields=['can_edit'], name='hosts_hostp_can_edi_2a886d_idx'), models.Index(fields=['can_manage_products'], name='hosts_hostp_can_man_97675c_idx'), models.Index(fields=['can_review_requests'], name='hosts_hostp_can_rev_9780ba_idx'), models.Index(fields=['can_manage_cloud_users'], name='hosts_hostp_can_man_ebcd88_idx')], - 'unique_together': {('user', 'host')}, - }, - ), - ] diff --git a/apps/hosts/migrations/0004_alter_host_port_delete_hostpermission.py b/apps/hosts/migrations/0004_alter_host_port_delete_hostpermission.py deleted file mode 100755 index d1871c3..0000000 --- a/apps/hosts/migrations/0004_alter_host_port_delete_hostpermission.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 11:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0003_alter_host_password_rename_password_host__password_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='host', - name='port', - field=models.IntegerField(default=5985, verbose_name='WinRM端口'), - ), - migrations.DeleteModel( - name='HostPermission', - ), - ] diff --git a/apps/hosts/migrations/0005_host_connection_type_alter_host_port.py b/apps/hosts/migrations/0005_host_connection_type_alter_host_port.py deleted file mode 100755 index ec70b3b..0000000 --- a/apps/hosts/migrations/0005_host_connection_type_alter_host_port.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-28 05:00 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0004_alter_host_port_delete_hostpermission'), - ] - - operations = [ - migrations.AddField( - model_name='host', - name='connection_type', - field=models.CharField(choices=[('winrm', 'WinRM'), ('ssh', 'SSH'), ('localwinserver', '本地WinServer')], default='winrm', max_length=20, verbose_name='连接类型'), - ), - migrations.AlterField( - model_name='host', - name='port', - field=models.IntegerField(default=5985, verbose_name='连接端口'), - ), - ] diff --git a/apps/hosts/migrations/0006_host_administrators.py b/apps/hosts/migrations/0006_host_administrators.py deleted file mode 100644 index d18a667..0000000 --- a/apps/hosts/migrations/0006_host_administrators.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-06 16:13 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("hosts", "0005_host_connection_type_alter_host_port"), - ] - - operations = [ - migrations.AddField( - model_name="host", - name="administrators", - field=models.ManyToManyField( - blank=True, - related_name="managed_hosts", - to=settings.AUTH_USER_MODEL, - verbose_name="授权管理员", - ), - ), - ] diff --git a/apps/hosts/migrations/0007_add_hostgroup_created_by.py b/apps/hosts/migrations/0007_add_hostgroup_created_by.py deleted file mode 100644 index 614386b..0000000 --- a/apps/hosts/migrations/0007_add_hostgroup_created_by.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-06 16:42 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("hosts", "0006_host_administrators"), - ] - - operations = [ - migrations.AddField( - model_name="hostgroup", - name="created_by", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="created_hostgroups", - to=settings.AUTH_USER_MODEL, - verbose_name="创建者", - ), - ), - ] diff --git a/apps/hosts/migrations/0008_add_providers_to_host_and_hostgroup.py b/apps/hosts/migrations/0008_add_providers_to_host_and_hostgroup.py deleted file mode 100644 index d46c3be..0000000 --- a/apps/hosts/migrations/0008_add_providers_to_host_and_hostgroup.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-07 14:04 - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("hosts", "0007_add_hostgroup_created_by"), - ] - - operations = [ - migrations.AddField( - model_name="host", - name="providers", - field=models.ManyToManyField( - blank=True, - help_text="由超级管理员分配的提供商用户,提供商可以管理此主机", - related_name="provider_hosts", - to=settings.AUTH_USER_MODEL, - verbose_name="管理提供商", - ), - ), - migrations.AddField( - model_name="hostgroup", - name="providers", - field=models.ManyToManyField( - blank=True, - help_text="由超级管理员分配的提供商用户,提供商可以管理此主机组", - related_name="provider_hostgroups", - to=settings.AUTH_USER_MODEL, - verbose_name="管理提供商", - ), - ), - ] diff --git a/apps/hosts/migrations/0009_host_tunnel_fields.py b/apps/hosts/migrations/0009_host_tunnel_fields.py deleted file mode 100644 index 9c9db7c..0000000 --- a/apps/hosts/migrations/0009_host_tunnel_fields.py +++ /dev/null @@ -1,84 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0008_add_providers_to_host_and_hostgroup'), - ] - - operations = [ - migrations.AddField( - model_name='host', - name='tunnel_token', - field=models.CharField( - blank=True, max_length=64, null=True, - unique=True, verbose_name='隧道Token' - ), - ), - migrations.AddField( - model_name='host', - name='tunnel_status', - field=models.CharField( - choices=[ - ('no_tunnel', '无隧道'), - ('offline', '隧道离线'), - ('online', '隧道在线'), - ('error', '隧道错误'), - ], - default='no_tunnel', max_length=20, - verbose_name='隧道状态' - ), - ), - migrations.AddField( - model_name='host', - name='tunnel_connected_at', - field=models.DateTimeField( - blank=True, null=True, verbose_name='隧道连接时间' - ), - ), - migrations.AddField( - model_name='host', - name='tunnel_last_seen_at', - field=models.DateTimeField( - blank=True, null=True, verbose_name='隧道最后心跳' - ), - ), - migrations.AddField( - model_name='host', - name='tunnel_client_version', - field=models.CharField( - blank=True, max_length=50, - verbose_name='隧道客户端版本' - ), - ), - migrations.AddField( - model_name='host', - name='tunnel_client_ip', - field=models.GenericIPAddressField( - blank=True, null=True, - verbose_name='隧道客户端IP' - ), - ), - migrations.AddField( - model_name='host', - name='tunnel_public_key', - field=models.TextField( - blank=True, verbose_name='隧道公钥(Ed25519)' - ), - ), - migrations.AlterField( - model_name='host', - name='connection_type', - field=models.CharField( - choices=[ - ('winrm', 'WinRM'), - ('ssh', 'SSH'), - ('localwinserver', '本地WinServer'), - ('tunnel', '隧道模式(零公网IP)'), - ], - default='winrm', max_length=20, - verbose_name='连接类型' - ), - ), - ] diff --git a/apps/hosts/migrations/0010_remove_host_host_type.py b/apps/hosts/migrations/0010_remove_host_host_type.py deleted file mode 100644 index 6352be4..0000000 --- a/apps/hosts/migrations/0010_remove_host_host_type.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-25 12:46 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0009_host_tunnel_fields'), - ] - - operations = [ - migrations.RemoveField( - model_name='host', - name='host_type', - ), - ] diff --git a/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py b/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py deleted file mode 100644 index 15da766..0000000 --- a/apps/hosts/migrations/0011_host_auth_method_host_cert_key_path_and_more.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-23 05:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0010_remove_host_host_type'), - ] - - operations = [ - migrations.AddField( - model_name='host', - name='auth_method', - field=models.CharField(choices=[('ntlm', '管理员账户密码'), ('certificate', '证书')], default='ntlm', max_length=20, verbose_name='连接方式'), - ), - migrations.AddField( - model_name='host', - name='cert_key_path', - field=models.CharField(blank=True, default='', max_length=512, verbose_name='客户端私钥路径'), - ), - migrations.AddField( - model_name='host', - name='cert_pem_path', - field=models.CharField(blank=True, default='', max_length=512, verbose_name='客户端证书路径'), - ), - migrations.AddField( - model_name='host', - name='os_type', - field=models.CharField(choices=[('windows', 'Windows')], default='windows', max_length=20, verbose_name='主机系统'), - ), - migrations.AlterField( - model_name='host', - name='connection_type', - field=models.CharField(choices=[('winrm', 'WinRM'), ('localwinserver', '本地WinServer'), ('ssh', 'SSH'), ('tunnel', '隧道模式(零公网IP)')], default='winrm', max_length=20, verbose_name='连接类型'), - ), - ] diff --git a/apps/hosts/migrations/0012_host_username_optional.py b/apps/hosts/migrations/0012_host_username_optional.py deleted file mode 100644 index 13e7c07..0000000 --- a/apps/hosts/migrations/0012_host_username_optional.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-23 09:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0011_host_auth_method_host_cert_key_path_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='host', - name='username', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='用户名'), - ), - ] diff --git a/apps/hosts/migrations/0013_add_cert_provision_fields.py b/apps/hosts/migrations/0013_add_cert_provision_fields.py deleted file mode 100644 index 6da3cac..0000000 --- a/apps/hosts/migrations/0013_add_cert_provision_fields.py +++ /dev/null @@ -1,48 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-29 15:57 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0012_host_username_optional'), - ] - - operations = [ - migrations.AddField( - model_name='host', - name='_ntlm_fallback_password', - field=models.CharField(blank=True, db_column='ntlm_fallback_password', default='', max_length=255, verbose_name='NTLM回退密码'), - ), - migrations.AddField( - model_name='host', - name='_pfx_password', - field=models.CharField(blank=True, db_column='pfx_password', default='', max_length=255, verbose_name='PFX密码'), - ), - migrations.AddField( - model_name='host', - name='cert_activated_at', - field=models.DateTimeField(blank=True, null=True, verbose_name='证书激活时间'), - ), - migrations.AddField( - model_name='host', - name='cert_provision_status', - field=models.CharField(choices=[('not_started', '未开始'), ('pending', '签发中'), ('ready', '已就绪'), ('configured', '已配置'), ('failed', '失败')], default='not_started', max_length=20, verbose_name='证书配置状态'), - ), - migrations.AddField( - model_name='host', - name='cert_root', - field=models.CharField(blank=True, default='', max_length=2, verbose_name='证书存储根路径'), - ), - migrations.AddField( - model_name='host', - name='cert_sub', - field=models.CharField(blank=True, default='', max_length=2, verbose_name='证书存储子路径'), - ), - migrations.AddField( - model_name='host', - name='ntlm_fallback_user', - field=models.CharField(blank=True, default='', max_length=100, verbose_name='NTLM回退用户名'), - ), - ] diff --git a/apps/hosts/migrations/0014_host_site_group_hostgroup_site_group.py b/apps/hosts/migrations/0014_host_site_group_hostgroup_site_group.py deleted file mode 100644 index f8d6df4..0000000 --- a/apps/hosts/migrations/0014_host_site_group_hostgroup_site_group.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-31 13:27 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0014_sitegroup_sitegrouphostname_and_more"), - ("hosts", "0013_add_cert_provision_fields"), - ] - - operations = [ - migrations.AddField( - model_name="host", - name="site_group", - field=models.ForeignKey( - blank=True, - help_text="该主机所属的站点组,留空表示默认组", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="hosts", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - migrations.AddField( - model_name="hostgroup", - name="site_group", - field=models.ForeignKey( - blank=True, - help_text="该主机组所属的站点组,留空表示默认组", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="host_groups", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - ] diff --git a/apps/hosts/migrations/0015_alter_host_site_group_alter_hostgroup_site_group.py b/apps/hosts/migrations/0015_alter_host_site_group_alter_hostgroup_site_group.py deleted file mode 100644 index 7fb4ad1..0000000 --- a/apps/hosts/migrations/0015_alter_host_site_group_alter_hostgroup_site_group.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-01 13:09 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0015_remove_sitegroup_dashboard_s_slug_5cb7f5_idx_and_more"), - ("hosts", "0014_host_site_group_hostgroup_site_group"), - ] - - operations = [ - migrations.AlterField( - model_name="host", - name="site_group", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="hosts", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - migrations.AlterField( - model_name="hostgroup", - name="site_group", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="host_groups", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - ] diff --git a/apps/hosts/migrations/__init__.py b/apps/hosts/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/hosts/models.py b/apps/hosts/models.py deleted file mode 100755 index 7e8a1b1..0000000 --- a/apps/hosts/models.py +++ /dev/null @@ -1,595 +0,0 @@ -from django.db import models -from django.conf import settings -from utils.crypto import encrypt_value, decrypt_value -import logging -import os - - -class Host(models.Model): - """ - 主机模型 - """ - OS_TYPE_CHOICES = [ - ('windows', 'Windows'), - ] - - CONNECTION_TYPE_CHOICES = [ - ('winrm', 'WinRM'), - ('localwinserver', '本地WinServer'), - ('ssh', 'SSH'), - ('tunnel', '隧道模式(零公网IP)'), - ] - - AUTH_METHOD_CHOICES = [ - ('ntlm', '管理员账户密码'), - ('certificate', '证书'), - ] - - STATUS_CHOICES = [ - ('online', '在线'), - ('offline', '离线'), - ('error', '错误'), - ] - - TUNNEL_STATUS_CHOICES = [ - ('no_tunnel', '无隧道'), - ('offline', '隧道离线'), - ('online', '隧道在线'), - ('error', '隧道错误'), - ] - - CERT_PROVISION_STATUS_CHOICES = [ - ('not_started', '未开始'), - ('pending', '签发中'), - ('ready', '已就绪'), - ('configured', '已配置'), - ('failed', '失败'), - ] - - name = models.CharField(max_length=100, verbose_name='主机名称') - os_type = models.CharField(max_length=20, choices=OS_TYPE_CHOICES, default='windows', verbose_name='主机系统') - hostname = models.CharField(max_length=255, verbose_name='主机地址') - connection_type = models.CharField(max_length=20, choices=CONNECTION_TYPE_CHOICES, default='winrm', verbose_name='连接类型') - auth_method = models.CharField(max_length=20, choices=AUTH_METHOD_CHOICES, default='ntlm', verbose_name='连接方式') - port = models.IntegerField(default=5985, verbose_name='连接端口') - rdp_port = models.IntegerField(default=3389, verbose_name='RDP端口') - use_ssl = models.BooleanField(default=False, verbose_name='使用SSL') - username = models.CharField(max_length=100, blank=True, default='', verbose_name='用户名') - _password = models.CharField(max_length=255, verbose_name='密码', db_column='password') # 加密存储 - cert_pem_path = models.CharField(max_length=512, blank=True, default='', verbose_name='客户端证书路径') - cert_key_path = models.CharField(max_length=512, blank=True, default='', verbose_name='客户端私钥路径') - os_version = models.CharField(max_length=100, blank=True, verbose_name='操作系统版本') - status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='offline', verbose_name='状态') - description = models.TextField(blank=True, verbose_name='描述') - created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='创建者') - - # 管理员列表 - 核心字段用于数据隔离 - administrators = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - verbose_name="授权管理员", - related_name='managed_hosts' - ) - - # 管理提供商 - 由超级管理员分配 - providers = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - verbose_name='管理提供商', - related_name='provider_hosts', - help_text='由超级管理员分配的提供商用户,提供商可以管理此主机' - ) - - site_group = models.ForeignKey( - 'dashboard.SiteGroup', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='hosts', - verbose_name='所属站点组', - ) - - created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') - updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') - - tunnel_token = models.CharField( - max_length=64, unique=True, null=True, blank=True, - verbose_name='隧道Token' - ) - tunnel_status = models.CharField( - max_length=20, choices=TUNNEL_STATUS_CHOICES, - default='no_tunnel', verbose_name='隧道状态' - ) - tunnel_connected_at = models.DateTimeField( - null=True, blank=True, verbose_name='隧道连接时间' - ) - tunnel_last_seen_at = models.DateTimeField( - null=True, blank=True, verbose_name='隧道最后心跳' - ) - tunnel_client_version = models.CharField( - max_length=50, blank=True, verbose_name='隧道客户端版本' - ) - tunnel_client_ip = models.GenericIPAddressField( - null=True, blank=True, verbose_name='隧道客户端IP' - ) - tunnel_public_key = models.TextField( - blank=True, verbose_name='隧道公钥(Ed25519)' - ) - - cert_root = models.CharField( - max_length=2, blank=True, default='', - verbose_name='证书存储根路径' - ) - cert_sub = models.CharField( - max_length=2, blank=True, default='', - verbose_name='证书存储子路径' - ) - _pfx_password = models.CharField( - max_length=255, blank=True, default='', - db_column='pfx_password', verbose_name='PFX密码' - ) - ntlm_fallback_user = models.CharField( - max_length=100, blank=True, default='', - verbose_name='NTLM回退用户名' - ) - _ntlm_fallback_password = models.CharField( - max_length=255, blank=True, default='', - db_column='ntlm_fallback_password', - verbose_name='NTLM回退密码' - ) - cert_activated_at = models.DateTimeField( - null=True, blank=True, verbose_name='证书激活时间' - ) - cert_provision_status = models.CharField( - max_length=20, - choices=CERT_PROVISION_STATUS_CHOICES, - default='not_started', - verbose_name='证书配置状态' - ) - - class Meta: - verbose_name = '主机' - verbose_name_plural = '主机' - db_table = 'hosts_host' # 与数据库中的实际表名一致 - - def __str__(self): - return self.name - - @property - def password(self): - try: - return decrypt_value(self._password) - except ValueError: - raise ValueError("密码解密失败,数据可能已损坏或密钥已变更") - - @password.setter - def password(self, raw_password): - self._password = encrypt_value(raw_password) - - @property - def pfx_password(self): - try: - return decrypt_value(self._pfx_password) - except ValueError: - raise ValueError("PFX密码解密失败") - - @pfx_password.setter - def pfx_password(self, raw_password): - self._pfx_password = encrypt_value(raw_password) - - @property - def ntlm_fallback_password(self): - try: - return decrypt_value(self._ntlm_fallback_password) - except ValueError: - raise ValueError("NTLM回退密码解密失败") - - @ntlm_fallback_password.setter - def ntlm_fallback_password(self, raw_password): - self._ntlm_fallback_password = encrypt_value(raw_password) - - def save(self, *args, **kwargs): - """ - 重写save方法 - 注意:连接测试由Admin的save_model处理,避免循环调用 - """ - # 先调用父类的save方法保存数据 - super().save(*args, **kwargs) - # 暂时禁用自动连接测试,由Admin处理 - - def get_connection_client(self): - if self.connection_type == 'winrm': - from utils.winrm_client import WinrmClient - kwargs = dict( - hostname=self.hostname, - port=self.port, - use_ssl=self.use_ssl, - ) - if self.auth_method == 'certificate': - return FallbackWinrmClient(self) - else: - kwargs.update( - username=self.username, - password=self.password, - auth_method='ntlm', - ) - return WinrmClient(**kwargs) - elif self.connection_type == 'localwinserver': - from utils.local_winserver_client import LocalWinServerClient - return LocalWinServerClient( - username=self.username, - password=self.password - ) - elif self.connection_type == 'tunnel': - from utils.gateway_client import GatewayClient - return TunnelConnectionAdapter(self, GatewayClient()) - elif self.connection_type == 'ssh': - raise NotImplementedError("SSH连接类型尚未实现") - else: - raise ValueError( - f"不支持的连接类型: {self.connection_type}" - ) - - def test_connection(self): - if os.environ.get('2C2A_DEMO', '').lower() == '1': - Host.objects.filter(pk=self.pk).update(status='online') - return - - if self.connection_type == 'tunnel': - new_status = 'online' if self.tunnel_status == 'online' else 'offline' - Host.objects.filter(pk=self.pk).update(status=new_status) - return - - if (self.auth_method == 'certificate' - and (not self.cert_pem_path - or not os.path.exists(self.cert_pem_path))): - logging.getLogger("2c2a").warning( - f"证书文件不存在,跳过连接测试: {self.name} " - f"(pem={self.cert_pem_path})" - ) - Host.objects.filter(pk=self.pk).update(status='pending') - return - - try: - client = self.get_connection_client() - - if self.connection_type == 'localwinserver': - result = client.execute_command( - 'echo Connection Test OK' - ) - else: - result = client.execute_command('whoami') - - if result.success: - new_status = 'online' - else: - new_status = 'error' - - except Exception as e: - new_status = 'error' - logger = logging.getLogger("2c2a") - logger.error( - f"测试主机连接失败: {self.name}, 错误: {str(e)}" - ) - - Host.objects.filter(pk=self.pk).update(status=new_status) - - -class TunnelConnectionAdapter: - def __init__(self, host, gateway_client): - self.host = host - self.gateway_client = gateway_client - self._fallback_client = None - - def _get_fallback_client(self): - if self._fallback_client is not None: - return self._fallback_client - if self.host.connection_type == 'tunnel' and self.host.hostname: - try: - from utils.winrm_client import WinrmClient - self._fallback_client = WinrmClient( - hostname=self.host.hostname, - port=self.host.port, - username=self.host.username, - password=self.host.password, - use_ssl=self.host.use_ssl, - ) - return self._fallback_client - except Exception: - pass - return None - - @property - def success(self): - return True - - def execute_command(self, command, arguments=None): - return self.execute_powershell(command) - - def execute_powershell(self, script, arguments=None): - script_bytes = script.encode('utf-8') - - result = self.gateway_client.remote_exec( - token=self.host.tunnel_token, - script=script_bytes, - ) - - if result is None: - fallback = self._get_fallback_client() - if fallback: - return fallback.execute_powershell(script) - from utils.winrm_client import WinrmResult - return WinrmResult( - status_code=1, - std_out='', - std_err='Gateway不可用且无备用连接方式' - ) - - from utils.winrm_client import WinrmResult - stdout = '' - stderr = '' - exit_code = 1 - - if result.get('success'): - data = result.get('data', {}) - if isinstance(data, dict): - stdout = data.get('stdout', '') - stderr = data.get('stderr', '') - exit_code = data.get('exit_code', 1) - if isinstance(stdout, bytes): - stdout = stdout.decode('utf-8', errors='ignore') - if isinstance(stderr, bytes): - stderr = stderr.decode('utf-8', errors='ignore') - - return WinrmResult( - status_code=exit_code, - std_out=stdout, - std_err=stderr, - ) - - def create_user(self, username, password, description=None, group=None): - from utils.winrm_client import _escape_ps_string - safe_user = _escape_ps_string(username) - safe_pass = _escape_ps_string(password) - safe_desc = _escape_ps_string(description or '') - - script = f''' -$pw = ConvertTo-SecureString "{safe_pass}" -AsPlainText -Force -New-LocalUser -Name "{safe_user}" -Password $pw -Description "{safe_desc}" -ErrorAction Stop -Add-LocalGroupMember -Group "Users" -Member "{safe_user}" -ErrorAction Stop -''' - if group: - safe_group = _escape_ps_string(group) - script += f'Add-LocalGroupMember -Group "{safe_group}" -Member "{safe_user}" -ErrorAction Stop\n' - - result = self.execute_powershell(script) - self.add_to_remote_users(username) - return result - - def delete_user(self, username): - from utils.winrm_client import _escape_ps_string - safe_user = _escape_ps_string(username) - script = f'Remove-LocalUser -Name "{safe_user}" -ErrorAction Stop' - return self.execute_powershell(script) - - def enable_user(self, username): - from utils.winrm_client import _escape_ps_string - safe_user = _escape_ps_string(username) - script = f'Enable-LocalUser -Name "{safe_user}" -ErrorAction Stop' - return self.execute_powershell(script) - - def disabled_user(self, username): - from utils.winrm_client import _escape_ps_string - safe_user = _escape_ps_string(username) - script = f'Disable-LocalUser -Name "{safe_user}" -ErrorAction Stop' - return self.execute_powershell(script) - - def reset_password(self, username, password): - from utils.winrm_client import _escape_ps_string - safe_user = _escape_ps_string(username) - safe_pass = _escape_ps_string(password) - script = f''' -$pw = ConvertTo-SecureString "{safe_pass}" -AsPlainText -Force -Set-LocalUser -Name "{safe_user}" -Password $pw -''' - result = self.execute_powershell(script) - if result.status_code == 0: - self.add_to_remote_users(username) - return result - - def add_to_remote_users(self, username): - from utils.winrm_client import _escape_ps_string - safe_user = _escape_ps_string(username) - script = ( - f'Add-LocalGroupMember -Group "Remote Desktop Users" ' - f'-Member "{safe_user}" -ErrorAction SilentlyContinue' - ) - return self.execute_powershell(script) - - -class FallbackWinrmClient: - _logger = logging.getLogger("2c2a") - - def __init__(self, host): - self.host = host - self._client = None - - def _try_connect(self): - if self._client is not None: - return - from utils.winrm_client import WinrmClient - from utils.cert_storage import get_cert_file_paths - last_exc = None - ca_trust_path = None - if self.host.cert_root and self.host.cert_sub: - paths = get_cert_file_paths(self.host.cert_root, self.host.cert_sub) - if paths['ca_cert'].exists(): - ca_trust_path = str(paths['ca_cert']) - configs = [ - ( - "SSL+Certificate", - dict( - hostname=self.host.hostname, - port=self.host.port, - use_ssl=True, - auth_method='certificate', - cert_pem_path=self.host.cert_pem_path, - cert_key_path=self.host.cert_key_path, - server_cert_validation='ignore', - ca_trust_path=ca_trust_path, - ), - ), - ] - if self.host.ntlm_fallback_user and self.host.ntlm_fallback_password: - configs.append(( - "HTTPS+NTLM", - dict( - hostname=self.host.hostname, - port=self.host.port, - use_ssl=True, - auth_method='ntlm', - username=self.host.ntlm_fallback_user, - password=self.host.ntlm_fallback_password, - server_cert_validation='ignore', - ca_trust_path=ca_trust_path, - ), - )) - configs.append(( - "HTTP+NTLM", - dict( - hostname=self.host.hostname, - port=5985, - use_ssl=False, - auth_method='ntlm', - username=self.host.ntlm_fallback_user, - password=self.host.ntlm_fallback_password, - ), - )) - for label, cfg in configs: - try: - client = WinrmClient(**cfg) - client.execute_command('whoami') - self._client = client - self._logger.info( - f"主机 {self.host.name} 连接成功," - f"使用方式: {label}" - ) - return - except Exception as e: - last_exc = e - self._logger.warning( - f"主机 {self.host.name} 连接方式 {label} " - f"失败: {e}" - ) - Host.objects.filter(pk=self.host.pk).update(status='error') - if last_exc is not None: - raise last_exc - raise RuntimeError("所有连接方式均失败") - - @property - def success(self): - return True - - def execute_command(self, command): - self._try_connect() - return self._client.execute_command(command) - - def execute_powershell(self, script): - self._try_connect() - return self._client.execute_powershell(script) - - def create_user(self, username, password, **kwargs): - self._try_connect() - return self._client.create_user(username, password, **kwargs) - - def delete_user(self, username): - self._try_connect() - return self._client.delete_user(username) - - def enable_user(self, username): - self._try_connect() - return self._client.enable_user(username) - - def disabled_user(self, username): - self._try_connect() - return self._client.disabled_user(username) - - def reset_password(self, username, password): - self._try_connect() - return self._client.reset_password(username, password) - - def op_user(self, username): - self._try_connect() - return self._client.op_user(username) - - def deop_user(self, username): - self._try_connect() - return self._client.deop_user(username) - - def add_to_remote_users(self, username): - self._try_connect() - return self._client.add_to_remote_users(username) - - def check_user_exists(self, username): - self._try_connect() - return self._client.check_user_exists(username) - - def generate_strong_password(self, length=None): - self._try_connect() - return self._client.generate_strong_password(length) - - def get_password_policy(self): - self._try_connect() - return self._client.get_password_policy() - - def create_user_with_reset_password_on_next_login( - self, username, password, description=None, group=None): - self._try_connect() - return self._client.create_user_with_reset_password_on_next_login( - username, password, description=description, group=group) - - -class HostGroup(models.Model): - """ - 主机组模型 - 用于将多个主机分组管理 - """ - name = models.CharField(max_length=100, verbose_name='组名称') - description = models.TextField(blank=True, verbose_name='描述') - hosts = models.ManyToManyField(Host, blank=True, verbose_name='主机') - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name='创建者', - related_name='created_hostgroups' - ) - # 管理提供商 - 由超级管理员分配 - providers = models.ManyToManyField( - settings.AUTH_USER_MODEL, - blank=True, - verbose_name='管理提供商', - related_name='provider_hostgroups', - help_text='由超级管理员分配的提供商用户,提供商可以管理此主机组' - ) - - site_group = models.ForeignKey( - 'dashboard.SiteGroup', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='host_groups', - verbose_name='所属站点组', - ) - - created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') - updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间') - - class Meta: - verbose_name = '主机组' - verbose_name_plural = '主机组' - db_table = 'hosts_hostgroup' - - def __str__(self): - return self.name \ No newline at end of file diff --git a/apps/hosts/tasks.py b/apps/hosts/tasks.py deleted file mode 100755 index 5c08848..0000000 --- a/apps/hosts/tasks.py +++ /dev/null @@ -1,381 +0,0 @@ -from celery import shared_task -from django.contrib.auth.models import User -from django.utils import timezone - -from apps.hosts.models import Host -from apps.tasks.models import AsyncTask -import logging -import re - -logger = logging.getLogger(__name__) - -CERT_THUMBPRINT_PATTERN = re.compile(r'^[A-Fa-f0-9]{40}$') -CERT_FILENAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-\.]{1,255}\.pem$') - - -def validate_cert_thumbprint(thumbprint: str) -> str: - if not thumbprint: - raise ValueError("证书指纹不能为空") - thumbprint = thumbprint.strip().upper() - if not CERT_THUMBPRINT_PATTERN.match(thumbprint): - raise ValueError("证书指纹格式无效,必须是40位十六进制字符") - return thumbprint - - -def validate_cert_filename(filename: str) -> str: - if not filename: - raise ValueError("证书文件名不能为空") - if not CERT_FILENAME_PATTERN.match(filename): - raise ValueError("证书文件名格式无效,只允许字母、数字、下划线、连字符和点,且必须以.pem结尾") - return filename - - -def validate_cert_content(content: str) -> str: - if not content: - raise ValueError("证书内容不能为空") - if '@"' in content or '"@' in content: - raise ValueError("证书内容包含非法字符") - if len(content) > 100000: - raise ValueError("证书内容过长") - return content - - -@shared_task(bind=True) -def configure_winrm_on_host(self, host_id, cert_thumbprint=None, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"配置WinRM - 主机 #{host_id}", - created_by_id=operator_id, - target_object_id=host_id, - target_content_type='hosts.Host', - status='running' - ) - - try: - host = Host.objects.get(id=host_id) - task.start_execution() - - task.progress = 10 - task.save() - - try: - from utils.winrm_client import WinrmClient - - client = WinrmClient( - hostname=host.hostname or host.ip_address, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) - - actual_thumbprint = cert_thumbprint or host.certificate_thumbprint - if actual_thumbprint: - actual_thumbprint = validate_cert_thumbprint(actual_thumbprint) - - ps_script = ''' - Enable-PSRemoting -Force - Set-Service -Name WinRM -StartupType Automatic - ''' - - if actual_thumbprint: - ps_script += f''' - $selectorset = @{{Transport="HTTPS"}} - $resourceset = @{{Port="5986"; CertificateThumbprint="{actual_thumbprint}"}} - Get-WSManInstance -ResourceURI winrm/config/listener -SelectorSet $selectorset -ErrorAction SilentlyContinue | Remove-WSManInstance -ErrorAction SilentlyContinue - New-WSManInstance -ResourceURI winrm/config/listener -SelectorSet $selectorset -ValueSet $resourceset - if (-not (Get-NetFirewallRule -Name "WinRM-HTTPS-In-TCP-Public" -ErrorAction SilentlyContinue)) {{ - New-NetFirewallRule -Name "WinRM-HTTPS-In-TCP-Public" -DisplayName "WinRM HTTPS Inbound" -Enabled True -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Public,Private,Domain - }} - ''' - - ps_script += ''' - Set-Item -Path "WSMan:\\localhost\\Service\\AllowUnencrypted" -Value $false - Set-Item -Path "WSMan:\\localhost\\Service\\Auth\\Basic" -Value $true - Restart-Service WinRM - ''' - - task.progress = 30 - task.save() - - result = client.execute_powershell(ps_script) - - if result.status_code == 0: - task.progress = 80 - task.save() - - from django.utils import timezone - host.init_status = 'ready' - host.initialized_at = timezone.now() - if cert_thumbprint: - host.certificate_thumbprint = cert_thumbprint - host.save() - - task.progress = 100 - task.complete_success({ - 'status_code': result.status_code, - 'stdout': result.std_out, - 'success': True - }) - - return { - 'success': True, - 'status_code': result.status_code, - 'host_id': host_id - } - else: - error_msg = result.std_err if result.std_err else 'Unknown error' - task.complete_failure(f"PowerShell script failed: {error_msg}") - - return { - 'success': False, - 'status_code': result.status_code, - 'error': error_msg - } - - except Exception as conn_error: - logger.error(f"连接主机失败: {str(conn_error)}", exc_info=True) - task.complete_failure(f"无法连接到主机: {str(conn_error)}") - - return { - 'success': False, - 'error': str(conn_error) - } - - except Exception as e: - logger.error(f"配置WinRM失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - return { - 'success': False, - 'error': str(e) - } - - -@shared_task(bind=True) -def test_winrm_connection(self, host_id, use_certificate_auth=False): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"测试WinRM连接 - 主机 #{host_id}", - target_object_id=host_id, - target_content_type='hosts.Host', - status='running' - ) - - try: - host = Host.objects.get(id=host_id) - task.start_execution() - - old_status = host.status - host.test_connection() - host.refresh_from_db() - new_status = host.status - success = new_status == 'online' - - if success: - if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'): - Host.objects.filter(pk=host.pk).update( - cert_provision_status='configured', - cert_activated_at=timezone.now(), - ) - status_display = dict(Host.STATUS_CHOICES).get(new_status, new_status) - task.progress = 100 - task.complete_success({ - 'connected': True, - 'status': new_status, - 'status_display': status_display, - 'old_status': old_status, - 'message': f'连接成功,主机状态: {status_display}', - }) - - return { - 'success': True, - 'connected': True, - 'status': new_status, - 'status_display': status_display, - } - else: - if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'): - Host.objects.filter(pk=host.pk).update(cert_provision_status='failed') - status_display = dict(Host.STATUS_CHOICES).get(new_status, new_status) - task.complete_failure(f"连接测试失败,主机状态: {status_display}") - return { - 'success': False, - 'connected': False, - 'status': new_status, - 'status_display': status_display, - 'error': f'连接失败,主机状态: {status_display}', - } - - except Exception as e: - logger.error(f"测试WinRM连接失败: {str(e)}", exc_info=True) - try: - host = Host.objects.get(id=host_id) - if host.auth_method == 'certificate' and host.cert_provision_status in ('pending', 'ready'): - Host.objects.filter(pk=host.pk).update(cert_provision_status='failed') - Host.objects.filter(pk=host.pk).update(status='error') - except Host.DoesNotExist: - logger.warning( - "测试WinRM连接失败后的清理阶段:主机 #%s 不存在,跳过证书状态更新", - host_id - ) - task.complete_failure(str(e)) - - return { - 'success': False, - 'connected': False, - 'error': str(e) - } - - -@shared_task(bind=True) -def test_winrm_connection_raw(self, connection_type, hostname, port, use_ssl, auth_method, username, password): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"测试WinRM连接 - {hostname}", - status='running' - ) - - try: - task.start_execution() - - if connection_type == 'localwinserver': - from utils.local_winserver_client import LocalWinServerClient - client = LocalWinServerClient( - username=username, - password=password, - ) - result = client.execute_command('echo Connection Test OK') - elif connection_type == 'winrm' and auth_method == 'ntlm': - from utils.winrm_client import WinrmClient - client = WinrmClient( - hostname=hostname, - port=int(port), - username=username, - password=password, - use_ssl=bool(use_ssl), - auth_method='ntlm', - ) - result = client.execute_command('whoami') - else: - raise ValueError(f'不支持的连接类型: {connection_type}') - - if result.success: - output = result.std_out.strip() if result.std_out else '' - task.progress = 100 - task.complete_success({ - 'connected': True, - 'output': output, - 'message': f'连接成功{f" ({output})" if output else ""}', - }) - - return { - 'success': True, - 'connected': True, - 'output': output, - 'message': f'连接成功{f" ({output})" if output else ""}', - } - else: - error_detail = result.std_err.strip() if result.std_err else f'命令执行返回非零状态码: {result.status_code}' - task.complete_failure(f"连接失败: {error_detail}") - return { - 'success': False, - 'connected': False, - 'error': f'连接失败: {error_detail}', - } - - except Exception as e: - logger.error(f"测试WinRM连接失败: {hostname}, 错误: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - return { - 'success': False, - 'connected': False, - 'error': f'连接测试失败: {str(e)}', - } - - -@shared_task(bind=True) -def install_certificates_on_host(self, host_id, cert_pem, cert_filename, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"安装证书 - 主机 #{host_id}", - created_by_id=operator_id, - target_object_id=host_id, - target_content_type='hosts.Host', - status='running' - ) - - try: - host = Host.objects.get(id=host_id) - task.start_execution() - - cert_filename = validate_cert_filename(cert_filename) - cert_pem = validate_cert_content(cert_pem) - - from utils.winrm_client import WinrmClient, _escape_for_here_string - - client = WinrmClient( - hostname=host.hostname or host.ip_address, - port=host.port, - username=host.username, - password=host.password, - use_ssl=host.use_ssl - ) - - safe_cert_content = _escape_for_here_string(cert_pem) - safe_filename = cert_filename.replace('"', '').replace("'", '').replace(';', '') - - ps_script = f''' - $tempDir = "$env:TEMP\\2c2a_Certs" - if (!(Test-Path $tempDir)) {{ - New-Item -ItemType Directory -Path $tempDir -Force - }} - - $certContent = @" -{safe_cert_content} -"@ - - $certPath = Join-Path $tempDir "{safe_filename}" - $certContent | Out-File -FilePath $certPath -Encoding UTF8 - - Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root - Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\My - - Write-Output "Certificate installed successfully" - - Remove-Item $tempDir -Recurse -Force - ''' - - result = client.execute_powershell(ps_script) - - if result.status_code == 0: - task.progress = 100 - task.complete_success({ - 'installed': True, - 'cert_filename': cert_filename, - 'output': result.std_out - }) - - return { - 'success': True, - 'installed': True - } - else: - error_msg = result.std_err if result.std_err else 'Unknown error' - task.complete_failure(f"Certificate installation failed: {error_msg}") - - return { - 'success': False, - 'installed': False, - 'error': error_msg - } - - except Exception as e: - logger.error(f"安装证书失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - return { - 'success': False, - 'error': str(e) - } diff --git a/apps/hosts/templates/hosts/hostgroup_list.html b/apps/hosts/templates/hosts/hostgroup_list.html deleted file mode 100755 index 72052e6..0000000 --- a/apps/hosts/templates/hosts/hostgroup_list.html +++ /dev/null @@ -1,74 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}主机组列表{% endblock %} - -{% block content %} -
-

主机组列表

- -
-
-
- -
-
- - -
-
-
- -
- - - - - - - - - - - - {% for group in hostgroups %} - - - - - - - - {% empty %} - - - - {% endfor %} - -
名称描述主机数量创建时间操作
{{ group.name }}{{ group.description|truncatechars:100 }}{{ group.hosts.count }}{{ group.created_at|date:"Y-m-d H:i:s" }} - 编辑 - 详情 -
暂无主机组
-
- - - {% if hostgroups.has_other_pages %} - - {% endif %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/apps/hosts/urls_admin.py b/apps/hosts/urls_admin.py deleted file mode 100644 index 8b43a45..0000000 --- a/apps/hosts/urls_admin.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -超管后台 - 主机管理 URL 配置 - -命名空间: admin_hosts (通过 admin: 命名空间访问) -超管可查看所有主机和主机组,无数据隔离。 -""" - -from django.urls import path - -from . import views_admin - -app_name = 'admin_hosts' - -urlpatterns = [ - # 主机管理 - path( - '', - views_admin.AdminHostListView.as_view(), - name='host_list' - ), - path( - 'wizard/', - views_admin.admin_host_wizard, - name='host_wizard' - ), - path( - 'wizard/generate-token/', - views_admin.admin_host_wizard_generate_token, - name='host_wizard_generate_token' - ), - path( - 'wizard/test-connection/', - views_admin.admin_host_wizard_test_connection, - name='host_wizard_test_connection' - ), - path( - 'wizard/generate-init-command/', - views_admin.admin_host_wizard_generate_init_command, - name='host_wizard_generate_init_command' - ), - path( - 'create/', - views_admin.AdminHostCreateView.as_view(), - name='host_create' - ), - path( - '/', - views_admin.AdminHostDetailView.as_view(), - name='host_detail' - ), - path( - '/edit/', - views_admin.AdminHostUpdateView.as_view(), - name='host_edit' - ), - path( - '/delete/', - views_admin.AdminHostDeleteView.as_view(), - name='host_delete' - ), - path( - '/test/', - views_admin.admin_host_test_connection, - name='host_test' - ), - path( - '/generate-init-command/', - views_admin.admin_host_generate_init_command, - name='host_generate_init_command' - ), - path( - 'generate-cert-command/', - views_admin.admin_host_generate_cert_command, - name='admin_host_generate_cert_command' - ), - - # 主机组管理 - path( - 'groups/', - views_admin.AdminHostGroupListView.as_view(), - name='hostgroup_list' - ), - path( - 'groups/create/', - views_admin.AdminHostGroupCreateView.as_view(), - name='hostgroup_create' - ), - path( - 'groups//edit/', - views_admin.AdminHostGroupUpdateView.as_view(), - name='hostgroup_edit' - ), - path( - 'groups//delete/', - views_admin.AdminHostGroupDeleteView.as_view(), - name='hostgroup_delete' - ), -] diff --git a/apps/hosts/urls_provider.py b/apps/hosts/urls_provider.py deleted file mode 100644 index 3640620..0000000 --- a/apps/hosts/urls_provider.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -主机管理 - 提供商后台 URL 配置 - -所有 URL 以 /provider/hosts/ 为前缀,命名空间为 'provider_hosts'。 -完整命名空间为 'provider:provider_hosts:'。 -""" - -from django.urls import path - -from . import views_provider - -app_name = 'provider_hosts' - -urlpatterns = [ - path( - '', - views_provider.HostListView.as_view(), - name='host_list' - ), - path( - 'create/', - views_provider.HostCreateView.as_view(), - name='host_create' - ), - path( - '/', - views_provider.HostDetailView.as_view(), - name='host_detail' - ), - path( - '/edit/', - views_provider.HostUpdateView.as_view(), - name='host_edit' - ), - path( - '/delete/', - views_provider.HostDeleteView.as_view(), - name='host_delete' - ), - path( - '/deploy/', - views_provider.HostDeployCommandView.as_view(), - name='host_deploy' - ), - path( - '/toggle/', - views_provider.HostToggleActiveView.as_view(), - name='host_toggle' - ), - - # 主机组管理 - path( - 'groups/', - views_provider.HostGroupListView.as_view(), - name='hostgroup_list' - ), - path( - 'groups/create/', - views_provider.HostGroupCreateView.as_view(), - name='hostgroup_create' - ), - path( - 'groups//edit/', - views_provider.HostGroupUpdateView.as_view(), - name='hostgroup_edit' - ), - path( - 'groups//delete/', - views_provider.HostGroupDeleteView.as_view(), - name='hostgroup_delete' - ), -] diff --git a/apps/hosts/views_admin.py b/apps/hosts/views_admin.py deleted file mode 100644 index eb18ea7..0000000 --- a/apps/hosts/views_admin.py +++ /dev/null @@ -1,1152 +0,0 @@ -""" -主机管理 - 超管后台视图 - -所有视图均使用 @admin_required 装饰器保护。 -超管可查看所有主机和主机组;提供商仅可查看自己创建或分配给自己的数据。 -""" - -import json -import logging -import os -import platform -import secrets -from datetime import timedelta - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group -from django.core.paginator import Paginator -from django.db.models import Q -from django.http import Http404, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views import View -from django.views.generic import DetailView, TemplateView - -from django.contrib.auth.decorators import login_required - -from apps.accounts.provider_decorators import admin_required -from utils.provider import get_provider_hosts, PROVIDER_GROUP_NAME - -from .forms_admin import AdminHostForm, AdminHostGroupForm -from .forms_wizard import ( - HostWizardForm, - CONNECTION_DEFAULT_PORTS, - CONNECTION_DEFAULT_SSL, -) -from .models import Host, HostGroup - -User = get_user_model() -logger = logging.getLogger(__name__) - - -def _is_local_winserver(): - return platform.system() == "Windows" and "server" in platform.version().lower() - - -def _generate_init_command_data(request, host): - from apps.bootstrap.models import InitialToken - import base64 - - token = secrets.token_urlsafe(32) - expires_at = timezone.now() + timedelta(hours=24) - - initial_token = InitialToken.objects.create( - token=token, - host=host, - expires_at=expires_at, - status="ISSUED", - ) - - pairing_code = initial_token.generate_pairing_code() - - config_data = { - "c_side_url": request.build_absolute_uri("/").rstrip("/"), - "token": initial_token.token, - "host_id": str(host.id), - "expires_at": initial_token.expires_at.isoformat(), - } - config_json = json.dumps(config_data) - encoded_config = base64.b64encode(config_json.encode("utf-8")).decode("utf-8") - - one_liner = ( - "& ([ScriptBlock]::Create(" - "(irm https://static.2c2a.cc.cd/hostinitbash.ps1)" - f")) -Secret '{encoded_config}'" - ) - - fallback_command = ( - '$e = "$env:TEMP\\h_side_init.exe"; ' - "irm https://2c2a.cc.cd/hostinitbash.exe " - f"-OutFile $e; & $e '{encoded_config}'" - ) - - return { - "pairing_code": pairing_code, - "one_liner": one_liner, - "fallback_command": fallback_command, - "expires_at": initial_token.expires_at.isoformat(), - "host_id": host.id, - "hostname": host.hostname, - } - - -def _get_host_form_context(): - return { - "default_ports": json.dumps(CONNECTION_DEFAULT_PORTS), - "default_ssl": json.dumps(CONNECTION_DEFAULT_SSL), - "is_local_winserver": json.dumps(_is_local_winserver()), - } - - -def _get_permission_context(form, host=None): - provider_users = User.objects.filter( - groups__name=PROVIDER_GROUP_NAME, - is_staff=True, - is_superuser=False, - ).order_by("username") - - all_groups = Group.objects.all().order_by("name") - - provider_users_json = json.dumps( - [{"id": u.id, "username": u.username} for u in provider_users] - ) - groups_json = json.dumps( - [ - { - "id": g.id, - "name": g.name, - "member_ids": list( - g.user_set.filter( - groups__name=PROVIDER_GROUP_NAME, - is_staff=True, - is_superuser=False, - ) - .values_list("id", flat=True) - .distinct() - ), - } - for g in all_groups - ] - ) - - provider_user_ids = list(provider_users.values_list("id", flat=True)) - - initial_provider_ids = [] - - if form.is_bound: - initial_provider_ids = [int(x) for x in form.data.getlist("providers")] - elif host and host.pk: - initial_provider_ids = list(host.providers.values_list("id", flat=True)) - - initial_permissions = [] - key_counter = 0 - for uid in initial_provider_ids: - u = provider_users.filter(id=uid).first() - if u: - initial_permissions.append( - { - "key": key_counter, - "type": "member", - "targetId": u.id, - "name": u.username, - "userIds": [u.id], - } - ) - key_counter += 1 - - return { - "provider_users": provider_users, - "all_groups": all_groups, - "provider_users_json": provider_users_json, - "groups_json": groups_json, - "provider_user_ids_json": json.dumps(provider_user_ids), - "initial_permissions_json": json.dumps(initial_permissions), - "initial_provider_ids_json": json.dumps(initial_provider_ids), - } - - -# ========== 主机管理 ========== - - -@method_decorator(admin_required, name="dispatch") -class AdminHostListView(TemplateView): - """ - 超管主机列表视图 - - 显示所有主机,支持搜索和按连接类型/状态筛选。 - 包含提供商列显示每个主机关联的提供商。 - """ - - template_name = "admin_base/hosts/host_list.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - hosts_qs = Host.objects.all().order_by("-created_at") - elif self.request.user.is_site_group_admin(site_group): - if site_group: - hosts_qs = Host.objects.filter(site_group=site_group).order_by( - "-created_at" - ) - else: - hosts_qs = Host.objects.none() - else: - hosts_qs = get_provider_hosts( - self.request.user, site_group=site_group - ).order_by("-created_at") - - # 搜索过滤 - search = self.request.GET.get("search", "").strip() - if search: - hosts_qs = hosts_qs.filter( - Q(name__icontains=search) - | Q(hostname__icontains=search) - | Q(username__icontains=search) - ) - - # 状态过滤 - status_filter = self.request.GET.get("status", "").strip() - if status_filter: - hosts_qs = hosts_qs.filter(status=status_filter) - - # 连接类型过滤 - conn_filter = self.request.GET.get("connection_type", "").strip() - if conn_filter: - hosts_qs = hosts_qs.filter(connection_type=conn_filter) - - # 分页 - paginator = Paginator(hosts_qs, 15) - page_number = self.request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context.update( - { - "page_obj": page_obj, - "hosts": page_obj, - "search": search, - "status_filter": status_filter, - "connection_type_filter": conn_filter, - "status_choices": Host.STATUS_CHOICES, - "connection_type_choices": Host.CONNECTION_TYPE_CHOICES, - "page_title": "主机管理", - "active_nav": "hosts", - } - ) - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminHostDetailView(DetailView): - """ - 超管主机详情视图 - - 显示主机基本信息、提供商列表、管理员列表。 - """ - - template_name = "admin_base/hosts/host_detail.html" - model = Host - context_object_name = "host" - pk_url_kwarg = "pk" - - def get_queryset(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return Host.objects.all() - if self.request.user.is_site_group_admin(site_group): - if site_group: - return Host.objects.filter(site_group=site_group) - return Host.objects.none() - return get_provider_hosts(self.request.user, site_group=site_group) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - host = self.object - - # 获取关联产品 - from apps.operations.models import Product - - products = Product.objects.filter(host=host) - - # 获取关联用户数 - from apps.operations.models import CloudComputerUser - - user_count = CloudComputerUser.objects.filter(product__in=products).count() - - # 检查 session 中是否有生成的密码 - generated_password = None - if self.request.session.get("generated_password_host_id") == host.pk: - generated_password = self.request.session.get("generated_password") - self.request.session.pop("generated_password", None) - self.request.session.pop("generated_password_host_id", None) - - context.update( - { - "products": products, - "user_count": user_count, - "generated_password": generated_password, - "page_title": f"主机 - {host.name}", - "active_nav": "hosts", - } - ) - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminHostCreateView(TemplateView): - """ - 超管主机创建视图 - - 处理 GET 和 POST 请求,创建新主机。 - 自动设置 created_by 为当前用户。 - """ - - template_name = "admin_base/hosts/host_form.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - form = kwargs.get("form", AdminHostForm()) - context.update( - { - "form": form, - "page_title": "添加主机", - "active_nav": "hosts", - "is_create": True, - } - ) - context.update(_get_permission_context(form)) - context.update(_get_host_form_context()) - return context - - def post(self, request, *args, **kwargs): - form = AdminHostForm(request.POST, request.FILES) - if form.is_valid(): - init_token_value = form.cleaned_data.get("init_token", "") - logger.info( - f"Wizard save: init_token={'yes' if init_token_value else 'no'}, value={init_token_value[:8] if init_token_value else 'N/A'}" - ) - existing_host = None - cert_token_obj = None - if init_token_value: - from apps.bootstrap.models import CertProvisionToken - - try: - cert_token_obj = CertProvisionToken.objects.get( - token=init_token_value - ) - if cert_token_obj.host: - existing_host = cert_token_obj.host - except CertProvisionToken.DoesNotExist: - pass - - if existing_host: - for field in [ - "name", - "os_type", - "hostname", - "connection_type", - "auth_method", - "port", - "rdp_port", - "use_ssl", - "username", - "description", - ]: - if field in form.cleaned_data: - setattr(existing_host, field, form.cleaned_data[field]) - pwd = form.cleaned_data.get("password", "") - if pwd: - existing_host.password = pwd - existing_host.save() - form.instance = existing_host - form.save_m2m() - # 保存证书文件 - if existing_host.auth_method == "certificate": - form._save_cert_files(existing_host) - host = existing_host - else: - host = form.save(commit=False) - host.created_by = request.user - site_group = getattr(request, "site_group", None) - if site_group: - host.site_group = site_group - host.save() - form.save_m2m() - # 保存证书文件 - if host.auth_method == "certificate": - form._save_cert_files(host) - - if cert_token_obj and not existing_host: - cert_token_obj.host = host - if cert_token_obj.cert_data: - cd = cert_token_obj.cert_data - host.cert_root = cd.get("cert_root", "") - host.cert_sub = cd.get("cert_sub", "") - host.pfx_password = cd.get("pfx_password", "") - host.ntlm_fallback_user = cd.get("ntlm_user", "") - host.ntlm_fallback_password = cd.get("ntlm_password", "") - host.cert_provision_status = "ready" - # 根据 cert_root 和 cert_sub 计算证书路径 - if host.cert_root and host.cert_sub: - from utils.cert_storage import get_cert_dir - - cert_dir = get_cert_dir(host.cert_root, host.cert_sub) - host.cert_pem_path = str(cert_dir / "client.crt") - host.cert_key_path = str(cert_dir / "client.key") - host.save() - cert_token_obj.cert_data = None - cert_token_obj.save() - - # 测试连接(异步) - from apps.hosts.tasks import test_winrm_connection - - test_winrm_connection.delay(host.pk) - messages.success( - request, f"主机 {host.name} 创建成功," f"连接测试正在后台执行" - ) - - # 如果自动生成了密码,提示用户 - if hasattr(form, "generated_password") and form.generated_password: - messages.info( - request, f"已为主机 {host.name} 自动生成密码," f"请妥善保存。" - ) - request.session["generated_password"] = form.generated_password - request.session["generated_password_host_id"] = host.pk - - init_token = request.POST.get("init_token") - if host.auth_method == "certificate" and init_token and not cert_token_obj: - try: - from apps.bootstrap.models import CertProvisionToken - - CertProvisionToken.objects.filter( - token=init_token, - host__isnull=True, - ).update(host=host) - except Exception: - pass - - return redirect("admin:admin_hosts:host_detail", pk=host.pk) - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(admin_required, name="dispatch") -class AdminHostUpdateView(TemplateView): - """ - 超管主机编辑视图 - - 处理 GET 和 POST 请求,编辑主机信息。 - 密码字段可选,留空则不修改。 - """ - - template_name = "admin_base/hosts/host_form.html" - - def get_host(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(Host, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - Host.objects.filter(site_group=site_group), pk=self.kwargs["pk"] - ) - raise Http404 - return get_object_or_404( - get_provider_hosts(self.request.user, site_group=site_group), - pk=self.kwargs["pk"], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - host = self.get_host() - form = kwargs.get("form", AdminHostForm(instance=host)) - context.update( - { - "form": form, - "host": host, - "page_title": f"编辑主机 - {host.name}", - "active_nav": "hosts", - "is_create": False, - } - ) - context.update(_get_permission_context(form, host)) - context.update(_get_host_form_context()) - return context - - def post(self, request, *args, **kwargs): - host = self.get_host() - form = AdminHostForm(request.POST, request.FILES, instance=host) - if form.is_valid(): - host = form.save() - - # 如果密码被修改,异步测试连接 - if "password" in form.changed_data and form.cleaned_data.get("password"): - from apps.hosts.tasks import test_winrm_connection - - test_winrm_connection.delay(host.pk) - messages.success( - request, f"主机 {host.name} 更新成功," f"连接测试正在后台执行" - ) - else: - messages.success(request, f"主机 {host.name} 更新成功") - - return redirect("admin:admin_hosts:host_detail", pk=host.pk) - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(admin_required, name="dispatch") -class AdminHostDeleteView(TemplateView): - """ - 超管主机删除视图 - - 显示确认页面,处理删除请求。 - 删除前清理关联的产品和公共主机信息。 - """ - - template_name = "admin_base/hosts/host_confirm_delete.html" - - def get_host(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(Host, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - Host.objects.filter(site_group=site_group), pk=self.kwargs["pk"] - ) - raise Http404 - return get_object_or_404( - get_provider_hosts(self.request.user, site_group=site_group), - pk=self.kwargs["pk"], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - host = self.get_host() - - from apps.operations.models import Product - - products = Product.objects.filter(host=host) - - context.update( - { - "host": host, - "product_count": products.count(), - "page_title": f"删除主机 - {host.name}", - "active_nav": "hosts", - } - ) - return context - - def post(self, request, *args, **kwargs): - host = self.get_host() - - # 删除关联的产品和公共主机信息 - from apps.operations.models import Product, PublicHostInfo - - Product.objects.filter(host=host).delete() - PublicHostInfo.objects.filter(internal_host=host).delete() - - host_name = host.name - host.delete() - - messages.success(request, f"主机 {host_name} 已删除") - return redirect("admin:admin_hosts:host_list") - - -@admin_required -def admin_host_test_connection(request, pk): - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - host = get_object_or_404(Host, pk=pk) - elif request.user.is_site_group_admin(site_group): - if site_group: - host = get_object_or_404(Host.objects.filter(site_group=site_group), pk=pk) - else: - raise Http404 - else: - host = get_object_or_404( - get_provider_hosts(request.user, site_group=site_group), pk=pk - ) - - from apps.hosts.tasks import test_winrm_connection - - result = test_winrm_connection.delay(host.pk) - - return JsonResponse( - { - "success": True, - "task_id": result.id, - "message": "连接测试已提交,正在后台执行", - } - ) - - -# ========== 主机组管理 ========== - - -@method_decorator(admin_required, name="dispatch") -class AdminHostGroupListView(TemplateView): - """ - 超管主机组列表视图 - - 显示所有主机组,支持搜索。 - 包含提供商列和主机数量。 - """ - - template_name = "admin_base/hosts/hostgroup_list.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - hostgroups_qs = HostGroup.objects.all().order_by("-created_at") - elif self.request.user.is_site_group_admin(site_group): - if site_group: - hostgroups_qs = HostGroup.objects.filter( - site_group=site_group - ).order_by("-created_at") - else: - hostgroups_qs = HostGroup.objects.none() - else: - hostgroups_qs = HostGroup.objects.filter( - created_by=self.request.user - ).order_by("-created_at") - - # 搜索过滤 - search = self.request.GET.get("search", "").strip() - if search: - hostgroups_qs = hostgroups_qs.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(hostgroups_qs, 15) - page_number = self.request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context.update( - { - "page_obj": page_obj, - "hostgroups": page_obj, - "search": search, - "page_title": "主机组管理", - "active_nav": "hostgroups", - } - ) - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminHostGroupCreateView(TemplateView): - """ - 超管主机组创建视图 - - 处理 GET 和 POST 请求,创建新主机组。 - 自动设置 created_by 为当前用户。 - """ - - template_name = "admin_base/hosts/hostgroup_form.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update( - { - "form": kwargs.get("form", AdminHostGroupForm()), - "page_title": "创建主机组", - "active_nav": "hostgroups", - "is_create": True, - } - ) - return context - - def post(self, request, *args, **kwargs): - form = AdminHostGroupForm(request.POST) - if form.is_valid(): - hostgroup = form.save(commit=False) - hostgroup.created_by = request.user - site_group = getattr(request, "site_group", None) - if site_group: - hostgroup.site_group = site_group - hostgroup.save() - form.save_m2m() - - messages.success(request, f"主机组 {hostgroup.name} 创建成功") - return redirect("admin:admin_hosts:hostgroup_list") - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(admin_required, name="dispatch") -class AdminHostGroupUpdateView(TemplateView): - """ - 超管主机组编辑视图 - - 处理 GET 和 POST 请求,编辑主机组信息。 - """ - - template_name = "admin_base/hosts/hostgroup_form.html" - - def get_hostgroup(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(HostGroup, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - HostGroup.objects.filter(site_group=site_group), - pk=self.kwargs["pk"], - ) - raise Http404 - return get_object_or_404( - HostGroup, - pk=self.kwargs["pk"], - created_by=self.request.user, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - hostgroup = self.get_hostgroup() - form = kwargs.get("form", AdminHostGroupForm(instance=hostgroup)) - context.update( - { - "form": form, - "hostgroup": hostgroup, - "page_title": f"编辑主机组 - {hostgroup.name}", - "active_nav": "hostgroups", - "is_create": False, - } - ) - return context - - def post(self, request, *args, **kwargs): - hostgroup = self.get_hostgroup() - form = AdminHostGroupForm(request.POST, instance=hostgroup) - if form.is_valid(): - hostgroup = form.save() - messages.success(request, f"主机组 {hostgroup.name} 更新成功") - return redirect("admin:admin_hosts:hostgroup_list") - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(admin_required, name="dispatch") -class AdminHostGroupDeleteView(TemplateView): - """ - 超管主机组删除视图 - - 显示确认页面,处理删除请求。 - """ - - template_name = "admin_base/hosts/hostgroup_confirm_delete.html" - - def get_hostgroup(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(HostGroup, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - HostGroup.objects.filter(site_group=site_group), - pk=self.kwargs["pk"], - ) - raise Http404 - return get_object_or_404( - HostGroup, - pk=self.kwargs["pk"], - created_by=self.request.user, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - hostgroup = self.get_hostgroup() - - context.update( - { - "hostgroup": hostgroup, - "host_count": hostgroup.hosts.count(), - "page_title": f"删除主机组 - {hostgroup.name}", - "active_nav": "hostgroups", - } - ) - return context - - def post(self, request, *args, **kwargs): - hostgroup = self.get_hostgroup() - hostgroup_name = hostgroup.name - hostgroup.delete() - - messages.success(request, f"主机组 {hostgroup_name} 已删除") - return redirect("admin:admin_hosts:hostgroup_list") - - -# ========== 主机创建向导 ========== - - -@admin_required -def admin_host_wizard(request): - """ - 主机创建向导视图 - - 引导超管分步添加主机: - - Step 1: 基本信息 (名称、地址、连接类型) - - Step 2: 连接配置 (端口、SSL、认证) - - Step 3: 分配提供商 (提供商、描述) - - 使用 Alpine.js 在客户端管理步骤切换, - 最终一次性提交表单创建主机。 - """ - if request.method == "POST": - form = HostWizardForm(request.POST, request.FILES) - if form.is_valid(): - host = form.save(commit=False) - host.created_by = request.user - site_group = getattr(request, "site_group", None) - if site_group: - host.site_group = site_group - host.save() - form.save_m2m() - # 保存证书文件 - if host.auth_method == "certificate": - form._save_cert_files(host) - - init_token_value = form.cleaned_data.get("init_token", "") - if init_token_value: - from apps.bootstrap.models import CertProvisionToken - - try: - cert_token_obj = CertProvisionToken.objects.get( - token=init_token_value - ) - if not cert_token_obj.host_id: - cert_token_obj.host = host - if cert_token_obj.cert_data: - cd = cert_token_obj.cert_data - host.cert_root = cd.get("cert_root", "") - host.cert_sub = cd.get("cert_sub", "") - host.pfx_password = cd.get("pfx_password", "") - host.ntlm_fallback_user = cd.get("ntlm_user", "") - host.ntlm_fallback_password = cd.get("ntlm_password", "") - host.cert_provision_status = "ready" - # 根据 cert_root 和 cert_sub 计算证书路径 - if host.cert_root and host.cert_sub: - from utils.cert_storage import get_cert_dir - - cert_dir = get_cert_dir(host.cert_root, host.cert_sub) - host.cert_pem_path = str(cert_dir / "client.crt") - host.cert_key_path = str(cert_dir / "client.key") - host.save() - cert_token_obj.cert_data = None - cert_token_obj.save() - host.refresh_from_db() - except CertProvisionToken.DoesNotExist: - pass - - from apps.hosts.tasks import test_winrm_connection - - test_winrm_connection.delay(host.pk) - messages.success( - request, f"主机 {host.name} 创建成功," f"连接测试正在后台执行" - ) - - # 如果自动生成了密码,提示用户 - if hasattr(form, "generated_password") and form.generated_password: - messages.info( - request, f"已为主机 {host.name} 自动生成密码," f"请妥善保存。" - ) - # 将生成的密码存入 session 以便在详情页展示 - request.session["generated_password"] = form.generated_password - request.session["generated_password_host_id"] = host.pk - - return redirect("admin:admin_hosts:host_detail", pk=host.pk) - else: - form = HostWizardForm() - - # 获取提供商列表及主机数量(用于向导第三步) - providers_with_count = form.get_providers_with_host_count() - - gateway_url = os.environ.get("TUNNEL_GATEWAY_URL", "wss://gateway.2c2a.com:9000") - server_base_url = os.environ.get( - "TUNNEL_SERVER_BASE_URL", request.build_absolute_uri("/").rstrip("/") - ) - - context = { - "form": form, - "providers_with_count": providers_with_count, - "connection_type_choices": [ - c - for c in Host.CONNECTION_TYPE_CHOICES - if c[0] in ("winrm", "localwinserver") - ], - "os_type_choices": Host.OS_TYPE_CHOICES, - "auth_method_choices": Host.AUTH_METHOD_CHOICES, - "default_ports": json.dumps(CONNECTION_DEFAULT_PORTS), - "default_ssl": json.dumps(CONNECTION_DEFAULT_SSL), - "is_local_winserver": json.dumps(_is_local_winserver()), - "gateway_url": gateway_url, - "server_base_url": server_base_url, - "page_title": "添加主机", - "active_nav": "hosts", - } - - return render( - request, - "admin_base/hosts/host_wizard.html", - context, - ) - - -@admin_required -def admin_host_wizard_generate_init_command(request): - if request.method != "POST": - return JsonResponse( - {"success": False, "error": "Method not allowed"}, - status=405, - ) - - import base64, json - from apps.bootstrap.models import CertProvisionToken - from apps.bootstrap.token_utils import encode_provision_token - - token_str = secrets.token_hex(32) - server_host = request.get_host() - scheme = "https" if request.is_secure() else "http" - - ip_address = "" - try: - body = json.loads(request.body) - ip_address = body.get("ip_address", "") - except (json.JSONDecodeError, ValueError): - ip_address = request.POST.get("ip_address", "") - - CertProvisionToken.objects.create( - token=token_str, - host=None, - server_host=server_host, - ip_address=ip_address, - expires_at=timezone.now() + timedelta(minutes=60), - status="ISSUED", - created_by=request.user, - ) - - encoded = encode_provision_token(token_str, scheme, server_host) - script_url = f"{scheme}://{server_host}/static/scripts/init.ps1" - one_liner = ( - f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " - f"& ([ScriptBlock]::Create($script)) {encoded}" - ) - fallback_command = ( - f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " - f"& ([ScriptBlock]::Create($script)) {encoded} debug" - ) - - return JsonResponse( - { - "success": True, - "data": { - "token": token_str, - "one_liner": one_liner, - "fallback_command": fallback_command, - }, - } - ) - - -@admin_required -def admin_host_wizard_generate_token(request): - if request.method != "POST": - return JsonResponse( - {"success": False, "error": "Method not allowed"}, status=405 - ) - - try: - token = secrets.token_urlsafe(32) - gateway_url = os.environ.get( - "TUNNEL_GATEWAY_URL", "wss://gateway.2c2a.com:9000" - ) - server_base_url = os.environ.get( - "TUNNEL_SERVER_BASE_URL", request.build_absolute_uri("/").rstrip("/") - ) - - return JsonResponse( - { - "success": True, - "tunnel_token": token, - "gateway_url": gateway_url, - "server_base_url": server_base_url, - } - ) - except Exception as e: - logger.error(f"Error generating tunnel token: {str(e)}", exc_info=True) - return JsonResponse( - { - "success": False, - "error": "Failed to generate tunnel token", - }, - status=500, - ) - - -@admin_required -def admin_host_wizard_test_connection(request): - if request.method != "POST": - return JsonResponse( - {"success": False, "error": "Method not allowed"}, status=405 - ) - - try: - data = json.loads(request.body) - except (json.JSONDecodeError, ValueError): - return JsonResponse({"success": False, "error": "请求数据格式无效"}, status=400) - - connection_type = data.get("connection_type", "winrm") - hostname = data.get("hostname", "").strip() - port = data.get("port", 5985) - use_ssl = data.get("use_ssl", False) - auth_method = data.get("auth_method", "ntlm") - username = data.get("username", "").strip() - password = data.get("password", "") - - if not hostname: - return JsonResponse({"success": False, "error": "主机地址不能为空"}, status=400) - - if auth_method == "ntlm": - if not username: - return JsonResponse( - {"success": False, "error": "用户名不能为空"}, status=400 - ) - if not password: - return JsonResponse({"success": False, "error": "密码不能为空"}, status=400) - - if auth_method == "certificate": - return JsonResponse( - { - "success": False, - "error": "证书认证方式请先保存主机后再测试连接", - } - ) - - from apps.hosts.tasks import test_winrm_connection_raw - - result = test_winrm_connection_raw.delay( - connection_type, - hostname, - port, - use_ssl, - auth_method, - username, - password, - ) - - return JsonResponse( - { - "success": True, - "task_id": result.id, - "message": "连接测试已提交,正在后台执行", - } - ) - - -@admin_required -def admin_host_generate_init_command(request, pk): - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - host = get_object_or_404(Host, pk=pk) - elif request.user.is_site_group_admin(site_group): - if site_group: - host = get_object_or_404(Host.objects.filter(site_group=site_group), pk=pk) - else: - raise Http404 - else: - host = get_object_or_404( - get_provider_hosts(request.user, site_group=site_group), pk=pk - ) - - if request.method != "POST": - return JsonResponse( - {"success": False, "error": "Method not allowed"}, - status=405, - ) - - try: - init_data = _generate_init_command_data(request, host) - return JsonResponse({"success": True, "data": init_data}) - except Exception as e: - return JsonResponse( - {"success": False, "error": str(e)}, - status=500, - ) - - -@login_required -def admin_host_generate_cert_command(request): - if request.method != "POST": - return JsonResponse( - {"success": False, "error": "Method not allowed"}, status=405 - ) - - host_id = request.POST.get("host_id") - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - host = get_object_or_404(Host, pk=host_id) - elif request.user.is_site_group_admin(site_group): - if site_group: - host = get_object_or_404( - Host.objects.filter(site_group=site_group), pk=host_id - ) - else: - raise Http404 - else: - host = get_object_or_404( - get_provider_hosts(request.user, site_group=site_group), pk=host_id - ) - - if host.auth_method != "certificate": - return JsonResponse( - {"success": False, "error": "Host is not configured for certificate auth"}, - status=400, - ) - - from apps.bootstrap.models import CertProvisionToken - from apps.bootstrap.token_utils import encode_provision_token - - token_str = secrets.token_hex(32) - server_host = request.get_host() - scheme = "https" if request.is_secure() else "http" - - provision_token = CertProvisionToken.objects.create( - token=token_str, - host=host, - server_host=server_host, - ip_address=host.hostname or "", - expires_at=timezone.now() + timedelta(minutes=60), - status="ISSUED", - created_by=request.user, - ) - - encoded = encode_provision_token(token_str, scheme, server_host) - script_url = f"{scheme}://{server_host}/static/scripts/init.ps1" - command = ( - f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " - f"& ([ScriptBlock]::Create($script)) {encoded}" - ) - debug_command = ( - f"$script = [Text.Encoding]::UTF8.GetString((iwr '{script_url}' -UseBasicParsing).RawContentStream.ToArray()); " - f"& ([ScriptBlock]::Create($script)) {encoded} debug" - ) - - return JsonResponse( - { - "success": True, - "command": command, - "debug_command": debug_command, - "token": token_str, - "expires_at": provision_token.expires_at.isoformat(), - } - ) diff --git a/apps/hosts/views_provider.py b/apps/hosts/views_provider.py deleted file mode 100644 index 3199e17..0000000 --- a/apps/hosts/views_provider.py +++ /dev/null @@ -1,639 +0,0 @@ -""" -主机管理 - 提供商后台视图 - -所有视图均使用 @provider_required 装饰器保护,确保只有提供商用户可以访问。 -数据隔离通过 utils.provider 中的 get_provider_hosts 函数实现。 -""" - -import base64 -import json -import logging -import secrets - -from datetime import timedelta - -from django.contrib import messages -from django.core.paginator import Paginator -from django.db.models import Q -from django.http import JsonResponse -from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views import View -from django.views.generic import DetailView, TemplateView - -from apps.accounts.provider_decorators import provider_required -from apps.provider.context_mixin import ProviderContextMixin -from utils.provider import get_provider_hosts - -from .forms_provider import HostCreateForm, HostUpdateForm, HostGroupForm -from .models import Host, HostGroup - -logger = logging.getLogger(__name__) - - -@method_decorator(provider_required, name='dispatch') -class HostListView(ProviderContextMixin, TemplateView): - """ - 主机列表视图 - - 提供分页、搜索和筛选功能,仅显示当前提供商可见的主机。 - """ - - template_name = 'admin_base/hosts/host_list.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - # 获取提供商可见的主机 - hosts_qs = get_provider_hosts(user, site_group=getattr(self.request, 'site_group', None)).order_by('-created_at') - - # 搜索过滤 - search = self.request.GET.get('search', '').strip() - if search: - hosts_qs = hosts_qs.filter( - Q(name__icontains=search) - | Q(hostname__icontains=search) - | Q(username__icontains=search) - ) - - # 状态过滤 - status_filter = self.request.GET.get('status', '').strip() - if status_filter: - hosts_qs = hosts_qs.filter(status=status_filter) - - # 连接类型过滤 - conn_filter = self.request.GET.get('connection_type', '').strip() - if conn_filter: - hosts_qs = hosts_qs.filter(connection_type=conn_filter) - - # 分页 - paginator = Paginator(hosts_qs, 15) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context.update({ - 'page_obj': page_obj, - 'hosts': page_obj, - 'search': search, - 'status_filter': status_filter, - 'connection_type_filter': conn_filter, - 'status_choices': Host.STATUS_CHOICES, - 'connection_type_choices': Host.CONNECTION_TYPE_CHOICES, - 'page_title': '主机管理', - 'active_nav': 'hosts', - }) - return context - - -@method_decorator(provider_required, name='dispatch') -class HostDetailView(ProviderContextMixin, DetailView): - """ - 主机详情视图 - - 显示主机基本信息、隧道状态、关联产品等。 - """ - - template_name = 'admin_base/hosts/host_detail.html' - model = Host - context_object_name = 'host' - pk_url_kwarg = 'pk' - provider_url_namespace = 'provider:provider_hosts' - - def get_queryset(self): - """确保提供商只能查看自己的主机""" - return get_provider_hosts(self.request.user, site_group=getattr(self.request, 'site_group', None)) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - host = self.object - - # 获取关联产品 - from apps.operations.models import Product - products = Product.objects.filter(host=host) - - # 获取关联用户数 - from apps.operations.models import CloudComputerUser - user_count = CloudComputerUser.objects.filter( - product__in=products - ).count() - - # 检查 session 中是否有生成的密码 - generated_password = None - if ( - self.request.session.get('generated_password_host_id') - == host.pk - ): - generated_password = self.request.session.get( - 'generated_password' - ) - # 一次性读取后清除 - self.request.session.pop('generated_password', None) - self.request.session.pop('generated_password_host_id', None) - - context.update({ - 'products': products, - 'user_count': user_count, - 'generated_password': generated_password, - 'page_title': f'主机 - {host.name}', - 'active_nav': 'hosts', - }) - return context - - -@method_decorator(provider_required, name='dispatch') -class HostCreateView(ProviderContextMixin, TemplateView): - """ - 主机创建视图 - - 处理 GET 和 POST 请求,创建新主机。 - 自动设置 created_by 为当前用户,并将用户添加到 administrators。 - """ - - template_name = 'admin_base/hosts/host_form.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update({ - 'form': kwargs.get('form', HostCreateForm()), - 'page_title': '添加主机', - 'active_nav': 'hosts', - 'is_create': True, - }) - return context - - def post(self, request, *args, **kwargs): - form = HostCreateForm(request.POST) - if form.is_valid(): - host = form.save(commit=False) - host.created_by = request.user - host.save() - # 将创建者添加到管理员列表 - host.administrators.add(request.user) - - # 测试连接(异步) - from apps.hosts.tasks import test_winrm_connection - test_winrm_connection.delay(host.pk) - messages.success( - request, - f'主机 {host.name} 创建成功,' - f'连接测试正在后台执行' - ) - - # 如果自动生成了密码,提示用户 - if form.generated_password: - messages.info( - request, - f'已为主机 {host.name} 自动生成密码,请妥善保存。' - ) - # 将生成的密码存入 session 以便在详情页展示 - request.session['generated_password'] = ( - form.generated_password - ) - request.session['generated_password_host_id'] = host.pk - - return redirect( - 'provider:provider_hosts:host_detail', pk=host.pk - ) - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(provider_required, name='dispatch') -class HostUpdateView(ProviderContextMixin, TemplateView): - """ - 主机编辑视图 - - 处理 GET 和 POST 请求,编辑主机信息。 - 密码字段可选,留空则不修改。 - """ - - template_name = 'admin_base/hosts/host_form.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_host(self): - return get_object_or_404( - get_provider_hosts(self.request.user, site_group=getattr(self.request, 'site_group', None)), - pk=self.kwargs['pk'] - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - host = self.get_host() - form = kwargs.get('form', HostUpdateForm(instance=host)) - context.update({ - 'form': form, - 'host': host, - 'page_title': f'编辑主机 - {host.name}', - 'active_nav': 'hosts', - 'is_create': False, - }) - return context - - def post(self, request, *args, **kwargs): - host = self.get_host() - form = HostUpdateForm(request.POST, instance=host) - if form.is_valid(): - host = form.save() - - # 如果密码被修改,异步测试连接 - if ( - 'password' in form.changed_data - and form.cleaned_data.get('password') - ): - from apps.hosts.tasks import test_winrm_connection - test_winrm_connection.delay(host.pk) - messages.success( - request, - f'主机 {host.name} 更新成功,' - f'连接测试正在后台执行' - ) - else: - messages.success( - request, f'主机 {host.name} 更新成功' - ) - - return redirect( - 'provider:provider_hosts:host_detail', pk=host.pk - ) - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(provider_required, name='dispatch') -class HostDeleteView(ProviderContextMixin, TemplateView): - """ - 主机删除视图 - - 显示确认页面,处理删除请求。 - 删除前清理关联的产品和公共主机信息。 - """ - - template_name = 'admin_base/hosts/host_confirm_delete.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_host(self): - return get_object_or_404( - get_provider_hosts(self.request.user, site_group=getattr(self.request, 'site_group', None)), - pk=self.kwargs['pk'] - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - host = self.get_host() - - from apps.operations.models import Product - products = Product.objects.filter(host=host) - - context.update({ - 'host': host, - 'product_count': products.count(), - 'page_title': f'删除主机 - {host.name}', - 'active_nav': 'hosts', - }) - return context - - def post(self, request, *args, **kwargs): - host = self.get_host() - - # 删除关联的产品和公共主机信息 - from apps.operations.models import Product, PublicHostInfo - Product.objects.filter(host=host).delete() - PublicHostInfo.objects.filter(internal_host=host).delete() - - host_name = host.name - host.delete() - - messages.success(request, f'主机 {host_name} 已删除') - return redirect('provider:provider_hosts:host_list') - - -@method_decorator(provider_required, name='dispatch') -class HostDeployCommandView(View): - """ - 生成部署命令视图 - - 为主机生成部署命令,复用 Admin 中的部署命令生成逻辑。 - 返回 JSON 响应,供前端 AJAX 调用。 - """ - - def get_host(self): - return get_object_or_404( - get_provider_hosts(self.request.user, site_group=getattr(self.request, 'site_group', None)), - pk=self.kwargs['pk'] - ) - - def get(self, request, *args, **kwargs): - host = self.get_host() - try: - from apps.bootstrap.models import InitialToken - - current_time = timezone.now() - valid_tokens = InitialToken.objects.filter( - host=host, - status__in=['ISSUED', 'PAIRED'], - expires_at__gt=current_time - ).order_by('-created_at') - - if valid_tokens.exists(): - existing_token = valid_tokens.first() - initial_token = existing_token - created = False - token_message = '复用现有引导令牌' - pairing_code = initial_token.generate_pairing_code() - else: - token = secrets.token_urlsafe(32) - expires_at = current_time + timedelta(hours=24) - - initial_token, created = ( - InitialToken.objects.get_or_create( - token=token, - defaults={ - 'host': host, - 'expires_at': expires_at, - 'status': 'ISSUED' - } - ) - ) - token_message = '新引导令牌已生成' - pairing_code = initial_token.generate_pairing_code() - - current_site = request.build_absolute_uri('/') - - secret_data = { - 'c_side_url': current_site.rstrip('/'), - 'token': initial_token.token, - 'host_id': str(host.id), - 'hostname': host.hostname, - 'generated_at': current_time.isoformat(), - 'expires_at': initial_token.expires_at.isoformat() - } - - json_str = json.dumps(secret_data, ensure_ascii=False) - encoded_bytes = base64.b64encode(json_str.encode('utf-8')) - encoded_str = encoded_bytes.decode('utf-8') - - deploy_command = ( - f'.\\h_side_init.exe "{encoded_str}"' - ) - - return JsonResponse({ - 'success': True, - 'deploy_command': deploy_command, - 'secret': encoded_str, - 'pairing_code': pairing_code, - 'pairing_code_expiry': ( - initial_token.pairing_code_expires_at.isoformat() - if initial_token.pairing_code_expires_at else None - ), - 'expires_at': initial_token.expires_at.isoformat(), - 'message': f'{token_message},将在24小时后过期', - 'token_id': initial_token.token, - 'token_status': initial_token.status, - 'created_new': created - }) - - except Exception as e: - logger.error( - f'生成部署命令时发生错误: {str(e)}', exc_info=True - ) - return JsonResponse({ - 'success': False, - 'error': '生成部署命令失败' - }, status=500) - - -@method_decorator(provider_required, name='dispatch') -class HostToggleActiveView(View): - """ - 切换主机活跃状态视图 - - AJAX 端点,用于快速切换主机的在线/离线状态。 - """ - - def get_host(self): - return get_object_or_404( - get_provider_hosts(self.request.user, site_group=getattr(self.request, 'site_group', None)), - pk=self.kwargs['pk'] - ) - - def post(self, request, *args, **kwargs): - host = self.get_host() - - if host.status == 'online': - new_status = 'offline' - Host.objects.filter(pk=host.pk).update(status=new_status) - else: - from apps.hosts.tasks import test_winrm_connection - test_winrm_connection.delay(host.pk) - new_status = 'pending' - - status_display = dict(Host.STATUS_CHOICES).get( - new_status, new_status - ) - return JsonResponse({ - 'success': True, - 'status': new_status, - 'status_display': status_display, - }) - - -# ========== 主机组管理 ========== - - -def get_provider_hostgroups(user, site_group=None): - qs = HostGroup.objects.filter( - Q(created_by=user) | Q(providers=user) - ).distinct() - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - -@method_decorator(provider_required, name='dispatch') -class HostGroupListView(ProviderContextMixin, TemplateView): - """ - 主机组列表视图 - - 提供分页和搜索功能,仅显示当前提供商可见的主机组。 - """ - - template_name = 'admin_base/hosts/hostgroup_list.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - # 获取提供商可见的主机组 - hostgroups_qs = get_provider_hostgroups(user, site_group=getattr(self.request, 'site_group', None)).order_by( - '-created_at' - ) - - # 搜索过滤 - search = self.request.GET.get('search', '').strip() - if search: - hostgroups_qs = hostgroups_qs.filter( - Q(name__icontains=search) - | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(hostgroups_qs, 15) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context.update({ - 'page_obj': page_obj, - 'hostgroups': page_obj, - 'search': search, - 'page_title': '主机组管理', - 'active_nav': 'hosts', - }) - return context - - -@method_decorator(provider_required, name='dispatch') -class HostGroupCreateView(ProviderContextMixin, TemplateView): - """ - 主机组创建视图 - - 处理 GET 和 POST 请求,创建新主机组。 - 自动设置 created_by 为当前用户。 - """ - - template_name = 'admin_base/hosts/hostgroup_form.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update({ - 'form': kwargs.get( - 'form', - HostGroupForm(provider_user=self.request.user) - ), - 'page_title': '创建主机组', - 'active_nav': 'hosts', - 'is_create': True, - }) - return context - - def post(self, request, *args, **kwargs): - form = HostGroupForm( - request.POST, provider_user=request.user - ) - if form.is_valid(): - hostgroup = form.save(commit=False) - hostgroup.created_by = request.user - hostgroup.save() - form.save_m2m() - - messages.success( - request, - f'主机组 {hostgroup.name} 创建成功' - ) - return redirect( - 'provider:provider_hosts:hostgroup_list' - ) - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(provider_required, name='dispatch') -class HostGroupUpdateView(ProviderContextMixin, TemplateView): - """ - 主机组编辑视图 - - 处理 GET 和 POST 请求,编辑主机组信息。 - """ - - template_name = 'admin_base/hosts/hostgroup_form.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_hostgroup(self): - return get_object_or_404( - get_provider_hostgroups(self.request.user, site_group=getattr(self.request, 'site_group', None)), - pk=self.kwargs['pk'] - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - hostgroup = self.get_hostgroup() - form = kwargs.get( - 'form', - HostGroupForm( - instance=hostgroup, - provider_user=self.request.user - ) - ) - context.update({ - 'form': form, - 'hostgroup': hostgroup, - 'page_title': f'编辑主机组 - {hostgroup.name}', - 'active_nav': 'hosts', - 'is_create': False, - }) - return context - - def post(self, request, *args, **kwargs): - hostgroup = self.get_hostgroup() - form = HostGroupForm( - request.POST, - instance=hostgroup, - provider_user=request.user - ) - if form.is_valid(): - hostgroup = form.save() - messages.success( - request, - f'主机组 {hostgroup.name} 更新成功' - ) - return redirect( - 'provider:provider_hosts:hostgroup_list' - ) - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(provider_required, name='dispatch') -class HostGroupDeleteView(ProviderContextMixin, TemplateView): - """ - 主机组删除视图 - - 显示确认页面,处理删除请求。 - """ - - template_name = 'admin_base/hosts/hostgroup_confirm_delete.html' - provider_url_namespace = 'provider:provider_hosts' - - def get_hostgroup(self): - return get_object_or_404( - get_provider_hostgroups(self.request.user, site_group=getattr(self.request, 'site_group', None)), - pk=self.kwargs['pk'] - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - hostgroup = self.get_hostgroup() - - context.update({ - 'hostgroup': hostgroup, - 'host_count': hostgroup.hosts.count(), - 'page_title': f'删除主机组 - {hostgroup.name}', - 'active_nav': 'hosts', - }) - return context - - def post(self, request, *args, **kwargs): - hostgroup = self.get_hostgroup() - hostgroup_name = hostgroup.name - hostgroup.delete() - - messages.success( - request, - f'主机组 {hostgroup_name} 已删除' - ) - return redirect('provider:provider_hosts:hostgroup_list') diff --git a/apps/operations/__init__.py b/apps/operations/__init__.py deleted file mode 100755 index 6723564..0000000 --- a/apps/operations/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -2c2a操作记录应用 -""" -default_app_config = 'apps.operations.apps.OperationsConfig' - -# 导入模型中的信号,以便可以从apps.operations直接导入 -try: - # 防止在Django应用初始化期间导入模型导致的AppRegistryNotReady错误 - from django.apps import apps - if apps.ready: - from .models import account_opening_request_pre_submit, account_opening_request_post_submit -except: - # 如果应用尚未准备好,不导出信号 - pass \ No newline at end of file diff --git a/apps/operations/admin.py b/apps/operations/admin.py deleted file mode 100755 index 9752161..0000000 --- a/apps/operations/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (apps.operations.views_admin) 和提供商后台 (apps.operations.views_provider) diff --git a/apps/operations/apps.py b/apps/operations/apps.py deleted file mode 100755 index 040f2ea..0000000 --- a/apps/operations/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -操作记录应用配置 -""" -from django.apps import AppConfig - - -class OperationsConfig(AppConfig): - """操作记录应用配置类""" - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.operations' - verbose_name = '操作记录' diff --git a/apps/operations/forms.py b/apps/operations/forms.py deleted file mode 100755 index 9bc4a2a..0000000 --- a/apps/operations/forms.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -操作记录表单 -""" - -import json -from django import forms -from django.utils.translation import gettext_lazy as _ - -from .models import SystemTask, AccountOpeningRequest, CloudComputerUser -from apps.hosts.models import Host - - -class SystemTaskFilterForm(forms.Form): - """ - 系统任务过滤表单 - """ - - task_type = forms.CharField( - required=False, - widget=forms.TextInput( - attrs={"class": "form-control", "placeholder": "任务类型"} - ), - label=_("任务类型"), - ) - - status = forms.ChoiceField( - required=False, - choices=[("", "全部")] + SystemTask._meta.get_field("status").choices, - widget=forms.Select(attrs={"class": "form-control"}), - label=_("状态"), - ) - - start_date = forms.DateField( - required=False, - widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}), - label=_("开始日期"), - ) - - end_date = forms.DateField( - required=False, - widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}), - label=_("结束日期"), - ) - - -class AccountOpeningRequestForm(forms.ModelForm): - """ - 用户开户申请表单 - """ - - # 重写username字段以符合新需求 - username = forms.CharField( - widget=forms.TextInput( - attrs={"class": "form-control", "placeholder": "请输入主机连接用户名"} - ), - label=_("主机连接用户名"), - help_text=_("将在云电脑主机上创建的连接用户名"), - ) - - # 重写user_fullname字段以符合新需求 - user_fullname = forms.CharField( - widget=forms.TextInput( - attrs={"class": "form-control", "placeholder": "请输入显示用户名"} - ), - label=_("主机显示用户名"), - help_text=_("用于在系统中显示的用户名"), - ) - - # 重写user_description字段以符合新需求 - user_description = forms.CharField( - widget=forms.Textarea( - attrs={"class": "form-control", "rows": 4, "placeholder": "请输入申请理由"} - ), - label=_("申请理由"), - help_text=_("请说明申请云电脑主机的用途和理由"), - ) - - target_product = forms.ModelChoiceField( - queryset=None, # 动态设置 - widget=forms.Select(attrs={"class": "form-control"}), - label=_("目标产品"), - help_text=_("请选择您要申请的产品"), - ) - - requested_disk_capacity = forms.CharField( - required=False, - widget=forms.HiddenInput(), - label=_("需求磁盘容量"), - help_text=_("额外申请的磁盘容量(MB)"), - ) - - class Meta: - model = AccountOpeningRequest - fields = [ - "username", - "user_fullname", - "user_description", - "target_product", - "requested_disk_capacity", - ] - - def __init__(self, *args, **kwargs): - # 从视图传入的产品查询集 - products_qs = kwargs.pop("products_qs", None) - site_group = kwargs.pop("site_group", None) - super().__init__(*args, **kwargs) - - if products_qs is not None: - self.fields["target_product"].queryset = products_qs - else: - # 默认按 site_group 过滤可用产品 - from .models import Product - - qs = Product.objects.filter(is_available=True) - if site_group: - qs = qs.filter(site_group=site_group) - else: - qs = qs.filter(site_group__isnull=True) - self.fields["target_product"].queryset = qs - - # 如果只有一个产品选项,将其设为初始值并隐藏 - if len(self.fields["target_product"].queryset) == 1: - target_product = self.fields["target_product"].queryset.first() - self.fields["target_product"].initial = target_product - # 将字段设为隐藏 - self.fields["target_product"].widget = forms.HiddenInput() - - def clean_requested_disk_capacity(self): - data = self.cleaned_data.get("requested_disk_capacity", "{}") - if not data: - return {} - if isinstance(data, dict): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError("磁盘容量格式无效") - if not isinstance(parsed, dict): - raise forms.ValidationError("磁盘容量必须为字典格式") - for disk, value in parsed.items(): - try: - val = int(value) - if val < 0: - raise forms.ValidationError(f"磁盘 {disk} 的容量不能为负数") - except (ValueError, TypeError): - raise forms.ValidationError(f"磁盘 {disk} 的容量必须为数字") - return parsed - - -class AccountOpeningRequestFilterForm(forms.Form): - """ - 开户申请过滤表单 - """ - - status = forms.ChoiceField( - required=False, - choices=[("", "全部")] - + AccountOpeningRequest._meta.get_field("status").choices, - widget=forms.Select(attrs={"class": "form-control"}), - label=_("状态"), - ) - - host = forms.ModelChoiceField( - required=False, - queryset=Host.objects.none(), - widget=forms.Select(attrs={"class": "form-control"}), - label=_("主机"), - ) - - search = forms.CharField( - required=False, - widget=forms.TextInput( - attrs={"class": "form-control", "placeholder": "搜索用户名、姓名或邮箱"} - ), - label=_("搜索"), - ) - - def __init__(self, *args, **kwargs): - site_group = kwargs.pop("site_group", None) - super().__init__(*args, **kwargs) - if site_group: - self.fields["host"].queryset = Host.objects.filter(site_group=site_group) - else: - self.fields["host"].queryset = Host.objects.filter(site_group__isnull=True) - - -class CloudComputerUserFilterForm(forms.Form): - """ - 云电脑用户过滤表单 - """ - - status = forms.ChoiceField( - required=False, - choices=[("", "全部")] + CloudComputerUser._meta.get_field("status").choices, - widget=forms.Select(attrs={"class": "form-control"}), - label=_("状态"), - ) - - product = forms.ModelChoiceField( - required=False, - queryset=None, - widget=forms.Select(attrs={"class": "form-control"}), - label=_("产品"), - ) - - search = forms.CharField( - required=False, - widget=forms.TextInput( - attrs={"class": "form-control", "placeholder": "搜索用户名、姓名或邮箱"} - ), - label=_("搜索"), - ) - - def __init__(self, *args, **kwargs): - site_group = kwargs.pop("site_group", None) - super().__init__(*args, **kwargs) - from .models import Product - - if site_group: - self.fields["product"].queryset = Product.objects.filter( - site_group=site_group - ) - else: - self.fields["product"].queryset = Product.objects.filter( - site_group__isnull=True - ) diff --git a/apps/operations/forms_admin.py b/apps/operations/forms_admin.py deleted file mode 100644 index 605b98e..0000000 --- a/apps/operations/forms_admin.py +++ /dev/null @@ -1,323 +0,0 @@ -""" -运营管理 - 超级管理员后台表单 - -包含产品、产品组、开户申请驳回等表单。 -超管可查看所有记录;站点组管理员仅可查看当前站点组记录; -提供商仅可查看自己相关的记录。 -""" - -import json -from django import forms -from django.utils.translation import gettext_lazy as _ - -from .models import Product, ProductGroup - - -# MD3 风格的通用 CSS 类 -_INPUT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface placeholder-md-outline ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition' -) -_SELECT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer' -) -_CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary' -) -_TEXTAREA_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface placeholder-md-outline ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition resize-y' -) - - -class AdminProductForm(forms.ModelForm): - """ - 超管产品管理表单 - - 与提供商表单类似,但不做数据隔离: - - 所有主机均可选择 - - 所有产品组均可选择 - """ - - default_disk_quota = forms.CharField( - label=_('默认磁盘配额'), - required=False, - widget=forms.Textarea(attrs={ - 'rows': 3, - 'placeholder': '{"C:": 10240, "D:": 20480}', - 'class': _TEXTAREA_CLASS + ' font-mono text-sm', - }), - help_text=_( - '每个磁盘的默认配额大小(MB),' - 'JSON 格式,如 {"C:": 10240, "D:": 20480}' - ), - ) - - allow_extra_quota_disks = forms.CharField( - label=_('允许额外申请容量的磁盘'), - required=False, - widget=forms.Textarea(attrs={ - 'rows': 2, - 'placeholder': '["C:", "D:"]', - 'class': _TEXTAREA_CLASS + ' font-mono text-sm', - }), - help_text=_( - '允许用户在申请时额外申请容量的磁盘列表,' - 'JSON 数组格式,如 ["C:", "D:"]' - ), - ) - - class Meta: - model = Product - fields = [ - 'display_name', 'display_description', - 'product_group', - 'host', 'is_available', 'auto_approval', 'visibility', - 'enable_host_protection', - 'display_hostname', 'rdp_port', - 'enable_disk_quota', - ] - widgets = { - 'display_name': forms.TextInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '输入产品显示名称', - }), - 'display_description': forms.Textarea(attrs={ - 'class': _TEXTAREA_CLASS, - 'rows': 3, - 'placeholder': '输入产品显示描述(可选)', - }), - 'product_group': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - 'host': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - 'is_available': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'auto_approval': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'visibility': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - 'enable_host_protection': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'display_hostname': forms.TextInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '输入显示地址', - }), - 'rdp_port': forms.NumberInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '3389', - }), - 'enable_disk_quota': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - } - labels = { - 'display_name': _('显示名称'), - 'display_description': _('显示描述'), - 'product_group': _('产品组'), - 'host': _('关联主机'), - 'is_available': _('是否可用'), - 'auto_approval': _('自动审核'), - 'visibility': _('可见性'), - 'enable_host_protection': _('启用主机保护(Gateway)'), - 'display_hostname': _('显示地址'), - 'rdp_port': _('RDP端口'), - 'enable_disk_quota': _('启用磁盘配额管理'), - } - help_texts = { - 'host': _('此产品运行所在的主机'), - 'is_available': _('是否在前端展示此产品'), - 'auto_approval': _('是否自动批准针对此产品的开户申请'), - 'visibility': _( - '公开对所有用户可见,' - '邀请访问仅对已授权用户可见' - ), - 'enable_host_protection': _( - '启用后,用户只能通过Gateway隧道访问RDP,' - '主机不暴露公网IP。需部署Gateway服务。' - ), - 'enable_disk_quota': _( - '是否启用磁盘配额管理,' - '启用后将自动为新用户设置磁盘配额' - ), - } - - def __init__(self, *args, user=None, site_group=None, **kwargs): - super().__init__(*args, **kwargs) - - from apps.hosts.models import Host - from utils.provider import get_provider_hosts - - if user and user.is_superuser: - host_qs = Host.objects.all() - pg_qs = ProductGroup.objects.all() - elif user and site_group and user.is_site_group_admin(site_group): - host_qs = Host.objects.filter(site_group=site_group) - pg_qs = ProductGroup.objects.filter(site_group=site_group) - elif user: - host_qs = get_provider_hosts(user, site_group=site_group) - pg_qs = ProductGroup.objects.filter(created_by=user) - else: - host_qs = Host.objects.all() - pg_qs = ProductGroup.objects.all() - - self.fields['host'].queryset = host_qs.order_by('name') - self.fields['product_group'].queryset = pg_qs.order_by('name') - - # 初始化 JSON 字段的显示值 - if self.instance and self.instance.pk: - if self.instance.default_disk_quota: - self.initial['default_disk_quota'] = json.dumps( - self.instance.default_disk_quota, - ensure_ascii=False, - indent=2, - ) - if self.instance.allow_extra_quota_disks: - self.initial['allow_extra_quota_disks'] = json.dumps( - self.instance.allow_extra_quota_disks, - ensure_ascii=False, - ) - - def clean_default_disk_quota(self): - data = self.cleaned_data.get('default_disk_quota', '') - if not data or not data.strip(): - return {} - if isinstance(data, dict): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('磁盘配额格式无效,请输入有效的 JSON') - if not isinstance(parsed, dict): - raise forms.ValidationError( - '磁盘配额必须为字典格式,如 {"C:": 10240}' - ) - for disk, value in parsed.items(): - try: - val = int(value) - if val < 0: - raise forms.ValidationError( - f'磁盘 {disk} 的配额不能为负数' - ) - except (ValueError, TypeError): - raise forms.ValidationError( - f'磁盘 {disk} 的配额必须为数字' - ) - return parsed - - def clean_allow_extra_quota_disks(self): - data = self.cleaned_data.get('allow_extra_quota_disks', '') - if not data or not data.strip(): - return [] - if isinstance(data, list): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError( - '磁盘列表格式无效,请输入有效的 JSON 数组' - ) - if not isinstance(parsed, list): - raise forms.ValidationError( - '磁盘列表必须为数组格式,如 ["C:", "D:"]' - ) - return parsed - - def save(self, commit=True): - instance = super().save(commit=False) - instance.default_disk_quota = self.cleaned_data.get( - 'default_disk_quota', {} - ) - instance.allow_extra_quota_disks = self.cleaned_data.get( - 'allow_extra_quota_disks', [] - ) - if commit: - instance.save() - return instance - - -class AdminProductGroupForm(forms.ModelForm): - """ - 超管产品组管理表单 - - 包含所有字段,不做数据隔离。 - """ - - class Meta: - model = ProductGroup - fields = [ - 'name', 'description', - 'display_order', 'is_active', 'visibility', - ] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '输入产品组名称', - }), - 'description': forms.Textarea(attrs={ - 'class': _TEXTAREA_CLASS, - 'rows': 3, - 'placeholder': '输入产品组描述(可选)', - }), - 'display_order': forms.NumberInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '0', - 'min': '0', - }), - 'is_active': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'visibility': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - } - labels = { - 'name': _('产品组名称'), - 'description': _('描述'), - 'display_order': _('显示顺序'), - 'is_active': _('是否启用'), - 'visibility': _('可见性'), - } - help_texts = { - 'display_order': _( - '产品组在前端展示的顺序,数字越小越靠前' - ), - 'is_active': _('是否在前端展示此产品组'), - 'visibility': _( - '公开对所有用户可见,' - '邀请访问仅对已授权用户可见' - ), - } - - -class AdminRequestRejectForm(forms.Form): - """ - 超管驳回开户申请表单 - - 用于输入驳回原因 - """ - - rejection_reason = forms.CharField( - label='驳回原因', - required=True, - widget=forms.Textarea(attrs={ - 'rows': 4, - 'placeholder': '请输入驳回原因...', - 'class': _TEXTAREA_CLASS, - }), - help_text='驳回原因将作为审核备注发送给申请人', - ) diff --git a/apps/operations/forms_provider.py b/apps/operations/forms_provider.py deleted file mode 100644 index 0e62245..0000000 --- a/apps/operations/forms_provider.py +++ /dev/null @@ -1,480 +0,0 @@ -""" -运营管理 - 提供商后台表单 - -包含开户申请、云电脑用户管理、邀请令牌、产品、产品组相关的表单。 -""" - -import json -from django import forms -from django.utils.translation import gettext_lazy as _ - -from .models import Product, ProductGroup, ProductInvitationToken - - -class AccountOpeningRequestRejectForm(forms.Form): - """ - 驳回开户申请表单 - - 用于输入驳回原因 - """ - - rejection_reason = forms.CharField( - label='驳回原因', - required=True, - widget=forms.Textarea(attrs={ - 'rows': 4, - 'placeholder': '请输入驳回原因...', - 'class': 'w-full px-4 py-3 bg-md-surface-container-high/50 border border-white/10 ' - 'rounded-md text-md-on-surface placeholder-md-on-surface-variant/50 ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary focus:border-transparent ' - 'transition resize-none', - }), - help_text='驳回原因将作为审核备注发送给申请人', - ) - - -class CloudComputerUserDiskQuotaForm(forms.Form): - """ - 磁盘配额设置表单 - - 用于设置用户的磁盘配额(MB),格式为 {"C:": 10240, "D:": 20480} - """ - - disk_quota = forms.CharField( - label=_('磁盘配额'), - help_text=_('JSON 格式,例如: {"C:": 10240, "D:": 20480}'), - widget=forms.Textarea(attrs={ - 'rows': 4, - 'placeholder': '{"C:": 10240, "D:": 20480}', - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition resize-y font-mono text-sm', - }), - ) - - def clean_disk_quota(self): - data = self.cleaned_data.get('disk_quota', '{}') - if not data or not data.strip(): - return {} - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('磁盘配额格式无效,请输入有效的 JSON 格式') - if not isinstance(parsed, dict): - raise forms.ValidationError('磁盘配额必须为字典格式,例如: {"C:": 10240}') - for disk, value in parsed.items(): - try: - val = int(value) - if val < 0: - raise forms.ValidationError(f'磁盘 {disk} 的配额不能为负数') - except (ValueError, TypeError): - raise forms.ValidationError(f'磁盘 {disk} 的配额必须为数字') - return parsed - - -class CloudComputerUserResetPasswordForm(forms.Form): - """ - 重置密码表单 - - 用于重置云电脑用户的 Windows 密码 - """ - - new_password = forms.CharField( - label=_('新密码'), - help_text=_('设置用户的新密码,建议使用复杂密码'), - min_length=8, - max_length=128, - widget=forms.PasswordInput(attrs={ - 'autocomplete': 'new-password', - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '请输入新密码', - }), - ) - - confirm_password = forms.CharField( - label=_('确认密码'), - help_text=_('再次输入新密码以确认'), - widget=forms.PasswordInput(attrs={ - 'autocomplete': 'new-password', - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '请再次输入新密码', - }), - ) - - def clean(self): - cleaned_data = super().clean() - new_password = cleaned_data.get('new_password') - confirm_password = cleaned_data.get('confirm_password') - - if new_password and confirm_password and new_password != confirm_password: - raise forms.ValidationError('两次输入的密码不一致') - - return cleaned_data - - -class ProductInvitationTokenForm(forms.ModelForm): - """ - 产品邀请令牌创建表单 - - 提供商只能选择自己创建的产品/产品组 - """ - - class Meta: - model = ProductInvitationToken - fields = ['product', 'product_group', 'max_uses', 'expires_at', 'is_active'] - widgets = { - 'product': forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface appearance-none focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition cursor-pointer', - }), - 'product_group': forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface appearance-none focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition cursor-pointer', - }), - 'max_uses': forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition', - 'placeholder': '0 表示无限制', - 'min': '0', - }), - 'expires_at': forms.DateTimeInput(attrs={ - 'type': 'datetime-local', - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition', - }), - 'is_active': forms.CheckboxInput(attrs={ - 'class': 'rounded border-md-outline bg-md-surface-container-high text-md-primary ' - 'focus:ring-md-primary focus:ring-2 cursor-pointer', - }), - } - labels = { - 'product': _('关联产品'), - 'product_group': _('关联产品组'), - 'max_uses': _('最大使用次数'), - 'expires_at': _('过期时间'), - 'is_active': _('是否启用'), - } - help_texts = { - 'product': _('此令牌关联的产品(与产品组至少选一个)'), - 'product_group': _('此令牌关联的产品组(与产品至少选一个)'), - 'max_uses': _('令牌最大可使用次数,0表示无限制'), - 'expires_at': _('令牌过期时间,留空表示永不过期'), - } - - def __init__(self, *args, **kwargs): - self.provider_user = kwargs.pop('provider_user', None) - super().__init__(*args, **kwargs) - - if self.provider_user: - from apps.operations.models import Product, ProductGroup - self.fields['product'].queryset = Product.objects.filter( - created_by=self.provider_user - ) - self.fields['product_group'].queryset = ProductGroup.objects.filter( - created_by=self.provider_user - ) - - def clean(self): - cleaned_data = super().clean() - product = cleaned_data.get('product') - product_group = cleaned_data.get('product_group') - if not product and not product_group: - raise forms.ValidationError( - _('关联产品和关联产品组至少需要选择一个。') - ) - return cleaned_data - - -# ========== 产品管理表单 ========== - - -# MD3 风格的通用 CSS 类 -_INPUT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface placeholder-md-outline ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary transition' -) -_SELECT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer' -) -_CHECKBOX_CLASS = ( - 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary' -) -_TEXTAREA_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md ' - 'px-4 py-3 text-md-on-surface placeholder-md-outline ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition resize-y' -) - - -class ProductForm(forms.ModelForm): - """ - 产品管理表单 - - 复用 Admin 中 ProductAdminForm 的逻辑,使用 MD3 风格的 Widget。 - 包含磁盘配额配置和主机保护开关。 - 提供商只能选择自己可见的主机。 - """ - - default_disk_quota = forms.CharField( - label=_('默认磁盘配额'), - required=False, - widget=forms.Textarea(attrs={ - 'rows': 3, - 'placeholder': '{"C:": 10240, "D:": 20480}', - 'class': _TEXTAREA_CLASS + ' font-mono text-sm', - }), - help_text=_( - '每个磁盘的默认配额大小(MB),' - 'JSON 格式,如 {"C:": 10240, "D:": 20480}' - ), - ) - - allow_extra_quota_disks = forms.CharField( - label=_('允许额外申请容量的磁盘'), - required=False, - widget=forms.Textarea(attrs={ - 'rows': 2, - 'placeholder': '["C:", "D:"]', - 'class': _TEXTAREA_CLASS + ' font-mono text-sm', - }), - help_text=_( - '允许用户在申请时额外申请容量的磁盘列表,' - 'JSON 数组格式,如 ["C:", "D:"]' - ), - ) - - class Meta: - model = Product - fields = [ - 'display_name', 'display_description', - 'product_group', - 'host', 'is_available', 'auto_approval', 'visibility', - 'enable_host_protection', - 'display_hostname', 'rdp_port', - 'enable_disk_quota', - ] - widgets = { - 'display_name': forms.TextInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '输入产品显示名称', - }), - 'display_description': forms.Textarea(attrs={ - 'class': _TEXTAREA_CLASS, - 'rows': 3, - 'placeholder': '输入产品显示描述(可选)', - }), - 'product_group': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - 'host': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - 'is_available': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'auto_approval': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'visibility': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - 'enable_host_protection': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'display_hostname': forms.TextInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '输入显示地址', - }), - 'rdp_port': forms.NumberInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '3389', - }), - 'enable_disk_quota': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - } - labels = { - 'display_name': _('显示名称'), - 'display_description': _('显示描述'), - 'product_group': _('产品组'), - 'host': _('关联主机'), - 'is_available': _('是否可用'), - 'auto_approval': _('自动审核'), - 'visibility': _('可见性'), - 'enable_host_protection': _('启用主机保护(Gateway)'), - 'display_hostname': _('显示地址'), - 'rdp_port': _('RDP端口'), - 'enable_disk_quota': _('启用磁盘配额管理'), - } - help_texts = { - 'host': _('此产品运行所在的主机'), - 'is_available': _('是否在前端展示此产品'), - 'auto_approval': _('是否自动批准针对此产品的开户申请'), - 'visibility': _( - '公开对所有用户可见,' - '邀请访问仅对已授权用户可见' - ), - 'enable_host_protection': _( - '启用后,用户只能通过Gateway隧道访问RDP,' - '主机不暴露公网IP。需部署Gateway服务。' - ), - 'enable_disk_quota': _( - '是否启用磁盘配额管理,' - '启用后将自动为新用户设置磁盘配额' - ), - } - - def __init__(self, *args, **kwargs): - self.provider_user = kwargs.pop('provider_user', None) - super().__init__(*args, **kwargs) - - # 提供商只能选择自己可见的主机 - if self.provider_user: - from utils.provider import get_provider_hosts - self.fields['host'].queryset = get_provider_hosts( - self.provider_user - ).order_by('name') - - # 提供商只能选择自己创建的产品组 - self.fields['product_group'].queryset = ( - ProductGroup.objects.filter( - created_by=self.provider_user - ).order_by('name') - ) - - # 初始化 JSON 字段的显示值 - if self.instance and self.instance.pk: - if self.instance.default_disk_quota: - self.initial['default_disk_quota'] = json.dumps( - self.instance.default_disk_quota, - ensure_ascii=False, - indent=2, - ) - if self.instance.allow_extra_quota_disks: - self.initial['allow_extra_quota_disks'] = json.dumps( - self.instance.allow_extra_quota_disks, - ensure_ascii=False, - ) - - def clean_default_disk_quota(self): - data = self.cleaned_data.get('default_disk_quota', '') - if not data or not data.strip(): - return {} - if isinstance(data, dict): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('磁盘配额格式无效,请输入有效的 JSON') - if not isinstance(parsed, dict): - raise forms.ValidationError( - '磁盘配额必须为字典格式,如 {"C:": 10240}' - ) - for disk, value in parsed.items(): - try: - val = int(value) - if val < 0: - raise forms.ValidationError( - f'磁盘 {disk} 的配额不能为负数' - ) - except (ValueError, TypeError): - raise forms.ValidationError( - f'磁盘 {disk} 的配额必须为数字' - ) - return parsed - - def clean_allow_extra_quota_disks(self): - data = self.cleaned_data.get('allow_extra_quota_disks', '') - if not data or not data.strip(): - return [] - if isinstance(data, list): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError( - '磁盘列表格式无效,请输入有效的 JSON 数组' - ) - if not isinstance(parsed, list): - raise forms.ValidationError( - '磁盘列表必须为数组格式,如 ["C:", "D:"]' - ) - return parsed - - def save(self, commit=True): - instance = super().save(commit=False) - instance.default_disk_quota = self.cleaned_data.get( - 'default_disk_quota', {} - ) - instance.allow_extra_quota_disks = self.cleaned_data.get( - 'allow_extra_quota_disks', [] - ) - if commit: - instance.save() - return instance - - -# ========== 产品组管理表单 ========== - - -class ProductGroupForm(forms.ModelForm): - """ - 产品组管理表单 - - 提供商创建产品组时自动设置 created_by。 - """ - - class Meta: - model = ProductGroup - fields = [ - 'name', 'description', - 'display_order', 'is_active', 'visibility', - ] - widgets = { - 'name': forms.TextInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '输入产品组名称', - }), - 'description': forms.Textarea(attrs={ - 'class': _TEXTAREA_CLASS, - 'rows': 3, - 'placeholder': '输入产品组描述(可选)', - }), - 'display_order': forms.NumberInput(attrs={ - 'class': _INPUT_CLASS, - 'placeholder': '0', - 'min': '0', - }), - 'is_active': forms.CheckboxInput(attrs={ - 'class': _CHECKBOX_CLASS, - }), - 'visibility': forms.Select(attrs={ - 'class': _SELECT_CLASS, - }), - } - labels = { - 'name': _('产品组名称'), - 'description': _('描述'), - 'display_order': _('显示顺序'), - 'is_active': _('是否启用'), - 'visibility': _('可见性'), - } - help_texts = { - 'display_order': _( - '产品组在前端展示的顺序,数字越小越靠前' - ), - 'is_active': _('是否在前端展示此产品组'), - 'visibility': _( - '公开对所有用户可见,' - '邀请访问仅对已授权用户可见' - ), - } diff --git a/apps/operations/forms_wizard.py b/apps/operations/forms_wizard.py deleted file mode 100644 index f5f9fe5..0000000 --- a/apps/operations/forms_wizard.py +++ /dev/null @@ -1,303 +0,0 @@ -""" -运营管理 - 产品向导式创建表单 - -分步引导超管创建产品,提供智能默认值和逐步验证。 -与 AdminProductForm 不同,此表单专注于创建流程的简化和引导。 -""" - -import json - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from .models import Product, ProductGroup - - -_INPUT_CLASS = ( - "w-full bg-white/5 backdrop-blur-xl border border-white/10 rounded-md " - "px-4 py-3 text-white placeholder-slate-500 " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 focus:border-cyan-500 transition" -) -_SELECT_CLASS = ( - "w-full bg-white/5 backdrop-blur-xl border border-white/10 rounded-md " - "px-4 py-3 text-white appearance-none " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 focus:border-cyan-500 " - "transition cursor-pointer" -) -_CHECKBOX_CLASS = ( - "w-5 h-5 rounded border-slate-700/50 bg-slate-900/50 " - "text-cyan-400 focus:ring-cyan-500 focus:ring-2 transition cursor-pointer accent-md-primary" -) -_TEXTAREA_CLASS = ( - "w-full bg-white/5 backdrop-blur-xl border border-white/10 rounded-md " - "px-4 py-3 text-white placeholder-slate-500 " - "focus:outline-none focus:ring-1 focus:ring-cyan-500/50 focus:border-cyan-500 " - "transition resize-y" -) - - -class ProductWizardForm(forms.ModelForm): - """ - 产品创建向导表单 - - 分为三步: - - Step 1: 基本信息 (display_name, display_description, product_group) - - Step 2: 主机关联与配置 (host, display_hostname, rdp_port, visibility, is_available, auto_approval) - - Step 3: 高级设置 (enable_host_protection, enable_disk_quota, default_disk_quota, allow_extra_quota_disks) - - 智能默认值: - - 选择主机后自动填充 display_hostname 和 rdp_port - - 可见性默认为公开 - """ - - default_disk_quota = forms.CharField( - label=_("默认磁盘配额"), - required=False, - widget=forms.Textarea( - attrs={ - "rows": 3, - "placeholder": '{"C:": 10240, "D:": 20480}', - "class": _TEXTAREA_CLASS + " font-mono text-sm", - "x-model": "defaultDiskQuota", - } - ), - help_text=_( - "每个磁盘的默认配额大小(MB)," 'JSON 格式,如 {"C:": 10240, "D:": 20480}' - ), - ) - - allow_extra_quota_disks = forms.CharField( - label=_("允许额外申请容量的磁盘"), - required=False, - widget=forms.Textarea( - attrs={ - "rows": 2, - "placeholder": '["C:", "D:"]', - "class": _TEXTAREA_CLASS + " font-mono text-sm", - "x-model": "allowExtraQuotaDisks", - } - ), - help_text=_( - "允许用户在申请时额外申请容量的磁盘列表," 'JSON 数组格式,如 ["C:", "D:"]' - ), - ) - - class Meta: - model = Product - fields = [ - "display_name", - "display_description", - "product_group", - "host", - "is_available", - "auto_approval", - "visibility", - "enable_host_protection", - "display_hostname", - "rdp_port", - "enable_disk_quota", - "limit_one_per_user", - ] - widgets = { - "display_name": forms.TextInput( - attrs={ - "class": _INPUT_CLASS, - "placeholder": "输入产品显示名称", - "x-model": "displayName", - "required": "", - } - ), - "display_description": forms.Textarea( - attrs={ - "class": _TEXTAREA_CLASS, - "rows": 3, - "placeholder": "输入产品显示描述(可选)", - "x-model": "displayDescription", - } - ), - "product_group": forms.Select( - attrs={ - "class": _SELECT_CLASS, - "x-model": "productGroup", - } - ), - "host": forms.Select( - attrs={ - "class": _SELECT_CLASS, - "x-model": "hostId", - "x-on:change": "onHostChange()", - } - ), - "is_available": forms.CheckboxInput( - attrs={ - "class": _CHECKBOX_CLASS, - "x-model": "isAvailable", - } - ), - "auto_approval": forms.CheckboxInput( - attrs={ - "class": _CHECKBOX_CLASS, - "x-model": "autoApproval", - } - ), - "visibility": forms.Select( - attrs={ - "class": _SELECT_CLASS, - "x-model": "visibility", - } - ), - "enable_host_protection": forms.CheckboxInput( - attrs={ - "class": _CHECKBOX_CLASS, - "x-model": "enableHostProtection", - } - ), - "display_hostname": forms.TextInput( - attrs={ - "class": _INPUT_CLASS, - "placeholder": "输入显示地址", - "x-model": "displayHostname", - "required": "", - } - ), - "rdp_port": forms.NumberInput( - attrs={ - "class": _INPUT_CLASS, - "placeholder": "3389", - "x-model.number": "rdpPort", - } - ), - "enable_disk_quota": forms.CheckboxInput( - attrs={ - "class": _CHECKBOX_CLASS, - "x-model": "enableDiskQuota", - } - ), - "limit_one_per_user": forms.CheckboxInput( - attrs={ - "class": _CHECKBOX_CLASS, - "x-model": "limitOnePerUser", - } - ), - } - labels = { - "display_name": _("显示名称"), - "display_description": _("显示描述"), - "product_group": _("产品组"), - "host": _("关联主机"), - "is_available": _("是否可用"), - "auto_approval": _("自动审核"), - "visibility": _("可见性"), - "enable_host_protection": _("启用主机保护(Gateway)"), - "display_hostname": _("显示地址"), - "rdp_port": _("RDP端口"), - "enable_disk_quota": _("启用磁盘配额管理"), - "limit_one_per_user": _("每人限购一个"), - } - help_texts = { - "host": _("此产品运行所在的主机"), - "is_available": _("是否在前端展示此产品"), - "auto_approval": _("是否自动批准针对此产品的开户申请"), - "visibility": _("公开对所有用户可见," "邀请访问仅对已授权用户可见"), - "enable_host_protection": _( - "启用后,用户只能通过Gateway隧道访问RDP," - "主机不暴露公网IP。需部署Gateway服务。" - ), - "enable_disk_quota": _( - "是否启用磁盘配额管理," "启用后将自动为新用户设置磁盘配额" - ), - "limit_one_per_user": _("启用后,每个用户只能拥有一个此产品"), - } - - def __init__(self, *args, user=None, site_group=None, **kwargs): - super().__init__(*args, **kwargs) - - from apps.hosts.models import Host - from utils.provider import get_provider_hosts - from django.db.models import Q - - if user and user.is_superuser: - host_qs = Host.objects.all() - pg_qs = ProductGroup.objects.all() - elif user and site_group and user.is_site_group_admin(site_group): - host_qs = Host.objects.filter(site_group=site_group) - pg_qs = ProductGroup.objects.filter(site_group=site_group) - elif user: - host_qs = get_provider_hosts(user, site_group=site_group) - pg_qs = ProductGroup.objects.filter(created_by=user) - else: - host_qs = Host.objects.all() - pg_qs = ProductGroup.objects.all() - - self.fields["host"].queryset = host_qs.order_by("name") - self.fields["product_group"].queryset = pg_qs.order_by("name") - - if not self.initial.get("rdp_port"): - self.initial["rdp_port"] = 3389 - if not self.initial.get("visibility"): - self.initial["visibility"] = "public" - - def get_hosts_info(self): - """ - 返回主机列表信息,用于向导第二步的智能填充。 - 包含主机ID、名称、地址、RDP端口等。 - """ - hosts = self.fields["host"].queryset - result = [] - for host in hosts: - result.append( - { - "id": host.pk, - "name": host.name, - "hostname": host.hostname, - "rdp_port": host.rdp_port or 3389, - "connection_type": host.connection_type, - "status": host.status, - } - ) - return result - - def clean_default_disk_quota(self): - data = self.cleaned_data.get("default_disk_quota", "") - if not data or not data.strip(): - return {} - if isinstance(data, dict): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError("磁盘配额格式无效,请输入有效的 JSON") - if not isinstance(parsed, dict): - raise forms.ValidationError('磁盘配额必须为字典格式,如 {"C:": 10240}') - for disk, value in parsed.items(): - try: - val = int(value) - if val < 0: - raise forms.ValidationError(f"磁盘 {disk} 的配额不能为负数") - except (ValueError, TypeError): - raise forms.ValidationError(f"磁盘 {disk} 的配额必须为数字") - return parsed - - def clean_allow_extra_quota_disks(self): - data = self.cleaned_data.get("allow_extra_quota_disks", "") - if not data or not data.strip(): - return [] - if isinstance(data, list): - return data - try: - parsed = json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError("磁盘列表格式无效,请输入有效的 JSON 数组") - if not isinstance(parsed, list): - raise forms.ValidationError('磁盘列表必须为数组格式,如 ["C:", "D:"]') - return parsed - - def save(self, commit=True): - instance = super().save(commit=False) - instance.default_disk_quota = self.cleaned_data.get("default_disk_quota", {}) - instance.allow_extra_quota_disks = self.cleaned_data.get( - "allow_extra_quota_disks", [] - ) - if commit: - instance.save() - return instance diff --git a/apps/operations/management/__init__.py b/apps/operations/management/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/operations/management/commands/__init__.py b/apps/operations/management/commands/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/operations/management/commands/create_public_host_info.py b/apps/operations/management/commands/create_public_host_info.py deleted file mode 100755 index 66f83e2..0000000 --- a/apps/operations/management/commands/create_public_host_info.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.core.management.base import BaseCommand -from apps.hosts.models import Host -from apps.operations.models import PublicHostInfo - - -class Command(BaseCommand): - help = '为现有主机创建公开主机信息' - - def add_arguments(self, parser): - parser.add_argument( - '--force', - action='store_true', - help='强制为所有主机创建公开信息,即使已存在', - ) - - def handle(self, *args, **options): - force = options['force'] - hosts = Host.objects.all() - created_count = 0 - skipped_count = 0 - - for host in hosts: - # 检查是否已存在对应的 PublicHostInfo - if not force and PublicHostInfo.objects.filter(internal_host=host).exists(): - self.stdout.write( - self.style.WARNING(f'Skipping {host.name} - PublicHostInfo already exists') - ) - skipped_count += 1 - continue - - # 创建 PublicHostInfo - public_info, created = PublicHostInfo.objects.get_or_create( - internal_host=host, - defaults={ - 'display_name': host.name, - 'display_description': host.description, - 'display_hostname': host.hostname, - 'display_rdp_port': host.rdp_port, - 'is_available': True, - } - ) - - if created: - created_count += 1 - self.stdout.write( - self.style.SUCCESS(f'Created PublicHostInfo for {host.name}') - ) - else: - # 如果记录已存在但在强制模式下,更新它 - if force: - public_info.display_name = host.name - public_info.display_description = host.description - public_info.display_hostname = host.hostname - public_info.display_rdp_port = host.rdp_port - public_info.is_available = True - public_info.save() - self.stdout.write( - self.style.WARNING(f'Updated PublicHostInfo for {host.name}') - ) - - self.stdout.write( - self.style.NOTICE( - f'Completed! Created: {created_count}, Skipped: {skipped_count}' - ) - ) \ No newline at end of file diff --git a/apps/operations/migrations/0001_initial.py b/apps/operations/migrations/0001_initial.py deleted file mode 100755 index e51756e..0000000 --- a/apps/operations/migrations/0001_initial.py +++ /dev/null @@ -1,164 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 03:24 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('hosts', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='AccountOpeningRequest', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('contact_email', models.EmailField(help_text='申请人联系方式', max_length=254, verbose_name='联系邮箱')), - ('contact_phone', models.CharField(blank=True, help_text='申请人联系电话', max_length=20, null=True, verbose_name='联系电话')), - ('username', models.CharField(help_text='希望在云电脑上创建的用户名', max_length=150, verbose_name='用户名')), - ('user_fullname', models.CharField(help_text='用户真实姓名', max_length=200, verbose_name='用户姓名')), - ('user_email', models.EmailField(help_text='用户邮箱地址', max_length=254, verbose_name='用户邮箱')), - ('user_description', models.TextField(blank=True, help_text='关于该用户的附加信息', verbose_name='用户描述')), - ('requested_password', models.CharField(blank=True, help_text='用户希望设置的初始密码,留空则系统生成', max_length=128, null=True, verbose_name='用户指定密码')), - ('status', models.CharField(choices=[('pending', '待审核'), ('approved', '已批准'), ('rejected', '已拒绝'), ('processing', '处理中'), ('completed', '已完成'), ('failed', '失败')], default='pending', help_text='开户申请的当前状态', max_length=20, verbose_name='申请状态')), - ('approval_date', models.DateTimeField(blank=True, help_text='申请被审核的时间', null=True, verbose_name='审核时间')), - ('approval_notes', models.TextField(blank=True, help_text='审核时的备注信息', verbose_name='审核备注')), - ('cloud_user_id', models.CharField(blank=True, help_text='在云电脑上实际创建的用户ID', max_length=255, verbose_name='云电脑用户ID')), - ('cloud_user_password', models.CharField(blank=True, help_text='为用户设置的初始密码', max_length=255, verbose_name='云电脑用户密码')), - ('result_message', models.TextField(blank=True, help_text='开户操作的结果信息', verbose_name='结果信息')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='申请创建时间', verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, help_text='申请信息最后更新时间', verbose_name='更新时间')), - ('applicant', models.ForeignKey(help_text='提交开户申请的用户', on_delete=django.db.models.deletion.CASCADE, related_name='account_opening_requests', to=settings.AUTH_USER_MODEL, verbose_name='申请人')), - ('approved_by', models.ForeignKey(blank=True, help_text='批准此申请的管理员', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='approved_account_requests', to=settings.AUTH_USER_MODEL, verbose_name='审核人')), - ], - options={ - 'verbose_name': '开户申请', - 'verbose_name_plural': '开户申请', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='Product', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='面向用户展示的产品名称', max_length=200, verbose_name='产品名称')), - ('description', models.TextField(blank=True, help_text='产品的详细描述,支持Markdown格式', verbose_name='产品描述')), - ('display_name', models.CharField(help_text='在前端展示的产品名称', max_length=200, verbose_name='显示名称')), - ('display_description', models.TextField(blank=True, help_text='在前端展示的产品描述,支持Markdown格式', verbose_name='显示描述')), - ('rdp_port', models.IntegerField(default=3389, help_text='用户连接时使用的RDP端口', verbose_name='RDP端口')), - ('display_hostname', models.CharField(help_text='在前端展示的产品访问地址', max_length=255, verbose_name='显示地址')), - ('is_available', models.BooleanField(default=True, help_text='是否在前端展示此产品', verbose_name='是否可用')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('host', models.ForeignKey(help_text='此产品运行所在的主机', on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='关联主机')), - ], - options={ - 'verbose_name': '产品', - 'verbose_name_plural': '产品', - }, - ), - migrations.CreateModel( - name='CloudComputerUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('username', models.CharField(help_text='在云电脑上的用户名', max_length=150, verbose_name='用户名')), - ('fullname', models.CharField(help_text='用户真实姓名', max_length=200, verbose_name='用户姓名')), - ('email', models.EmailField(help_text='用户邮箱地址', max_length=254, verbose_name='用户邮箱')), - ('description', models.TextField(blank=True, help_text='关于该用户的附加信息', verbose_name='用户描述')), - ('status', models.CharField(choices=[('active', '激活'), ('inactive', '未激活'), ('disabled', '已禁用'), ('deleted', '已删除')], default='active', help_text='用户在云电脑上的状态', max_length=20, verbose_name='用户状态')), - ('is_admin', models.BooleanField(default=False, help_text='是否具有管理员权限', verbose_name='管理员权限')), - ('groups', models.TextField(blank=True, help_text='用户所属的组(逗号分隔)', verbose_name='用户组')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='用户在云电脑上创建的时间', verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, help_text='信息最后更新时间', verbose_name='更新时间')), - ('created_from_request', models.ForeignKey(blank=True, help_text='创建此用户的开户申请', null=True, on_delete=django.db.models.deletion.SET_NULL, to='operations.accountopeningrequest', verbose_name='来源申请')), - ('product', models.ForeignKey(help_text='该用户所属的云电脑产品', on_delete=django.db.models.deletion.CASCADE, to='operations.product', verbose_name='所属产品')), - ], - options={ - 'verbose_name': '云电脑用户', - 'verbose_name_plural': '云电脑用户', - 'ordering': ['-created_at'], - }, - ), - migrations.AddField( - model_name='accountopeningrequest', - name='target_product', - field=models.ForeignKey(blank=True, help_text='要在哪个产品上创建用户', null=True, on_delete=django.db.models.deletion.CASCADE, to='operations.product', verbose_name='目标产品'), - ), - migrations.CreateModel( - name='SystemTask', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='任务的名称', max_length=200, verbose_name='任务名称')), - ('task_type', models.CharField(help_text='任务的类型,如batch_create_user等', max_length=100, verbose_name='任务类型')), - ('description', models.TextField(blank=True, help_text='任务的详细描述', null=True, verbose_name='任务描述')), - ('status', models.CharField(choices=[('pending', '等待中'), ('running', '执行中'), ('success', '成功'), ('failed', '失败'), ('cancelled', '已取消')], default='pending', help_text='任务的执行状态', max_length=20, verbose_name='任务状态')), - ('progress', models.IntegerField(default=0, help_text='任务执行进度,0-100', verbose_name='执行进度')), - ('result', models.TextField(blank=True, help_text='任务执行的结果信息', null=True, verbose_name='执行结果')), - ('error_message', models.TextField(blank=True, help_text='任务执行失败时的错误信息', null=True, verbose_name='错误信息')), - ('created_at', models.DateTimeField(auto_now_add=True, help_text='任务创建时间', verbose_name='创建时间')), - ('started_at', models.DateTimeField(blank=True, help_text='任务开始执行的时间', null=True, verbose_name='开始时间')), - ('completed_at', models.DateTimeField(blank=True, help_text='任务完成的时间', null=True, verbose_name='完成时间')), - ('created_by', models.ForeignKey(blank=True, help_text='创建该任务的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tasks', to=settings.AUTH_USER_MODEL, verbose_name='创建者')), - ], - options={ - 'verbose_name': '系统任务', - 'verbose_name_plural': '系统任务', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['status'], name='operations__status_4c55d9_idx'), models.Index(fields=['task_type'], name='operations__task_ty_240239_idx'), models.Index(fields=['created_at'], name='operations__created_b8e18a_idx')], - }, - ), - migrations.AddIndex( - model_name='product', - index=models.Index(fields=['is_available'], name='operations__is_avai_fe927c_idx'), - ), - migrations.AddIndex( - model_name='product', - index=models.Index(fields=['host'], name='operations__host_id_e9faab_idx'), - ), - migrations.AddIndex( - model_name='product', - index=models.Index(fields=['created_at'], name='operations__created_8529c5_idx'), - ), - migrations.AddIndex( - model_name='cloudcomputeruser', - index=models.Index(fields=['product'], name='operations__product_684d9f_idx'), - ), - migrations.AddIndex( - model_name='cloudcomputeruser', - index=models.Index(fields=['username'], name='operations__usernam_fe9d86_idx'), - ), - migrations.AddIndex( - model_name='cloudcomputeruser', - index=models.Index(fields=['status'], name='operations__status_27a9ba_idx'), - ), - migrations.AddIndex( - model_name='cloudcomputeruser', - index=models.Index(fields=['created_at'], name='operations__created_98027d_idx'), - ), - migrations.AlterUniqueTogether( - name='cloudcomputeruser', - unique_together={('product', 'username')}, - ), - migrations.AddIndex( - model_name='accountopeningrequest', - index=models.Index(fields=['applicant'], name='operations__applica_1b5fd9_idx'), - ), - migrations.AddIndex( - model_name='accountopeningrequest', - index=models.Index(fields=['status'], name='operations__status_c941d7_idx'), - ), - migrations.AddIndex( - model_name='accountopeningrequest', - index=models.Index(fields=['target_product'], name='operations__target__a69322_idx'), - ), - migrations.AddIndex( - model_name='accountopeningrequest', - index=models.Index(fields=['created_at'], name='operations__created_9035ca_idx'), - ), - ] diff --git a/apps/operations/migrations/0002_publichostinfo.py b/apps/operations/migrations/0002_publichostinfo.py deleted file mode 100755 index 81a70a0..0000000 --- a/apps/operations/migrations/0002_publichostinfo.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-26 03:39 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('hosts', '0002_alter_hostgroup_hosts'), - ('operations', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='PublicHostInfo', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('display_name', models.CharField(help_text='在前端展示的主机名称', max_length=200, verbose_name='显示名称')), - ('display_description', models.TextField(blank=True, help_text='在前端展示的主机描述,支持Markdown格式', verbose_name='显示描述')), - ('display_hostname', models.CharField(help_text='在前端展示的主机地址', max_length=255, verbose_name='显示地址')), - ('display_rdp_port', models.IntegerField(default=3389, help_text='在前端展示的RDP端口', verbose_name='显示RDP端口')), - ('is_available', models.BooleanField(default=True, help_text='是否在前端展示此主机', verbose_name='是否可用')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('internal_host', models.OneToOneField(help_text='关联的内部主机', on_delete=django.db.models.deletion.CASCADE, to='hosts.host', verbose_name='内部主机')), - ], - options={ - 'verbose_name': '公开主机信息', - 'verbose_name_plural': '公开主机信息', - 'indexes': [models.Index(fields=['is_available'], name='operations__is_avai_9e6881_idx'), models.Index(fields=['internal_host'], name='operations__interna_acf3ff_idx')], - }, - ), - ] diff --git a/apps/operations/migrations/0003_manual_fix.py b/apps/operations/migrations/0003_manual_fix.py deleted file mode 100755 index 180817f..0000000 --- a/apps/operations/migrations/0003_manual_fix.py +++ /dev/null @@ -1,15 +0,0 @@ -# 数据库结构修复迁移 -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0002_publichostinfo'), - ] - - operations = [ - # 我们不删除不存在的字段,而是什么都不做 - # 这样可以标记迁移为已应用,避免错误 - migrations.RunSQL('SELECT 1;', reverse_sql='SELECT 1;'), - ] diff --git a/apps/operations/migrations/0004_remove_accountopeningrequest_requested_password_and_more.py b/apps/operations/migrations/0004_remove_accountopeningrequest_requested_password_and_more.py deleted file mode 100755 index 82e02d0..0000000 --- a/apps/operations/migrations/0004_remove_accountopeningrequest_requested_password_and_more.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-01 11:58 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0003_manual_fix'), - ] - - operations = [ - migrations.RemoveField( - model_name='accountopeningrequest', - name='requested_password', - ), - migrations.AddField( - model_name='cloudcomputeruser', - name='initial_password', - field=models.CharField(blank=True, help_text='用户的初始密码,查看后将被清除', max_length=255, verbose_name='初始密码'), - ), - migrations.AddField( - model_name='cloudcomputeruser', - name='password_viewed', - field=models.BooleanField(default=False, help_text='指示初始密码是否已被查看', verbose_name='密码已查看'), - ), - migrations.AddField( - model_name='cloudcomputeruser', - name='password_viewed_at', - field=models.DateTimeField(blank=True, help_text='初始密码被查看的时间', null=True, verbose_name='密码查看时间'), - ), - migrations.AddField( - model_name='product', - name='auto_approval', - field=models.BooleanField(default=False, help_text='是否自动批准针对此产品的开户申请', verbose_name='自动审核'), - ), - ] diff --git a/apps/operations/migrations/0005_cloudcomputeruser_owner.py b/apps/operations/migrations/0005_cloudcomputeruser_owner.py deleted file mode 100644 index c1c8be4..0000000 --- a/apps/operations/migrations/0005_cloudcomputeruser_owner.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 15:15 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('operations', '0004_remove_accountopeningrequest_requested_password_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='cloudcomputeruser', - name='owner', - field=models.ForeignKey(blank=True, help_text='拥有此云电脑账户的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cloud_users', to=settings.AUTH_USER_MODEL, verbose_name='所有者'), - ), - ] diff --git a/apps/operations/migrations/0006_add_product_created_by.py b/apps/operations/migrations/0006_add_product_created_by.py deleted file mode 100644 index c5ac101..0000000 --- a/apps/operations/migrations/0006_add_product_created_by.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-06 16:32 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("operations", "0005_cloudcomputeruser_owner"), - ] - - operations = [ - migrations.AddField( - model_name="product", - name="created_by", - field=models.ForeignKey( - blank=True, - help_text="创建此产品的用户", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="created_products", - to=settings.AUTH_USER_MODEL, - verbose_name="创建者", - ), - ), - migrations.AddIndex( - model_name="product", - index=models.Index( - fields=["created_by"], name="operations__created_4da3a5_idx" - ), - ), - ] diff --git a/apps/operations/migrations/0007_add_product_group.py b/apps/operations/migrations/0007_add_product_group.py deleted file mode 100644 index 88e60a1..0000000 --- a/apps/operations/migrations/0007_add_product_group.py +++ /dev/null @@ -1,117 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-08 10:20 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("operations", "0006_add_product_created_by"), - ] - - operations = [ - migrations.CreateModel( - name="ProductGroup", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField( - help_text="产品组的名称", - max_length=200, - verbose_name="产品组名称", - ), - ), - ( - "description", - models.TextField( - blank=True, - help_text="产品组的详细描述,支持Markdown格式", - verbose_name="产品组描述", - ), - ), - ( - "display_order", - models.IntegerField( - default=0, - help_text="产品组在前端展示的顺序,数字越小越靠前", - verbose_name="显示顺序", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="是否在前端展示此产品组", - verbose_name="是否启用", - ), - ), - ( - "created_at", - models.DateTimeField(auto_now_add=True, verbose_name="创建时间"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, verbose_name="更新时间"), - ), - ], - options={ - "verbose_name": "产品组", - "verbose_name_plural": "产品组", - "ordering": ["display_order", "name"], - }, - ), - migrations.AddField( - model_name="productgroup", - name="auto_assign_providers", - field=models.ManyToManyField( - blank=True, - help_text="这些提供商创建的产品将自动加入此产品组", - related_name="auto_product_groups", - to=settings.AUTH_USER_MODEL, - verbose_name="自动分配提供商", - ), - ), - migrations.AddField( - model_name="product", - name="product_group", - field=models.ForeignKey( - blank=True, - help_text="产品所属的产品组", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="products", - to="operations.productgroup", - verbose_name="产品组", - ), - ), - migrations.AddIndex( - model_name="product", - index=models.Index( - fields=["product_group"], name="operations__product_981a27_idx" - ), - ), - migrations.AddIndex( - model_name="productgroup", - index=models.Index( - fields=["is_active"], name="operations__is_acti_e01f52_idx" - ), - ), - migrations.AddIndex( - model_name="productgroup", - index=models.Index( - fields=["display_order"], name="operations__display_2722d2_idx" - ), - ), - ] diff --git a/apps/operations/migrations/0008_accountopeningrequest_requested_disk_capacity_and_more.py b/apps/operations/migrations/0008_accountopeningrequest_requested_disk_capacity_and_more.py deleted file mode 100644 index 73bcf16..0000000 --- a/apps/operations/migrations/0008_accountopeningrequest_requested_disk_capacity_and_more.py +++ /dev/null @@ -1,62 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-17 13:01 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("operations", "0007_add_product_group"), - ] - - operations = [ - migrations.AddField( - model_name="accountopeningrequest", - name="requested_disk_capacity", - field=models.JSONField( - blank=True, - default=dict, - help_text='用户额外申请的磁盘容量(MB),如 {"C:": 20480, "D:": 40960}', - verbose_name="需求磁盘容量", - ), - ), - migrations.AddField( - model_name="cloudcomputeruser", - name="disk_quota", - field=models.JSONField( - blank=True, - default=dict, - help_text='用户的磁盘配额配置(MB),如 {"C:": 10240, "D:": 20480}', - verbose_name="磁盘配额", - ), - ), - migrations.AddField( - model_name="product", - name="allow_extra_quota_disks", - field=models.JSONField( - blank=True, - default=list, - help_text='允许用户在申请时额外申请容量的磁盘列表,如 ["C:", "D:"]', - verbose_name="允许额外申请容量的磁盘", - ), - ), - migrations.AddField( - model_name="product", - name="default_disk_quota", - field=models.JSONField( - blank=True, - default=dict, - help_text='每个磁盘的默认配额大小(MB),如 {"C:": 10240, "D:": 20480}', - verbose_name="默认磁盘配额", - ), - ), - migrations.AddField( - model_name="product", - name="enable_disk_quota", - field=models.BooleanField( - default=False, - help_text="是否启用磁盘配额管理,启用后将自动为新用户设置磁盘配额", - verbose_name="启用磁盘配额管理", - ), - ), - ] diff --git a/apps/operations/migrations/0009_rdpdomainroute.py b/apps/operations/migrations/0009_rdpdomainroute.py deleted file mode 100644 index 98185a7..0000000 --- a/apps/operations/migrations/0009_rdpdomainroute.py +++ /dev/null @@ -1,84 +0,0 @@ -from django.db import migrations, models -import django.db.models.deletion -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('operations', '0008_accountopeningrequest_requested_disk_capacity_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='RdpDomainRoute', - fields=[ - ('id', models.BigAutoField( - auto_created=True, primary_key=True, - serialize=False, verbose_name='ID' - )), - ('domain', models.CharField( - max_length=255, unique=True, - verbose_name='RDP域名', - help_text='分配给用户的临时RDP访问域名' - )), - ('tunnel_token', models.CharField( - max_length=64, verbose_name='隧道Token', - help_text='关联主机的隧道Token' - )), - ('is_active', models.BooleanField( - default=True, verbose_name='是否有效', - help_text='域名是否仍然有效' - )), - ('expires_at', models.DateTimeField( - verbose_name='过期时间', - help_text='域名过期时间,10分钟无连接后过期' - )), - ('last_activity_at', models.DateTimeField( - auto_now=True, verbose_name='最后活动时间', - help_text='最后一次RDP连接活动时间' - )), - ('created_at', models.DateTimeField( - auto_now_add=True, verbose_name='创建时间' - )), - ('assigned_to', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - verbose_name='分配用户', - help_text='被分配此RDP域名的用户' - )), - ('product', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to='operations.product', - verbose_name='关联产品', - help_text='此域名关联的云电脑产品' - )), - ], - options={ - 'verbose_name': 'RDP域名路由', - 'verbose_name_plural': 'RDP域名路由', - 'ordering': ['-created_at'], - }, - ), - migrations.AddIndex( - model_name='rdpdomainroute', - index=models.Index(fields=['domain'], name='operations_rdp_domain_idx'), - ), - migrations.AddIndex( - model_name='rdpdomainroute', - index=models.Index(fields=['is_active'], name='operations_rdp_active_idx'), - ), - migrations.AddIndex( - model_name='rdpdomainroute', - index=models.Index(fields=['assigned_to'], name='operations_rdp_user_idx'), - ), - migrations.AddIndex( - model_name='rdpdomainroute', - index=models.Index(fields=['expires_at'], name='operations_rdp_expires_idx'), - ), - migrations.AddIndex( - model_name='rdpdomainroute', - index=models.Index(fields=['product'], name='operations_rdp_product_idx'), - ), - ] diff --git a/apps/operations/migrations/0010_product_enable_host_protection.py b/apps/operations/migrations/0010_product_enable_host_protection.py deleted file mode 100644 index 11c3f28..0000000 --- a/apps/operations/migrations/0010_product_enable_host_protection.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0009_rdpdomainroute'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='enable_host_protection', - field=models.BooleanField( - default=False, - help_text=( - '启用后,用户只能通过Gateway隧道访问该产品的RDP,' - '主机不暴露公网IP。需要部署Gateway服务。' - ), - verbose_name='启用主机保护(通过Gateway)' - ), - ), - ] diff --git a/apps/operations/migrations/0011_rename_operations_rdp_domain_idx_operations__domain_5c01f5_idx_and_more.py b/apps/operations/migrations/0011_rename_operations_rdp_domain_idx_operations__domain_5c01f5_idx_and_more.py deleted file mode 100644 index bc49a89..0000000 --- a/apps/operations/migrations/0011_rename_operations_rdp_domain_idx_operations__domain_5c01f5_idx_and_more.py +++ /dev/null @@ -1,38 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-25 12:35 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0010_product_enable_host_protection'), - ] - - operations = [ - migrations.RenameIndex( - model_name='rdpdomainroute', - new_name='operations__domain_5c01f5_idx', - old_name='operations_rdp_domain_idx', - ), - migrations.RenameIndex( - model_name='rdpdomainroute', - new_name='operations__is_acti_ba9cb3_idx', - old_name='operations_rdp_active_idx', - ), - migrations.RenameIndex( - model_name='rdpdomainroute', - new_name='operations__assigne_ecc986_idx', - old_name='operations_rdp_user_idx', - ), - migrations.RenameIndex( - model_name='rdpdomainroute', - new_name='operations__expires_8306ea_idx', - old_name='operations_rdp_expires_idx', - ), - migrations.RenameIndex( - model_name='rdpdomainroute', - new_name='operations__product_ccd619_idx', - old_name='operations_rdp_product_idx', - ), - ] diff --git a/apps/operations/migrations/0012_product_visibility_productgroup_visibility_and_more.py b/apps/operations/migrations/0012_product_visibility_productgroup_visibility_and_more.py deleted file mode 100644 index 179aa57..0000000 --- a/apps/operations/migrations/0012_product_visibility_productgroup_visibility_and_more.py +++ /dev/null @@ -1,111 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-29 10:10 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('operations', '0011_rename_operations_rdp_domain_idx_operations__domain_5c01f5_idx_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='visibility', - field=models.CharField(choices=[('public', '公开'), ('invite_only', '邀请访问')], default='public', help_text='产品的可见性:公开对所有用户可见,邀请访问仅对已授权用户可见', max_length=20, verbose_name='可见性'), - ), - migrations.AddField( - model_name='productgroup', - name='visibility', - field=models.CharField(choices=[('public', '公开'), ('invite_only', '邀请访问')], default='public', help_text='产品组的可见性:公开对所有用户可见,邀请访问仅对已授权用户可见', max_length=20, verbose_name='可见性'), - ), - migrations.CreateModel( - name='ProductInvitationToken', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(help_text='用于邀请链接的唯一令牌', max_length=64, unique=True, verbose_name='邀请令牌')), - ('max_uses', models.IntegerField(default=0, help_text='令牌最大可使用次数,0表示无限制', verbose_name='最大使用次数')), - ('used_count', models.IntegerField(default=0, help_text='令牌已被使用的次数', verbose_name='已使用次数')), - ('expires_at', models.DateTimeField(blank=True, help_text='令牌过期时间,留空表示永不过期', null=True, verbose_name='过期时间')), - ('is_active', models.BooleanField(default=True, help_text='令牌是否有效', verbose_name='是否启用')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('created_by', models.ForeignKey(help_text='创建此邀请令牌的用户', on_delete=django.db.models.deletion.CASCADE, related_name='created_invitation_tokens', to=settings.AUTH_USER_MODEL, verbose_name='创建者')), - ('product', models.ForeignKey(blank=True, help_text='此令牌关联的产品(与产品组至少选一个)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitation_tokens', to='operations.product', verbose_name='关联产品')), - ('product_group', models.ForeignKey(blank=True, help_text='此令牌关联的产品组(与产品至少选一个)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invitation_tokens', to='operations.productgroup', verbose_name='关联产品组')), - ], - options={ - 'verbose_name': '产品邀请令牌', - 'verbose_name_plural': '产品邀请令牌', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='ProductAccessGrant', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('granted_at', models.DateTimeField(auto_now_add=True, verbose_name='授权时间')), - ('expires_at', models.DateTimeField(blank=True, help_text='访问权限过期时间,留空表示永久有效', null=True, verbose_name='授权过期时间')), - ('is_revoked', models.BooleanField(default=False, help_text='权限是否已被撤销', verbose_name='是否已撤销')), - ('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='撤销时间')), - ('granted_by_token', models.ForeignKey(blank=True, help_text='通过哪个邀请令牌获得的权限', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='access_grants', to='operations.productinvitationtoken', verbose_name='来源邀请令牌')), - ('product', models.ForeignKey(blank=True, help_text='授权访问的产品', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='operations.product', verbose_name='关联产品')), - ('product_group', models.ForeignKey(blank=True, help_text='授权访问的产品组', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_grants', to='operations.productgroup', verbose_name='关联产品组')), - ('revoked_by', models.ForeignKey(blank=True, help_text='撤销此权限的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revoked_access_grants', to=settings.AUTH_USER_MODEL, verbose_name='撤销人')), - ('user', models.ForeignKey(help_text='获得访问权限的用户', on_delete=django.db.models.deletion.CASCADE, related_name='product_access_grants', to=settings.AUTH_USER_MODEL, verbose_name='用户')), - ], - options={ - 'verbose_name': '产品访问授权', - 'verbose_name_plural': '产品访问授权', - 'ordering': ['-granted_at'], - }, - ), - migrations.AddIndex( - model_name='productinvitationtoken', - index=models.Index(fields=['token'], name='operations__token_43deaf_idx'), - ), - migrations.AddIndex( - model_name='productinvitationtoken', - index=models.Index(fields=['is_active'], name='operations__is_acti_8900c8_idx'), - ), - migrations.AddIndex( - model_name='productinvitationtoken', - index=models.Index(fields=['expires_at'], name='operations__expires_b456ac_idx'), - ), - migrations.AddIndex( - model_name='productinvitationtoken', - index=models.Index(fields=['created_by'], name='operations__created_a0e391_idx'), - ), - migrations.AddIndex( - model_name='productaccessgrant', - index=models.Index(fields=['user'], name='operations__user_id_2e640b_idx'), - ), - migrations.AddIndex( - model_name='productaccessgrant', - index=models.Index(fields=['product'], name='operations__product_f577f6_idx'), - ), - migrations.AddIndex( - model_name='productaccessgrant', - index=models.Index(fields=['product_group'], name='operations__product_691717_idx'), - ), - migrations.AddIndex( - model_name='productaccessgrant', - index=models.Index(fields=['is_revoked'], name='operations__is_revo_0fe57f_idx'), - ), - migrations.AddIndex( - model_name='productaccessgrant', - index=models.Index(fields=['granted_at'], name='operations__granted_d3fe5b_idx'), - ), - migrations.AddConstraint( - model_name='productaccessgrant', - constraint=models.UniqueConstraint(condition=models.Q(('product__isnull', False)), fields=('user', 'product'), name='unique_user_product_grant'), - ), - migrations.AddConstraint( - model_name='productaccessgrant', - constraint=models.UniqueConstraint(condition=models.Q(('product_group__isnull', False)), fields=('user', 'product_group'), name='unique_user_productgroup_grant'), - ), - ] diff --git a/apps/operations/migrations/0013_productgroup_created_by.py b/apps/operations/migrations/0013_productgroup_created_by.py deleted file mode 100644 index ea51bb4..0000000 --- a/apps/operations/migrations/0013_productgroup_created_by.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-29 10:16 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('operations', '0012_product_visibility_productgroup_visibility_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='productgroup', - name='created_by', - field=models.ForeignKey(blank=True, help_text='创建此产品组的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_product_groups', to=settings.AUTH_USER_MODEL, verbose_name='创建者'), - ), - ] diff --git a/apps/operations/migrations/0014_encrypt_initial_password.py b/apps/operations/migrations/0014_encrypt_initial_password.py deleted file mode 100644 index d54220f..0000000 --- a/apps/operations/migrations/0014_encrypt_initial_password.py +++ /dev/null @@ -1,60 +0,0 @@ -from django.db import migrations, models -import hashlib -import base64 - - -def _get_fernet(): - from django.conf import settings - from cryptography.fernet import Fernet - key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() - return Fernet(base64.urlsafe_b64encode(key)) - - -def encrypt_existing_passwords(apps, schema_editor): - CloudComputerUser = apps.get_model('operations', 'CloudComputerUser') - fernet = _get_fernet() - for user in CloudComputerUser.objects.filter( - _initial_password__isnull=False - ).exclude(_initial_password=''): - try: - fernet.decrypt(user._initial_password.encode()) - except Exception: - encrypted = fernet.encrypt(user._initial_password.encode()).decode() - CloudComputerUser.objects.filter(pk=user.pk).update( - _initial_password=encrypted - ) - - -def decrypt_passwords_back(apps, schema_editor): - CloudComputerUser = apps.get_model('operations', 'CloudComputerUser') - fernet = _get_fernet() - for user in CloudComputerUser.objects.filter( - _initial_password__isnull=False - ).exclude(_initial_password=''): - try: - decrypted = fernet.decrypt(user._initial_password.encode()).decode() - CloudComputerUser.objects.filter(pk=user.pk).update( - _initial_password=decrypted - ) - except Exception: - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0013_productgroup_created_by'), - ] - - operations = [ - migrations.RemoveField( - model_name='cloudcomputeruser', - name='initial_password', - ), - migrations.AddField( - model_name='cloudcomputeruser', - name='_initial_password', - field=models.CharField(blank=True, db_column='initial_password', help_text='用户的初始密码(加密存储),查看后将被清除', max_length=512, verbose_name='初始密码(加密)'), - ), - migrations.RunPython(encrypt_existing_passwords, decrypt_passwords_back), - ] diff --git a/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py b/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py deleted file mode 100644 index 6145bf1..0000000 --- a/apps/operations/migrations/0015_alter_rdpdomainroute_domain_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 4.2.30 on 2026-05-21 16:14 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0014_encrypt_initial_password'), - ] - - operations = [ - migrations.AlterField( - model_name='rdpdomainroute', - name='domain', - field=models.CharField(help_text='分配给用户的临时RDP访问域名(仅用于显示/追踪,不再用于SNI路由)', max_length=255, unique=True, verbose_name='RDP域名'), - ), - migrations.AlterField( - model_name='rdpdomainroute', - name='tunnel_token', - field=models.CharField(blank=True, help_text='关联主机的隧道Token,用于RD Gateway路由', max_length=64, verbose_name='隧道Token'), - ), - ] diff --git a/apps/operations/migrations/0016_add_product_limit_one_per_user.py b/apps/operations/migrations/0016_add_product_limit_one_per_user.py deleted file mode 100644 index 0d687d5..0000000 --- a/apps/operations/migrations/0016_add_product_limit_one_per_user.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0015_alter_rdpdomainroute_domain_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='product', - name='limit_one_per_user', - field=models.BooleanField(default=False, help_text='是否限制每个用户只能拥有一个此产品', verbose_name='每人限购一个'), - ), - ] diff --git a/apps/operations/migrations/0017_accountopeningrequest_retry_count.py b/apps/operations/migrations/0017_accountopeningrequest_retry_count.py deleted file mode 100644 index 0c7fc19..0000000 --- a/apps/operations/migrations/0017_accountopeningrequest_retry_count.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-31 04:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("operations", "0016_add_product_limit_one_per_user"), - ] - - operations = [ - migrations.AddField( - model_name="accountopeningrequest", - name="retry_count", - field=models.IntegerField( - default=0, help_text="管理员重试开户的次数", verbose_name="重试次数" - ), - ), - ] diff --git a/apps/operations/migrations/0018_product_site_group_productgroup_site_group.py b/apps/operations/migrations/0018_product_site_group_productgroup_site_group.py deleted file mode 100644 index 8b36e96..0000000 --- a/apps/operations/migrations/0018_product_site_group_productgroup_site_group.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-31 13:27 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0014_sitegroup_sitegrouphostname_and_more"), - ("operations", "0017_accountopeningrequest_retry_count"), - ] - - operations = [ - migrations.AddField( - model_name="product", - name="site_group", - field=models.ForeignKey( - blank=True, - help_text="该产品所属的站点组,留空表示默认组", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="products", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - migrations.AddField( - model_name="productgroup", - name="site_group", - field=models.ForeignKey( - blank=True, - help_text="该产品组所属的站点组,留空表示默认组", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="product_groups", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - ] diff --git a/apps/operations/migrations/0019_alter_product_site_group_and_more.py b/apps/operations/migrations/0019_alter_product_site_group_and_more.py deleted file mode 100644 index 7d53b9a..0000000 --- a/apps/operations/migrations/0019_alter_product_site_group_and_more.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-01 13:09 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("dashboard", "0015_remove_sitegroup_dashboard_s_slug_5cb7f5_idx_and_more"), - ("operations", "0018_product_site_group_productgroup_site_group"), - ] - - operations = [ - migrations.AlterField( - model_name="product", - name="site_group", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="products", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - migrations.AlterField( - model_name="productgroup", - name="site_group", - field=models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="product_groups", - to="dashboard.sitegroup", - verbose_name="所属站点组", - ), - ), - ] diff --git a/apps/operations/migrations/__init__.py b/apps/operations/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/operations/models.py b/apps/operations/models.py deleted file mode 100755 index ecb9256..0000000 --- a/apps/operations/models.py +++ /dev/null @@ -1,1572 +0,0 @@ -""" -操作记录模型 -""" -from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.contrib.auth import get_user_model -from django.conf import settings -from django.utils import timezone -from django.dispatch import Signal -from utils.crypto import encrypt_value, decrypt_value -import logging - -User = get_user_model() - -logger = logging.getLogger(__name__) - - -# 定义开户申请提交前的信号 -account_opening_request_pre_submit = Signal() -# 定义开户申请提交后的信号 -account_opening_request_post_submit = Signal() - - -class PublicHostInfo(models.Model): - """ - 公开主机信息模型 - - 用于在前端展示主机信息,而不暴露敏感信息 - """ - # 内部主机关联 - internal_host = models.OneToOneField( - 'hosts.Host', - on_delete=models.CASCADE, - verbose_name=_('内部主机'), - help_text=_('关联的内部主机') - ) - - # 显示信息 - display_name = models.CharField( - max_length=200, - verbose_name=_('显示名称'), - help_text=_('在前端展示的主机名称') - ) - display_description = models.TextField( - blank=True, - verbose_name=_('显示描述'), - help_text=_('在前端展示的主机描述,支持Markdown格式') - ) - - # 连接信息(对外公开的部分) - display_hostname = models.CharField( - max_length=255, - verbose_name=_('显示地址'), - help_text=_('在前端展示的主机地址') - ) - display_rdp_port = models.IntegerField( - default=3389, - verbose_name=_('显示RDP端口'), - help_text=_('在前端展示的RDP端口') - ) - - # 可用性 - is_available = models.BooleanField( - default=True, - verbose_name=_('是否可用'), - help_text=_('是否在前端展示此主机') - ) - - # 时间信息 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间') - ) - - class Meta: - verbose_name = _('公开主机信息') - verbose_name_plural = _('公开主机信息') - indexes = [ - models.Index(fields=['is_available']), - models.Index(fields=['internal_host']), - ] - - def __str__(self): - return self.display_name - - -class SystemTask(models.Model): - """ - 系统任务模型 - - 记录系统中的异步任务,如批量操作、定时任务等 - """ - # 任务信息 - name = models.CharField( - max_length=200, - verbose_name=_('任务名称'), - help_text=_('任务的名称') - ) - task_type = models.CharField( - max_length=100, - verbose_name=_('任务类型'), - help_text=_('任务的类型,如batch_create_user等') - ) - description = models.TextField( - blank=True, - null=True, - verbose_name=_('任务描述'), - help_text=_('任务的详细描述') - ) - - # 执行信息 - status = models.CharField( - max_length=20, - choices=[ - ('pending', _('等待中')), - ('running', _('执行中')), - ('success', _('成功')), - ('failed', _('失败')), - ('cancelled', _('已取消')), - ], - default='pending', - verbose_name=_('任务状态'), - help_text=_('任务的执行状态') - ) - progress = models.IntegerField( - default=0, - verbose_name=_('执行进度'), - help_text=_('任务执行进度,0-100') - ) - result = models.TextField( - blank=True, - null=True, - verbose_name=_('执行结果'), - help_text=_('任务执行的结果信息') - ) - error_message = models.TextField( - blank=True, - null=True, - verbose_name=_('错误信息'), - help_text=_('任务执行失败时的错误信息') - ) - - # 关联信息 - created_by = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='created_tasks', - verbose_name=_('创建者'), - help_text=_('创建该任务的用户') - ) - - # 时间信息 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间'), - help_text=_('任务创建时间') - ) - started_at = models.DateTimeField( - blank=True, - null=True, - verbose_name=_('开始时间'), - help_text=_('任务开始执行的时间') - ) - completed_at = models.DateTimeField( - blank=True, - null=True, - verbose_name=_('完成时间'), - help_text=_('任务完成的时间') - ) - - class Meta: - verbose_name = _('系统任务') - verbose_name_plural = _('系统任务') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['status']), - models.Index(fields=['task_type']), - models.Index(fields=['created_at']), - ] - - def __str__(self): - """返回任务名称""" - return self.name - - def update_progress(self, progress): - """ - 更新任务进度 - - Args: - progress: 进度值,0-100 - """ - self.progress = min(max(progress, 0), 100) - self.save(update_fields=['progress']) - - def start(self): - """开始执行任务""" - from django.utils import timezone - self.status = 'running' - self.started_at = timezone.now() - self.save(update_fields=['status', 'started_at']) - - def complete(self, result=None): - """ - 完成任务 - - Args: - result: 执行结果 - """ - from django.utils import timezone - self.status = 'success' - self.completed_at = timezone.now() - self.progress = 100 - if result: - self.result = result - self.save(update_fields=['status', 'completed_at', 'progress', 'result']) - - def fail(self, error_message): - """ - 任务失败 - - Args: - error_message: 错误信息 - """ - from django.utils import timezone - self.status = 'failed' - self.completed_at = timezone.now() - self.error_message = error_message - self.save(update_fields=['status', 'completed_at', 'error_message']) - - def cancel(self): - """取消任务""" - from django.utils import timezone - self.status = 'cancelled' - self.completed_at = timezone.now() - self.save(update_fields=['status', 'completed_at']) - - -class ProductGroup(models.Model): - """ - 产品组模型 - - 用于对产品进行分组管理 - """ - name = models.CharField( - max_length=200, - verbose_name=_('产品组名称'), - help_text=_('产品组的名称') - ) - description = models.TextField( - blank=True, - verbose_name=_('产品组描述'), - help_text=_('产品组的详细描述,支持Markdown格式') - ) - display_order = models.IntegerField( - default=0, - verbose_name=_('显示顺序'), - help_text=_('产品组在前端展示的顺序,数字越小越靠前') - ) - is_active = models.BooleanField( - default=True, - verbose_name=_('是否启用'), - help_text=_('是否在前端展示此产品组') - ) - visibility = models.CharField( - max_length=20, - choices=[ - ('public', _('公开')), - ('invite_only', _('邀请访问')), - ], - default='public', - verbose_name=_('可见性'), - help_text=_('产品组的可见性:公开对所有用户可见,邀请访问仅对已授权用户可见') - ) - auto_assign_providers = models.ManyToManyField( - User, - blank=True, - related_name='auto_product_groups', - verbose_name=_('自动分配提供商'), - help_text=_('这些提供商创建的产品将自动加入此产品组') - ) - - site_group = models.ForeignKey( - 'dashboard.SiteGroup', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='product_groups', - verbose_name=_('所属站点组'), - ) - - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='created_product_groups', - verbose_name=_('创建者'), - help_text=_('创建此产品组的用户') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间') - ) - - class Meta: - verbose_name = _('产品组') - verbose_name_plural = _('产品组') - ordering = ['display_order', 'name'] - indexes = [ - models.Index(fields=['is_active']), - models.Index(fields=['display_order']), - ] - - def __str__(self): - return self.name - - -class Product(models.Model): - """ - 产品模型 - - 代表面向用户的产品,一个主机可以对应多个产品 - """ - name = models.CharField( - max_length=200, - verbose_name=_('产品名称'), - help_text=_('面向用户展示的产品名称') - ) - description = models.TextField( - blank=True, - verbose_name=_('产品描述'), - help_text=_('产品的详细描述,支持Markdown格式') - ) - display_name = models.CharField( - max_length=200, - verbose_name=_('显示名称'), - help_text=_('在前端展示的产品名称') - ) - display_description = models.TextField( - blank=True, - verbose_name=_('显示描述'), - help_text=_('在前端展示的产品描述,支持Markdown格式') - ) - - product_group = models.ForeignKey( - ProductGroup, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='products', - verbose_name=_('产品组'), - help_text=_('产品所属的产品组') - ) - - # 关联主机 - host = models.ForeignKey( - 'hosts.Host', - on_delete=models.CASCADE, - verbose_name=_('关联主机'), - help_text=_('此产品运行所在的主机') - ) - - site_group = models.ForeignKey( - 'dashboard.SiteGroup', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='products', - verbose_name=_('所属站点组'), - ) - - # 产品配置 - rdp_port = models.IntegerField( - default=3389, - verbose_name=_('RDP端口'), - help_text=_('用户连接时使用的RDP端口') - ) - display_hostname = models.CharField( - max_length=255, - verbose_name=_('显示地址'), - help_text=_('在前端展示的产品访问地址') - ) - - # 产品状态 - is_available = models.BooleanField( - default=True, - verbose_name=_('是否可用'), - help_text=_('是否在前端展示此产品') - ) - auto_approval = models.BooleanField( - default=False, - verbose_name=_('自动审核'), - help_text=_('是否自动批准针对此产品的开户申请') - ) - visibility = models.CharField( - max_length=20, - choices=[ - ('public', _('公开')), - ('invite_only', _('邀请访问')), - ], - default='public', - verbose_name=_('可见性'), - help_text=_('产品的可见性:公开对所有用户可见,邀请访问仅对已授权用户可见') - ) - - limit_one_per_user = models.BooleanField( - default=False, - verbose_name=_('每人限购一个'), - help_text=_('是否限制每个用户只能拥有一个此产品') - ) - - enable_disk_quota = models.BooleanField( - default=False, - verbose_name=_('启用磁盘配额管理'), - help_text=_('是否启用磁盘配额管理,启用后将自动为新用户设置磁盘配额') - ) - enable_host_protection = models.BooleanField( - default=False, - verbose_name=_('启用主机保护(通过Gateway)'), - help_text=_( - '启用后,用户只能通过Gateway隧道访问该产品的RDP,' - '主机不暴露公网IP。需要部署Gateway服务。' - ) - ) - default_disk_quota = models.JSONField( - default=dict, - blank=True, - verbose_name=_('默认磁盘配额'), - help_text=_('每个磁盘的默认配额大小(MB),如 {"C:": 10240, "D:": 20480}') - ) - allow_extra_quota_disks = models.JSONField( - default=list, - blank=True, - verbose_name=_('允许额外申请容量的磁盘'), - help_text=_('允许用户在申请时额外申请容量的磁盘列表,如 ["C:", "D:"]') - ) - - # 创建者 - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name=_('创建者'), - help_text=_('创建此产品的用户'), - related_name='created_products' - ) - - # 时间信息 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间') - ) - - class Meta: - verbose_name = _('产品') - verbose_name_plural = _('产品') - indexes = [ - models.Index(fields=['is_available']), - models.Index(fields=['host']), - models.Index(fields=['created_at']), - models.Index(fields=['created_by']), - models.Index(fields=['product_group'], name='operations__product_981a27_idx'), - ] - - def __str__(self): - return self.display_name - - @property - def status(self): - """ - 产品状态,继承自主机状态 - """ - return self.host.status - - @property - def hostname(self): - """ - 产品主机名,使用显示地址 - """ - return self.display_hostname - - -class AccountOpeningRequest(models.Model): - """ - 用户开户申请模型 - - 用于记录用户提交的开户申请信息 - """ - # 申请人信息 - applicant = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='account_opening_requests', - verbose_name=_('申请人'), - help_text=_('提交开户申请的用户') - ) - contact_email = models.EmailField( - verbose_name=_('联系邮箱'), - help_text=_('申请人联系方式') - ) - contact_phone = models.CharField( - max_length=20, - blank=True, - null=True, - verbose_name=_('联系电话'), - help_text=_('申请人联系电话') - ) - - # 开户信息 - username = models.CharField( - max_length=150, - verbose_name=_('用户名'), - help_text=_('希望在云电脑上创建的用户名') - ) - user_fullname = models.CharField( - max_length=200, - verbose_name=_('用户姓名'), - help_text=_('用户真实姓名') - ) - user_email = models.EmailField( - verbose_name=_('用户邮箱'), - help_text=_('用户邮箱地址') - ) - user_description = models.TextField( - blank=True, - verbose_name=_('用户描述'), - help_text=_('关于该用户的附加信息') - ) - - # 目标产品(替代原来的target_host) - target_product = models.ForeignKey( - Product, - on_delete=models.CASCADE, - verbose_name=_('目标产品'), - help_text=_('要在哪个产品上创建用户'), - null=True, - blank=True - ) - - requested_disk_capacity = models.JSONField( - default=dict, - blank=True, - verbose_name=_('需求磁盘容量'), - help_text=_('用户额外申请的磁盘容量(MB),如 {"C:": 20480, "D:": 40960}') - ) - - # 审核信息 - status = models.CharField( - max_length=20, - choices=[ - ('pending', _('待审核')), - ('approved', _('已批准')), - ('rejected', _('已拒绝')), - ('processing', _('处理中')), - ('completed', _('已完成')), - ('failed', _('失败')), - ], - default='pending', - verbose_name=_('申请状态'), - help_text=_('开户申请的当前状态') - ) - approved_by = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='approved_account_requests', - verbose_name=_('审核人'), - help_text=_('批准此申请的管理员') - ) - approval_date = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('审核时间'), - help_text=_('申请被审核的时间') - ) - approval_notes = models.TextField( - blank=True, - verbose_name=_('审核备注'), - help_text=_('审核时的备注信息') - ) - - # 结果信息 - cloud_user_id = models.CharField( - max_length=255, - blank=True, - verbose_name=_('云电脑用户ID'), - help_text=_('在云电脑上实际创建的用户ID') - ) - cloud_user_password = models.CharField( - max_length=255, - blank=True, - verbose_name=_('云电脑用户密码'), - help_text=_('为用户设置的初始密码') - ) - result_message = models.TextField( - blank=True, - verbose_name=_('结果信息'), - help_text=_('开户操作的结果信息') - ) - retry_count = models.IntegerField( - default=0, - verbose_name=_('重试次数'), - help_text=_('管理员重试开户的次数') - ) - - # 时间信息 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间'), - help_text=_('申请创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间'), - help_text=_('申请信息最后更新时间') - ) - - class Meta: - verbose_name = _('开户申请') - verbose_name_plural = _('开户申请') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['applicant']), - models.Index(fields=['status']), - models.Index(fields=['target_product']), - models.Index(fields=['created_at']), - ] - - def __str__(self): - product_name = self.target_product.display_name if self.target_product else 'Unknown Product' - return f'{self.username} - {product_name}' - - def approve(self, approver, notes=''): - """ - 批准开户申请 - - Args: - approver: 批准申请的管理员 - notes: 审核备注 - """ - self.status = 'approved' - self.approved_by = approver - self.approval_date = timezone.now() - self.approval_notes = notes - # 不直接调用save,而是通过super().save()让重写的save方法处理后续操作 - super().save() - - # 状态从pending变为approved时,通过Celery异步处理用户创建 - # (save()中的逻辑也会检测此条件并派发任务,但approve()绕过了save(), - # 所以需要在此处显式派发) - try: - from apps.operations.tasks import process_account_creation - process_account_creation.delay(self.pk) - except Exception as e: - logger.error( - f"Failed to dispatch account creation task " - f"for request {self.pk}: {str(e)}" - ) - - def reject(self, approver, notes=''): - """ - 拒绝开户申请 - - Args: - approver: 拒绝申请的管理员 - notes: 审核备注 - """ - self.status = 'rejected' - self.approved_by = approver - self.approval_date = timezone.now() - self.approval_notes = notes - self.save() - - def start_processing(self): - """ - 开始处理开户申请 - """ - self.status = 'processing' - self.save() - - def complete(self, cloud_user_id, cloud_user_password, result_message=''): - """ - 完成开户申请 - - Args: - cloud_user_id: 在云电脑上创建的用户ID - cloud_user_password: 用户初始密码(出于安全考虑,不会存储) - result_message: 结果信息 - """ - self.status = 'completed' - self.cloud_user_id = cloud_user_id - # 出于安全考虑,不存储用户密码明文 - # self.cloud_user_password = cloud_user_password - self.result_message = result_message - self.save() - - def fail(self, result_message=''): - """ - 开户申请失败 - - Args: - result_message: 失败原因 - """ - self.status = 'failed' - self.result_message = result_message - self.save() - - def retry(self, operator=None): - """ - 重试失败的开户申请 - - 将状态从 failed 重置为 approved, - 清除上次的错误信息,并重新触发开户流程。 - - Args: - operator: 执行重试操作的管理员 - - Returns: - bool: 重试是否成功启动 - """ - if self.status != 'failed': - return False - - self.status = 'approved' - self.result_message = '' - self.retry_count += 1 - if operator: - self.approval_notes = ( - f'重试(第{self.retry_count}次) - ' - f'操作人: {operator.username}' - ) - self.save(update_fields=[ - 'status', 'result_message', 'retry_count', - 'approval_notes', - ]) - - try: - from apps.operations.tasks import process_account_creation - process_account_creation.delay(self.pk) - logger.info( - f"Retry dispatched for request {self.pk}, " - f"attempt #{self.retry_count}" - ) - return True - except Exception as e: - logger.error( - f"Failed to dispatch retry task for " - f"request {self.pk}: {str(e)}" - ) - self.status = 'failed' - self.result_message = '重试失败,请联系管理员了解详情' - self.save(update_fields=['status', 'result_message']) - return False - - def save(self, *args, **kwargs): - """ - 重写save方法,当状态变为'approved'时自动处理用户创建 - """ - is_new_instance = not self.pk - - if is_new_instance: - logger.info(f"AccountOpeningRequest.save(): 发送 pre-submit 信号,实例ID: {self.pk}, 目标产品: {getattr(self.target_product, 'id', 'None')}, 联系邮箱: {self.contact_email}") - account_opening_request_pre_submit.send(sender=self.__class__, instance=self) - logger.info(f"AccountOpeningRequest.save(): pre-submit 信号发送完成,实例状态: {self.status}") - - old_status = None - if self.pk: - try: - old_status = AccountOpeningRequest.objects.filter(pk=self.pk).values_list('status', flat=True).first() - except Exception: - pass - - auto_approved = False - if (is_new_instance and self.target_product and - self.target_product.auto_approval and self.status == 'pending'): - self.status = 'approved' - from django.contrib.auth import get_user_model - from typing import cast - User = get_user_model() - system_user = User.objects.filter(is_superuser=True).first() - if system_user: - self.approved_by = cast(User, system_user) - self.approval_date = timezone.now() - self.approval_notes = '自动审核通过' - auto_approved = True - - super().save(*args, **kwargs) - - if is_new_instance: - logger.info(f"AccountOpeningRequest.save(): 发送 post-submit 信号,实例ID: {self.pk}, 最终状态: {self.status}") - account_opening_request_post_submit.send(sender=self.__class__, instance=self) - - if ((old_status == 'pending' and self.status == 'approved') or - (is_new_instance and auto_approved and self.status == 'approved')): - try: - from apps.operations.tasks import process_account_creation - process_account_creation.delay(self.pk) - except Exception as e: - logger.error(f"Failed to dispatch account creation task for request {self.pk}: {str(e)}") - - def auto_process_creation(self): - """审批通过后自动创建用户""" - from django.db import transaction - import os - - product = self.target_product - if not product: - logger.error(f"AccountOpeningRequest {self.id} has no target_product") - return - - host = product.host - - user_disk_quota = {} - if product.enable_disk_quota and product.default_disk_quota: - user_disk_quota = dict(product.default_disk_quota) - if self.requested_disk_capacity: - for disk, capacity in self.requested_disk_capacity.items(): - if disk in product.allow_extra_quota_disks: - user_disk_quota[disk] = capacity - - # DEMO模式 - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f'DEMO模式: 模拟创建用户 {self.username}') - password = CloudComputerUser.generate_complex_password() - - with transaction.atomic(): - self.status = 'completed' - self.result_message = f"用户 {self.username} 已在DEMO模式下创建(模拟)" - self.save(update_fields=['status', 'result_message']) - - CloudComputerUser.objects.get_or_create( - username=self.username, - product=self.target_product, - defaults={ - 'fullname': self.user_fullname, - 'email': self.user_email, - 'description': self.user_description, - 'created_from_request': self, - 'owner': self.applicant, - 'initial_password': password, - 'disk_quota': user_disk_quota, - } - ) - return - - # 正式模式 - try: - client = host.get_connection_client() - - password = CloudComputerUser.generate_complex_password() - result = client.create_user( - username=self.username, - password=password, - description=self.user_description - ) - - if result.status_code == 0: - if user_disk_quota: - try: - from utils.disk_quota import set_user_disk_quotas - quota_result = set_user_disk_quotas( - client, self.username, user_disk_quota - ) - if not quota_result['success']: - logger.warning( - f"用户 {self.username} 磁盘配额设置部分失败: " - f"{quota_result.get('errors', [])}" - ) - except Exception as e: - logger.error( - f"用户 {self.username} 磁盘配额设置失败: {str(e)}" - ) - - # 远程成功后,事务写本地 - with transaction.atomic(): - self.status = 'completed' - self.result_message = f"用户 {self.username} 已成功创建" - self.save(update_fields=['status', 'result_message']) - - CloudComputerUser.objects.get_or_create( - username=self.username, - product=self.target_product, - defaults={ - 'fullname': self.user_fullname, - 'email': self.user_email, - 'description': self.user_description, - 'created_from_request': self, - 'owner': self.applicant, - 'initial_password': password, - 'disk_quota': user_disk_quota, - } - ) - else: - error_msg = result.std_err or '未知错误' - self.status = 'failed' - self.result_message = f"创建用户失败: {error_msg}" - self.save(update_fields=['status', 'result_message']) - - except Exception as e: - self.status = 'failed' - self.result_message = f"处理异常: {str(e)}" - self.save(update_fields=['status', 'result_message']) - - -class CloudComputerUser(models.Model): - """ - 云电脑用户模型 - - 记录在各个云电脑产品上创建的用户信息 - """ - # 用户信息 - username = models.CharField( - max_length=150, - verbose_name=_('用户名'), - help_text=_('在云电脑上的用户名') - ) - fullname = models.CharField( - max_length=200, - verbose_name=_('用户姓名'), - help_text=_('用户真实姓名') - ) - email = models.EmailField( - verbose_name=_('用户邮箱'), - help_text=_('用户邮箱地址') - ) - description = models.TextField( - blank=True, - verbose_name=_('用户描述'), - help_text=_('关于该用户的附加信息') - ) - - # 关联的产品(替代原来的host) - product = models.ForeignKey( - Product, - on_delete=models.CASCADE, - verbose_name=_('所属产品'), - help_text=_('该用户所属的云电脑产品') - ) - - # 状态信息 - status = models.CharField( - max_length=20, - choices=[ - ('active', _('激活')), - ('inactive', _('未激活')), - ('disabled', _('已禁用')), - ('deleted', _('已删除')), - ], - default='active', - verbose_name=_('用户状态'), - help_text=_('用户在云电脑上的状态') - ) - - # 权限信息 - is_admin = models.BooleanField( - default=False, - verbose_name=_('管理员权限'), - help_text=_('是否具有管理员权限') - ) - groups = models.TextField( - blank=True, - verbose_name=_('用户组'), - help_text=_('用户所属的组(逗号分隔)') - ) - - disk_quota = models.JSONField( - default=dict, - blank=True, - verbose_name=_('磁盘配额'), - help_text=_('用户的磁盘配额配置(MB),如 {"C:": 10240, "D:": 20480}') - ) - - # 创建信息 - created_from_request = models.ForeignKey( - AccountOpeningRequest, - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name=_('来源申请'), - help_text=_('创建此用户的开户申请') - ) - owner = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='cloud_users', - verbose_name=_('所有者'), - help_text=_('拥有此云电脑账户的用户') - ) - - # 密码信息(临时存储) - _initial_password = models.CharField( - max_length=512, - blank=True, - db_column='initial_password', - verbose_name=_('初始密码(加密)'), - help_text=_('用户的初始密码(加密存储),查看后将被清除') - ) - - @property - def initial_password(self): - if not self._initial_password: - return '' - try: - return decrypt_value(self._initial_password) - except ValueError: - raise ValueError("密码解密失败,数据可能已损坏或密钥已变更") - - @initial_password.setter - def initial_password(self, value): - if value: - self._initial_password = encrypt_value(value) - else: - self._initial_password = '' - password_viewed = models.BooleanField( - default=False, - verbose_name=_('密码已查看'), - help_text=_('指示初始密码是否已被查看') - ) - password_viewed_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('密码查看时间'), - help_text=_('初始密码被查看的时间') - ) - - # 时间信息 - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间'), - help_text=_('用户在云电脑上创建的时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间'), - help_text=_('信息最后更新时间') - ) - - class Meta: - verbose_name = _('云电脑用户') - verbose_name_plural = _('云电脑用户') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['product']), - models.Index(fields=['username']), - models.Index(fields=['status']), - models.Index(fields=['created_at']), - ] - unique_together = [['product', 'username']] # 确保同一产品上用户名唯一 - - def __str__(self): - return f'{self.username}@{self.product.display_name}' - - def activate(self): - """ - 激活用户 - """ - self.status = 'active' - self.save(update_fields=['status', 'updated_at']) - - def deactivate(self): - """ - 禁用用户 - """ - self.status = 'inactive' - self.save(update_fields=['status', 'updated_at']) - - def disable(self): - """ - 删除用户 - """ - self.status = 'disabled' - self.save(update_fields=['status', 'updated_at']) - - def delete_user(self): - """ - 标记用户为已删除 - """ - self.status = 'deleted' - self.save(update_fields=['status', 'updated_at']) - - def save(self, *args, **kwargs): - """ - 重写save方法,当状态改变时通过Celery异步执行远程操作 - """ - old_status = None - if self.pk: - try: - old_status = CloudComputerUser.objects.filter(pk=self.pk).values_list('status', flat=True).first() - except Exception: - pass - - super().save(*args, **kwargs) - - if old_status is not None: - remote_action = None - if old_status != 'disabled' and self.status == 'disabled': - remote_action = 'disable' - elif old_status == 'disabled' and self.status == 'active': - remote_action = 'enable' - elif old_status != 'deleted' and self.status == 'deleted': - remote_action = 'delete' - - if remote_action: - try: - from apps.operations.tasks import execute_cloud_user_remote_action - execute_cloud_user_remote_action.delay(self.pk, remote_action) - except Exception as e: - logger.error(f"Failed to dispatch remote action '{remote_action}' for user {self.username}: {str(e)}") - - def disable_remote_user(self): - import os - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f'DEMO模式: 模拟禁用用户 {self.username} 在产品 {self.product.display_name}') - return - - try: - product = self.product - host = product.host - client = host.get_connection_client() - - result = client.disabled_user(self.username) - if result.status_code != 0: - error_msg = result.std_err if result.std_err else 'Unknown error' - logger.error(f"Failed to disable user {self.username} on host {host.name}: {error_msg}") - except Exception as e: - logger.error(f"Error disabling user {self.username} on host {host.name}: {str(e)}") - - def enable_remote_user(self): - import os - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f'DEMO模式: 模拟启用用户 {self.username} 在产品 {self.product.display_name}') - return - - try: - product = self.product - host = product.host - client = host.get_connection_client() - - result = client.enable_user(self.username) - if result.status_code != 0: - error_msg = result.std_err if result.std_err else 'Unknown error' - logger.error(f"Failed to enable user {self.username} on host {host.name}: {error_msg}") - except Exception as e: - logger.error(f"Error enabling user {self.username} on host {host.name}: {str(e)}") - - def delete_remote_user(self): - import os - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f'DEMO模式: 模拟删除用户 {self.username} 在产品 {self.product.display_name}') - return - - try: - product = self.product - host = product.host - client = host.get_connection_client() - - result = client.delete_user(self.username) - if result.status_code != 0: - error_msg = result.std_err if result.std_err else 'Unknown error' - logger.error(f"Failed to delete user {self.username} on host {host.name}: {error_msg}") - except Exception as e: - logger.error(f"Error deleting user {self.username} on host {host.name}: {str(e)}") - - def get_and_burn_password(self): - from django.utils import timezone - - if self.password_viewed: - raise Exception('密码已被查看,无法再次获取。如需重置请联系管理员。') - - if not self._initial_password: - raise Exception('密码不存在') - - password = self.initial_password - self.password_viewed = True - self.password_viewed_at = timezone.now() - self.initial_password = '' - self.save(update_fields=['password_viewed', 'password_viewed_at', '_initial_password']) - return password - - def reset_windows_password(self, new_password): - import os - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f'DEMO模式: 模拟重置用户 {self.username} 的密码') - return - - try: - product = self.product - host = product.host - client = host.get_connection_client() - - result = client.reset_password(self.username, new_password) - if result.status_code != 0: - error_msg = result.std_err if result.std_err else 'Unknown error' - logger.error(f"Failed to reset password for user {self.username} on host {host.name}: {error_msg}") - raise Exception(f"远程重置密码失败: {error_msg}") - except Exception as e: - logger.error(f"Error resetting password for user {self.username} on host {host.name}: {str(e)}") - raise - - def reset_and_get_new_password(self): - """重置密码并返回新密码(用于阅后即焚流程)""" - from django.utils import timezone - - new_password = self.generate_complex_password() - self.reset_windows_password(new_password) - - self._initial_password = '' - self.initial_password = new_password - self.password_viewed = False - self.password_viewed_at = None - self.save(update_fields=['password_viewed', 'password_viewed_at', '_initial_password']) - - password = self.get_and_burn_password() - return password - - @staticmethod - def generate_complex_password(length=16): - """ - 生成复杂密码 - - Args: - length: 密码长度,默认为16位 - - Returns: - 生成的复杂密码 - """ - import secrets - import string - - # 包含大写字母、小写字母、数字和特殊字符 - # 排除 PowerShell 双引号字符串中有歧义的字符: " $ ` - _special = '!@#%^&*()_+-=[]{}|;:,.<>?' - alphabet = string.ascii_letters + string.digits + _special - - # 确保至少包含每种类型的字符 - while True: - password = ''.join(secrets.choice(alphabet) for i in range(length)) - - # 检查是否包含所需类型的字符 - has_upper = any(c.isupper() for c in password) - has_lower = any(c.islower() for c in password) - has_digit = any(c.isdigit() for c in password) - has_special = any(c in _special for c in password) - - if has_upper and has_lower and has_digit and has_special: - return password - - -class RdpDomainRoute(models.Model): - """ - RDP域名路由(SNI路由已弃用,现使用RD Gateway + tunnel_token路由) - domain字段仅用于显示和追踪,不再用于SNI路由 - """ - domain = models.CharField( - max_length=255, unique=True, - verbose_name=_('RDP域名'), - help_text=_('分配给用户的临时RDP访问域名(仅用于显示/追踪,不再用于SNI路由)') - ) - product = models.ForeignKey( - Product, - on_delete=models.CASCADE, - verbose_name=_('关联产品'), - help_text=_('此域名关联的云电脑产品') - ) - assigned_to = models.ForeignKey( - User, - on_delete=models.CASCADE, - verbose_name=_('分配用户'), - help_text=_('被分配此RDP域名的用户') - ) - tunnel_token = models.CharField( - max_length=64, blank=True, - verbose_name=_('隧道Token'), - help_text=_('关联主机的隧道Token,用于RD Gateway路由') - ) - is_active = models.BooleanField( - default=True, - verbose_name=_('是否有效'), - help_text=_('域名是否仍然有效') - ) - expires_at = models.DateTimeField( - verbose_name=_('过期时间'), - help_text=_('域名过期时间,10分钟无连接后过期') - ) - last_activity_at = models.DateTimeField( - auto_now=True, - verbose_name=_('最后活动时间'), - help_text=_('最后一次RDP连接活动时间') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - - class Meta: - verbose_name = _('RDP域名路由') - verbose_name_plural = _('RDP域名路由') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['domain']), - models.Index(fields=['is_active']), - models.Index(fields=['assigned_to']), - models.Index(fields=['expires_at']), - models.Index(fields=['product']), - ] - - def __str__(self): - status = '有效' if self.is_active else '已过期' - return f'{self.domain} -> {self.product.display_name} ({status})' - - @staticmethod - def generate_domain(): - import secrets - import string - prefix = ''.join( - secrets.choice(string.ascii_lowercase + string.digits) - for _ in range(8) - ) - from django.conf import settings as django_settings - base_domain = getattr( - django_settings, 'RDP_DOMAIN', '2c2a.com' - ) - return f'rdp-{prefix}.{base_domain}' - - def is_expired(self): - return timezone.now() > self.expires_at - - def deactivate(self): - self.is_active = False - self.save(update_fields=['is_active']) - - @property - def is_protected(self): - return self.product.enable_host_protection - - -class ProductInvitationToken(models.Model): - """ - 产品邀请令牌模型 - - 用于生成邀请链接,用户访问链接后可解锁指定产品或产品组的访问权限 - """ - token = models.CharField( - max_length=64, - unique=True, - verbose_name=_('邀请令牌'), - help_text=_('用于邀请链接的唯一令牌') - ) - product = models.ForeignKey( - Product, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='invitation_tokens', - verbose_name=_('关联产品'), - help_text=_('此令牌关联的产品(与产品组至少选一个)') - ) - product_group = models.ForeignKey( - ProductGroup, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='invitation_tokens', - verbose_name=_('关联产品组'), - help_text=_('此令牌关联的产品组(与产品至少选一个)') - ) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='created_invitation_tokens', - verbose_name=_('创建者'), - help_text=_('创建此邀请令牌的用户') - ) - max_uses = models.IntegerField( - default=0, - verbose_name=_('最大使用次数'), - help_text=_('令牌最大可使用次数,0表示无限制') - ) - used_count = models.IntegerField( - default=0, - verbose_name=_('已使用次数'), - help_text=_('令牌已被使用的次数') - ) - expires_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('过期时间'), - help_text=_('令牌过期时间,留空表示永不过期') - ) - is_active = models.BooleanField( - default=True, - verbose_name=_('是否启用'), - help_text=_('令牌是否有效') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间') - ) - - class Meta: - verbose_name = _('产品邀请令牌') - verbose_name_plural = _('产品邀请令牌') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['token']), - models.Index(fields=['is_active']), - models.Index(fields=['expires_at']), - models.Index(fields=['created_by']), - ] - - def __str__(self): - target = self.product.display_name if self.product else (self.product_group.name if self.product_group else _('未知')) - return f'{self.token[:8]}... -> {target}' - - def is_expired(self): - if self.expires_at and timezone.now() > self.expires_at: - return True - return False - - def is_exhausted(self): - if self.max_uses > 0 and self.used_count >= self.max_uses: - return True - return False - - def is_valid(self): - return self.is_active and not self.is_expired() and not self.is_exhausted() - - def increment_usage(self): - from django.db.models import F - self.used_count = F('used_count') + 1 - self.save(update_fields=['used_count', 'updated_at']) - self.refresh_from_db() - - def generate_token(self): - import secrets - import string - return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(32)) - - def save(self, *args, **kwargs): - if not self.token: - self.token = self.generate_token() - super().save(*args, **kwargs) - - -class ProductAccessGrant(models.Model): - """ - 产品访问授权记录模型 - - 记录用户通过邀请链接获得的产品或产品组访问权限 - """ - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='product_access_grants', - verbose_name=_('用户'), - help_text=_('获得访问权限的用户') - ) - product = models.ForeignKey( - Product, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='access_grants', - verbose_name=_('关联产品'), - help_text=_('授权访问的产品') - ) - product_group = models.ForeignKey( - ProductGroup, - on_delete=models.CASCADE, - null=True, - blank=True, - related_name='access_grants', - verbose_name=_('关联产品组'), - help_text=_('授权访问的产品组') - ) - granted_by_token = models.ForeignKey( - ProductInvitationToken, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='access_grants', - verbose_name=_('来源邀请令牌'), - help_text=_('通过哪个邀请令牌获得的权限') - ) - granted_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('授权时间') - ) - expires_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('授权过期时间'), - help_text=_('访问权限过期时间,留空表示永久有效') - ) - is_revoked = models.BooleanField( - default=False, - verbose_name=_('是否已撤销'), - help_text=_('权限是否已被撤销') - ) - revoked_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('撤销时间') - ) - revoked_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='revoked_access_grants', - verbose_name=_('撤销人'), - help_text=_('撤销此权限的用户') - ) - - class Meta: - verbose_name = _('产品访问授权') - verbose_name_plural = _('产品访问授权') - ordering = ['-granted_at'] - indexes = [ - models.Index(fields=['user']), - models.Index(fields=['product']), - models.Index(fields=['product_group']), - models.Index(fields=['is_revoked']), - models.Index(fields=['granted_at']), - ] - constraints = [ - models.UniqueConstraint( - fields=['user', 'product'], - condition=models.Q(product__isnull=False), - name='unique_user_product_grant' - ), - models.UniqueConstraint( - fields=['user', 'product_group'], - condition=models.Q(product_group__isnull=False), - name='unique_user_productgroup_grant' - ), - ] - - def __str__(self): - target = self.product.display_name if self.product else (self.product_group.name if self.product_group else _('未知')) - status = _('已撤销') if self.is_revoked else (_('已过期') if self.is_expired() else _('有效')) - return f'{self.user.username} -> {target} ({status})' - - def is_expired(self): - if self.expires_at and timezone.now() > self.expires_at: - return True - return False - - def is_effective(self): - return not self.is_revoked and not self.is_expired() diff --git a/apps/operations/services.py b/apps/operations/services.py deleted file mode 100644 index eeb2297..0000000 --- a/apps/operations/services.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -业务逻辑服务层 -将复杂的业务逻辑从业务视图和Admin中抽离出来,提供统一的服务接口 -""" -import logging -from django.db import transaction -from .models import CloudComputerUser - -logger = logging.getLogger(__name__) - - -def execute_account_opening(account_request): - """ - 执行开户操作:通过 WinRM 在目标主机上创建用户 - - Args: - account_request: AccountOpeningRequest 实例 - - Raises: - Exception: 连接或执行失败时抛出 - """ - with transaction.atomic(): - try: - # 记录开始处理 - logger.info(f"开始处理开户申请: {account_request.username}") - account_request.start_processing() - - # 系统生成强密码 - password = CloudComputerUser.generate_complex_password() - - # 连接到目标主机 - host = account_request.target_product.host - client = host.get_connection_client() - - # 执行远程用户创建 - result = client.create_user(account_request.username, password) - - if result.status_code == 0: - # 计算用户磁盘配额 - user_disk_quota = {} - product = account_request.target_product - if product.enable_disk_quota and product.default_disk_quota: - user_disk_quota = dict(product.default_disk_quota) - if account_request.requested_disk_capacity: - for disk, capacity in account_request.requested_disk_capacity.items(): - if disk in product.allow_extra_quota_disks: - user_disk_quota[disk] = capacity - - # 设置磁盘配额 - if user_disk_quota: - try: - from utils.disk_quota import set_user_disk_quotas - quota_result = set_user_disk_quotas( - client, account_request.username, user_disk_quota - ) - if not quota_result['success']: - logger.warning( - f"磁盘配额设置部分失败: " - f"{quota_result.get('errors', [])}" - ) - except Exception as e: - logger.error(f"磁盘配额设置失败: {str(e)}") - - # 成功创建用户 - cloud_user, created = CloudComputerUser.objects.get_or_create( - username=account_request.username, - product=account_request.target_product, - defaults={ - 'fullname': account_request.user_fullname, - 'email': account_request.user_email, - 'description': account_request.user_description, - 'created_from_request': account_request, - 'initial_password': password, - 'disk_quota': user_disk_quota, - } - ) - - # 更新申请状态 - account_request.complete( - cloud_user_id=account_request.username, - cloud_user_password='', - result_message=f"用户 {account_request.username} 已成功创建" - ) - - logger.info(f"开户申请处理成功: {account_request.username}") - return True - else: - # 创建用户失败 - error_msg = result.std_err if result.std_err else '未知错误' - account_request.fail("开户处理失败,请联系管理员了解详情") - logger.error(f"开户申请处理失败: {account_request.username}, 错误: {error_msg}") - raise Exception(f"创建用户失败: {error_msg}") - - except Exception as e: - # 处理过程中的任何异常 - error_msg = str(e) - account_request.fail("开户处理失败,请联系管理员了解详情") - logger.error(f"开户申请处理异常: {account_request.username}, 异常: {error_msg}") - raise - - -def update_user_admin_permission(cloud_user, make_admin): - """ - 更新用户的管理员权限 - - Args: - cloud_user: CloudComputerUser 实例 - make_admin: bool, True表示授予管理员权限,False表示撤销 - - Raises: - Exception: 权限操作失败时抛出 - """ - try: - # 连接到产品关联的主机 - product = cloud_user.product - host = product.host - client = host.get_connection_client() - - if make_admin: - # 授予管理员权限 - success = client.op_user(cloud_user.username) - if not success: - raise Exception(f"为用户 {cloud_user.username} 授予管理员权限失败") - else: - # 剥夺管理员权限 - success = client.deop_user(cloud_user.username) - if not success: - raise Exception(f"撤销用户 {cloud_user.username} 的管理员权限失败") - - logger.info(f"{'授予' if make_admin else '撤销'}用户 {cloud_user.username} 管理员权限成功") - return True - - except Exception as e: - logger.error(f"更新用户管理员权限失败: {cloud_user.username}, 错误: {str(e)}") - raise - - -def get_user_password_and_burn(cloud_user): - """ - 获取用户密码并销毁(阅后即焚) - - Args: - cloud_user: CloudComputerUser 实例 - - Returns: - str: 用户密码 - - Raises: - Exception: 获取密码失败时抛出 - """ - try: - password = cloud_user.get_and_burn_password() - logger.info(f"用户 {cloud_user.username} 成功获取并销毁初始密码") - return password - except Exception as e: - logger.error(f"获取用户 {cloud_user.username} 密码失败: {str(e)}") - raise - - -def toggle_user_status(cloud_user, action): - """ - 切换用户状态 - - Args: - cloud_user: CloudComputerUser 实例 - action: str, 操作类型 ('activate', 'deactivate', 'disable', 'delete') - - Returns: - bool: 操作是否成功 - - Raises: - Exception: 状态切换失败时抛出 - """ - try: - if action == 'activate': - cloud_user.activate() - elif action == 'deactivate': - cloud_user.deactivate() - elif action == 'disable': - cloud_user.disable() - elif action == 'delete': - cloud_user.delete_user() - else: - raise ValueError(f"无效的操作类型: {action}") - - logger.info(f"用户 {cloud_user.username} 状态切换成功: {action}") - return True - - except Exception as e: - logger.error(f"切换用户 {cloud_user.username} 状态失败: {action}, 错误: {str(e)}") - raise \ No newline at end of file diff --git a/apps/operations/tasks.py b/apps/operations/tasks.py deleted file mode 100755 index dc38c60..0000000 --- a/apps/operations/tasks.py +++ /dev/null @@ -1,672 +0,0 @@ -from celery import shared_task -from django.contrib.auth.models import User -from apps.operations.models import AccountOpeningRequest, CloudComputerUser -from apps.hosts.models import Host -from apps.tasks.models import AsyncTask -from apps.tasks.models import TaskProgress -import logging -import secrets -import string - -logger = logging.getLogger(__name__) - - -def generate_secure_password(length=16): - # 排除 PowerShell 双引号字符串中有歧义的字符: " $ ` - _special = "!@#%^&*()_+-=[]{}|;:,.<>?" - alphabet = string.ascii_letters + string.digits + _special - while True: - password = ''.join(secrets.choice(alphabet) for _ in range(length)) - has_upper = any(c.isupper() for c in password) - has_lower = any(c.islower() for c in password) - has_digit = any(c.isdigit() for c in password) - has_special = any(c in _special for c in password) - if has_upper and has_lower and has_digit and has_special: - return password - - -@shared_task(bind=True) -def process_opening_request(self, request_id, operator_id): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"处理开户请求 #{request_id}", - created_by_id=operator_id, - target_object_id=request_id, - target_content_type='operations.AccountOpeningRequest', - status='running' - ) - - try: - request_obj = AccountOpeningRequest.objects.get(id=request_id) - task.start_execution() - - task.progress = 10 - task.save() - - TaskProgress.objects.create( - task=task, - progress=10, - message="开始处理开户请求" - ) - - available_host = Host.objects.filter( - is_active=True, - init_status='ready' - ).first() - - if not available_host: - raise Exception("没有可用的主机资源") - - task.progress = 30 - task.save() - - TaskProgress.objects.create( - task=task, - progress=30, - message="找到可用主机" - ) - - username = request_obj.username - password = generate_secure_password() - - task.progress = 50 - task.save() - - TaskProgress.objects.create( - task=task, - progress=50, - message="执行PowerShell命令创建用户" - ) - - client = available_host.get_connection_client() - - result = client.create_user( - username=username, - password=password, - description=getattr(request_obj, 'user_description', 'Cloud computer user') - ) - - if result.status_code != 0: - error_msg = result.std_err if result.std_err else 'Unknown error' - raise Exception(f"创建用户失败: {error_msg}") - - task.progress = 70 - task.save() - - TaskProgress.objects.create( - task=task, - progress=70, - message="用户创建成功" - ) - - request_obj.host = available_host - request_obj.windows_username = username - request_obj.windows_password = password - request_obj.status = 'approved' - request_obj.save() - - cloud_user, created = CloudComputerUser.objects.get_or_create( - account_opening_request=request_obj, - defaults={ - 'windows_username': username, - 'host': available_host, - 'status': 'active' - } - ) - if not created: - cloud_user.windows_username = username - cloud_user.host = available_host - cloud_user.status = 'active' - cloud_user.save() - - task.progress = 90 - task.save() - - TaskProgress.objects.create( - task=task, - progress=90, - message="更新请求状态" - ) - - task.progress = 100 - task.complete_success({ - 'host': available_host.hostname, - 'username': username, - 'success': True, - 'cloud_user_id': cloud_user.id - }) - - TaskProgress.objects.create( - task=task, - progress=100, - message="开户请求处理完成" - ) - - return { - 'success': True, - 'host': available_host.hostname, - 'username': username, - 'cloud_user_id': cloud_user.id - } - - except Exception as e: - logger.error(f"处理开户请求失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - try: - rollback_opening_request(request_id) - except Exception as rollback_error: - logger.error(f"回滚开户请求失败: {str(rollback_error)}", exc_info=True) - - return { - 'success': False, - 'error': str(e) - } - - -@shared_task(bind=True) -def remote_set_admin(self, cloud_user_id, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"设置管理员 - 用户 #{cloud_user_id}", - created_by_id=operator_id, - target_object_id=cloud_user_id, - target_content_type='operations.CloudComputerUser', - status='running' - ) - - try: - cloud_user = CloudComputerUser.objects.select_related('product', 'product__host').get(pk=cloud_user_id) - task.start_execution() - - host = cloud_user.product.host - client = host.get_connection_client() - client.op_user(cloud_user.username) - - task.progress = 100 - task.complete_success({'username': cloud_user.username, 'is_admin': True}) - - return {'success': True, 'username': cloud_user.username} - - except Exception as e: - logger.error(f"远程设置管理员失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - return {'success': False, 'error': str(e)} - - -@shared_task(bind=True) -def remote_remove_admin(self, cloud_user_id, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"取消管理员 - 用户 #{cloud_user_id}", - created_by_id=operator_id, - target_object_id=cloud_user_id, - target_content_type='operations.CloudComputerUser', - status='running' - ) - - try: - cloud_user = CloudComputerUser.objects.select_related('product', 'product__host').get(pk=cloud_user_id) - task.start_execution() - - host = cloud_user.product.host - client = host.get_connection_client() - client.deop_user(cloud_user.username) - - task.progress = 100 - task.complete_success({'username': cloud_user.username, 'is_admin': False}) - - return {'success': True, 'username': cloud_user.username} - - except Exception as e: - logger.error(f"远程取消管理员失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - return {'success': False, 'error': str(e)} - - -@shared_task(bind=True) -def remote_reset_windows_password(self, cloud_user_id, new_password, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"重置Windows密码 - 用户 #{cloud_user_id}", - created_by_id=operator_id, - target_object_id=cloud_user_id, - target_content_type='operations.CloudComputerUser', - status='running' - ) - - try: - cloud_user = CloudComputerUser.objects.select_related('product', 'product__host').get(pk=cloud_user_id) - task.start_execution() - - cloud_user.reset_windows_password(new_password) - - task.progress = 100 - task.complete_success({'username': cloud_user.username}) - - return {'success': True, 'username': cloud_user.username} - - except Exception as e: - logger.error(f"远程重置密码失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - return {'success': False, 'error': str(e)} - - -@shared_task(bind=True) -def remote_set_disk_quota(self, cloud_user_id, disk, quota_mb, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"设置磁盘配额 - 用户 #{cloud_user_id}", - created_by_id=operator_id, - target_object_id=cloud_user_id, - target_content_type='operations.CloudComputerUser', - status='running' - ) - - try: - cloud_user = CloudComputerUser.objects.select_related('product', 'product__host').get(pk=cloud_user_id) - task.start_execution() - - from utils.disk_quota import set_disk_quota_via_client - host = cloud_user.product.host - client = host.get_connection_client() - result = set_disk_quota_via_client(client, cloud_user.username, disk, quota_mb) - - if result['success']: - task.progress = 100 - task.complete_success(result) - return result - else: - task.complete_failure(result.get('message', '设置配额失败')) - return result - - except Exception as e: - logger.error(f"远程设置磁盘配额失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - return {'success': False, 'error': str(e)} - - -@shared_task(bind=True) -def remote_set_user_disk_quotas(self, cloud_user_id, disk_quota, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"设置用户磁盘配额 - 用户 #{cloud_user_id}", - created_by_id=operator_id, - target_object_id=cloud_user_id, - target_content_type='operations.CloudComputerUser', - status='running' - ) - - try: - cloud_user = CloudComputerUser.objects.select_related('product', 'product__host').get(pk=cloud_user_id) - task.start_execution() - - from utils.disk_quota import set_user_disk_quotas - host = cloud_user.product.host - client = host.get_connection_client() - result = set_user_disk_quotas(client, cloud_user.username, disk_quota) - - if result['success']: - task.progress = 100 - task.complete_success(result) - return result - else: - task.complete_failure('; '.join(result.get('errors', ['设置配额失败']))) - return result - - except Exception as e: - logger.error(f"远程设置用户磁盘配额失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - return {'success': False, 'error': str(e)} - - -@shared_task(bind=True) -def remote_get_disk_info(self, host_id, operator_id=None): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"获取磁盘信息 - 主机 #{host_id}", - created_by_id=operator_id, - target_object_id=host_id, - target_content_type='hosts.Host', - status='running' - ) - - try: - host = Host.objects.get(pk=host_id) - task.start_execution() - - from utils.disk_quota import get_disk_info_via_client - client = host.get_connection_client() - disks = get_disk_info_via_client(client) - - task.progress = 100 - task.complete_success({'disks': disks}) - - return {'success': True, 'data': disks} - - except Exception as e: - logger.error(f"远程获取磁盘信息失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - return {'success': False, 'error': str(e)} - - -@shared_task( - bind=True, - max_retries=3, - default_retry_delay=30, - autoretry_for=(Exception,), -) -def execute_cloud_user_remote_action(self, user_id, action): - """ - 异步执行云电脑用户的远程操作(禁用/启用/删除) - 将远程 WinRM 调用从 save() 中解耦,避免阻塞数据库事务 - """ - try: - cloud_user = CloudComputerUser.objects.get(pk=user_id) - except CloudComputerUser.DoesNotExist: - logger.error(f"CloudComputerUser with pk={user_id} does not exist, skipping remote action '{action}'") - return {'success': False, 'error': f'User {user_id} not found'} - - if action == 'disable': - cloud_user.disable_remote_user() - elif action == 'enable': - cloud_user.enable_remote_user() - elif action == 'delete': - cloud_user.delete_remote_user() - else: - logger.error(f"Unknown remote action '{action}' for user {cloud_user.username}") - return {'success': False, 'error': f'Unknown action: {action}'} - - return {'success': True, 'action': action, 'user_id': user_id} - - -@shared_task( - bind=True, - max_retries=2, - default_retry_delay=60, -) -def process_account_creation(self, request_id): - """ - 异步处理开户请求的用户创建流程 - 将远程 WinRM 调用从 AccountOpeningRequest.save() 中解耦 - """ - try: - request_obj = AccountOpeningRequest.objects.get(pk=request_id) - except AccountOpeningRequest.DoesNotExist: - logger.error(f"AccountOpeningRequest with pk={request_id} does not exist") - return {'success': False, 'error': f'Request {request_id} not found'} - - try: - request_obj.auto_process_creation() - return {'success': True, 'request_id': request_id} - except Exception as e: - logger.error(f"Account creation failed for request {request_id}: {str(e)}", exc_info=True) - try: - request_obj.refresh_from_db() - if request_obj.status not in ('completed', 'failed'): - request_obj.status = 'failed' - request_obj.result_message = f"异步处理异常: {str(e)}" - request_obj.save(update_fields=['status', 'result_message']) - except Exception as save_err: - logger.error(f"Failed to update request status: {str(save_err)}") - return {'success': False, 'error': str(e)} - - -@shared_task -def cleanup_expired_rdp_domains(): - from django.utils import timezone - from apps.operations.models import RdpDomainRoute - - expired_routes = RdpDomainRoute.objects.filter( - is_active=True, - expires_at__lt=timezone.now(), - ) - - cleaned = 0 - for route in expired_routes: - route.is_active = False - route.save(update_fields=['is_active']) - cleaned += 1 - - logger.info(f"Cleaned up {cleaned} expired RDP domain routes") - return {'cleaned': cleaned} - - -@shared_task -def allocate_rdp_domain(user_id, product_id): - from django.contrib.auth import get_user_model - from django.utils import timezone - from datetime import timedelta - from apps.operations.models import RdpDomainRoute, Product - - User = get_user_model() - - try: - user = User.objects.get(id=user_id) - product = Product.objects.get(id=product_id) - host = product.host - - if not product.enable_host_protection: - return { - 'success': False, - 'error': 'Host protection not enabled for this product', - } - - if host.connection_type != 'tunnel' or not host.tunnel_token: - return { - 'success': False, - 'error': 'Host is not a tunnel host', - } - - domain = RdpDomainRoute.generate_domain() - - route = RdpDomainRoute.objects.create( - domain=domain, - product=product, - assigned_to=user, - tunnel_token=host.tunnel_token, - is_active=True, - expires_at=timezone.now() + timedelta(minutes=10), - ) - - return { - 'success': True, - 'domain': domain, - 'expires_at': route.expires_at.isoformat(), - } - - except Exception as e: - logger.error(f"RDP domain allocation failed: {e}") - return { - 'success': False, - 'error': str(e), - } - - -def rollback_opening_request(request_id): - try: - request_obj = AccountOpeningRequest.objects.get(id=request_id) - if request_obj.host and request_obj.windows_username: - client = request_obj.host.get_connection_client() - - result = client.disabled_user(request_obj.windows_username) - - if result.status_code == 0: - logger.info(f"已禁用用户 {request_obj.windows_username}") - else: - logger.warning(f"禁用用户失败: {result.std_err}") - - request_obj.status = 'pending' - request_obj.save() - - except Exception as e: - logger.error(f"回滚操作失败: {str(e)}") - - -@shared_task(bind=True) -def reset_user_password(self, user_id, operator_id): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"重置用户密码 - 用户 #{user_id}", - created_by_id=operator_id, - target_object_id=user_id, - target_content_type='operations.CloudComputerUser', - status='running' - ) - - try: - user = CloudComputerUser.objects.get(id=user_id) - task.start_execution() - - new_password = generate_secure_password() - - client = user.host.get_connection_client() - - result = client.reset_password(user.windows_username, new_password) - - if result.status_code != 0: - error_msg = result.std_err if result.std_err else 'Unknown error' - raise Exception(f"重置密码失败: {error_msg}") - - if hasattr(user, 'account_opening_request') and user.account_opening_request: - user.account_opening_request.windows_password = new_password - user.account_opening_request.save() - - task.progress = 100 - task.complete_success({ - 'success': True, - 'message': '密码重置成功', - 'username': user.windows_username - }) - - return { - 'success': True, - 'message': '密码重置成功', - 'username': user.windows_username - } - - except Exception as e: - logger.error(f"重置密码失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - return { - 'success': False, - 'error': str(e) - } - - -@shared_task(bind=True) -def batch_process_opening_requests(self, request_ids, operator_id): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"批量处理开户请求 ({len(request_ids)}个)", - created_by_id=operator_id, - status='running' - ) - - try: - task.start_execution() - - results = { - 'processed': 0, - 'successful': 0, - 'failed': 0, - 'errors': [] - } - - total_requests = len(request_ids) - - for idx, request_id in enumerate(request_ids): - try: - progress = int((idx / total_requests) * 80) + 10 - task.progress = progress - task.save() - - result = process_opening_request.delay(request_id, operator_id).get() - - results['processed'] += 1 - if result['success']: - results['successful'] += 1 - else: - results['failed'] += 1 - results['errors'].append({ - 'request_id': request_id, - 'error': result.get('error', 'Unknown error') - }) - - except Exception as e: - results['failed'] += 1 - results['errors'].append({ - 'request_id': request_id, - 'error': str(e) - }) - - task.progress = 100 - task.complete_success(results) - - return results - - except Exception as e: - logger.error(f"批量处理开户请求失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - return { - 'success': False, - 'error': str(e) - } - - -@shared_task(bind=True) -def cleanup_inactive_users(self, days_inactive=30): - task = AsyncTask.objects.create( - task_id=self.request.id, - name=f"清理非活跃用户 (超过{days_inactive}天未使用)", - status='running' - ) - - try: - task.start_execution() - - from django.utils import timezone - from datetime import timedelta - - cutoff_date = timezone.now() - timedelta(days=days_inactive) - - inactive_users = CloudComputerUser.objects.filter( - last_login__lt=cutoff_date, - status='active' - ) - - cleaned_count = 0 - for user in inactive_users: - client = user.host.get_connection_client() - - result = client.disabled_user(user.windows_username) - - if result.status_code == 0: - user.status = 'disabled' - user.save() - cleaned_count += 1 - else: - logger.warning(f"无法禁用用户 {user.windows_username}: {result.std_err}") - - task.progress = 100 - task.complete_success({ - 'cleaned_users': cleaned_count, - 'total_inactive': inactive_users.count() - }) - - return { - 'success': True, - 'cleaned_users': cleaned_count, - 'total_inactive': inactive_users.count() - } - - except Exception as e: - logger.error(f"清理非活跃用户失败: {str(e)}", exc_info=True) - task.complete_failure(str(e)) - - return { - 'success': False, - 'error': str(e) - } diff --git a/apps/operations/urls.py b/apps/operations/urls.py deleted file mode 100755 index 2f69424..0000000 --- a/apps/operations/urls.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -操作记录URL配置 -""" -from django.urls import path -from . import views - -app_name = 'operations' - -urlpatterns = [ - # 系统任务相关URL - path('tasks/', views.SystemTaskListView.as_view(), name='task_list'), - path('tasks//', views.SystemTaskDetailView.as_view(), name='task_detail'), - - # 开户申请相关URL - path('account-openings/', views.AccountOpeningRequestListView.as_view(), name='account_opening_list'), - path('account-openings/create/', views.AccountOpeningRequestCreateView.as_view(), name='account_opening_create'), - path('account-openings/confirm/', views.account_opening_confirm, name='account_opening_confirm'), - path('account-openings/submit/', views.account_opening_submit, name='account_opening_submit'), - # 已删除:approve/reject/process 路由 - 功能已迁移至 Django Admin - path('account-openings//', views.account_opening_detail, name='account_opening_detail'), - - # 云电脑用户相关URL - path('cloud-users/', views.CloudComputerUserListView.as_view(), name='cloud_user_list'), - # 已删除:toggle-status 路由 - 功能已迁移至 Django Admin - - # 我的云电脑相关URL - path('my-cloud-computers/', views.MyCloudComputersView.as_view(), name='my_cloud_computers'), - path('my-cloud-computers//', views.my_cloud_computer_detail, name='my_cloud_computer_detail'), - path('my-cloud-computers//get-password/', views.get_password_and_burn, name='get_password_and_burn'), - path('my-cloud-computers//reset-password/', views.reset_password_and_burn, name='reset_password_and_burn'), - - # 磁盘配额相关API - path('api/product//disk-config/', views.get_product_disk_config, name='product_disk_config'), - path('api/host//disk-info/', views.get_host_disk_info, name='host_disk_info'), - - # 邀请链接相关URL - path('invite//', views.product_invite_view, name='product_invite'), - - path('rdp/connect//', views.rdp_connect, name='rdp_connect'), -] \ No newline at end of file diff --git a/apps/operations/urls_admin.py b/apps/operations/urls_admin.py deleted file mode 100644 index aa82ef9..0000000 --- a/apps/operations/urls_admin.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -超管后台 - 运营管理 URL 配置 - -命名空间: admin_operations -超管可查看所有运营数据,无数据隔离。 -""" - -from django.urls import path - -from . import views_admin - -app_name = 'admin_operations' - -urlpatterns = [ - # ========== 产品管理 ========== - path('products/', views_admin.AdminProductListView.as_view(), name='product_list'), - path('products/wizard/', views_admin.admin_product_wizard, name='product_wizard'), - path('products/create/', views_admin.AdminProductCreateView.as_view(), name='product_create'), - path('products//edit/', views_admin.AdminProductUpdateView.as_view(), name='product_edit'), - path('products//delete/', views_admin.AdminProductDeleteView.as_view(), name='product_delete'), - - # ========== 产品组管理 ========== - path('product-groups/', views_admin.AdminProductGroupListView.as_view(), name='productgroup_list'), - path('product-groups/create/', views_admin.AdminProductGroupCreateView.as_view(), name='productgroup_create'), - path('product-groups//edit/', views_admin.AdminProductGroupUpdateView.as_view(), name='productgroup_edit'), - path('product-groups//delete/', views_admin.AdminProductGroupDeleteView.as_view(), name='productgroup_delete'), - - # ========== 开户申请管理 ========== - path('requests/', views_admin.AdminRequestListView.as_view(), name='request_list'), - path('requests//', views_admin.AdminRequestDetailView.as_view(), name='request_detail'), - path('requests//approve/', views_admin.AdminRequestApproveView.as_view(), name='request_approve'), - path('requests//reject/', views_admin.AdminRequestRejectView.as_view(), name='request_reject'), - path('requests//retry/', views_admin.AdminRequestRetryView.as_view(), name='request_retry'), - path('requests/batch-approve/', views_admin.AdminRequestBatchApproveView.as_view(), name='request_batch_approve'), - path('requests/batch-reject/', views_admin.AdminRequestBatchRejectView.as_view(), name='request_batch_reject'), - - # ========== 云电脑用户管理 ========== - path('users/', views_admin.AdminCloudUserListView.as_view(), name='user_list'), - path('users//', views_admin.AdminCloudUserDetailView.as_view(), name='user_detail'), - path('users//action/', views_admin.admin_cloud_user_action, name='user_action'), - path('users//set-quota/', views_admin.admin_cloud_user_set_quota, name='user_set_quota'), - - # ========== 邀请令牌管理 ========== - path('tokens/', views_admin.AdminTokenListView.as_view(), name='token_list'), - path('tokens//', views_admin.AdminTokenDetailView.as_view(), name='token_detail'), - - # ========== 访问授权管理 ========== - path('grants/', views_admin.AdminGrantListView.as_view(), name='grant_list'), - - # ========== RDP域名路由管理 ========== - path('routes/', views_admin.AdminRouteListView.as_view(), name='route_list'), - - # ========== 系统任务管理 ========== - path('tasks/', views_admin.AdminTaskListView.as_view(), name='task_list'), -] diff --git a/apps/operations/urls_provider.py b/apps/operations/urls_provider.py deleted file mode 100644 index 5146754..0000000 --- a/apps/operations/urls_provider.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -运营管理 - 提供商后台 URL 配置 - -开户申请、邀请令牌、访问授权、RDP域名路由、系统任务、 -产品管理、产品组管理相关路由, -挂载在 /provider/operations/ 下。 -命名空间: provider_operations -""" - -from django.urls import path - -from . import views_provider - -app_name = 'provider_operations' - -urlpatterns = [ - # ---- 产品管理 ---- - path( - 'products/', - views_provider.ProductListView.as_view(), - name='product_list', - ), - path( - 'products/create/', - views_provider.ProductCreateView.as_view(), - name='product_create', - ), - path( - 'products//', - views_provider.ProductDetailView.as_view(), - name='product_detail', - ), - path( - 'products//edit/', - views_provider.ProductUpdateView.as_view(), - name='product_edit', - ), - path( - 'products//delete/', - views_provider.ProductDeleteView.as_view(), - name='product_delete', - ), - - # ---- 产品组管理 ---- - path( - 'product-groups/', - views_provider.ProductGroupListView.as_view(), - name='productgroup_list', - ), - path( - 'product-groups/create/', - views_provider.ProductGroupCreateView.as_view(), - name='productgroup_create', - ), - path( - 'product-groups//edit/', - views_provider.ProductGroupUpdateView.as_view(), - name='productgroup_edit', - ), - path( - 'product-groups//delete/', - views_provider.ProductGroupDeleteView.as_view(), - name='productgroup_delete', - ), - - # ---- 云电脑用户 ---- - path( - 'users/', - views_provider.CloudComputerUserListView.as_view(), - name='user_list', - ), - path( - 'users//', - views_provider.CloudComputerUserDetailView.as_view(), - name='user_detail', - ), - path( - 'users//sync-admin/', - views_provider.CloudComputerUserSyncAdminView.as_view(), - name='user_sync_admin', - ), - path( - 'users//disk-quota/', - views_provider.CloudComputerUserSetDiskQuotaView.as_view(), - name='user_disk_quota', - ), - path( - 'users//reset-password/', - views_provider.CloudComputerUserResetPasswordView.as_view(), - name='user_reset_password', - ), - path( - 'users/batch-activate/', - views_provider.CloudComputerUserBatchActivateView.as_view(), - name='user_batch_activate', - ), - path( - 'users/batch-deactivate/', - views_provider.CloudComputerUserBatchDeactivateView.as_view(), - name='user_batch_deactivate', - ), - path( - 'users/batch-disable/', - views_provider.CloudComputerUserBatchDisableView.as_view(), - name='user_batch_disable', - ), - - # ---- 开户申请 ---- - path( - 'requests/', - views_provider.AccountOpeningRequestListView.as_view(), - name='request_list', - ), - # 批量批准(必须在 之前,避免路径冲突) - path( - 'requests/batch-approve/', - views_provider.AccountOpeningRequestBatchApproveView.as_view(), - name='request_batch_approve', - ), - # 批量驳回(必须在 之前,避免路径冲突) - path( - 'requests/batch-reject/', - views_provider.AccountOpeningRequestBatchRejectView.as_view(), - name='request_batch_reject', - ), - # 开户申请详情 - path( - 'requests//', - views_provider.AccountOpeningRequestDetailView.as_view(), - name='request_detail', - ), - # 批准单条申请 - path( - 'requests//approve/', - views_provider.AccountOpeningRequestApproveView.as_view(), - name='request_approve', - ), - # 驳回单条申请 - path( - 'requests//reject/', - views_provider.AccountOpeningRequestRejectView.as_view(), - name='request_reject', - ), - # 执行开户 - path( - 'requests//execute/', - views_provider.AccountOpeningRequestExecuteView.as_view(), - name='request_execute', - ), - - # ---- 邀请令牌 ---- - path( - 'tokens/', - views_provider.ProductInvitationTokenListView.as_view(), - name='token_list', - ), - path( - 'tokens//', - views_provider.ProductInvitationTokenDetailView.as_view(), - name='token_detail', - ), - path( - 'tokens/create/', - views_provider.ProductInvitationTokenCreateView.as_view(), - name='token_create', - ), - path( - 'tokens/batch-enable/', - views_provider.ProductInvitationTokenBatchEnableView.as_view(), - name='token_batch_enable', - ), - path( - 'tokens/batch-disable/', - views_provider.ProductInvitationTokenBatchDisableView.as_view(), - name='token_batch_disable', - ), - - # ---- 访问授权 ---- - path( - 'grants/', - views_provider.ProductAccessGrantListView.as_view(), - name='grant_list', - ), - path( - 'grants/batch-revoke/', - views_provider.ProductAccessGrantBatchRevokeView.as_view(), - name='grant_batch_revoke', - ), - - # ---- RDP 域名路由 ---- - path( - 'routes/', - views_provider.RdpDomainRouteListView.as_view(), - name='route_list', - ), - - # ---- 系统任务(只读参考) ---- - path( - 'tasks/', - views_provider.SystemTaskListView.as_view(), - name='task_list', - ), -] diff --git a/apps/operations/views.py b/apps/operations/views.py deleted file mode 100755 index 0922a9b..0000000 --- a/apps/operations/views.py +++ /dev/null @@ -1,955 +0,0 @@ -""" -操作记录视图 -""" - -from django.shortcuts import render, get_object_or_404, redirect -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from django.urls import reverse_lazy -from django.views.generic import ListView, CreateView, DetailView -from django.utils.decorators import method_decorator -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import JsonResponse, HttpResponseForbidden -import logging - -logger = logging.getLogger(__name__) -from django.views.decorators.csrf import csrf_protect -from django.views.decorators.http import require_POST -from django.utils.translation import gettext_lazy as _ -from django.db.models import Q -from datetime import timedelta -from .models import AccountOpeningRequest, SystemTask, CloudComputerUser, Product -from .forms import ( - AccountOpeningRequestForm, - AccountOpeningRequestFilterForm, - CloudComputerUserFilterForm, -) -from apps.hosts.models import Host - - -@method_decorator(login_required, name="dispatch") -class SystemTaskListView(ListView): - """系统任务列表视图""" - - model = SystemTask - template_name = "operations/systemtask_list.html" - context_object_name = "tasks" - paginate_by = 20 - - def get_queryset(self): - """获取查询集""" - queryset = SystemTask.objects.all() - - # 应用过滤条件 - form = SystemTaskFilterForm(self.request.GET) - if form.is_valid(): - task_type = form.cleaned_data.get("task_type") - status = form.cleaned_data.get("status") - start_date = form.cleaned_data.get("start_date") - end_date = form.cleaned_data.get("end_date") - - if task_type: - queryset = queryset.filter(task_type__icontains=task_type[:50]) - if status: - queryset = queryset.filter(status=status) - if start_date: - queryset = queryset.filter(created_at__gte=start_date) - if end_date: - # 包含结束日期的整天 - end_date = end_date + timedelta(days=1) - queryset = queryset.filter(created_at__lt=end_date) - - return queryset.select_related("created_by").order_by("-created_at") - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context["filter_form"] = SystemTaskFilterForm(self.request.GET) - return context - - -@method_decorator(login_required, name="dispatch") -class SystemTaskDetailView(DetailView): - """系统任务详情视图""" - - model = SystemTask - template_name = "operations/systemtask_detail.html" - context_object_name = "task" - - -@login_required -def task_progress(request, task_id): - """ - 获取任务进度 - - Args: - request: HTTP请求对象 - task_id: 任务ID - - Returns: - JsonResponse: JSON格式的响应 - """ - try: - task = SystemTask.objects.get(pk=task_id) - return JsonResponse( - { - "success": True, - "data": { - "id": task.id, - "name": task.name, - "status": task.status, - "progress": task.progress, - "result": task.result, - "error_message": task.error_message, - }, - } - ) - except SystemTask.DoesNotExist: - return JsonResponse({"success": False, "message": "任务不存在"}) - - -@method_decorator(login_required, name="dispatch") -class AccountOpeningRequestCreateView(CreateView): - """创建开户申请视图""" - - model = AccountOpeningRequest - form_class = AccountOpeningRequestForm - template_name = "operations/account_opening_request_form.html" - success_url = reverse_lazy("operations:account_opening_confirm") - - def get_form_kwargs(self): - """获取表单初始化参数""" - kwargs = super().get_form_kwargs() - - # 获取目标产品ID参数 - target_product_id = self.request.GET.get("target_product") - target_host_id = self.request.GET.get("target_host") # 兼容旧参数 - - # 获取可用产品查询集 - site_group = getattr(self.request, "site_group", None) - if site_group: - products_qs = Product.objects.filter( - is_available=True, site_group=site_group - ) - else: - products_qs = Product.objects.filter( - is_available=True, site_group__isnull=True - ) - - # 如果指定了特定产品,限制查询集 - if target_product_id: - try: - if site_group: - target_product = Product.objects.filter(site_group=site_group).get( - id=target_product_id, is_available=True - ) - else: - target_product = Product.objects.get( - id=target_product_id, is_available=True, site_group__isnull=True - ) - products_qs = Product.objects.filter(id=target_product.id) - except Product.DoesNotExist: - pass - elif target_host_id: - # 兼容旧参数:如果通过target_host指定,则找出关联的产品 - try: - from apps.hosts.models import Host - - host = Host.objects.get(id=target_host_id) - # 获取与该主机关联的所有可用产品 - if site_group: - products_qs = Product.objects.filter( - host=host, is_available=True, site_group=site_group - ) - else: - products_qs = Product.objects.filter( - host=host, is_available=True, site_group__isnull=True - ) - except Host.DoesNotExist: - pass - - # 将产品查询集传递给表单 - kwargs["products_qs"] = products_qs - return kwargs - - def form_valid(self, form): - """表单验证成功后的处理""" - # 将表单数据存储到session中以供确认页面使用 - confirm_data = { - "contact_email": self.request.user.email, # 使用当前用户的邮箱,而不是从表单获取 - "username": form.cleaned_data["username"], - "user_fullname": form.cleaned_data["user_fullname"], - "user_description": form.cleaned_data["user_description"], - "target_product_id": form.cleaned_data["target_product"].id, - "target_product_name": form.cleaned_data["target_product"].display_name, - "requested_disk_capacity": form.cleaned_data.get( - "requested_disk_capacity", {} - ), - } - self.request.session["confirm_data"] = confirm_data - - # 重定向到确认页面,而不是直接保存 - return redirect("operations:account_opening_confirm") - - def form_invalid(self, form): - """表单验证失败后的处理""" - messages.error(self.request, "开户申请信息填写有误,请检查输入信息。") - return super().form_invalid(form) - - -@login_required -def account_opening_confirm(request): - """开户申请确认页面""" - confirm_data = request.session.get("confirm_data") - if not confirm_data: - messages.error(request, "未找到待确认的申请信息,请重新填写申请。") - return redirect("operations:account_opening_create") - - context = {"confirm_data": confirm_data} - return render(request, "operations/account_opening_confirm.html", context) - - -@csrf_protect -@require_POST -@login_required -def account_opening_submit(request): - """提交开户申请""" - confirm_data = request.session.get("confirm_data") - if not confirm_data: - messages.error(request, "未找到待提交的申请信息。") - logger.warning( - f"用户 {request.user.username} 尝试提交开户申请,但未找到确认数据" - ) - return redirect("operations:account_opening_create") - - # 创建开户申请对象 - account_request = AccountOpeningRequest() - account_request.applicant = request.user - account_request.contact_email = ( - request.user.email - ) # 使用当前用户的邮箱,而不是表单中的数据 - account_request.username = confirm_data["username"] - account_request.user_fullname = confirm_data["user_fullname"] - account_request.user_email = request.user.email # 使用当前用户的邮箱 - account_request.user_description = confirm_data["user_description"] - account_request.requested_disk_capacity = confirm_data.get( - "requested_disk_capacity", {} - ) - # 移除了requested_password字段,由系统自动生成 - - # 设置目标产品 - try: - site_group = getattr(request, "site_group", None) - if site_group: - target_product = Product.objects.filter(site_group=site_group).get( - id=confirm_data["target_product_id"] - ) - else: - target_product = Product.objects.get( - id=confirm_data["target_product_id"], site_group__isnull=True - ) - account_request.target_product = target_product - logger.info( - f"用户 {request.user.username} 提交开户申请,目标产品: {target_product.name}, 用户名: {account_request.username}, 联系邮箱: {account_request.contact_email}" - ) - except Product.DoesNotExist: - messages.error(request, "指定的目标产品不存在。") - logger.error( - f'用户 {request.user.username} 尝试提交申请,但目标产品ID {confirm_data["target_product_id"]} 不存在' - ) - return redirect("operations:account_opening_create") - - try: - logger.info(f"准备保存开户申请,当前状态: {account_request.status}") - account_request.save() - logger.info( - f"开户申请已保存,ID: {account_request.id}, 最终状态: {account_request.status}" - ) - messages.success(request, "开户申请已成功提交,请等待审核。") - - # 清除session中的确认数据 - del request.session["confirm_data"] - - return redirect("operations:account_opening_list") - except Exception as e: - logger.error(f"提交申请时发生错误: {str(e)}", exc_info=True) - messages.error(request, "提交申请时发生错误,请稍后重试") - return redirect("operations:account_opening_create") - - -class AccountOpeningRequestListView(ListView): - """开户申请列表视图""" - - model = AccountOpeningRequest - template_name = "operations/account_opening_request_list.html" - context_object_name = "requests" - paginate_by = 20 - - def get_queryset(self): - """获取查询集""" - queryset = AccountOpeningRequest.objects.all() - - site_group = getattr(self.request, "site_group", None) - if site_group: - queryset = queryset.filter(target_product__site_group=site_group) - else: - queryset = queryset.filter(target_product__site_group__isnull=True) - - if self.request.user.is_authenticated: - if not (self.request.user.is_staff or self.request.user.is_superuser): - queryset = queryset.filter(applicant=self.request.user) - else: - # 未认证用户不显示任何申请 - queryset = queryset.none() - - # 应用过滤条件 - form = AccountOpeningRequestFilterForm( - self.request.GET, - site_group=getattr(self.request, "site_group", None), - ) - if form.is_valid(): - status = form.cleaned_data.get("status") - if status: - queryset = queryset.filter(status=status) - - host = form.cleaned_data.get("host") - if host: - # 查询与该主机相关的产品的申请 - queryset = queryset.filter(target_product__host=host) - - search = form.cleaned_data.get("search") - if search: - queryset = queryset.filter( - Q(username__icontains=search[:50]) - | Q(user_fullname__icontains=search[:50]) - | Q(contact_email__icontains=search[:50]) - ) - - return queryset.select_related( - "applicant", "target_product", "target_product__host", "approved_by" - ).order_by("-created_at") - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context["filter_form"] = AccountOpeningRequestFilterForm( - self.request.GET, - site_group=getattr(self.request, "site_group", None), - ) - context["statuses"] = AccountOpeningRequest._meta.get_field("status").choices - - # 如果是管理员,显示所有主机;否则只显示与用户申请相关的产品的主机 - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_authenticated and ( - self.request.user.is_staff or self.request.user.is_superuser - ): - if site_group: - context["hosts"] = Host.objects.filter(site_group=site_group) - else: - context["hosts"] = Host.objects.filter(site_group__isnull=True) - elif self.request.user.is_authenticated: - host_qs = Host.objects.filter( - product__accountopeningrequest__applicant=self.request.user - ).distinct() - if site_group: - host_qs = host_qs.filter(site_group=site_group) - else: - host_qs = host_qs.filter(site_group__isnull=True) - context["hosts"] = host_qs - else: - context["hosts"] = Host.objects.none() - - return context - - -@login_required -def account_opening_detail(request, pk): - """查看开户申请详情""" - site_group = getattr(request, "site_group", None) - if site_group: - account_request = get_object_or_404( - AccountOpeningRequest.objects.filter( - Q(target_product__site_group=site_group) - ), - pk=pk, - ) - else: - account_request = get_object_or_404( - AccountOpeningRequest, pk=pk, target_product__site_group__isnull=True - ) - - # 检查权限:用户只能查看自己提交的申请 - if account_request.applicant != request.user and not ( - request.user.is_staff or request.user.is_superuser - ): - messages.error(request, "您没有权限查看此申请的详情。") - return redirect("operations:account_opening_list") - - timeline = [] - timeline.append( - { - "label": "提交申请", - "time": account_request.created_at, - "done": True, - } - ) - if account_request.status in ( - "approved", - "rejected", - "processing", - "completed", - "failed", - ): - timeline.append( - { - "label": "审核完成", - "time": account_request.approval_date, - "done": ( - account_request.status != "failed" - or account_request.approval_date is not None - ), - "detail": ("批准" if account_request.status != "rejected" else "驳回"), - } - ) - if account_request.status in ("processing", "completed", "failed"): - timeline.append( - { - "label": "执行开户", - "time": ( - account_request.updated_at - if account_request.status == "completed" - else None - ), - "done": account_request.status in ("completed",), - "detail": ( - "开户处理失败,请联系管理员了解详情" - if account_request.status == "failed" - else account_request.result_message - if account_request.result_message - else None - ), - } - ) - - context = { - "request": account_request, - "timeline": timeline, - } - return render(request, "operations/account_opening_request_detail.html", context) - - -@method_decorator(login_required, name="dispatch") -class CloudComputerUserListView(ListView): - """云电脑用户列表视图""" - - model = CloudComputerUser - template_name = "operations/cloud_computer_user_list.html" - context_object_name = "cloud_users" - paginate_by = 20 - - def get_queryset(self): - """获取查询集""" - queryset = CloudComputerUser.objects.all() - - site_group = getattr(self.request, "site_group", None) - if site_group: - queryset = queryset.filter(product__site_group=site_group) - else: - queryset = queryset.filter(product__site_group__isnull=True) - - form = CloudComputerUserFilterForm( - self.request.GET, - site_group=getattr(self.request, "site_group", None), - ) - if form.is_valid(): - status = form.cleaned_data.get("status") - if status: - queryset = queryset.filter(status=status) - - product = form.cleaned_data.get("product") - if product: - queryset = queryset.filter(product=product) - - search = form.cleaned_data.get("search") - if search: - queryset = queryset.filter( - Q(username__icontains=search[:50]) - | Q(fullname__icontains=search[:50]) - | Q(email__icontains=search[:50]) - ) - - return queryset.select_related( - "product", "created_from_request__applicant" - ).order_by("-created_at") - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context["filter_form"] = CloudComputerUserFilterForm( - self.request.GET, - site_group=getattr(self.request, "site_group", None), - ) - context["statuses"] = CloudComputerUser._meta.get_field("status").choices - site_group = getattr(self.request, "site_group", None) - if site_group: - context["products"] = Product.objects.filter(site_group=site_group) - else: - context["products"] = Product.objects.filter(site_group__isnull=True) - return context - - -# 已删除:toggle_cloud_user_status -# 用户状态切换功能已迁移至 Django Admin 的 Action 实现 - - -@method_decorator(login_required, name="dispatch") -class MyCloudComputersView(ListView): - """我的云电脑用户列表视图 - - 显示当前用户拥有的云电脑用户 - """ - - model = CloudComputerUser - template_name = "operations/my_cloud_computers.html" - context_object_name = "cloud_users" - paginate_by = 20 - - def get_queryset(self): - """获取查询集 - 只显示当前用户通过开户申请创建的云电脑用户""" - queryset = CloudComputerUser.objects.filter( - created_from_request__applicant=self.request.user - ) - - site_group = getattr(self.request, "site_group", None) - if site_group: - queryset = queryset.filter(product__site_group=site_group) - else: - queryset = queryset.filter(product__site_group__isnull=True) - - form = CloudComputerUserFilterForm( - self.request.GET, - site_group=getattr(self.request, "site_group", None), - ) - if form.is_valid(): - status = form.cleaned_data.get("status") - if status: - queryset = queryset.filter(status=status) - - search = form.cleaned_data.get("search") - if search: - queryset = queryset.filter( - Q(username__icontains=search) - | Q(fullname__icontains=search) - | Q(email__icontains=search) - | Q(product__display_name__icontains=search) - ) - - # 按产品筛选 - product_filter = self.request.GET.get("product") - if product_filter: - queryset = queryset.filter(product__display_name=product_filter) - - return queryset.select_related("product", "created_from_request").order_by( - "-created_at" - ) - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context["filter_form"] = CloudComputerUserFilterForm( - self.request.GET, - site_group=getattr(self.request, "site_group", None), - ) - context["statuses"] = CloudComputerUser._meta.get_field("status").choices - - context["current_product"] = self.request.GET.get("product", "") - context["current_search"] = self.request.GET.get("search", "") - context["current_status"] = self.request.GET.get("status", "") - - from collections import defaultdict - - cloud_users_by_product = defaultdict(list) - for user in context["cloud_users"]: - cloud_users_by_product[user.product.display_name].append(user) - context["cloud_users_by_product"] = dict(cloud_users_by_product) - - return context - - -@login_required -def my_cloud_computer_detail(request, pk): - """我的云电脑用户详情页面""" - cloud_user = get_object_or_404(CloudComputerUser, pk=pk) - - # 权限检查:owner优先,兼容旧数据用created_from_request - if cloud_user.owner: - if cloud_user.owner != request.user: - return HttpResponseForbidden("无权访问") - elif cloud_user.created_from_request: - if cloud_user.created_from_request.applicant != request.user: - return HttpResponseForbidden("无权访问") - else: - return HttpResponseForbidden("无权访问") - - context = {"cloud_user": cloud_user} - return render(request, "operations/my_cloud_computer_detail.html", context) - - -@login_required -@require_POST -def get_password_and_burn(request, pk): - """获取密码并销毁 - 阅后即焚""" - cloud_user = get_object_or_404(CloudComputerUser, pk=pk) - - # 权限检查 - has_access = False - if cloud_user.owner and cloud_user.owner == request.user: - has_access = True - elif ( - cloud_user.created_from_request - and cloud_user.created_from_request.applicant == request.user - ): - has_access = True - - if not has_access: - return JsonResponse({"success": False, "error": "无权访问"}, status=403) - - try: - password = cloud_user.get_and_burn_password() - return JsonResponse({"success": True, "password": password}) - except Exception as e: - logger.error(f"获取密码失败: {str(e)}", exc_info=True) - return JsonResponse({"success": False, "error": "Failed to retrieve password"}) - - -@login_required -@require_POST -def reset_password_and_burn(request, pk): - """重置密码并返回新密码(阅后即焚)""" - cloud_user = get_object_or_404(CloudComputerUser, pk=pk) - - # 权限检查 - has_access = False - if cloud_user.owner and cloud_user.owner == request.user: - has_access = True - elif ( - cloud_user.created_from_request - and cloud_user.created_from_request.applicant == request.user - ): - has_access = True - - if not has_access: - return JsonResponse({"success": False, "error": "无权访问"}, status=403) - - try: - new_password = cloud_user.reset_and_get_new_password() - return JsonResponse({"success": True, "password": new_password}) - except Exception as e: - logger.error(f"重置密码失败: {str(e)}", exc_info=True) - return JsonResponse({"success": False, "error": str(e)}) - - -@login_required -def get_product_disk_config(request, product_id): - """获取产品的磁盘配额配置""" - try: - site_group = getattr(request, "site_group", None) - if site_group: - product = Product.objects.filter(site_group=site_group).get( - pk=product_id, is_available=True - ) - else: - product = Product.objects.get( - pk=product_id, is_available=True, site_group__isnull=True - ) - except Product.DoesNotExist: - return JsonResponse({"success": False, "error": "产品不存在"}, status=404) - - return JsonResponse( - { - "success": True, - "data": { - "enable_disk_quota": product.enable_disk_quota, - "default_disk_quota": product.default_disk_quota, - "allow_extra_quota_disks": product.allow_extra_quota_disks, - }, - } - ) - - -@login_required -def get_host_disk_info(request, host_id): - """获取主机的磁盘信息""" - - site_group = getattr(request, "site_group", None) - host_qs = Host.objects.filter(pk=host_id) - if site_group: - host_qs = host_qs.filter(site_group=site_group) - else: - host_qs = host_qs.filter(site_group__isnull=True) - try: - host = host_qs.get() - except Host.DoesNotExist: - return JsonResponse({"success": False, "error": "主机不存在"}, status=404) - - if not request.user.is_superuser and not request.user.is_staff: - if ( - not host.administrators.filter(pk=request.user.pk).exists() - and not host.providers.filter(pk=request.user.pk).exists() - ): - return JsonResponse({"success": False, "error": "无权访问"}, status=403) - - try: - from apps.operations.tasks import remote_get_disk_info - - result = remote_get_disk_info.delay(host_id, operator_id=request.user.pk) - return JsonResponse( - { - "success": True, - "task_id": result.id, - "message": "磁盘信息获取已提交,正在后台执行", - } - ) - except Exception as e: - logger.error(f"Error dispatching disk info task: {str(e)}", exc_info=True) - return JsonResponse({"success": False, "error": "Failed to get disk info"}) - - -def product_invite_view(request, token): - """ - 产品邀请链接视图 - - 用户访问邀请链接后,解锁对应产品或产品组的访问权限 - GET: 显示邀请信息和确认页面 - POST: 确认接受邀请,执行授权操作 - """ - from django.shortcuts import render, redirect - from django.contrib import messages - from django.contrib.auth import get_user_model - from django.urls import reverse - from django.db.models import Q - from .models import ProductInvitationToken, ProductAccessGrant - - User = get_user_model() - - site_group = getattr(request, "site_group", None) - if site_group: - invite_token = ( - ProductInvitationToken.objects.filter(token=token) - .filter( - Q(product__site_group=site_group) - | Q(product_group__site_group=site_group) - ) - .first() - ) - else: - invite_token = ( - ProductInvitationToken.objects.filter(token=token) - .filter( - Q(product__site_group__isnull=True) - | Q(product_group__site_group__isnull=True) - ) - .first() - ) - - if not invite_token: - return render( - request, - "operations/invite_result.html", - { - "success": False, - "message": "邀请链接无效或不存在。", - }, - ) - - if not invite_token.is_valid(): - if invite_token.is_expired(): - return render( - request, - "operations/invite_result.html", - { - "success": False, - "message": "邀请链接已过期。", - }, - ) - if invite_token.is_exhausted(): - return render( - request, - "operations/invite_result.html", - { - "success": False, - "message": "邀请链接已达到最大使用次数。", - }, - ) - return render( - request, - "operations/invite_result.html", - { - "success": False, - "message": "邀请链接已被禁用。", - }, - ) - - if not request.user.is_authenticated: - login_url = reverse("accounts:login") + f"?next={request.path}" - return redirect(login_url) - - existing_grant = ProductAccessGrant.objects.filter( - user=request.user, - product=invite_token.product, - product_group=invite_token.product_group, - is_revoked=False, - ).first() - - if existing_grant and not existing_grant.is_expired(): - return render( - request, - "operations/invite_result.html", - { - "success": True, - "message": "您已经拥有该产品的访问权限。", - "product": invite_token.product, - "product_group": invite_token.product_group, - }, - ) - - if request.method == "GET": - target_name = ( - invite_token.product.display_name - if invite_token.product - else ( - invite_token.product_group.name - if invite_token.product_group - else "未知" - ) - ) - return render( - request, - "operations/invite_result.html", - { - "success": None, - "message": f'您即将解锁 "{target_name}" 的访问权限。', - "product": invite_token.product, - "product_group": invite_token.product_group, - "needs_confirm": True, - "token": token, - }, - ) - - try: - grant, created = ProductAccessGrant.objects.get_or_create( - user=request.user, - product=invite_token.product, - product_group=invite_token.product_group, - defaults={ - "granted_by_token": invite_token, - "expires_at": invite_token.expires_at, - }, - ) - if not created: - if grant.is_revoked or grant.is_expired(): - grant.is_revoked = False - grant.revoked_at = None - grant.revoked_by = None - grant.granted_by_token = invite_token - grant.expires_at = invite_token.expires_at - grant.save( - update_fields=[ - "is_revoked", - "revoked_at", - "revoked_by", - "granted_by_token", - "expires_at", - ] - ) - except Exception as e: - logger.error(f"创建授权记录失败: {str(e)}", exc_info=True) - return render( - request, - "operations/invite_result.html", - { - "success": False, - "message": "授权处理失败,请稍后重试。", - }, - ) - - invite_token.increment_usage() - - target_name = ( - invite_token.product.display_name - if invite_token.product - else (invite_token.product_group.name if invite_token.product_group else "未知") - ) - - return render( - request, - "operations/invite_result.html", - { - "success": True, - "message": f'恭喜!您已成功解锁 "{target_name}" 的访问权限。', - "product": invite_token.product, - "product_group": invite_token.product_group, - }, - ) - - -@login_required -def rdp_connect(request, product_id): - from django.http import HttpResponse - from django.conf import settings - from utils.gateway_client import GatewayClient - from apps.dashboard.models import SystemConfig - - site_group = getattr(request, "site_group", None) - if site_group: - product = get_object_or_404( - Product.objects.filter(site_group=site_group), pk=product_id - ) - else: - product = get_object_or_404(Product, pk=product_id, site_group__isnull=True) - - cloud_user = ( - CloudComputerUser.objects.filter( - product=product, - status="active", - ) - .filter(Q(owner=request.user) | Q(created_from_request__applicant=request.user)) - .first() - ) - - if cloud_user is None: - messages.error(request, "您没有访问此云电脑的权限。") - return redirect("operations:my_cloud_computers") - - host = product.host - if not host or not host.tunnel_token: - messages.error(request, "该产品关联的主机未配置隧道,无法通过RD Gateway连接。") - return redirect("operations:my_cloud_computers") - - gateway_address = getattr(settings, "GATEWAY_ADDRESS", "rdp.2c2a.com") - gateway_port = getattr(settings, "GATEWAY_PORT", 443) - expires_in = getattr(settings, "GATEWAY_PAA_TOKEN_EXPIRY_SECONDS", 600) - - client_ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() - if not client_ip: - client_ip = request.META.get("REMOTE_ADDR") - - client = GatewayClient() - paa_token = client.issue_paa_token( - user_email=request.user.email, - tunnel_token=host.tunnel_token, - client_ip=client_ip, - expires_in=expires_in, - ) - - rdp_content = client.generate_rdp_file( - gateway_address=gateway_address, - gateway_port=gateway_port, - user_email=request.user.email, - paa_token=paa_token, - ) - - filename = f"{product.display_name}.rdp" - response = HttpResponse(rdp_content, content_type="application/x-rdp") - response["Content-Disposition"] = f'attachment; filename="{filename}"' - return response diff --git a/apps/operations/views_admin.py b/apps/operations/views_admin.py deleted file mode 100644 index bd85bc5..0000000 --- a/apps/operations/views_admin.py +++ /dev/null @@ -1,1773 +0,0 @@ -""" -运营管理 - 超级管理员后台视图 - -包含产品、产品组、开户申请、云电脑用户、邀请令牌、访问授权、 -RDP域名路由、系统任务等视图。 -所有视图均受超级管理员身份验证保护。 -超管可查看所有数据;提供商仅可查看自己创建的数据。 -""" - -import json -import logging - -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.core.paginator import Paginator -from django.db.models import Q -from django.http import HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.utils import timezone -from django.utils.decorators import method_decorator -from django.views import View -from django.views.generic import DetailView, ListView, TemplateView - -from apps.accounts.provider_decorators import admin_required -from utils.provider import get_provider_products -from apps.operations.models import ( - AccountOpeningRequest, - CloudComputerUser, - Product, - ProductGroup, - ProductInvitationToken, - ProductAccessGrant, - RdpDomainRoute, -) -from apps.tasks.models import AsyncTask - -from .forms_admin import ( - AdminProductForm, - AdminProductGroupForm, - AdminRequestRejectForm, -) - -logger = logging.getLogger(__name__) -User = get_user_model() - - -# ========== 辅助函数 ========== - - -def _get_selected_ids(request): - """ - 从 POST 请求中提取选中的 ID 列表 - - 支持两种格式: - 1. 表单字段: selected_ids=1&selected_ids=2 - 2. JSON 字符串: selected_ids=[1,2,3] - """ - ids = request.POST.getlist("selected_ids") - if ids: - return [int(i) for i in ids if i.strip().isdigit()] - - raw = request.POST.get("selected_ids", "") - if raw: - try: - parsed = json.loads(raw) - if isinstance(parsed, list): - return [ - int(i) - for i in parsed - if isinstance(i, (int, str)) and str(i).isdigit() - ] - except (json.JSONDecodeError, ValueError): - pass - - return [] - - -# =========================================================================== -# 产品管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminProductListView(TemplateView): - """ - 超管产品列表视图 - - - 查看所有产品(无数据隔离) - - 搜索、筛选、分页 - - 显示创建者信息 - """ - - template_name = "admin_base/operations/product_list.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - queryset = Product.objects.select_related( - "host", - "product_group", - "created_by", - ) - elif self.request.user.is_site_group_admin(site_group): - if site_group: - queryset = Product.objects.filter(site_group=site_group).select_related( - "host", - "product_group", - "created_by", - ) - else: - queryset = Product.objects.none() - else: - queryset = get_provider_products( - self.request.user, site_group=site_group - ).select_related( - "host", - "product_group", - "created_by", - ) - - # 搜索 - search = self.request.GET.get("search", "").strip() - if search: - queryset = queryset.filter( - Q(display_name__icontains=search) - | Q(host__name__icontains=search) - | Q(created_by__username__icontains=search) - ) - - # 可用状态筛选 - available_filter = self.request.GET.get("is_available", "").strip() - if available_filter: - queryset = queryset.filter(is_available=available_filter == "true") - - # 可见性筛选 - visibility_filter = self.request.GET.get("visibility", "").strip() - if visibility_filter: - queryset = queryset.filter(visibility=visibility_filter) - - # 排序 - queryset = queryset.order_by("-created_at") - - # 分页 - paginator = Paginator(queryset, 20) - page_number = self.request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context.update( - { - "page_obj": page_obj, - "products": page_obj, - "search": search, - "available_filter": available_filter, - "visibility_filter": visibility_filter, - "visibility_choices": Product._meta.get_field("visibility").choices, - "page_title": "产品管理", - "active_nav": "products", - } - ) - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminProductCreateView(TemplateView): - """ - 超管产品创建视图 - - 处理 GET 和 POST 请求,创建新产品。 - """ - - template_name = "admin_base/operations/product_form.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - site_group = getattr(self.request, "site_group", None) - context.update( - { - "form": kwargs.get( - "form", - AdminProductForm(user=self.request.user, site_group=site_group), - ), - "page_title": "创建产品", - "active_nav": "products", - "is_create": True, - "existing_disk_quota": kwargs.get("existing_disk_quota", "{}"), - "existing_extra_disks": kwargs.get("existing_extra_disks", "[]"), - "initial_host_id": kwargs.get("initial_host_id", '""'), - "enable_disk_quota_initial": kwargs.get( - "enable_disk_quota_initial", "false" - ), - } - ) - return context - - def post(self, request, *args, **kwargs): - site_group = getattr(request, "site_group", None) - form = AdminProductForm(request.POST, user=request.user, site_group=site_group) - if form.is_valid(): - product = form.save(commit=False) - product.created_by = request.user - if site_group: - product.site_group = site_group - product.save() - messages.success( - request, - f"产品 {product.display_name} 创建成功", - ) - return redirect("admin:admin_operations:product_list") - - return self.render_to_response( - self.get_context_data( - form=form, - existing_disk_quota=request.POST.get("default_disk_quota", "{}"), - existing_extra_disks=request.POST.get("allow_extra_quota_disks", "[]"), - initial_host_id=json.dumps(request.POST.get("host", "")), - enable_disk_quota_initial=json.dumps( - "enable_disk_quota" in request.POST - ), - ) - ) - - -# ========== 产品创建向导 ========== - - -@admin_required -def admin_product_wizard(request): - """ - 产品创建向导视图 - - 引导超管分步创建产品: - - Step 1: 基本信息 (显示名称、描述、产品组) - - Step 2: 主机关联与配置 (主机、显示地址、RDP端口、可见性、状态) - - Step 3: 高级设置 (主机保护、磁盘配额、创建预览) - - 使用 Alpine.js 在客户端管理步骤切换, - 最终一次性提交表单创建产品。 - """ - from .forms_wizard import ProductWizardForm - - site_group = getattr(request, "site_group", None) - - if request.method == "POST": - form = ProductWizardForm(request.POST, user=request.user, site_group=site_group) - if form.is_valid(): - product = form.save(commit=False) - product.created_by = request.user - if site_group: - product.site_group = site_group - product.save() - - messages.success( - request, - f"产品 {product.display_name} 创建成功", - ) - return redirect( - "admin:admin_operations:product_edit", - pk=product.pk, - ) - else: - form = ProductWizardForm(user=request.user, site_group=site_group) - - hosts_info = form.get_hosts_info() - - context = { - "form": form, - "hosts_info": json.dumps(hosts_info), - "visibility_choices": Product._meta.get_field("visibility").choices, - "page_title": "创建产品", - "active_nav": "products", - } - - return render( - request, - "admin_base/operations/product_wizard.html", - context, - ) - - -@method_decorator(admin_required, name="dispatch") -class AdminProductUpdateView(TemplateView): - """ - 超管产品编辑视图 - - 处理 GET 和 POST 请求,编辑产品信息。 - """ - - template_name = "admin_base/operations/product_form.html" - - def get_product(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404( - Product.objects.select_related("host", "product_group", "created_by"), - pk=self.kwargs["pk"], - ) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - Product.objects.filter(site_group=site_group).select_related( - "host", "product_group", "created_by" - ), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - Product.objects.none(), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - get_provider_products( - self.request.user, site_group=site_group - ).select_related("host", "product_group", "created_by"), - pk=self.kwargs["pk"], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - product = self.get_product() - site_group = getattr(self.request, "site_group", None) - form = kwargs.get( - "form", - AdminProductForm( - instance=product, user=self.request.user, site_group=site_group - ), - ) - context.update( - { - "form": form, - "product": product, - "page_title": f"编辑产品 - {product.display_name}", - "active_nav": "products", - "is_create": False, - "existing_disk_quota": kwargs.get( - "existing_disk_quota", - json.dumps(product.default_disk_quota or {}), - ), - "existing_extra_disks": kwargs.get( - "existing_extra_disks", - json.dumps(product.allow_extra_quota_disks or []), - ), - "initial_host_id": kwargs.get( - "initial_host_id", - json.dumps(product.host_id), - ), - "enable_disk_quota_initial": kwargs.get( - "enable_disk_quota_initial", - json.dumps(product.enable_disk_quota), - ), - } - ) - return context - - def post(self, request, *args, **kwargs): - product = self.get_product() - site_group = getattr(request, "site_group", None) - form = AdminProductForm( - request.POST, instance=product, user=request.user, site_group=site_group - ) - if form.is_valid(): - product = form.save() - messages.success( - request, - f"产品 {product.display_name} 更新成功", - ) - return redirect("admin:admin_operations:product_list") - - return self.render_to_response( - self.get_context_data( - form=form, - existing_disk_quota=request.POST.get("default_disk_quota", "{}"), - existing_extra_disks=request.POST.get("allow_extra_quota_disks", "[]"), - initial_host_id=json.dumps(request.POST.get("host", "")), - enable_disk_quota_initial=json.dumps( - "enable_disk_quota" in request.POST - ), - ) - ) - - -@method_decorator(admin_required, name="dispatch") -class AdminProductDeleteView(TemplateView): - """ - 超管产品删除视图 - - 显示确认页面,处理删除请求。 - """ - - template_name = "admin_base/operations/product_confirm_delete.html" - - def get_product(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(Product, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - Product.objects.filter(site_group=site_group), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - Product.objects.none(), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - get_provider_products(self.request.user, site_group=site_group), - pk=self.kwargs["pk"], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - product = self.get_product() - - # 获取关联用户数 - user_count = CloudComputerUser.objects.filter(product=product).count() - - context.update( - { - "product": product, - "user_count": user_count, - "page_title": f"删除产品 - {product.display_name}", - "active_nav": "products", - } - ) - return context - - def post(self, request, *args, **kwargs): - product = self.get_product() - product_name = product.display_name - product.delete() - - messages.success( - request, - f"产品 {product_name} 已删除", - ) - return redirect("admin:admin_operations:product_list") - - -# =========================================================================== -# 产品组管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminProductGroupListView(TemplateView): - """ - 超管产品组列表视图 - - - 查看所有产品组(无数据隔离) - - 搜索、分页 - """ - - template_name = "admin_base/operations/productgroup_list.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - queryset = ProductGroup.objects.select_related( - "created_by", - ) - elif self.request.user.is_site_group_admin(site_group): - if site_group: - queryset = ProductGroup.objects.filter( - site_group=site_group - ).select_related("created_by") - else: - queryset = ProductGroup.objects.none() - else: - queryset = ProductGroup.objects.filter( - created_by=self.request.user - ).select_related("created_by") - - # 搜索 - search = self.request.GET.get("search", "").strip() - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # 排序 - queryset = queryset.order_by("display_order", "name") - - # 分页 - paginator = Paginator(queryset, 20) - page_number = self.request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context.update( - { - "page_obj": page_obj, - "productgroups": page_obj, - "search": search, - "page_title": "产品组管理", - "active_nav": "productgroups", - } - ) - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminProductGroupCreateView(TemplateView): - """ - 超管产品组创建视图 - - 处理 GET 和 POST 请求,创建新产品组。 - """ - - template_name = "admin_base/operations/productgroup_form.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update( - { - "form": kwargs.get("form", AdminProductGroupForm()), - "page_title": "创建产品组", - "active_nav": "productgroups", - "is_create": True, - } - ) - return context - - def post(self, request, *args, **kwargs): - form = AdminProductGroupForm(request.POST) - if form.is_valid(): - productgroup = form.save(commit=False) - productgroup.created_by = request.user - site_group = getattr(request, "site_group", None) - if site_group: - productgroup.site_group = site_group - productgroup.save() - messages.success( - request, - f"产品组 {productgroup.name} 创建成功", - ) - return redirect("admin:admin_operations:productgroup_list") - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(admin_required, name="dispatch") -class AdminProductGroupUpdateView(TemplateView): - """ - 超管产品组编辑视图 - - 处理 GET 和 POST 请求,编辑产品组信息。 - """ - - template_name = "admin_base/operations/productgroup_form.html" - - def get_productgroup(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(ProductGroup, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - ProductGroup.objects.filter(site_group=site_group), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - ProductGroup.objects.none(), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - ProductGroup, - pk=self.kwargs["pk"], - created_by=self.request.user, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - productgroup = self.get_productgroup() - form = kwargs.get( - "form", - AdminProductGroupForm(instance=productgroup), - ) - context.update( - { - "form": form, - "productgroup": productgroup, - "page_title": f"编辑产品组 - {productgroup.name}", - "active_nav": "productgroups", - "is_create": False, - } - ) - return context - - def post(self, request, *args, **kwargs): - productgroup = self.get_productgroup() - form = AdminProductGroupForm(request.POST, instance=productgroup) - if form.is_valid(): - productgroup = form.save() - messages.success( - request, - f"产品组 {productgroup.name} 更新成功", - ) - return redirect("admin:admin_operations:productgroup_list") - - return self.render_to_response(self.get_context_data(form=form)) - - -@method_decorator(admin_required, name="dispatch") -class AdminProductGroupDeleteView(TemplateView): - """ - 超管产品组删除视图 - - 显示确认页面,处理删除请求。 - """ - - template_name = "admin_base/operations/productgroup_confirm_delete.html" - - def get_productgroup(self): - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - return get_object_or_404(ProductGroup, pk=self.kwargs["pk"]) - if self.request.user.is_site_group_admin(site_group): - if site_group: - return get_object_or_404( - ProductGroup.objects.filter(site_group=site_group), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - ProductGroup.objects.none(), - pk=self.kwargs["pk"], - ) - return get_object_or_404( - ProductGroup, - pk=self.kwargs["pk"], - created_by=self.request.user, - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - productgroup = self.get_productgroup() - - # 获取关联产品数 - product_count = Product.objects.filter(product_group=productgroup).count() - - context.update( - { - "productgroup": productgroup, - "product_count": product_count, - "page_title": f"删除产品组 - {productgroup.name}", - "active_nav": "productgroups", - } - ) - return context - - def post(self, request, *args, **kwargs): - productgroup = self.get_productgroup() - productgroup_name = productgroup.name - productgroup.delete() - - messages.success( - request, - f"产品组 {productgroup_name} 已删除", - ) - return redirect("admin:admin_operations:productgroup_list") - - -# =========================================================================== -# 开户申请管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestListView(ListView): - """ - 超管开户申请列表视图 - - - 查看所有开户申请(无数据隔离) - - 状态筛选、搜索、批量操作 - """ - - model = AccountOpeningRequest - template_name = "admin_base/operations/request_list.html" - context_object_name = "requests" - paginate_by = 20 - - def get_queryset(self): - qs = AccountOpeningRequest.objects.select_related( - "applicant", - "target_product", - "target_product__host", - "approved_by", - ) - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(target_product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - qs = qs.filter(target_product__in=provider_products) - - # 状态筛选 - status = self.request.GET.get("status", "").strip() - if status: - qs = qs.filter(status=status) - - # 搜索 - search = self.request.GET.get("search", "").strip() - if search: - qs = qs.filter( - Q(username__icontains=search[:50]) - | Q(user_fullname__icontains=search[:50]) - | Q(contact_email__icontains=search[:50]) - | Q(applicant__username__icontains=search[:50]) - ) - - return qs.order_by("-created_at") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["status_choices"] = AccountOpeningRequest._meta.get_field( - "status" - ).choices - context["current_status"] = self.request.GET.get("status", "") - context["current_search"] = self.request.GET.get("search", "") - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - base_qs = AccountOpeningRequest.objects.all() - elif self.request.user.is_site_group_admin(site_group): - if site_group: - base_qs = AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ) - else: - base_qs = AccountOpeningRequest.objects.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - base_qs = AccountOpeningRequest.objects.filter( - target_product__in=provider_products - ) - counts = { - "pending": base_qs.filter(status="pending").count(), - "approved": base_qs.filter(status="approved").count(), - "rejected": base_qs.filter(status="rejected").count(), - "processing": base_qs.filter(status="processing").count(), - "completed": base_qs.filter(status="completed").count(), - "failed": base_qs.filter(status="failed").count(), - } - context["status_choices_with_counts"] = [ - (v, l, counts.get(v, 0)) for v, l in context["status_choices"] - ] - context["total_count"] = base_qs.count() - context["page_title"] = "开户申请" - context["active_nav"] = "requests" - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestDetailView(DetailView): - """ - 超管开户申请详情视图 - - 展示申请完整信息、状态时间线,以及批准/驳回按钮。 - """ - - model = AccountOpeningRequest - template_name = "admin_base/operations/request_detail.html" - context_object_name = "request_obj" - - def get_queryset(self): - qs = AccountOpeningRequest.objects.select_related( - "applicant", - "target_product", - "target_product__host", - "approved_by", - ) - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(target_product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - qs = qs.filter(target_product__in=provider_products) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - obj = self.object - - # 构建状态时间线 - timeline = [] - timeline.append( - { - "label": "提交申请", - "time": obj.created_at, - "done": True, - } - ) - if obj.status in ( - "approved", - "rejected", - "processing", - "completed", - "failed", - ): - timeline.append( - { - "label": "审核完成", - "time": obj.approval_date, - "done": (obj.status != "failed" or obj.approval_date is not None), - "detail": ("批准" if obj.status != "rejected" else "驳回"), - } - ) - if obj.status in ("processing", "completed", "failed"): - timeline.append( - { - "label": "执行开户", - "time": (obj.updated_at if obj.status == "completed" else None), - "done": obj.status in ("completed",), - "detail": (obj.result_message if obj.result_message else None), - } - ) - context["timeline"] = timeline - context["reject_form"] = AdminRequestRejectForm() - context["page_title"] = "申请详情" - context["active_nav"] = "requests" - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestApproveView(View): - """超管批准单条开户申请 (POST)""" - - def post(self, request, pk): - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - obj = get_object_or_404(AccountOpeningRequest, pk=pk) - elif request.user.is_site_group_admin(site_group): - if site_group: - obj = get_object_or_404( - AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ), - pk=pk, - ) - else: - obj = get_object_or_404( - AccountOpeningRequest.objects.none(), - pk=pk, - ) - else: - provider_products = get_provider_products( - request.user, site_group=site_group - ) - obj = get_object_or_404( - AccountOpeningRequest, - pk=pk, - target_product__in=provider_products, - ) - if obj.status != "pending": - messages.warning( - request, - f"申请 {obj.username} 当前状态为" - f" {obj.get_status_display()},无法批准。", - ) - return redirect("admin:admin_operations:request_detail", pk=obj.pk) - - obj.approve(approver=request.user, notes="") - messages.success(request, f"已批准申请 {obj.username}。") - return redirect("admin:admin_operations:request_detail", pk=obj.pk) - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestRejectView(View): - """ - 超管驳回单条开户申请 - - POST: 提交驳回(含驳回原因) - """ - - def post(self, request, pk): - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - obj = get_object_or_404(AccountOpeningRequest, pk=pk) - elif request.user.is_site_group_admin(site_group): - if site_group: - obj = get_object_or_404( - AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ), - pk=pk, - ) - else: - obj = get_object_or_404( - AccountOpeningRequest.objects.none(), - pk=pk, - ) - else: - provider_products = get_provider_products( - request.user, site_group=site_group - ) - obj = get_object_or_404( - AccountOpeningRequest, - pk=pk, - target_product__in=provider_products, - ) - if obj.status != "pending": - messages.warning( - request, - f"申请 {obj.username} 当前状态为" - f" {obj.get_status_display()},无法驳回。", - ) - return redirect("admin:admin_operations:request_detail", pk=obj.pk) - - form = AdminRequestRejectForm(request.POST) - if form.is_valid(): - reason = form.cleaned_data["rejection_reason"] - obj.reject(approver=request.user, notes=reason) - messages.success(request, f"已驳回申请 {obj.username}。") - return redirect("admin:admin_operations:request_detail", pk=obj.pk) - - messages.error(request, "请输入驳回原因。") - return redirect("admin:admin_operations:request_detail", pk=obj.pk) - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestRetryView(View): - """ - 超管重试失败的开户申请 - - POST: 将失败状态的申请重新触发开户流程。 - """ - - def post(self, request, pk): - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - obj = get_object_or_404(AccountOpeningRequest, pk=pk) - elif request.user.is_site_group_admin(site_group): - if site_group: - obj = get_object_or_404( - AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ), - pk=pk, - ) - else: - obj = get_object_or_404( - AccountOpeningRequest.objects.none(), - pk=pk, - ) - else: - provider_products = get_provider_products( - request.user, site_group=site_group - ) - obj = get_object_or_404( - AccountOpeningRequest, - pk=pk, - target_product__in=provider_products, - ) - if obj.status != "failed": - messages.warning( - request, - f"申请 {obj.username} 当前状态为" - f" {obj.get_status_display()},无法重试。", - ) - return redirect( - "admin:admin_operations:request_detail", - pk=obj.pk, - ) - - success = obj.retry(operator=request.user) - if success: - messages.success( - request, - f"申请 {obj.username} 已重新提交处理" - f"(第{obj.retry_count}次重试)。", - ) - else: - messages.error( - request, - f"申请 {obj.username} 重试失败,请稍后再试。", - ) - return redirect( - "admin:admin_operations:request_detail", - pk=obj.pk, - ) - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestBatchApproveView(View): - """ - 超管批量批准开户申请 (POST) - - 请求体需包含 selected_ids。 - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, "未选择任何申请。") - return redirect("admin:admin_operations:request_list") - - qs = AccountOpeningRequest.objects.filter( - pk__in=selected_ids, - status="pending", - ) - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - pass - elif request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(target_product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - request.user, site_group=site_group - ) - qs = qs.filter(target_product__in=provider_products) - updated_count = 0 - for obj in qs: - obj.approve(approver=request.user, notes="") - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f"成功批准了 {updated_count} 个开户申请。", - ) - else: - messages.warning( - request, - "没有符合条件的待审核申请需要批准。", - ) - - return redirect("admin:admin_operations:request_list") - - -@method_decorator(admin_required, name="dispatch") -class AdminRequestBatchRejectView(View): - """ - 超管批量驳回开户申请 (POST) - - 请求体需包含 selected_ids 和可选的 rejection_reason。 - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, "未选择任何申请。") - return redirect("admin:admin_operations:request_list") - - rejection_reason = request.POST.get( - "rejection_reason", - "批量驳回", - ) - - qs = AccountOpeningRequest.objects.filter( - pk__in=selected_ids, - status="pending", - ) - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - pass - elif request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(target_product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - request.user, site_group=site_group - ) - qs = qs.filter(target_product__in=provider_products) - updated_count = 0 - for obj in qs: - obj.reject(approver=request.user, notes=rejection_reason) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f"成功驳回了 {updated_count} 个开户申请。", - ) - else: - messages.warning( - request, - "没有符合条件的待审核申请需要驳回。", - ) - - return redirect("admin:admin_operations:request_list") - - -# =========================================================================== -# 云电脑用户管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminCloudUserListView(TemplateView): - """ - 超管云电脑用户列表视图 - - - 查看所有云电脑用户(无数据隔离) - - 搜索、状态筛选、产品筛选 - """ - - template_name = "admin_base/operations/user_list.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - queryset = CloudComputerUser.objects.select_related( - "product", - "product__host", - "created_from_request", - "created_from_request__applicant", - "owner", - ) - elif self.request.user.is_site_group_admin(site_group): - if site_group: - queryset = CloudComputerUser.objects.filter( - product__site_group=site_group - ).select_related( - "product", - "product__host", - "created_from_request", - "created_from_request__applicant", - "owner", - ) - else: - queryset = CloudComputerUser.objects.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - queryset = CloudComputerUser.objects.filter( - product__in=provider_products - ).select_related( - "product", - "product__host", - "created_from_request", - "created_from_request__applicant", - "owner", - ) - - # 搜索 - search = self.request.GET.get("search", "").strip() - if search: - q_filter = ( - Q(username__icontains=search) - | Q(fullname__icontains=search) - | Q(email__icontains=search) - | Q(product__display_name__icontains=search) - ) - queryset = queryset.filter(q_filter).distinct() - - # 状态筛选 - status_filter = self.request.GET.get("status", "").strip() - if status_filter: - queryset = queryset.filter(status=status_filter) - - # 产品筛选 - product_filter = self.request.GET.get("product", "").strip() - if product_filter: - queryset = queryset.filter(product_id=product_filter) - - # 排序 - queryset = queryset.order_by("-created_at") - - # 分页 - paginator = Paginator(queryset, 20) - page_number = self.request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - # 状态选项 - status_choices = CloudComputerUser._meta.get_field("status").choices - - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - products_for_filter = Product.objects.all().order_by("display_name") - elif self.request.user.is_site_group_admin(site_group): - if site_group: - products_for_filter = Product.objects.filter( - site_group=site_group - ).order_by("display_name") - else: - products_for_filter = Product.objects.none() - else: - products_for_filter = get_provider_products( - self.request.user, site_group=site_group - ).order_by("display_name") - - context.update( - { - "page_obj": page_obj, - "users": page_obj, - "search": search, - "status_filter": status_filter, - "product_filter": product_filter, - "status_choices": status_choices, - "products": products_for_filter, - "page_title": "云电脑用户", - "active_nav": "cloud_users", - } - ) - - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminCloudUserDetailView(DetailView): - """ - 超管云电脑用户详情视图 - - 显示用户信息、管理员状态、磁盘配额等 - """ - - model = CloudComputerUser - template_name = "admin_base/operations/user_detail.html" - context_object_name = "cloud_user" - - def get_queryset(self): - qs = CloudComputerUser.objects.select_related( - "product", - "product__host", - "created_from_request", - "created_from_request__applicant", - "owner", - ) - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - qs = qs.filter(product__in=provider_products) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - cloud_user = self.object - - context.update( - { - "page_title": f"用户详情 - {cloud_user.username}", - "active_nav": "cloud_users", - "disk_quota_json": ( - json.dumps(cloud_user.disk_quota, ensure_ascii=False) - if cloud_user.disk_quota - else "{}" - ), - } - ) - - return context - - -@admin_required -def admin_cloud_user_action(request, pk): - if request.method != "POST": - return JsonResponse( - {"success": False, "message": "仅支持 POST 请求"}, status=405 - ) - - qs = CloudComputerUser.objects.select_related("product", "product__host") - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - pass - elif request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products(request.user, site_group=site_group) - qs = qs.filter(product__in=provider_products) - - cloud_user = get_object_or_404(qs, pk=pk) - - if cloud_user.status == "deleted": - return JsonResponse({"success": False, "message": "已删除的用户无法执行操作"}) - - action = request.POST.get("action", "") - - if action == "disable": - cloud_user.disable() - cloud_user.refresh_from_db() - return JsonResponse( - { - "success": True, - "message": f"用户 {cloud_user.username} 已封禁", - "status": cloud_user.status, - } - ) - - elif action == "enable": - if cloud_user.status != "disabled": - return JsonResponse({"success": False, "message": "仅已封禁的用户可以解封"}) - cloud_user.activate() - cloud_user.refresh_from_db() - return JsonResponse( - { - "success": True, - "message": f"用户 {cloud_user.username} 已解封", - "status": cloud_user.status, - } - ) - - elif action == "delete": - cloud_user.delete_user() - cloud_user.refresh_from_db() - return JsonResponse( - { - "success": True, - "message": f"用户 {cloud_user.username} 已删除", - "status": cloud_user.status, - } - ) - - elif action == "set_admin": - if cloud_user.is_admin: - return JsonResponse({"success": False, "message": "该用户已是管理员"}) - cloud_user.is_admin = True - cloud_user.save(update_fields=["is_admin", "updated_at"]) - from apps.operations.tasks import remote_set_admin - - remote_set_admin.delay(cloud_user.pk, operator_id=request.user.pk) - return JsonResponse( - { - "success": True, - "message": f"用户 {cloud_user.username} 已设为管理员", - "is_admin": True, - } - ) - - elif action == "remove_admin": - if not cloud_user.is_admin: - return JsonResponse({"success": False, "message": "该用户不是管理员"}) - cloud_user.is_admin = False - cloud_user.save(update_fields=["is_admin", "updated_at"]) - from apps.operations.tasks import remote_remove_admin - - remote_remove_admin.delay(cloud_user.pk, operator_id=request.user.pk) - return JsonResponse( - { - "success": True, - "message": f"用户 {cloud_user.username} 已取消管理员", - "is_admin": False, - } - ) - - elif action == "reset_password": - new_password = CloudComputerUser.generate_complex_password() - cloud_user.initial_password = new_password - cloud_user.password_viewed = False - cloud_user.password_viewed_at = None - cloud_user.save( - update_fields=[ - "initial_password", - "password_viewed", - "password_viewed_at", - "updated_at", - ] - ) - from apps.operations.tasks import remote_reset_windows_password - - remote_reset_windows_password.delay( - cloud_user.pk, - new_password, - operator_id=request.user.pk, - ) - return JsonResponse( - { - "success": True, - "message": f"用户 {cloud_user.username} 密码已重置", - "new_password": new_password, - } - ) - - else: - return JsonResponse({"success": False, "message": "无效的操作类型"}, status=400) - - -@admin_required -def admin_cloud_user_set_quota(request, pk): - if request.method != "POST": - return JsonResponse( - {"success": False, "message": "仅支持 POST 请求"}, status=405 - ) - - qs = CloudComputerUser.objects.select_related("product", "product__host") - site_group = getattr(request, "site_group", None) - if request.user.is_superuser: - pass - elif request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products(request.user, site_group=site_group) - qs = qs.filter(product__in=provider_products) - - cloud_user = get_object_or_404(qs, pk=pk) - - if not cloud_user.product.enable_disk_quota: - return JsonResponse({"success": False, "message": "该产品未启用磁盘配额管理"}) - - disk = request.POST.get("disk", "").strip().upper() - quota_str = request.POST.get("quota", "").strip() - - if not disk or not quota_str: - return JsonResponse({"success": False, "message": "磁盘盘符和配额值不能为空"}) - - try: - quota_mb = int(quota_str) - if quota_mb < 0: - return JsonResponse({"success": False, "message": "配额值不能为负数"}) - except (ValueError, TypeError): - return JsonResponse({"success": False, "message": "配额值必须为数字"}) - - import re - - if not re.match(r"^[A-Za-z]:\\?$", disk): - return JsonResponse({"success": False, "message": f"无效的磁盘盘符: {disk}"}) - disk = disk.rstrip("\\") - - new_quota = dict(cloud_user.disk_quota) if cloud_user.disk_quota else {} - new_quota[disk] = quota_mb - cloud_user.disk_quota = new_quota - cloud_user.save(update_fields=["disk_quota", "updated_at"]) - - try: - from apps.operations.tasks import remote_set_disk_quota - - remote_set_disk_quota.delay( - cloud_user.pk, - disk, - quota_mb, - operator_id=request.user.pk, - ) - except Exception as e: - logger.error(f"远程设置磁盘配额失败: {e}", exc_info=True) - return JsonResponse({"success": False, "message": "远程设置配额任务已提交"}) - - return JsonResponse( - { - "success": True, - "message": f"磁盘 {disk} 配额已设置为 {quota_mb} MB", - "disk": disk, - "quota": quota_mb, - } - ) - - -# =========================================================================== -# 邀请令牌管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminTokenListView(ListView): - """ - 超管邀请令牌列表视图 - - - 查看所有邀请令牌(无数据隔离) - - 显示邀请链接 - """ - - model = ProductInvitationToken - template_name = "admin_base/operations/token_list.html" - context_object_name = "tokens" - paginate_by = 20 - - def get_queryset(self): - qs = ProductInvitationToken.objects.select_related( - "product", - "product_group", - "created_by", - ) - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter( - product__in=Product.objects.filter(site_group=site_group) - ) - else: - qs = qs.none() - else: - qs = qs.filter(created_by=self.request.user) - return qs.order_by("-created_at") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["active_nav"] = "operations_tokens" - context["page_title"] = "邀请令牌" - - from django.conf import settings - - site_url = getattr(settings, "SITE_URL", "") - for token in context["tokens"]: - token.invite_link = f"{site_url}/operations/invite/{token.token}/" - return context - - -@method_decorator(admin_required, name="dispatch") -class AdminTokenDetailView(DetailView): - """ - 超管邀请令牌详情视图 - - - 查看令牌基本信息 - - 查看所有使用该令牌的用户列表(ProductAccessGrant) - """ - - model = ProductInvitationToken - template_name = "admin_base/operations/token_detail.html" - context_object_name = "token_obj" - - def get_queryset(self): - qs = ProductInvitationToken.objects.select_related( - "product", - "product_group", - "created_by", - ) - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter( - product__in=Product.objects.filter(site_group=site_group) - ) - else: - qs = qs.none() - else: - qs = qs.filter(created_by=self.request.user) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - token_obj = context["token_obj"] - context["active_nav"] = "operations_tokens" - context["page_title"] = "邀请令牌详情" - - from django.conf import settings - - site_url = getattr(settings, "SITE_URL", "") - token_obj.invite_link = f"{site_url}/operations/invite/{token_obj.token}/" - - grants = ( - ProductAccessGrant.objects.filter( - granted_by_token=token_obj, - ) - .select_related( - "user", - "product", - "product_group", - ) - .order_by("-granted_at") - ) - - paginator = Paginator(grants, 20) - page_number = self.request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - context["grants"] = page_obj - context["grant_count"] = grants.count() - context["effective_grant_count"] = ( - grants.filter( - is_revoked=False, - ) - .exclude( - expires_at__lt=timezone.now(), - ) - .count() - if grants.exists() - else 0 - ) - return context - - -# =========================================================================== -# 访问授权管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminGrantListView(ListView): - """ - 超管访问授权列表视图 - - - 查看所有访问授权(无数据隔离) - """ - - model = ProductAccessGrant - template_name = "admin_base/operations/grant_list.html" - context_object_name = "grants" - paginate_by = 20 - - def get_queryset(self): - qs = ProductAccessGrant.objects.select_related( - "user", - "product", - "product_group", - "granted_by_token", - ) - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - qs = qs.filter(product__in=provider_products) - return qs.order_by("-granted_at") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["active_nav"] = "grants" - context["page_title"] = "访问授权" - return context - - -# =========================================================================== -# RDP 域名路由管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminRouteListView(ListView): - """ - 超管RDP域名路由列表视图 - - - 查看所有域名路由(无数据隔离) - - 只读视图 - """ - - model = RdpDomainRoute - template_name = "admin_base/operations/route_list.html" - context_object_name = "routes" - paginate_by = 20 - - def get_queryset(self): - qs = RdpDomainRoute.objects.select_related( - "product", - "assigned_to", - ) - site_group = getattr(self.request, "site_group", None) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - qs = qs.filter(product__site_group=site_group) - else: - qs = qs.none() - else: - provider_products = get_provider_products( - self.request.user, site_group=site_group - ) - qs = qs.filter(product__in=provider_products) - return qs.order_by("-created_at") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["active_nav"] = "operations_routes" - context["page_title"] = "域名路由" - return context - - -# =========================================================================== -# 系统任务管理 -# =========================================================================== - - -@method_decorator(admin_required, name="dispatch") -class AdminTaskListView(ListView): - """ - 超管系统任务列表视图 - - - 查看所有 Celery 异步任务(无数据隔离) - - 只读参考视图 - """ - - model = AsyncTask - template_name = "admin_base/operations/task_list.html" - context_object_name = "tasks" - paginate_by = 20 - - def get_queryset(self): - qs = AsyncTask.objects.select_related( - "created_by", - ).prefetch_related("progress_updates") - site_group = getattr(self.request, "site_group", None) - search = self.request.GET.get("search", "").strip() - if search: - qs = qs.filter( - Q(name__icontains=search) - | Q(target_content_type__icontains=search) - | Q(task_id__icontains=search) - ) - if self.request.user.is_superuser: - pass - elif self.request.user.is_site_group_admin(site_group): - if site_group: - from apps.hosts.models import Host - from apps.operations.models import Product - - sg_host_ids = set( - Host.objects.filter(site_group=site_group).values_list( - "pk", flat=True - ) - ) - sg_product_ids = set( - Product.objects.filter(site_group=site_group).values_list( - "pk", flat=True - ) - ) - qs = qs.filter( - Q( - target_content_type="hosts.Host", - target_object_id__in=sg_host_ids, - ) - | Q( - target_content_type="operations.Product", - target_object_id__in=sg_product_ids, - ) - | Q( - target_content_type="operations.AccountOpeningRequest", - target_object_id__in=AccountOpeningRequest.objects.filter( - target_product__site_group=site_group - ).values_list("pk", flat=True), - ) - ) - else: - qs = qs.filter(created_by=self.request.user) - else: - qs = qs.filter(created_by=self.request.user) - return qs.order_by("-created_at") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["active_nav"] = "tasks" - context["page_title"] = "系统任务" - context["search"] = self.request.GET.get("search", "") - return context diff --git a/apps/operations/views_provider.py b/apps/operations/views_provider.py deleted file mode 100644 index 4b7dfc7..0000000 --- a/apps/operations/views_provider.py +++ /dev/null @@ -1,1736 +0,0 @@ -""" -运营管理 - 提供商后台视图 - -包含开户申请、云电脑用户、邀请令牌、访问授权、RDP域名路由、系统任务、 -产品管理、产品组管理等视图。 -所有视图均受提供商身份验证保护,并实施提供商数据隔离。 -""" - -import json -import logging - -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.core.paginator import Paginator -from django.db.models import Q -from django.http import HttpResponseRedirect, JsonResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.urls import reverse -from django.views import View -from django.views.generic import DetailView, ListView, TemplateView - -from apps.operations.models import ( - AccountOpeningRequest, - CloudComputerUser, - Product, - ProductGroup, - ProductInvitationToken, - ProductAccessGrant, - RdpDomainRoute, - SystemTask, -) -from apps.provider.decorators import is_provider, provider_required -from apps.provider.context_mixin import ProviderContextMixin -from utils.provider import get_provider_products - -from .forms_provider import ( - AccountOpeningRequestRejectForm, - CloudComputerUserDiskQuotaForm, - CloudComputerUserResetPasswordForm, - ProductForm, - ProductGroupForm, -) - -logger = logging.getLogger(__name__) -User = get_user_model() - - -# ========== 基础混入类 ========== - - -class ProviderOperationBaseView(View): - """ - 提供商运营管理基础视图混入类 - - - 验证提供商身份 - - 提供数据隔离的查询集 - """ - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_authenticated: - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(request.get_full_path()) - if not is_provider(request.user): - return redirect('accounts:login') - return super().dispatch(request, *args, **kwargs) - - def get_provider_queryset(self): - """ - 获取提供商可见的云电脑用户查询集 - 提供商只能看到自己产品下的用户 - """ - return CloudComputerUser.objects.filter( - product__created_by=self.request.user - ).select_related( - 'product', - 'product__host', - 'created_from_request', - 'created_from_request__applicant', - ) - - def get_provider_products(self): - """获取提供商创建的产品""" - return Product.objects.filter(created_by=self.request.user) - - -# ========== 用户列表视图 ========== - - -class CloudComputerUserListView(ProviderContextMixin, ProviderOperationBaseView, TemplateView): - """ - 云电脑用户列表视图 - - 支持搜索、状态筛选、分页、批量操作 - """ - template_name = 'admin_base/operations/user_list.html' - paginate_by = 20 - provider_url_namespace = 'provider:provider_operations' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - queryset = self.get_provider_queryset() - - # 搜索过滤 - search = self.request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - username__icontains=search - ) | queryset.filter( - fullname__icontains=search - ) | queryset.filter( - email__icontains=search - ) | queryset.filter( - product__display_name__icontains=search - ) - # 去重 - queryset = queryset.distinct() - - # 状态过滤 - status_filter = self.request.GET.get('status', '').strip() - if status_filter: - queryset = queryset.filter(status=status_filter) - - # 产品过滤 - product_filter = self.request.GET.get('product', '').strip() - if product_filter: - queryset = queryset.filter(product_id=product_filter) - - # 排序 - queryset = queryset.order_by('-created_at') - - # 分页 - paginator = Paginator(queryset, self.paginate_by) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - # 状态选项 - status_choices = CloudComputerUser._meta.get_field('status').choices - - context.update({ - 'page_obj': page_obj, - 'users': page_obj, - 'search': search, - 'status_filter': status_filter, - 'product_filter': product_filter, - 'status_choices': status_choices, - 'products': self.get_provider_products(), - 'page_title': '云电脑用户', - 'active_nav': 'cloud_users', - }) - - return context - - -# ========== 用户详情视图 ========== - - -class CloudComputerUserDetailView(ProviderContextMixin, ProviderOperationBaseView, DetailView): - """ - 云电脑用户详情视图 - - 显示用户信息、管理员状态、磁盘配额等 - """ - template_name = 'admin_base/operations/user_detail.html' - context_object_name = 'cloud_user' - pk_url_kwarg = 'pk' - provider_url_namespace = 'provider:provider_operations' - - def get_queryset(self): - return self.get_provider_queryset() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - cloud_user = self.object - - context.update({ - 'page_title': f'用户详情 - {cloud_user.username}', - 'active_nav': 'cloud_users', - 'disk_quota_json': json.dumps(cloud_user.disk_quota, ensure_ascii=False) if cloud_user.disk_quota else '{}', - }) - - return context - - -# ========== 同步管理员状态视图 ========== - - -class CloudComputerUserSyncAdminView(ProviderOperationBaseView, View): - """ - 同步管理员状态视图 - - POST 请求:切换用户的管理员权限(授予/撤销) - """ - - def get_queryset(self): - return self.get_provider_queryset() - - def post(self, request, pk): - cloud_user = get_object_or_404(self.get_queryset(), pk=pk) - - try: - from .services import update_user_admin_permission - - new_is_admin = not cloud_user.is_admin - update_user_admin_permission(cloud_user, new_is_admin) - - # 更新数据库 - cloud_user.is_admin = new_is_admin - cloud_user.save(update_fields=['is_admin', 'updated_at']) - - action = '授予' if new_is_admin else '撤销' - messages.success( - request, - f'成功{action}用户 {cloud_user.username} 的管理员权限' - ) - except Exception as e: - action = '授予' if not cloud_user.is_admin else '撤销' - messages.error( - request, - f'{action}用户 {cloud_user.username} 的管理员权限失败: {str(e)}' - ) - - return HttpResponseRedirect( - reverse('provider_operations:user_detail', kwargs={'pk': pk}) - ) - - -# ========== 设置磁盘配额视图 ========== - - -class CloudComputerUserSetDiskQuotaView(ProviderContextMixin, ProviderOperationBaseView, View): - """ - 设置磁盘配额视图 - - GET 请求:显示配额设置表单 - POST 请求:提交配额设置并远程执行 - """ - provider_url_namespace = 'provider:provider_operations' - - def get_queryset(self): - return self.get_provider_queryset() - - def get(self, request, pk): - cloud_user = get_object_or_404(self.get_queryset(), pk=pk) - initial_data = { - 'disk_quota': json.dumps(cloud_user.disk_quota, ensure_ascii=False, indent=2) if cloud_user.disk_quota else '{}', - } - form = CloudComputerUserDiskQuotaForm(initial=initial_data) - - return render(request, 'admin_base/operations/user_disk_quota.html', { - 'form': form, - 'cloud_user': cloud_user, - 'page_title': f'设置磁盘配额 - {cloud_user.username}', - 'active_nav': 'cloud_users', - }) - - def post(self, request, pk): - cloud_user = get_object_or_404(self.get_queryset(), pk=pk) - form = CloudComputerUserDiskQuotaForm(request.POST) - - if form.is_valid(): - disk_quota = form.cleaned_data['disk_quota'] - - try: - # 更新数据库 - cloud_user.disk_quota = disk_quota - cloud_user.save(update_fields=['disk_quota', 'updated_at']) - - # 远程设置磁盘配额 - if disk_quota and cloud_user.product.enable_disk_quota: - from apps.operations.tasks import remote_set_user_disk_quotas - remote_set_user_disk_quotas.delay( - cloud_user.pk, disk_quota, - operator_id=request.user.pk, - ) - messages.success( - request, - f'已保存用户 {cloud_user.username} 的磁盘配额配置,' - f'远程设置正在后台执行' - ) - else: - messages.success( - request, - f'已保存用户 {cloud_user.username} 的磁盘配额配置' - ) - - return HttpResponseRedirect( - reverse('provider_operations:user_detail', kwargs={'pk': pk}) - ) - - except Exception as e: - messages.error( - request, - f'设置用户 {cloud_user.username} 磁盘配额失败: {str(e)}' - ) - else: - messages.error(request, '表单数据无效,请检查后重试') - - return render(request, 'admin_base/operations/user_disk_quota.html', { - 'form': form, - 'cloud_user': cloud_user, - 'page_title': f'设置磁盘配额 - {cloud_user.username}', - 'active_nav': 'cloud_users', - }) - - -# ========== 重置密码视图 ========== - - -class CloudComputerUserResetPasswordView(ProviderContextMixin, ProviderOperationBaseView, View): - """ - 重置密码视图 - - GET 请求:显示密码重置表单 - POST 请求:提交新密码并远程执行 - """ - provider_url_namespace = 'provider:provider_operations' - - def get_queryset(self): - return self.get_provider_queryset() - - def get(self, request, pk): - cloud_user = get_object_or_404(self.get_queryset(), pk=pk) - form = CloudComputerUserResetPasswordForm() - - return render(request, 'admin_base/operations/user_reset_password.html', { - 'form': form, - 'cloud_user': cloud_user, - 'page_title': f'重置密码 - {cloud_user.username}', - 'active_nav': 'cloud_users', - }) - - def post(self, request, pk): - cloud_user = get_object_or_404(self.get_queryset(), pk=pk) - form = CloudComputerUserResetPasswordForm(request.POST) - - if form.is_valid(): - new_password = form.cleaned_data['new_password'] - - try: - cloud_user.reset_windows_password(new_password) - messages.success( - request, - f'成功重置用户 {cloud_user.username} 的密码' - ) - return HttpResponseRedirect( - reverse('provider_operations:user_detail', kwargs={'pk': pk}) - ) - except Exception as e: - messages.error( - request, - f'重置用户 {cloud_user.username} 的密码失败: {str(e)}' - ) - else: - messages.error(request, '表单数据无效,请检查后重试') - - return render(request, 'admin_base/operations/user_reset_password.html', { - 'form': form, - 'cloud_user': cloud_user, - 'page_title': f'重置密码 - {cloud_user.username}', - 'active_nav': 'cloud_users', - }) - - -# ========== 批量操作视图 ========== - - -class CloudComputerUserBatchActivateView(ProviderOperationBaseView, View): - """ - 批量激活用户视图 - - POST 请求:批量激活选中的用户 - """ - - def post(self, request): - user_ids = request.POST.getlist('selected_ids') - - if not user_ids: - messages.warning(request, '未选择任何用户') - return HttpResponseRedirect(reverse('provider_operations:user_list')) - - queryset = self.get_provider_queryset().filter( - pk__in=user_ids, - status__in=['inactive', 'disabled'], - ) - - updated_count = queryset.update(status='active') - - if updated_count > 0: - messages.success(request, f'成功激活了 {updated_count} 个用户') - else: - messages.warning(request, '没有符合条件的用户需要激活') - - return HttpResponseRedirect(reverse('provider_operations:user_list')) - - -class CloudComputerUserBatchDeactivateView(ProviderOperationBaseView, View): - """ - 批量停用用户视图 - - POST 请求:批量停用选中的用户 - """ - - def post(self, request): - user_ids = request.POST.getlist('selected_ids') - - if not user_ids: - messages.warning(request, '未选择任何用户') - return HttpResponseRedirect(reverse('provider_operations:user_list')) - - queryset = self.get_provider_queryset().filter( - pk__in=user_ids, - status='active', - ) - - updated_count = queryset.update(status='inactive') - - if updated_count > 0: - messages.success(request, f'成功停用了 {updated_count} 个用户') - else: - messages.warning(request, '没有符合条件的用户需要停用') - - return HttpResponseRedirect(reverse('provider_operations:user_list')) - - -class CloudComputerUserBatchDisableView(ProviderOperationBaseView, View): - """ - 批量禁用用户视图 - - POST 请求:批量禁用选中的用户 - """ - - def post(self, request): - user_ids = request.POST.getlist('selected_ids') - - if not user_ids: - messages.warning(request, '未选择任何用户') - return HttpResponseRedirect(reverse('provider_operations:user_list')) - - queryset = self.get_provider_queryset().filter( - pk__in=user_ids, - ).exclude(status='deleted') - - updated_count = queryset.update(status='disabled') - - if updated_count > 0: - messages.success(request, f'成功禁用了 {updated_count} 个用户') - else: - messages.warning(request, '没有符合条件的用户需要禁用') - - return HttpResponseRedirect(reverse('provider_operations:user_list')) - - -# =========================================================================== -# 共享辅助函数 -# =========================================================================== - - -def _get_selected_ids(request): - """ - 从 POST 请求中提取选中的 ID 列表 - - 支持两种格式: - 1. 表单字段: selected_ids=1&selected_ids=2 - 2. JSON 字符串: selected_ids=[1,2,3] - """ - ids = request.POST.getlist('selected_ids') - if ids: - return [int(i) for i in ids if i.strip().isdigit()] - - raw = request.POST.get('selected_ids', '') - if raw: - try: - parsed = json.loads(raw) - if isinstance(parsed, list): - return [ - int(i) for i in parsed - if isinstance(i, (int, str)) - and str(i).isdigit() - ] - except (json.JSONDecodeError, ValueError): - pass - - return [] - - -# =========================================================================== -# 开户申请管理 -# =========================================================================== - - -class ProviderRequestMixin(ProviderContextMixin): - """ - 提供商开户申请数据隔离 Mixin - - - dispatch: 验证提供商身份 - - get_queryset: 限制为当前提供商产品下的申请 - """ - - provider_url_namespace = 'provider:provider_operations' - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_authenticated: - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(request.get_full_path()) - if not is_provider(request.user): - from django.http import HttpResponseForbidden - return HttpResponseForbidden( - '您没有提供商权限,无法访问此页面。' - ) - return super().dispatch(request, *args, **kwargs) - - def get_provider_queryset(self): - """获取当前提供商可见的开户申请查询集""" - return AccountOpeningRequest.objects.filter( - target_product__created_by=self.request.user - ).select_related( - 'applicant', 'target_product', - 'target_product__host', 'approved_by', - ) - - def get_queryset(self): - return self.get_provider_queryset() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'account_opening' - context['page_title'] = '开户申请' - return context - - -class AccountOpeningRequestListView(ProviderRequestMixin, ListView): - """ - 开户申请列表视图 - - - 分页展示 - - 状态筛选 - - 搜索 - - 批量操作(批准 / 驳回) - """ - - model = AccountOpeningRequest - template_name = 'admin_base/operations/request_list.html' - context_object_name = 'requests' - paginate_by = 20 - - def get_queryset(self): - qs = super().get_queryset() - - # 状态筛选 - status = self.request.GET.get('status', '').strip() - if status: - qs = qs.filter(status=status) - - # 搜索 - search = self.request.GET.get('search', '').strip() - if search: - qs = qs.filter( - Q( - username__icontains=search[:50], - user_fullname__icontains=search[:50], - contact_email__icontains=search[:50], - applicant__username__icontains=search[:50], - ) - ) - - return qs.order_by('-created_at') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['status_choices'] = ( - AccountOpeningRequest._meta.get_field( - 'status' - ).choices - ) - context['current_status'] = ( - self.request.GET.get('status', '') - ) - context['current_search'] = ( - self.request.GET.get('search', '') - ) - base_qs = self.get_provider_queryset() - counts = { - 'pending': base_qs.filter( - status='pending' - ).count(), - 'approved': base_qs.filter( - status='approved' - ).count(), - 'rejected': base_qs.filter( - status='rejected' - ).count(), - 'processing': base_qs.filter( - status='processing' - ).count(), - 'completed': base_qs.filter( - status='completed' - ).count(), - 'failed': base_qs.filter( - status='failed' - ).count(), - } - context['status_choices_with_counts'] = [ - (v, l, counts.get(v, 0)) - for v, l in context['status_choices'] - ] - context['total_count'] = base_qs.count() - return context - - -class AccountOpeningRequestDetailView(ProviderRequestMixin, DetailView): - """ - 开户申请详情视图 - - 展示申请完整信息、状态时间线,以及批准/驳回/执行开户按钮。 - """ - - model = AccountOpeningRequest - template_name = 'admin_base/operations/request_detail.html' - context_object_name = 'request_obj' - - def get_queryset(self): - return self.get_provider_queryset() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - obj = self.object - # 构建状态时间线 - timeline = [] - timeline.append({ - 'label': '提交申请', - 'time': obj.created_at, - 'done': True, - }) - if obj.status in ( - 'approved', 'rejected', 'processing', - 'completed', 'failed', - ): - timeline.append({ - 'label': '审核完成', - 'time': obj.approval_date, - 'done': ( - obj.status != 'failed' - or obj.approval_date is not None - ), - 'detail': ( - '批准' - if obj.status != 'rejected' - else '驳回' - ), - }) - if obj.status in ('processing', 'completed', 'failed'): - timeline.append({ - 'label': '执行开户', - 'time': ( - obj.updated_at - if obj.status == 'completed' - else None - ), - 'done': obj.status in ('completed',), - 'detail': ( - obj.result_message - if obj.result_message - else None - ), - }) - context['timeline'] = timeline - context['reject_form'] = ( - AccountOpeningRequestRejectForm() - ) - return context - - -class AccountOpeningRequestApproveView(ProviderRequestMixin, View): - """批准单条开户申请 (POST)""" - - def post(self, request, pk): - obj = get_object_or_404( - self.get_provider_queryset(), pk=pk - ) - if obj.status != 'pending': - messages.warning( - request, - f'申请 {obj.username} 当前状态为' - f' {obj.get_status_display()},无法批准。', - ) - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - - obj.approve(approver=request.user, notes='') - messages.success( - request, f'已批准申请 {obj.username}。', - ) - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - - -class AccountOpeningRequestRejectView(ProviderRequestMixin, View): - """ - 驳回单条开户申请 - - GET: 展示驳回表单 - POST: 提交驳回(含驳回原因) - """ - - def get(self, request, pk): - obj = get_object_or_404( - self.get_provider_queryset(), pk=pk - ) - if obj.status != 'pending': - messages.warning( - request, - f'申请 {obj.username} 当前状态为' - f' {obj.get_status_display()},无法驳回。', - ) - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - form = AccountOpeningRequestRejectForm() - return render( - request, - 'admin_base/operations/request_reject.html', - { - 'request_obj': obj, - 'form': form, - 'active_nav': 'account_opening', - 'page_title': '驳回申请', - }, - ) - - def post(self, request, pk): - obj = get_object_or_404( - self.get_provider_queryset(), pk=pk - ) - if obj.status != 'pending': - messages.warning( - request, - f'申请 {obj.username} 当前状态为' - f' {obj.get_status_display()},无法驳回。', - ) - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - - form = AccountOpeningRequestRejectForm(request.POST) - if form.is_valid(): - reason = form.cleaned_data['rejection_reason'] - obj.reject(approver=request.user, notes=reason) - messages.success( - request, f'已驳回申请 {obj.username}。', - ) - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - - return render( - request, - 'admin_base/operations/request_reject.html', - { - 'request_obj': obj, - 'form': form, - 'active_nav': 'account_opening', - 'page_title': '驳回申请', - }, - ) - - -class AccountOpeningRequestExecuteView(ProviderRequestMixin, View): - """ - 执行开户操作 (POST) - - 对已批准的申请执行实际的用户创建操作。 - """ - - def post(self, request, pk): - obj = get_object_or_404( - self.get_provider_queryset(), pk=pk - ) - if obj.status not in ('approved', 'pending'): - messages.warning( - request, - f'申请 {obj.username} 当前状态为' - f' {obj.get_status_display()},' - f'无法执行开户操作。', - ) - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - - try: - from . import services - services.execute_account_opening(obj) - messages.success( - request, - f'申请 {obj.username} 开户操作已执行。', - ) - except Exception as e: - logger.error( - f'执行开户失败: {obj.username}, ' - f'错误: {str(e)}', - exc_info=True, - ) - messages.error( - request, - f'执行开户操作失败: {str(e)}', - ) - - return redirect( - 'provider_operations:request_detail', - pk=obj.pk, - ) - - -class AccountOpeningRequestBatchApproveView(ProviderRequestMixin, View): - """ - 批量批准开户申请 (POST) - - 请求体需包含 selected_ids。 - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何申请。') - return redirect('provider_operations:request_list') - - qs = self.get_provider_queryset().filter( - pk__in=selected_ids, status='pending', - ) - updated_count = 0 - for obj in qs: - obj.approve(approver=request.user, notes='') - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f'成功批准了 {updated_count} 个开户申请。', - ) - else: - messages.warning( - request, - '没有符合条件的待审核申请需要批准。', - ) - - return redirect('provider_operations:request_list') - - -class AccountOpeningRequestBatchRejectView(ProviderRequestMixin, View): - """ - 批量驳回开户申请 (POST) - - 请求体需包含 selected_ids 和可选的 rejection_reason。 - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何申请。') - return redirect('provider_operations:request_list') - - rejection_reason = request.POST.get( - 'rejection_reason', '批量驳回', - ) - - qs = self.get_provider_queryset().filter( - pk__in=selected_ids, status='pending', - ) - updated_count = 0 - for obj in qs: - obj.reject( - approver=request.user, notes=rejection_reason, - ) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f'成功驳回了 {updated_count} 个开户申请。', - ) - else: - messages.warning( - request, - '没有符合条件的待审核申请需要驳回。', - ) - - return redirect('provider_operations:request_list') - - -# =========================================================================== -# 邀请令牌管理 -# =========================================================================== - - -class ProductInvitationTokenListView(ProviderRequestMixin, ListView): - """ - 产品邀请令牌列表视图 - - - 提供商数据隔离:只看到自己创建的令牌 - - 支持批量启用/禁用 - - 显示邀请链接和复制按钮 - """ - - model = ProductInvitationToken - template_name = 'admin_base/operations/token_list.html' - context_object_name = 'tokens' - paginate_by = 20 - - def get_queryset(self): - return ProductInvitationToken.objects.filter( - created_by=self.request.user - ).select_related( - 'product', 'product_group', - ).order_by('-created_at') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'invitation_tokens' - context['page_title'] = '邀请令牌' - context['is_provider'] = True - - # 生成邀请链接 - from django.conf import settings - site_url = getattr(settings, 'SITE_URL', '') - for token in context['tokens']: - token.invite_link = ( - f'{site_url}/operations/invite/{token.token}/' - ) - return context - - -class ProductInvitationTokenDetailView(ProviderRequestMixin, DetailView): - """ - 产品邀请令牌详情视图 - - - 查看令牌基本信息 - - 查看所有使用该令牌的用户列表(ProductAccessGrant) - - 提供商数据隔离:只能查看自己创建的令牌 - """ - - model = ProductInvitationToken - template_name = 'admin_base/operations/token_detail.html' - context_object_name = 'token_obj' - - def get_queryset(self): - return ProductInvitationToken.objects.filter( - created_by=self.request.user, - ).select_related( - 'product', 'product_group', 'created_by', - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - token_obj = context['token_obj'] - context['active_nav'] = 'invitation_tokens' - context['page_title'] = '邀请令牌详情' - context['is_provider'] = True - - from django.conf import settings - site_url = getattr(settings, 'SITE_URL', '') - token_obj.invite_link = ( - f'{site_url}/operations/invite/{token_obj.token}/' - ) - - grants = ProductAccessGrant.objects.filter( - granted_by_token=token_obj, - ).select_related( - 'user', 'product', 'product_group', - ).order_by('-granted_at') - - from django.core.paginator import Paginator - paginator = Paginator(grants, 20) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - context['grants'] = page_obj - context['grant_count'] = grants.count() - from django.utils import timezone - context['effective_grant_count'] = grants.filter( - is_revoked=False, - ).exclude( - expires_at__lt=timezone.now(), - ).count() if grants.exists() else 0 - return context - - -class ProductInvitationTokenCreateView(ProviderRequestMixin, View): - """ - 创建邀请令牌视图 - - GET: 展示创建表单 - POST: 提交创建 - """ - - def get(self, request): - from .forms_provider import ProductInvitationTokenForm - form = ProductInvitationTokenForm(provider_user=request.user) - return render(request, 'admin_base/operations/token_create.html', { - 'form': form, - 'active_nav': 'invitation_tokens', - 'page_title': '创建邀请令牌', - }) - - def post(self, request): - from .forms_provider import ProductInvitationTokenForm - form = ProductInvitationTokenForm( - request.POST, provider_user=request.user, - ) - if form.is_valid(): - token_obj = form.save(commit=False) - token_obj.created_by = request.user - token_obj.save() - messages.success( - request, - f'邀请令牌创建成功:{token_obj.token[:8]}...', - ) - return redirect('provider_operations:token_list') - - return render(request, 'admin_base/operations/token_create.html', { - 'form': form, - 'active_nav': 'invitation_tokens', - 'page_title': '创建邀请令牌', - }) - - -class ProductInvitationTokenBatchEnableView(ProviderRequestMixin, View): - """ - 批量启用邀请令牌 (POST) - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何令牌。') - return redirect('provider_operations:token_list') - - updated_count = ProductInvitationToken.objects.filter( - pk__in=selected_ids, - created_by=request.user, - is_active=False, - ).update(is_active=True) - - if updated_count > 0: - messages.success( - request, - f'成功启用了 {updated_count} 个邀请令牌。', - ) - else: - messages.warning( - request, - '没有需要启用的邀请令牌。', - ) - - return redirect('provider_operations:token_list') - - -class ProductInvitationTokenBatchDisableView(ProviderRequestMixin, View): - """ - 批量禁用邀请令牌 (POST) - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何令牌。') - return redirect('provider_operations:token_list') - - updated_count = ProductInvitationToken.objects.filter( - pk__in=selected_ids, - created_by=request.user, - is_active=True, - ).update(is_active=False) - - if updated_count > 0: - messages.success( - request, - f'成功禁用了 {updated_count} 个邀请令牌。', - ) - else: - messages.warning( - request, - '没有需要禁用的邀请令牌。', - ) - - return redirect('provider_operations:token_list') - - -# =========================================================================== -# 访问授权管理 -# =========================================================================== - - -class ProductAccessGrantListView(ProviderRequestMixin, ListView): - """ - 产品访问授权列表视图 - - - 提供商数据隔离:只看到自己产品/产品组相关的授权 - - 支持批量撤销 - """ - - model = ProductAccessGrant - template_name = 'admin_base/operations/grant_list.html' - context_object_name = 'grants' - paginate_by = 20 - - def get_queryset(self): - return ProductAccessGrant.objects.filter( - Q(product__created_by=self.request.user) - | Q(product_group__created_by=self.request.user) - ).select_related( - 'user', 'product', 'product_group', - 'granted_by_token', - ).order_by('-granted_at') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'access_grants' - context['page_title'] = '访问授权' - context['is_provider'] = True - return context - - -class ProductAccessGrantBatchRevokeView(ProviderRequestMixin, View): - """ - 批量撤销访问授权 (POST) - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何授权记录。') - return redirect('provider_operations:grant_list') - - from django.utils import timezone - qs = ProductAccessGrant.objects.filter( - pk__in=selected_ids, - is_revoked=False, - ).filter( - Q(product__created_by=request.user) - | Q(product_group__created_by=request.user), - ) - - updated_count = 0 - for grant in qs: - grant.is_revoked = True - grant.revoked_at = timezone.now() - grant.revoked_by = request.user - grant.save( - update_fields=['is_revoked', 'revoked_at', 'revoked_by'], - ) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f'成功撤销了 {updated_count} 个授权。', - ) - else: - messages.warning( - request, - '没有需要撤销的授权。', - ) - - return redirect('provider_operations:grant_list') - - -# =========================================================================== -# RDP 域名路由管理 -# =========================================================================== - - -class RdpDomainRouteListView(ProviderRequestMixin, ListView): - """ - RDP域名路由列表视图 - - - 新增提供商数据隔离:通过 product__created_by 过滤 - - 只读视图,提供商无法修改路由 - """ - - model = RdpDomainRoute - template_name = 'admin_base/operations/route_list.html' - context_object_name = 'routes' - paginate_by = 20 - - def get_queryset(self): - return RdpDomainRoute.objects.filter( - product__created_by=self.request.user - ).select_related( - 'product', 'assigned_to', - ).order_by('-created_at') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'domain_routes' - context['page_title'] = '域名路由' - return context - - -# =========================================================================== -# 系统任务(只读参考) -# =========================================================================== - - -class SystemTaskListView(ProviderRequestMixin, ListView): - """ - 系统任务列表视图 - - - 只读参考视图,无独立管理页面 - - 提供商数据隔离:只看到自己创建的任务 - """ - - model = SystemTask - template_name = 'admin_base/operations/task_list.html' - context_object_name = 'tasks' - paginate_by = 20 - - def get_queryset(self): - return SystemTask.objects.filter( - created_by=self.request.user - ).order_by('-created_at') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'activity_log' - context['page_title'] = '系统任务' - return context - - -# =========================================================================== -# 产品管理 -# =========================================================================== - - -class ProviderProductMixin(ProviderContextMixin): - """ - 提供商产品数据隔离 Mixin - - - dispatch: 验证提供商身份 - - get_queryset: 限制为当前提供商创建的产品 - """ - - provider_url_namespace = 'provider:provider_operations' - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_authenticated: - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(request.get_full_path()) - if not is_provider(request.user): - from django.http import HttpResponseForbidden - return HttpResponseForbidden( - '您没有提供商权限,无法访问此页面。' - ) - return super().dispatch(request, *args, **kwargs) - - def get_provider_product_queryset(self): - """获取当前提供商可见的产品查询集""" - return Product.objects.filter( - created_by=self.request.user - ).select_related( - 'host', 'product_group', 'created_by', - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'products' - context['page_title'] = '产品管理' - return context - - -class ProductListView(ProviderProductMixin, TemplateView): - """ - 产品列表视图 - - 支持搜索、筛选、分页 - """ - - template_name = 'admin_base/operations/product_list.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - queryset = self.get_provider_product_queryset() - - # 搜索 - search = self.request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(display_name__icontains=search) - | Q(host__name__icontains=search) - ) - - # 可用状态筛选 - available_filter = self.request.GET.get('is_available', '').strip() - if available_filter: - queryset = queryset.filter(is_available=available_filter == 'true') - - # 可见性筛选 - visibility_filter = self.request.GET.get('visibility', '').strip() - if visibility_filter: - queryset = queryset.filter(visibility=visibility_filter) - - # 排序 - queryset = queryset.order_by('-created_at') - - # 分页 - paginator = Paginator(queryset, 15) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context.update({ - 'page_obj': page_obj, - 'products': page_obj, - 'search': search, - 'available_filter': available_filter, - 'visibility_filter': visibility_filter, - 'visibility_choices': Product._meta.get_field('visibility').choices, - 'page_title': '产品管理', - 'active_nav': 'products', - }) - return context - - -class ProductDetailView(ProviderProductMixin, DetailView): - """ - 产品详情视图 - - 显示产品信息、磁盘配额配置、关联用户数等 - """ - - template_name = 'admin_base/operations/product_detail.html' - context_object_name = 'product' - pk_url_kwarg = 'pk' - - def get_queryset(self): - return self.get_provider_product_queryset() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - product = self.object - - # 获取关联用户数 - user_count = CloudComputerUser.objects.filter( - product=product - ).count() - - # 磁盘配额信息 - disk_quota_json = '{}' - if product.default_disk_quota: - disk_quota_json = json.dumps( - product.default_disk_quota, - ensure_ascii=False, - indent=2, - ) - - extra_disks_json = '[]' - if product.allow_extra_quota_disks: - extra_disks_json = json.dumps( - product.allow_extra_quota_disks, - ensure_ascii=False, - ) - - context.update({ - 'user_count': user_count, - 'disk_quota_json': disk_quota_json, - 'extra_disks_json': extra_disks_json, - 'page_title': f'产品 - {product.display_name}', - 'active_nav': 'products', - }) - return context - - -class ProductCreateView(ProviderProductMixin, TemplateView): - """ - 产品创建视图 - - 处理 GET 和 POST 请求,创建新产品。 - 自动设置 created_by 为当前用户。 - """ - - template_name = 'admin_base/operations/product_form.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update({ - 'form': kwargs.get( - 'form', - ProductForm(provider_user=self.request.user), - ), - 'page_title': '创建产品', - 'active_nav': 'products', - 'is_create': True, - }) - return context - - def post(self, request, *args, **kwargs): - form = ProductForm( - request.POST, - provider_user=request.user, - ) - if form.is_valid(): - product = form.save(commit=False) - product.created_by = request.user - product.save() - - messages.success( - request, - f'产品 {product.display_name} 创建成功', - ) - return redirect( - 'provider_operations:product_detail', - pk=product.pk, - ) - - return self.render_to_response( - self.get_context_data(form=form) - ) - - -class ProductUpdateView(ProviderProductMixin, TemplateView): - """ - 产品编辑视图 - - 处理 GET 和 POST 请求,编辑产品信息。 - """ - - template_name = 'admin_base/operations/product_form.html' - - def get_product(self): - """获取当前编辑的产品,确保数据隔离""" - return get_object_or_404( - self.get_provider_product_queryset(), - pk=self.kwargs['pk'], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - product = self.get_product() - form = kwargs.get( - 'form', - ProductForm( - instance=product, - provider_user=self.request.user, - ), - ) - context.update({ - 'form': form, - 'product': product, - 'page_title': f'编辑产品 - {product.display_name}', - 'active_nav': 'products', - 'is_create': False, - }) - return context - - def post(self, request, *args, **kwargs): - product = self.get_product() - form = ProductForm( - request.POST, - instance=product, - provider_user=request.user, - ) - if form.is_valid(): - product = form.save() - messages.success( - request, - f'产品 {product.display_name} 更新成功', - ) - return redirect( - 'provider_operations:product_detail', - pk=product.pk, - ) - - return self.render_to_response( - self.get_context_data(form=form) - ) - - -class ProductDeleteView(ProviderProductMixin, TemplateView): - """ - 产品删除视图 - - 显示确认页面,处理删除请求。 - """ - - template_name = 'admin_base/operations/product_confirm_delete.html' - - def get_product(self): - return get_object_or_404( - self.get_provider_product_queryset(), - pk=self.kwargs['pk'], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - product = self.get_product() - - # 获取关联用户数 - user_count = CloudComputerUser.objects.filter( - product=product - ).count() - - context.update({ - 'product': product, - 'user_count': user_count, - 'page_title': f'删除产品 - {product.display_name}', - 'active_nav': 'products', - }) - return context - - def post(self, request, *args, **kwargs): - product = self.get_product() - product_name = product.display_name - product.delete() - - messages.success( - request, - f'产品 {product_name} 已删除', - ) - return redirect('provider_operations:product_list') - - -# =========================================================================== -# 产品组管理 -# =========================================================================== - - -class ProviderProductGroupMixin(ProviderContextMixin): - """ - 提供商产品组数据隔离 Mixin - - - dispatch: 验证提供商身份 - - get_queryset: 限制为当前提供商创建的产品组 - """ - - provider_url_namespace = 'provider:provider_operations' - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_authenticated: - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(request.get_full_path()) - if not is_provider(request.user): - from django.http import HttpResponseForbidden - return HttpResponseForbidden( - '您没有提供商权限,无法访问此页面。' - ) - return super().dispatch(request, *args, **kwargs) - - def get_provider_productgroup_queryset(self): - """获取当前提供商可见的产品组查询集""" - return ProductGroup.objects.filter( - created_by=self.request.user - ).order_by('display_order', 'name') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'product_groups' - context['page_title'] = '产品组管理' - return context - - -class ProductGroupListView(ProviderProductGroupMixin, TemplateView): - """ - 产品组列表视图 - - 支持搜索、分页 - """ - - template_name = 'admin_base/operations/productgroup_list.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - queryset = self.get_provider_productgroup_queryset() - - # 搜索 - search = self.request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(name__icontains=search) - | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(queryset, 15) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context.update({ - 'page_obj': page_obj, - 'productgroups': page_obj, - 'search': search, - 'page_title': '产品组管理', - 'active_nav': 'product_groups', - }) - return context - - -class ProductGroupCreateView(ProviderProductGroupMixin, TemplateView): - """ - 产品组创建视图 - - 处理 GET 和 POST 请求,创建新产品组。 - 自动设置 created_by 为当前用户。 - """ - - template_name = 'admin_base/operations/productgroup_form.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update({ - 'form': kwargs.get( - 'form', - ProductGroupForm(), - ), - 'page_title': '创建产品组', - 'active_nav': 'product_groups', - 'is_create': True, - }) - return context - - def post(self, request, *args, **kwargs): - form = ProductGroupForm(request.POST) - if form.is_valid(): - productgroup = form.save(commit=False) - productgroup.created_by = request.user - productgroup.save() - - messages.success( - request, - f'产品组 {productgroup.name} 创建成功', - ) - return redirect('provider_operations:productgroup_list') - - return self.render_to_response( - self.get_context_data(form=form) - ) - - -class ProductGroupUpdateView(ProviderProductGroupMixin, TemplateView): - """ - 产品组编辑视图 - - 处理 GET 和 POST 请求,编辑产品组信息。 - """ - - template_name = 'admin_base/operations/productgroup_form.html' - - def get_productgroup(self): - """获取当前编辑的产品组,确保数据隔离""" - return get_object_or_404( - self.get_provider_productgroup_queryset(), - pk=self.kwargs['pk'], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - productgroup = self.get_productgroup() - form = kwargs.get( - 'form', - ProductGroupForm(instance=productgroup), - ) - context.update({ - 'form': form, - 'productgroup': productgroup, - 'page_title': f'编辑产品组 - {productgroup.name}', - 'active_nav': 'product_groups', - 'is_create': False, - }) - return context - - def post(self, request, *args, **kwargs): - productgroup = self.get_productgroup() - form = ProductGroupForm(request.POST, instance=productgroup) - if form.is_valid(): - productgroup = form.save() - messages.success( - request, - f'产品组 {productgroup.name} 更新成功', - ) - return redirect('provider_operations:productgroup_list') - - return self.render_to_response( - self.get_context_data(form=form) - ) - - -class ProductGroupDeleteView(ProviderProductGroupMixin, TemplateView): - """ - 产品组删除视图 - - 显示确认页面,处理删除请求。 - """ - - template_name = 'admin_base/operations/productgroup_confirm_delete.html' - - def get_productgroup(self): - return get_object_or_404( - self.get_provider_productgroup_queryset(), - pk=self.kwargs['pk'], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - productgroup = self.get_productgroup() - - # 获取关联产品数 - product_count = Product.objects.filter( - product_group=productgroup - ).count() - - context.update({ - 'productgroup': productgroup, - 'product_count': product_count, - 'page_title': f'删除产品组 - {productgroup.name}', - 'active_nav': 'product_groups', - }) - return context - - def post(self, request, *args, **kwargs): - productgroup = self.get_productgroup() - productgroup_name = productgroup.name - productgroup.delete() - - messages.success( - request, - f'产品组 {productgroup_name} 已删除', - ) - return redirect('provider_operations:productgroup_list') diff --git a/apps/provider/__init__.py b/apps/provider/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/provider/apps.py b/apps/provider/apps.py deleted file mode 100644 index 5661ef1..0000000 --- a/apps/provider/apps.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.apps import AppConfig - - -class ProviderConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.provider' - verbose_name = '提供商后台' - label = 'provider' diff --git a/apps/provider/context_mixin.py b/apps/provider/context_mixin.py deleted file mode 100644 index 999b362..0000000 --- a/apps/provider/context_mixin.py +++ /dev/null @@ -1,20 +0,0 @@ -class ProviderContextMixin: - """ - 提供商视图上下文混入类 - - 为提供商视图注入通用上下文变量(active_nav 等)。 - 提供商和超管共用同一套模板和 URL,侧边栏通过 - {% if user.is_superuser %} 条件渲染实现差异化。 - """ - - provider_page_title = '2c2a 提供商后台' - - def get_provider_context(self): - return { - 'page_title': self.provider_page_title, - } - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update(self.get_provider_context()) - return context diff --git a/apps/provider/decorators.py b/apps/provider/decorators.py deleted file mode 100644 index 8861dae..0000000 --- a/apps/provider/decorators.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -提供商认证装饰器和辅助函数 - -集中管理提供商身份验证逻辑,供整个项目使用。 -替代 hosts/admin.py、operations/admin.py、tickets/admin.py 中重复的 is_provider 函数。 -""" - -from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect -from functools import wraps - - -PROVIDER_GROUP_NAME = '主机提供商' - - -def is_provider(user): - """ - 检查用户是否属于提供商组 - - 超级管理员不属于提供商组,即使其权限更高。 - 此逻辑与 Admin 后台的数据隔离保持一致。 - - Args: - user: 用户对象 - - Returns: - bool: 如果用户属于提供商组且不是超级管理员,返回 True - """ - if user.is_superuser: - return False - return user.groups.filter(name=PROVIDER_GROUP_NAME).exists() - - -def provider_required(view_func): - """ - 装饰器:要求当前用户为提供商 - - - 未登录用户将被重定向到登录页 - - 非提供商用户将被重定向到登录页 - """ - @wraps(view_func) - @login_required - def wrapper(request, *args, **kwargs): - if not is_provider(request.user): - return redirect('accounts:login') - return view_func(request, *args, **kwargs) - return wrapper - - -def superuser_required(view_func): - """ - 装饰器:要求当前用户为超级管理员 - - - 未登录用户将被重定向到登录页 - - 非超级管理员将被重定向到提供商仪表盘 - """ - @wraps(view_func) - @login_required - def wrapper(request, *args, **kwargs): - if not request.user.is_superuser: - return redirect('provider:dashboard') - return view_func(request, *args, **kwargs) - return wrapper diff --git a/apps/provider/migrations/__init__.py b/apps/provider/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/provider/urls.py b/apps/provider/urls.py deleted file mode 100644 index 7e399db..0000000 --- a/apps/provider/urls.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -提供商后台 URL 配置 - -所有 URL 以 /provider/ 为前缀。 -""" - -from django.urls import path -from . import views - -app_name = 'provider' - -urlpatterns = [ - # 仪表盘 - path('', views.ProviderDashboardView.as_view(), name='dashboard'), - - # 主机管理 - path('hosts/', views.HostListView.as_view(), name='hosts'), - - # 主机组 - path('host-groups/', views.HostGroupListView.as_view(), name='host_groups'), - - # 产品管理 - path('products/', views.ProductListView.as_view(), name='products'), - - # 产品组 - path('product-groups/', views.ProductGroupListView.as_view(), - name='product_groups'), - - # 开户申请 - path('account-opening/', views.AccountOpeningListView.as_view(), - name='account_opening'), - - # 云电脑用户 - path('cloud-users/', views.CloudUserListView.as_view(), - name='cloud_users'), - - # 邀请令牌 - path('invitation-tokens/', views.InvitationTokenListView.as_view(), - name='invitation_tokens'), - - # 授权记录 - path('access-grants/', views.AccessGrantListView.as_view(), - name='access_grants'), - - # 工单管理 - path('tickets/', views.TicketListView.as_view(), name='tickets'), - - # 工单分类 - path('ticket-categories/', views.TicketCategoryListView.as_view(), - name='ticket_categories'), - - # 活动日志 - path('activity-log/', views.ActivityLogView.as_view(), - name='activity_log'), - - # 域名路由 - path('domain-routes/', views.DomainRouteListView.as_view(), - name='domain_routes'), - - # QQ验证 - path('qq-verify/', views.QQVerifyView.as_view(), name='qq_verify'), - - # 插件配置 - path('plugins/', views.PluginConfigView.as_view(), name='plugins'), -] diff --git a/apps/provider/views.py b/apps/provider/views.py deleted file mode 100644 index fa52760..0000000 --- a/apps/provider/views.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -提供商后台视图 - -包含仪表盘和各模块的占位视图。 -所有视图均使用 @provider_required 装饰器保护。 -""" - -from django.views.generic import TemplateView -from django.utils.decorators import method_decorator - -from .decorators import provider_required - - -class ProviderBaseView: - """ - 提供商后台基础视图混入类 - - 通过重写 dispatch 方法实现提供商身份验证, - 避免在混入类上使用 method_decorator(混入类无 dispatch 方法)。 - """ - - def dispatch(self, request, *args, **kwargs): - from django.shortcuts import redirect - if not request.user.is_authenticated: - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(request.get_full_path()) - from .decorators import is_provider - if not is_provider(request.user): - return redirect('accounts:login') - return super().dispatch(request, *args, **kwargs) - - -# ========== 仪表盘 ========== - -class ProviderDashboardView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/dashboard.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - # 统计数据 - from apps.hosts.models import Host, HostGroup - from apps.operations.models import ( - Product, ProductGroup, AccountOpeningRequest, - CloudComputerUser, ProductInvitationToken, - ProductAccessGrant, RdpDomainRoute, - ) - - stats = { - 'host_count': Host.objects.filter(providers=user).count(), - 'hostgroup_count': HostGroup.objects.filter(providers=user).count(), - 'product_count': Product.objects.filter(created_by=user).count(), - 'productgroup_count': ProductGroup.objects.filter(created_by=user).count(), - 'pending_request_count': AccountOpeningRequest.objects.filter( - target_product__created_by=user, status='pending' - ).count(), - 'active_user_count': CloudComputerUser.objects.filter( - product__created_by=user, status='active' - ).count(), - 'invitation_token_count': ProductInvitationToken.objects.filter( - created_by=user, is_active=True - ).count(), - 'access_grant_count': ProductAccessGrant.objects.filter( - product__created_by=user, is_revoked=False - ).count(), - 'rdp_route_count': RdpDomainRoute.objects.filter( - product__created_by=user - ).count(), - } - - context['stats'] = stats - context['page_title'] = '仪表盘' - context['active_nav'] = 'dashboard' - return context - - -# ========== 占位视图(后续实现具体功能) ========== - -class HostListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '主机管理' - context['active_nav'] = 'hosts' - context['feature_icon'] = 'dns' - context['feature_name'] = '主机管理' - return context - - -class HostGroupListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '主机组' - context['active_nav'] = 'host_groups' - context['feature_icon'] = 'folder' - context['feature_name'] = '主机组' - return context - - -class ProductListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '产品管理' - context['active_nav'] = 'products' - context['feature_icon'] = 'inventory_2' - context['feature_name'] = '产品管理' - return context - - -class ProductGroupListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '产品组' - context['active_nav'] = 'product_groups' - context['feature_icon'] = 'category' - context['feature_name'] = '产品组' - return context - - -class AccountOpeningListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '开户申请' - context['active_nav'] = 'account_opening' - context['feature_icon'] = 'how_to_reg' - context['feature_name'] = '开户申请' - return context - - -class CloudUserListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '云电脑用户' - context['active_nav'] = 'cloud_users' - context['feature_icon'] = 'people' - context['feature_name'] = '云电脑用户' - return context - - -class InvitationTokenListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '邀请令牌' - context['active_nav'] = 'invitation_tokens' - context['feature_icon'] = 'mail' - context['feature_name'] = '邀请令牌' - return context - - -class AccessGrantListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '授权记录' - context['active_nav'] = 'access_grants' - context['feature_icon'] = 'key' - context['feature_name'] = '授权记录' - return context - - -class TicketListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '工单管理' - context['active_nav'] = 'tickets' - context['feature_icon'] = 'confirmation_number' - context['feature_name'] = '工单管理' - return context - - -class TicketCategoryListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '工单分类' - context['active_nav'] = 'ticket_categories' - context['feature_icon'] = 'label' - context['feature_name'] = '工单分类' - return context - - -class ActivityLogView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '活动日志' - context['active_nav'] = 'activity_log' - context['feature_icon'] = 'history' - context['feature_name'] = '活动日志' - return context - - -class DomainRouteListView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '域名路由' - context['active_nav'] = 'domain_routes' - context['feature_icon'] = 'language' - context['feature_name'] = '域名路由' - return context - - -class QQVerifyView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = 'QQ验证' - context['active_nav'] = 'qq_verify' - context['feature_icon'] = 'verified' - context['feature_name'] = 'QQ验证' - return context - - -class PluginConfigView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/coming_soon.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '插件配置' - context['active_nav'] = 'plugins' - context['feature_icon'] = 'extension' - context['feature_name'] = '插件配置' - return context diff --git a/apps/provider_backend/__init__.py b/apps/provider_backend/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/provider_backend/admin.py b/apps/provider_backend/admin.py deleted file mode 100644 index b2f16ba..0000000 --- a/apps/provider_backend/admin.py +++ /dev/null @@ -1 +0,0 @@ -# provider_backend 无需后台管理 diff --git a/apps/provider_backend/api.py b/apps/provider_backend/api.py deleted file mode 100644 index b147871..0000000 --- a/apps/provider_backend/api.py +++ /dev/null @@ -1,272 +0,0 @@ -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from django.utils.decorators import method_decorator - -from .decorators import provider_required, is_provider - - -@method_decorator(provider_required, name='dispatch') -class HostDeployAPI(APIView): - """ - 主机部署 API - - POST /api/hosts//deploy/ - 生成部署命令 - """ - - def post(self, request, pk): - from apps.hosts.models import Host - site_group = getattr(request, 'site_group', None) - host_qs = Host.objects.filter(pk=pk, providers=request.user) - if site_group: - host_qs = host_qs.filter(site_group=site_group) - else: - host_qs = host_qs.filter(site_group__isnull=True) - try: - host = host_qs.get() - except Host.DoesNotExist: - return Response( - {'error': '主机不存在'}, - status=status.HTTP_404_NOT_FOUND - ) - - # TODO: 实现部署命令生成逻辑 - return Response({ - 'status': 'success', - 'message': f'主机 {host.name} 部署命令生成功能即将上线', - 'host_id': host.pk, - 'host_name': host.name, - }) - - -@method_decorator(provider_required, name='dispatch') -class AccountRequestActionAPI(APIView): - """ - 开户申请操作 API - - POST /api/account-requests//action/ - 操作类型: approve / reject / process - """ - - def post(self, request, pk): - from apps.operations.models import AccountOpeningRequest - try: - account_request = AccountOpeningRequest.objects.get( - pk=pk, - target_product__created_by=request.user - ) - except AccountOpeningRequest.DoesNotExist: - return Response( - {'error': '开户申请不存在'}, - status=status.HTTP_404_NOT_FOUND - ) - - action = request.data.get('action') - if action not in ('approve', 'reject', 'process'): - return Response( - {'error': '无效的操作类型,支持: approve, reject, process'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # TODO: 实现开户申请操作逻辑 - return Response({ - 'status': 'success', - 'message': f'开户申请 {account_request.pk} 的 {action} 操作即将上线', - 'request_id': account_request.pk, - 'action': action, - }) - - -@method_decorator(provider_required, name='dispatch') -class CloudUserActionAPI(APIView): - """ - 云电脑用户操作 API - - POST /api/cloud-users//action/ - 操作类型: activate / deactivate / disable / reset-password - """ - - def post(self, request, pk): - from apps.operations.models import CloudComputerUser - site_group = getattr(request, 'site_group', None) - qs = CloudComputerUser.objects.filter( - pk=pk, - product__created_by=request.user - ) - if site_group: - qs = qs.filter(product__site_group=site_group) - else: - qs = qs.filter(product__site_group__isnull=True) - try: - cloud_user = qs.get() - except CloudComputerUser.DoesNotExist: - return Response( - {'error': '云电脑用户不存在'}, - status=status.HTTP_404_NOT_FOUND - ) - - action = request.data.get('action') - valid_actions = ('activate', 'deactivate', 'disable', 'reset-password') - if action not in valid_actions: - return Response( - {'error': f'无效的操作类型,支持: {", ".join(valid_actions)}'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # TODO: 实现云电脑用户操作逻辑 - return Response({ - 'status': 'success', - 'message': f'云电脑用户 {cloud_user.username} 的 {action} 操作即将上线', - 'user_id': cloud_user.pk, - 'username': cloud_user.username, - 'action': action, - }) - - -@method_decorator(provider_required, name='dispatch') -class InvitationTokenActionAPI(APIView): - """ - 邀请令牌操作 API - - POST /api/invitation-tokens//action/ - 操作类型: activate / deactivate - """ - - def post(self, request, pk): - from apps.operations.models import ProductInvitationToken - try: - token = ProductInvitationToken.objects.get( - pk=pk, - created_by=request.user - ) - except ProductInvitationToken.DoesNotExist: - return Response( - {'error': '邀请令牌不存在'}, - status=status.HTTP_404_NOT_FOUND - ) - - action = request.data.get('action') - if action not in ('activate', 'deactivate'): - return Response( - {'error': '无效的操作类型,支持: activate, deactivate'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # TODO: 实现邀请令牌操作逻辑 - return Response({ - 'status': 'success', - 'message': f'邀请令牌 {token.token[:8]}... 的 {action} 操作即将上线', - 'token_id': token.pk, - 'action': action, - }) - - -class ProductShareLinkAPI(APIView): - """ - 产品分享链接 API - - GET /api/products//share-link/ - 获取产品的邀请令牌信息(检查是否已有活跃令牌) - - POST /api/products//share-link/ - 为产品创建新的邀请令牌并返回分享链接 - """ - - permission_classes = [IsAuthenticated] - - def get(self, request, pk): - if not (request.user.is_superuser or is_provider(request.user)): - return Response( - {'error': '权限不足'}, - status=status.HTTP_403_FORBIDDEN, - ) - from apps.operations.models import Product, ProductInvitationToken - site_group = getattr(request, 'site_group', None) - product_qs = Product.objects.filter( - pk=pk, - created_by=request.user, - visibility='invite_only', - ) - if site_group: - product_qs = product_qs.filter(site_group=site_group) - else: - product_qs = product_qs.filter(site_group__isnull=True) - try: - product = product_qs.get() - except Product.DoesNotExist: - return Response( - {'error': '产品不存在或非邀请访问产品'}, - status=status.HTTP_404_NOT_FOUND, - ) - - active_token = ProductInvitationToken.objects.filter( - product=product, - created_by=request.user, - is_active=True, - ).order_by('-created_at').first() - - if active_token: - invite_link = request.build_absolute_uri( - f'/operations/invite/{active_token.token}/' - ) - return Response({ - 'has_existing': True, - 'invite_link': invite_link, - 'token': active_token.token[:8] + '...', - 'used_count': active_token.used_count, - 'max_uses': active_token.max_uses, - 'created_at': active_token.created_at.isoformat(), - 'expires_at': ( - active_token.expires_at.isoformat() - if active_token.expires_at else None - ), - }) - - return Response({'has_existing': False}) - - def post(self, request, pk): - if not (request.user.is_superuser or is_provider(request.user)): - return Response( - {'error': '权限不足'}, - status=status.HTTP_403_FORBIDDEN, - ) - from apps.operations.models import Product, ProductInvitationToken - site_group = getattr(request, 'site_group', None) - product_qs = Product.objects.filter( - pk=pk, - created_by=request.user, - visibility='invite_only', - ) - if site_group: - product_qs = product_qs.filter(site_group=site_group) - else: - product_qs = product_qs.filter(site_group__isnull=True) - try: - product = product_qs.get() - except Product.DoesNotExist: - return Response( - {'error': '产品不存在或非邀请访问产品'}, - status=status.HTTP_404_NOT_FOUND, - ) - - token_obj = ProductInvitationToken.objects.create( - product=product, - created_by=request.user, - is_active=True, - ) - - invite_link = request.build_absolute_uri( - f'/operations/invite/{token_obj.token}/' - ) - - return Response({ - 'has_existing': False, - 'invite_link': invite_link, - 'token': token_obj.token[:8] + '...', - 'used_count': 0, - 'max_uses': 0, - 'created_at': token_obj.created_at.isoformat(), - 'expires_at': None, - }) diff --git a/apps/provider_backend/api_urls.py b/apps/provider_backend/api_urls.py deleted file mode 100644 index e90d2c5..0000000 --- a/apps/provider_backend/api_urls.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.urls import path -from . import api - -app_name = 'provider_api' - -urlpatterns = [ - path('hosts//deploy/', api.HostDeployAPI.as_view(), name='host_deploy'), - path('account-requests//action/', api.AccountRequestActionAPI.as_view(), name='accountrequest_action'), - path('cloud-users//action/', api.CloudUserActionAPI.as_view(), name='clouduser_action'), - path( - 'invitation-tokens//action/', - api.InvitationTokenActionAPI.as_view(), - name='invitationtoken_action', - ), - path( - 'products//share-link/', - api.ProductShareLinkAPI.as_view(), - name='product_share_link', - ), -] diff --git a/apps/provider_backend/apps.py b/apps/provider_backend/apps.py deleted file mode 100644 index 450a63f..0000000 --- a/apps/provider_backend/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class ProviderBackendConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.provider_backend' - verbose_name = '提供商后台' diff --git a/apps/provider_backend/decorators.py b/apps/provider_backend/decorators.py deleted file mode 100644 index ba9c1d2..0000000 --- a/apps/provider_backend/decorators.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect -from django.urls import reverse -from functools import wraps - - -PROVIDER_GROUP_NAME = '主机提供商' - - -def is_provider(user): - """检查用户是否属于提供商组""" - if user.is_superuser: - return False - return user.groups.filter(name=PROVIDER_GROUP_NAME).exists() - - -def provider_required(view_func): - """ - 装饰器:要求当前用户为提供商 - 非提供商用户将被重定向到登录页 - """ - @wraps(view_func) - @login_required - def wrapper(request, *args, **kwargs): - if not is_provider(request.user): - return redirect('accounts:login') - return view_func(request, *args, **kwargs) - return wrapper - - -def superadmin_required(view_func): - """ - 装饰器:要求当前用户为超级管理员 - 非超级管理员将被重定向到提供商仪表盘 - """ - @wraps(view_func) - @login_required - def wrapper(request, *args, **kwargs): - if not request.user.is_superuser: - return redirect('provider:dashboard') - return view_func(request, *args, **kwargs) - return wrapper diff --git a/apps/provider_backend/middleware.py b/apps/provider_backend/middleware.py deleted file mode 100644 index f09a871..0000000 --- a/apps/provider_backend/middleware.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.shortcuts import redirect -from django.urls import reverse - - -class ProviderRedirectMiddleware: - """ - 提供商重定向中间件 - - 将提供商用户从 /admin/ 重定向到 /provider/, - 防止提供商访问 Django Admin 后台。 - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - if request.path.startswith('/admin/') and not request.path.startswith('/admin/login/'): - if hasattr(request, 'user') and request.user.is_authenticated: - from .decorators import is_provider - if is_provider(request.user) and not request.user.is_superuser: - return redirect('provider:dashboard') - return self.get_response(request) diff --git a/apps/provider_backend/migrations/__init__.py b/apps/provider_backend/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/provider_backend/models.py b/apps/provider_backend/models.py deleted file mode 100644 index 71a8362..0000000 --- a/apps/provider_backend/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/apps/provider_backend/tests/__init__.py b/apps/provider_backend/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/provider_backend/urls.py b/apps/provider_backend/urls.py deleted file mode 100644 index 6f9b8f8..0000000 --- a/apps/provider_backend/urls.py +++ /dev/null @@ -1,69 +0,0 @@ -from django.urls import path, include -from . import views - -app_name = 'provider' - -urlpatterns = [ - # 仪表盘 - path('', views.DashboardView.as_view(), name='dashboard'), - - # 主机管理 - path('hosts/', views.HostListView.as_view(), name='host_list'), - path('hosts/create/', views.HostCreateWizard.as_view(), name='host_create'), - path('hosts//', views.HostDetailView.as_view(), name='host_detail'), - path('hosts//edit/', views.HostUpdateView.as_view(), name='host_update'), - path('hosts//deploy/', views.HostDeployView.as_view(), name='host_deploy'), - - # 主机组管理 - path('host-groups/', views.HostGroupListView.as_view(), name='hostgroup_list'), - path('host-groups/create/', views.HostGroupCreateView.as_view(), name='hostgroup_create'), - path('host-groups//edit/', views.HostGroupUpdateView.as_view(), name='hostgroup_update'), - - # 产品管理 - path('products/', views.ProductListView.as_view(), name='product_list'), - path('products/create/', views.ProductCreateView.as_view(), name='product_create'), - path('products//', views.ProductDetailView.as_view(), name='product_detail'), - path('products//edit/', views.ProductUpdateView.as_view(), name='product_update'), - - # 产品组管理 - path('product-groups/', views.ProductGroupListView.as_view(), name='productgroup_list'), - path('product-groups/create/', views.ProductGroupCreateView.as_view(), name='productgroup_create'), - path('product-groups//edit/', views.ProductGroupUpdateView.as_view(), name='productgroup_update'), - - # 开户申请管理 - path('account-requests/', views.AccountRequestListView.as_view(), name='accountrequest_list'), - path('account-requests//', views.AccountRequestDetailView.as_view(), name='accountrequest_detail'), - - # 云电脑用户管理 - path('cloud-users/', views.CloudUserListView.as_view(), name='clouduser_list'), - path('cloud-users//', views.CloudUserDetailView.as_view(), name='clouduser_detail'), - - # 邀请令牌管理 - path('invitation-tokens/', views.InvitationTokenListView.as_view(), name='invitationtoken_list'), - path('invitation-tokens/create/', views.InvitationTokenCreateView.as_view(), name='invitationtoken_create'), - - # 访问授权管理 - path('access-grants/', views.AccessGrantListView.as_view(), name='accessgrant_list'), - - # 工单管理 - path('tickets/', views.TicketListView.as_view(), name='ticket_list'), - path('tickets/create/', views.TicketCreateView.as_view(), name='ticket_create'), - path('tickets//', views.TicketDetailView.as_view(), name='ticket_detail'), - - # 工单分类 - path('ticket-categories/', views.TicketCategoryListView.as_view(), name='ticketcategory_list'), - - # 工单活动 - path('ticket-activities/', views.TicketActivityListView.as_view(), name='ticketactivity_list'), - - # RDP路由 - path('rdp-routes/', views.RdpRouteListView.as_view(), name='rdproute_list'), - - # QQ验证配置 - path('qq-config/', views.QQConfigListView.as_view(), name='qqconfig_list'), - path('qq-config/create/', views.QQConfigCreateView.as_view(), name='qqconfig_create'), - path('qq-config//edit/', views.QQConfigUpdateView.as_view(), name='qqconfig_update'), - - # API 端点 - path('api/', include('apps.provider_backend.api_urls')), -] diff --git a/apps/provider_backend/views.py b/apps/provider_backend/views.py deleted file mode 100644 index 664b39b..0000000 --- a/apps/provider_backend/views.py +++ /dev/null @@ -1,575 +0,0 @@ -from django.views.generic import TemplateView, ListView, CreateView, UpdateView, DetailView, FormView -from django.utils.decorators import method_decorator -from django.shortcuts import redirect - -from .decorators import provider_required - - -@method_decorator(provider_required, name='dispatch') -class ProviderBaseView: - """提供商后台基础视图混入类,自动应用 provider_required 装饰器""" - pass - - -# ========== 仪表盘 ========== - -class DashboardView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/dashboard.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '仪表盘' - return context - - -# ========== 主机管理 ========== - -from apps.hosts.models import Host, HostGroup - - -class HostListView(ProviderBaseView, ListView): - model = Host - template_name = 'admin_base/provider/host_list.html' - context_object_name = 'hosts' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = Host.objects.filter( - providers=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '主机管理' - return context - - -class HostCreateWizard(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/host_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建主机' - return context - - -class HostDetailView(ProviderBaseView, DetailView): - model = Host - template_name = 'admin_base/provider/host_detail.html' - context_object_name = 'host' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = Host.objects.filter(providers=self.request.user) - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '主机详情' - return context - - -class HostUpdateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/host_update.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '编辑主机' - site_group = getattr(self.request, 'site_group', None) - qs = Host.objects.filter( - pk=kwargs['pk'], providers=self.request.user - ) - if site_group is not None: - qs = qs.filter(site_group=site_group) - context['host'] = qs.first() - return context - - -class HostDeployView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/host_deploy.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '部署主机' - site_group = getattr(self.request, 'site_group', None) - qs = Host.objects.filter( - pk=kwargs['pk'], providers=self.request.user - ) - if site_group is not None: - qs = qs.filter(site_group=site_group) - context['host'] = qs.first() - return context - - -# ========== 主机组管理 ========== - -class HostGroupListView(ProviderBaseView, ListView): - model = HostGroup - template_name = 'admin_base/provider/hostgroup_list.html' - context_object_name = 'hostgroups' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = HostGroup.objects.filter( - providers=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '主机组管理' - return context - - -class HostGroupCreateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/hostgroup_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建主机组' - return context - - -class HostGroupUpdateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/hostgroup_update.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '编辑主机组' - site_group = getattr(self.request, 'site_group', None) - qs = HostGroup.objects.filter( - pk=kwargs['pk'], providers=self.request.user - ) - if site_group is not None: - qs = qs.filter(site_group=site_group) - context['hostgroup'] = qs.first() - return context - - -# ========== 产品管理 ========== - -from apps.operations.models import ( - Product, ProductGroup, AccountOpeningRequest, - CloudComputerUser, ProductInvitationToken, ProductAccessGrant, - RdpDomainRoute, -) - - -class ProductListView(ProviderBaseView, ListView): - model = Product - template_name = 'admin_base/provider/product_list.html' - context_object_name = 'products' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = Product.objects.filter( - created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '产品管理' - return context - - -class ProductCreateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/product_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建产品' - return context - - -class ProductDetailView(ProviderBaseView, DetailView): - model = Product - template_name = 'admin_base/provider/product_detail.html' - context_object_name = 'product' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = Product.objects.filter(created_by=self.request.user) - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '产品详情' - return context - - -class ProductUpdateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/product_update.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '编辑产品' - site_group = getattr(self.request, 'site_group', None) - qs = Product.objects.filter( - pk=kwargs['pk'], created_by=self.request.user - ) - if site_group is not None: - qs = qs.filter(site_group=site_group) - context['product'] = qs.first() - return context - - -# ========== 产品组管理 ========== - -class ProductGroupListView(ProviderBaseView, ListView): - model = ProductGroup - template_name = 'admin_base/provider/productgroup_list.html' - context_object_name = 'productgroups' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = ProductGroup.objects.filter( - created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '产品组管理' - return context - - -class ProductGroupCreateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/productgroup_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建产品组' - return context - - -class ProductGroupUpdateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/productgroup_update.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '编辑产品组' - site_group = getattr(self.request, 'site_group', None) - qs = ProductGroup.objects.filter( - pk=kwargs['pk'], created_by=self.request.user - ) - if site_group is not None: - qs = qs.filter(site_group=site_group) - context['productgroup'] = qs.first() - return context - - -# ========== 开户申请管理 ========== - -class AccountRequestListView(ProviderBaseView, ListView): - model = AccountOpeningRequest - template_name = 'admin_base/provider/accountrequest_list.html' - context_object_name = 'account_requests' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = AccountOpeningRequest.objects.filter( - target_product__created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(target_product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '开户申请' - return context - - -class AccountRequestDetailView(ProviderBaseView, DetailView): - model = AccountOpeningRequest - template_name = 'admin_base/provider/accountrequest_detail.html' - context_object_name = 'account_request' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = AccountOpeningRequest.objects.filter( - target_product__created_by=self.request.user - ) - if site_group is not None: - qs = qs.filter(target_product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '开户申请详情' - return context - - -# ========== 云电脑用户管理 ========== - -class CloudUserListView(ProviderBaseView, ListView): - model = CloudComputerUser - template_name = 'admin_base/provider/clouduser_list.html' - context_object_name = 'cloud_users' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = CloudComputerUser.objects.filter( - product__created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '云电脑用户' - return context - - -class CloudUserDetailView(ProviderBaseView, DetailView): - model = CloudComputerUser - template_name = 'admin_base/provider/clouduser_detail.html' - context_object_name = 'cloud_user' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = CloudComputerUser.objects.filter( - product__created_by=self.request.user - ) - if site_group is not None: - qs = qs.filter(product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '云电脑用户详情' - return context - - -# ========== 邀请令牌管理 ========== - -class InvitationTokenListView(ProviderBaseView, ListView): - model = ProductInvitationToken - template_name = 'admin_base/provider/invitationtoken_list.html' - context_object_name = 'invitation_tokens' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = ProductInvitationToken.objects.filter( - created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '邀请令牌' - return context - - -class InvitationTokenCreateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/invitationtoken_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建邀请令牌' - return context - - -# ========== 访问授权管理 ========== - -class AccessGrantListView(ProviderBaseView, ListView): - model = ProductAccessGrant - template_name = 'admin_base/provider/accessgrant_list.html' - context_object_name = 'access_grants' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = ProductAccessGrant.objects.filter( - product__created_by=self.request.user - ).order_by('-granted_at') - if site_group is not None: - qs = qs.filter(product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '访问授权' - return context - - -# ========== 工单管理 ========== - -from apps.tickets.models import Ticket, TicketCategory, TicketActivity - - -class TicketListView(ProviderBaseView, ListView): - model = Ticket - template_name = 'admin_base/provider/ticket_list.html' - context_object_name = 'tickets' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = Ticket.objects.filter( - assignee=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '工单管理' - return context - - -class TicketCreateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/ticket_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建工单' - return context - - -class TicketDetailView(ProviderBaseView, DetailView): - model = Ticket - template_name = 'admin_base/provider/ticket_detail.html' - context_object_name = 'ticket' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = Ticket.objects.filter( - assignee=self.request.user - ) - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '工单详情' - return context - - -# ========== 工单分类 ========== - -class TicketCategoryListView(ProviderBaseView, ListView): - model = TicketCategory - template_name = 'admin_base/provider/ticketcategory_list.html' - context_object_name = 'ticket_categories' - - def get_queryset(self): - return TicketCategory.objects.filter( - is_active=True - ).order_by('display_order', 'name') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '工单分类' - return context - - -# ========== 工单活动 ========== - -class TicketActivityListView(ProviderBaseView, ListView): - model = TicketActivity - template_name = 'admin_base/provider/ticketactivity_list.html' - context_object_name = 'ticket_activities' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = TicketActivity.objects.filter( - ticket__assignee=self.request.user - ).order_by('-created_at')[:50] - if site_group is not None: - qs = qs.filter(ticket__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '工单活动' - return context - - -# ========== RDP路由 ========== - -class RdpRouteListView(ProviderBaseView, ListView): - model = RdpDomainRoute - template_name = 'admin_base/provider/rdproute_list.html' - context_object_name = 'rdp_routes' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = RdpDomainRoute.objects.filter( - product__created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = 'RDP路由' - return context - - -# ========== QQ验证配置 ========== - -from plugins.models import QQVerificationConfig - - -class QQConfigListView(ProviderBaseView, ListView): - model = QQVerificationConfig - template_name = 'admin_base/provider/qqconfig_list.html' - context_object_name = 'qq_configs' - - def get_queryset(self): - site_group = getattr(self.request, 'site_group', None) - qs = QQVerificationConfig.objects.filter( - product__created_by=self.request.user - ).order_by('-created_at') - if site_group is not None: - qs = qs.filter(product__site_group=site_group) - return qs - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = 'QQ验证配置' - return context - - -class QQConfigCreateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/qqconfig_create.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '创建QQ验证配置' - return context - - -class QQConfigUpdateView(ProviderBaseView, TemplateView): - template_name = 'admin_base/provider/qqconfig_update.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['page_title'] = '编辑QQ验证配置' - site_group = getattr(self.request, 'site_group', None) - qs = QQVerificationConfig.objects.filter( - pk=kwargs['pk'], product__created_by=self.request.user - ) - if site_group is not None: - qs = qs.filter(product__site_group=site_group) - context['qq_config'] = qs.first() - return context diff --git a/apps/tasks/migrations/0001_initial.py b/apps/tasks/migrations/0001_initial.py deleted file mode 100755 index b2de847..0000000 --- a/apps/tasks/migrations/0001_initial.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-01 11:58 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='AsyncTask', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('task_id', models.CharField(max_length=255, unique=True, verbose_name='任务ID')), - ('name', models.CharField(max_length=255, verbose_name='任务名称')), - ('status', models.CharField(choices=[('pending', '待处理'), ('running', '执行中'), ('success', '成功'), ('failed', '失败'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='任务状态')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')), - ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')), - ('progress', models.IntegerField(default=0, verbose_name='进度百分比')), - ('result', models.JSONField(blank=True, null=True, verbose_name='任务结果')), - ('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')), - ('target_object_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='目标对象ID')), - ('target_content_type', models.CharField(blank=True, max_length=100, null=True, verbose_name='目标对象类型')), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='创建者')), - ], - options={ - 'verbose_name': '异步任务', - 'verbose_name_plural': '异步任务', - 'db_table': 'async_task', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='TaskProgress', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('progress', models.IntegerField(verbose_name='进度百分比')), - ('message', models.TextField(blank=True, null=True, verbose_name='进度消息')), - ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='时间戳')), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_updates', to='tasks.asynctask')), - ], - options={ - 'verbose_name': '任务进度', - 'verbose_name_plural': '任务进度', - 'db_table': 'task_progress', - 'ordering': ['-timestamp'], - }, - ), - ] diff --git a/apps/tasks/migrations/__init__.py b/apps/tasks/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/apps/tasks/models.py b/apps/tasks/models.py deleted file mode 100755 index fc2eb29..0000000 --- a/apps/tasks/models.py +++ /dev/null @@ -1,100 +0,0 @@ -from django.db import models -from django.utils import timezone - - -class AsyncTask(models.Model): - """异步任务状态跟踪模型""" - STATUS_CHOICES = [ - ('pending', '待处理'), - ('running', '执行中'), - ('success', '成功'), - ('failed', '失败'), - ('cancelled', '已取消'), - ] - - task_id = models.CharField(max_length=255, unique=True, verbose_name="任务ID") - name = models.CharField(max_length=255, verbose_name="任务名称") - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='pending', - verbose_name="任务状态" - ) - created_by = models.ForeignKey( - 'accounts.User', - on_delete=models.SET_NULL, - null=True, - blank=True, - verbose_name="创建者" - ) - created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") - started_at = models.DateTimeField(null=True, blank=True, verbose_name="开始时间") - completed_at = models.DateTimeField(null=True, blank=True, verbose_name="完成时间") - progress = models.IntegerField(default=0, verbose_name="进度百分比") # 进度百分比 0-100 - result = models.JSONField(null=True, blank=True, verbose_name="任务结果") # 任务结果 - error_message = models.TextField(null=True, blank=True, verbose_name="错误信息") - target_object_id = models.PositiveIntegerField(null=True, blank=True, verbose_name="目标对象ID") - target_content_type = models.CharField(max_length=100, null=True, blank=True, verbose_name="目标对象类型") - - class Meta: - verbose_name = "异步任务" - verbose_name_plural = "异步任务" - db_table = "async_task" - ordering = ['-created_at'] - - def __str__(self): - return f"{self.name} - {self.status}" - - def start_execution(self): - """标记任务开始执行""" - self.status = 'running' - self.started_at = timezone.now() - self.save() - - def complete_success(self, result_data=None): - """标记任务执行成功""" - self.status = 'success' - self.completed_at = timezone.now() - self.progress = 100 - if result_data: - self.result = result_data - self.save() - - def complete_failure(self, error_msg): - """标记任务执行失败""" - self.status = 'failed' - self.completed_at = timezone.now() - self.error_message = error_msg - self.save() - - def cancel_task(self): - """取消任务""" - self.status = 'cancelled' - self.completed_at = timezone.now() - self.save() - - @property - def duration(self): - """任务执行时长""" - if self.completed_at and self.started_at: - return self.completed_at - self.started_at - elif self.started_at: - return timezone.now() - self.started_at - return None - - -class TaskProgress(models.Model): - """任务进度详情模型""" - task = models.ForeignKey(AsyncTask, on_delete=models.CASCADE, related_name='progress_updates') - progress = models.IntegerField(verbose_name="进度百分比") - message = models.TextField(blank=True, null=True, verbose_name="进度消息") - timestamp = models.DateTimeField(auto_now_add=True, verbose_name="时间戳") - - class Meta: - verbose_name = "任务进度" - verbose_name_plural = "任务进度" - db_table = "task_progress" - ordering = ['-timestamp'] - - def __str__(self): - return f"{self.task.name} - {self.progress}%" \ No newline at end of file diff --git a/apps/tasks/urls.py b/apps/tasks/urls.py deleted file mode 100644 index 2c9102b..0000000 --- a/apps/tasks/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path( - '/', - views.async_task_status, - name='async_task_status', - ), -] diff --git a/apps/tasks/views.py b/apps/tasks/views.py deleted file mode 100644 index 366bdee..0000000 --- a/apps/tasks/views.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging - -from django.http import JsonResponse -from django.contrib.auth.decorators import login_required - -from apps.tasks.models import AsyncTask - -logger = logging.getLogger(__name__) - - -@login_required -def async_task_status(request, task_id): - try: - task = AsyncTask.objects.get(task_id=task_id) - except AsyncTask.DoesNotExist: - return JsonResponse( - {'success': False, 'error': '任务不存在'}, - status=404, - ) - - data = { - 'success': True, - 'task_id': task.task_id, - 'name': task.name, - 'status': task.status, - 'progress': task.progress, - 'created_at': task.created_at.isoformat() if task.created_at else None, - 'started_at': task.started_at.isoformat() if task.started_at else None, - 'completed_at': task.completed_at.isoformat() if task.completed_at else None, - 'error_message': task.error_message, - } - - if task.status in ('success', 'failed') and task.result: - if isinstance(task.result, dict): - data.update(task.result) - - return JsonResponse(data) diff --git a/apps/themes/__init__.py b/apps/themes/__init__.py deleted file mode 100755 index b967415..0000000 --- a/apps/themes/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -主题应用模块 - 提供多主题系统支持 - -功能: -1. Material Design 3 和 Neumorphism (新拟态) 主题 -2. 可编辑的页面内容管理 -3. 仪表盘组件布局配置 -4. CSS 变量系统 -5. 移动端响应式适配 -""" -default_app_config = 'apps.themes.apps.ThemesConfig' diff --git a/apps/themes/admin.py b/apps/themes/admin.py deleted file mode 100755 index 2d74e98..0000000 --- a/apps/themes/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (apps.themes.views_admin) diff --git a/apps/themes/apps.py b/apps/themes/apps.py deleted file mode 100755 index 343ee6d..0000000 --- a/apps/themes/apps.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -主题应用配置 -""" -from django.apps import AppConfig - - -class ThemesConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.themes' - verbose_name = '主题管理' - - def ready(self): - """应用启动时执行""" - # 可以在这里注册信号等 - pass diff --git a/apps/themes/context_processors.py b/apps/themes/context_processors.py deleted file mode 100755 index 75e035d..0000000 --- a/apps/themes/context_processors.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -主题上下文处理器 - -将主题配置和页面内容注入到所有模板上下文中 -使用缓存优化性能 -""" -from .models import ThemeConfig, PageContent - - -def theme_context(request): - """ - 主题上下文处理器 - - 注入以下变量到模板: - - theme_config: ThemeConfig 实例 - - page_contents: {position: PageContent} 字典 - - theme_css_url: 当前主题的 CSS 文件路径 - - custom_css_vars: 自定义 CSS 变量字符串 - - Returns: - dict: 模板上下文变量 - """ - # 获取主题配置(带缓存) - config = ThemeConfig.get_config() - - # 获取所有启用的页面内容(带缓存) - contents = PageContent.get_all_enabled() - - # 构建 CSS 文件路径 - theme_css_url = f'css/themes/{config.active_theme}.css' - - # 生成自定义 CSS 变量 - custom_css_vars = config.generate_css_variables() - - return { - 'theme_config': config, - 'page_contents': contents, - 'theme_css_url': theme_css_url, - 'custom_css_vars': custom_css_vars, - } diff --git a/apps/themes/forms_admin.py b/apps/themes/forms_admin.py deleted file mode 100644 index 7dac635..0000000 --- a/apps/themes/forms_admin.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -主题系统超级管理员表单 -""" - -from django import forms -from .models import ThemeConfig, PageContent, WidgetLayout - - -class ThemeConfigForm(forms.ModelForm): - """主题配置表单(单例)""" - - class Meta: - model = ThemeConfig - fields = [ - 'active_theme', - 'branding', - 'custom_colors', - 'css_overrides', - 'enable_mobile_optimization', - ] - - def clean_branding(self): - """验证 branding 为有效 JSON""" - import json - data = self.cleaned_data.get('branding') - if data and isinstance(data, str): - try: - json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('品牌资源必须是有效的 JSON 格式') - return data - - def clean_custom_colors(self): - """验证 custom_colors 为有效 JSON""" - import json - data = self.cleaned_data.get('custom_colors') - if data and isinstance(data, str): - try: - json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('自定义颜色必须是有效的 JSON 格式') - return data - - -class PageContentForm(forms.ModelForm): - """页面内容表单""" - - class Meta: - model = PageContent - fields = [ - 'position', - 'title', - 'content', - 'is_enabled', - 'metadata', - ] - - def clean_metadata(self): - """验证 metadata 为有效 JSON""" - import json - data = self.cleaned_data.get('metadata') - if data and isinstance(data, str): - try: - json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('元数据必须是有效的 JSON 格式') - return data - - -class WidgetLayoutForm(forms.ModelForm): - """组件布局表单""" - - class Meta: - model = WidgetLayout - fields = [ - 'widget_type', - 'display_order', - 'column_span', - 'row_span', - 'is_visible', - 'responsive', - ] - - def clean_responsive(self): - """验证 responsive 为有效 JSON""" - import json - data = self.cleaned_data.get('responsive') - if data and isinstance(data, str): - try: - json.loads(data) - except json.JSONDecodeError: - raise forms.ValidationError('响应式配置必须是有效的 JSON 格式') - return data diff --git a/apps/themes/migrations/0001_initial.py b/apps/themes/migrations/0001_initial.py deleted file mode 100644 index 1dab1a0..0000000 --- a/apps/themes/migrations/0001_initial.py +++ /dev/null @@ -1,65 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-04 08:21 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='ThemeConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('active_theme', models.CharField(choices=[('material-design-3', 'Material Design 3'), ('neumorphism', '新拟态')], db_index=True, default='material-design-3', max_length=50, verbose_name='当前主题')), - ('branding', models.JSONField(blank=True, default=dict, help_text='存储品牌资源路径:logo, logo_dark, favicon, login_bg', verbose_name='品牌资源')), - ('custom_colors', models.JSONField(blank=True, default=dict, help_text='自定义 CSS 颜色变量', verbose_name='自定义颜色')), - ('css_overrides', models.TextField(blank=True, help_text='自定义 CSS 样式覆盖', verbose_name='自定义 CSS')), - ('enable_mobile_optimization', models.BooleanField(default=True, verbose_name='启用移动端优化')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ], - options={ - 'verbose_name': '主题配置', - 'verbose_name_plural': '主题配置', - }, - ), - migrations.CreateModel( - name='WidgetLayout', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('widget_type', models.CharField(db_index=True, max_length=50, unique=True, verbose_name='组件类型')), - ('display_order', models.PositiveIntegerField(db_index=True, default=0, verbose_name='显示顺序')), - ('column_span', models.PositiveSmallIntegerField(choices=[(1, '1列'), (2, '2列'), (3, '3列'), (4, '全宽')], default=1, help_text='在12列栅格中占据的列数比例', verbose_name='列跨度')), - ('row_span', models.PositiveSmallIntegerField(choices=[(1, '1行'), (2, '2行')], default=1, verbose_name='行跨度')), - ('is_visible', models.BooleanField(db_index=True, default=True, verbose_name='是否显示')), - ('responsive', models.JSONField(blank=True, default=dict, help_text='各设备的显示配置', verbose_name='响应式配置')), - ], - options={ - 'verbose_name': '组件布局', - 'verbose_name_plural': '组件布局', - 'ordering': ['display_order'], - 'indexes': [models.Index(fields=['display_order', 'is_visible'], name='themes_widg_display_b4e1ac_idx')], - }, - ), - migrations.CreateModel( - name='PageContent', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('position', models.CharField(choices=[('login_welcome', '登录页欢迎语'), ('login_subtitle', '登录页副标题'), ('dashboard_notice', '仪表盘公告'), ('footer_text', '页脚文字'), ('footer_copyright', '版权信息'), ('maintenance_message', '维护提示'), ('register_terms', '注册条款')], db_index=True, max_length=50, unique=True, verbose_name='位置标识')), - ('title', models.CharField(blank=True, max_length=200, verbose_name='标题')), - ('content', models.TextField(blank=True, help_text='支持 HTML 格式', verbose_name='内容')), - ('is_enabled', models.BooleanField(db_index=True, default=True, verbose_name='是否启用')), - ('metadata', models.JSONField(blank=True, default=dict, help_text='存储额外配置:icon, color, link 等', verbose_name='元数据')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ], - options={ - 'verbose_name': '页面内容', - 'verbose_name_plural': '页面内容', - 'indexes': [models.Index(fields=['position', 'is_enabled'], name='themes_page_positio_508e18_idx')], - }, - ), - ] diff --git a/apps/themes/migrations/__init__.py b/apps/themes/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/themes/models.py b/apps/themes/models.py deleted file mode 100755 index 34d51b0..0000000 --- a/apps/themes/models.py +++ /dev/null @@ -1,320 +0,0 @@ -""" -主题系统数据模型 - -优化设计原则: -1. 使用 JSONField 存储灵活配置,减少字段膨胀 -2. 利用 Django 缓存机制,避免重复查询 -3. 单例模式通过 get_or_create 实现,无需强制 pk=1 -""" -from django.db import models -from django.core.cache import cache -from django.utils import timezone - - -class ThemeConfig(models.Model): - """ - 主题配置模型(单例) - - 存储全局主题设置、品牌资源和自定义 CSS 变量 - 使用缓存优化查询性能 - """ - - THEME_CHOICES = [ - ('material-design-3', 'Material Design 3'), - ('neumorphism', '新拟态'), - ] - - CACHE_KEY = 'theme_config_singleton' - CACHE_TIMEOUT = 3600 # 1小时缓存 - - # 基础主题设置 - active_theme = models.CharField( - '当前主题', - max_length=50, - choices=THEME_CHOICES, - default='material-design-3', - db_index=True - ) - - # 品牌资源 - 使用单一 JSONField 存储路径 - # 结构: {"logo": "path", "logo_dark": "path", "favicon": "path", "login_bg": "path"} - branding = models.JSONField( - '品牌资源', - default=dict, - blank=True, - help_text='存储品牌资源路径:logo, logo_dark, favicon, login_bg' - ) - - # 自定义颜色 - JSONField 存储所有颜色变量 - # 结构: {"primary": "#xxx", "secondary": "#xxx", "accent": "#xxx", ...} - custom_colors = models.JSONField( - '自定义颜色', - default=dict, - blank=True, - help_text='自定义 CSS 颜色变量' - ) - - # 高级 CSS 变量覆盖 - css_overrides = models.TextField( - '自定义 CSS', - blank=True, - help_text='自定义 CSS 样式覆盖' - ) - - # 移动端适配开关 - enable_mobile_optimization = models.BooleanField( - '启用移动端优化', - default=True - ) - - # 时间戳 - updated_at = models.DateTimeField('更新时间', auto_now=True) - - class Meta: - verbose_name = '主题配置' - verbose_name_plural = verbose_name - - def __str__(self): - return f'主题配置 - {self.get_active_theme_display()}' - - def save(self, *args, **kwargs): - """保存时清除缓存""" - super().save(*args, **kwargs) - cache.delete(self.CACHE_KEY) - - def delete(self, *args, **kwargs): - """删除时清除缓存""" - cache.delete(self.CACHE_KEY) - super().delete(*args, **kwargs) - - @classmethod - def get_config(cls): - """ - 获取配置单例(带缓存) - - Returns: - ThemeConfig: 配置实例 - """ - config = cache.get(cls.CACHE_KEY) - if config is None: - config, _ = cls.objects.get_or_create(pk=1) - cache.set(cls.CACHE_KEY, config, cls.CACHE_TIMEOUT) - return config - - @classmethod - def invalidate_cache(cls): - """手动清除缓存""" - cache.delete(cls.CACHE_KEY) - - def get_branding(self, key, default=''): - """安全获取品牌资源路径""" - return self.branding.get(key, default) if self.branding else default - - def get_color(self, key, default=None): - """安全获取自定义颜色""" - return self.custom_colors.get(key, default) if self.custom_colors else default - - def generate_css_variables(self): - """ - 生成 CSS 变量字符串 - - Returns: - str: CSS 变量定义 - """ - if not self.custom_colors: - return '' - - lines = [':root {'] - for key, value in self.custom_colors.items(): - css_key = f'--theme-{key.replace("_", "-")}' - lines.append(f' {css_key}: {value};') - lines.append('}') - return '\n'.join(lines) - - -class PageContent(models.Model): - """ - 可编辑页面内容 - - 使用 position 作为唯一标识符,支持多语言扩展 - """ - - POSITION_CHOICES = [ - ('login_welcome', '登录页欢迎语'), - ('login_subtitle', '登录页副标题'), - ('dashboard_notice', '仪表盘公告'), - ('footer_text', '页脚文字'), - ('footer_copyright', '版权信息'), - ('maintenance_message', '维护提示'), - ('register_terms', '注册条款'), - ] - - CACHE_KEY_PREFIX = 'page_content_' - CACHE_TIMEOUT = 3600 - - position = models.CharField( - '位置标识', - max_length=50, - choices=POSITION_CHOICES, - unique=True, - db_index=True - ) - title = models.CharField( - '标题', - max_length=200, - blank=True - ) - content = models.TextField( - '内容', - blank=True, - help_text='支持 HTML 格式' - ) - is_enabled = models.BooleanField( - '是否启用', - default=True, - db_index=True - ) - # 元数据 - 存储额外配置 - metadata = models.JSONField( - '元数据', - default=dict, - blank=True, - help_text='存储额外配置:icon, color, link 等' - ) - updated_at = models.DateTimeField('更新时间', auto_now=True) - - class Meta: - verbose_name = '页面内容' - verbose_name_plural = verbose_name - indexes = [ - models.Index(fields=['position', 'is_enabled']), - ] - - def __str__(self): - return f'{self.get_position_display()}' - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - cache.delete(f'{self.CACHE_KEY_PREFIX}{self.position}') - cache.delete(f'{self.CACHE_KEY_PREFIX}all') - - def delete(self, *args, **kwargs): - cache.delete(f'{self.CACHE_KEY_PREFIX}{self.position}') - cache.delete(f'{self.CACHE_KEY_PREFIX}all') - super().delete(*args, **kwargs) - - @classmethod - def get_content(cls, position, default=''): - """ - 获取指定位置的内容(带缓存) - - Args: - position: 位置标识 - default: 默认值 - - Returns: - str: 内容文本 - """ - cache_key = f'{cls.CACHE_KEY_PREFIX}{position}' - result = cache.get(cache_key) - - if result is None: - try: - obj = cls.objects.get(position=position, is_enabled=True) - result = obj.content - except cls.DoesNotExist: - result = default - cache.set(cache_key, result, cls.CACHE_TIMEOUT) - - return result - - @classmethod - def get_all_enabled(cls): - """ - 获取所有启用的内容(带缓存) - - Returns: - dict: {position: PageContent} - """ - cache_key = f'{cls.CACHE_KEY_PREFIX}all' - result = cache.get(cache_key) - - if result is None: - result = { - obj.position: obj - for obj in cls.objects.filter(is_enabled=True) - } - cache.set(cache_key, result, cls.CACHE_TIMEOUT) - - return result - - -class WidgetLayout(models.Model): - """ - 仪表盘组件布局配置 - - 与 dashboard.DashboardWidget 配合使用,提供布局控制 - 此模型只存储布局信息,不复制组件数据 - """ - - # 关联到 dashboard.DashboardWidget 的 widget_type - widget_type = models.CharField( - '组件类型', - max_length=50, - unique=True, - db_index=True - ) - - # 布局配置 - display_order = models.PositiveIntegerField( - '显示顺序', - default=0, - db_index=True - ) - column_span = models.PositiveSmallIntegerField( - '列跨度', - default=1, - choices=[(1, '1列'), (2, '2列'), (3, '3列'), (4, '全宽')], - help_text='在12列栅格中占据的列数比例' - ) - row_span = models.PositiveSmallIntegerField( - '行跨度', - default=1, - choices=[(1, '1行'), (2, '2行')], - ) - is_visible = models.BooleanField( - '是否显示', - default=True, - db_index=True - ) - - # 响应式配置 - JSONField 存储各断点的显示设置 - # 结构: {"mobile": true, "tablet": true, "desktop": true} - responsive = models.JSONField( - '响应式配置', - default=dict, - blank=True, - help_text='各设备的显示配置' - ) - - class Meta: - verbose_name = '组件布局' - verbose_name_plural = verbose_name - ordering = ['display_order'] - indexes = [ - models.Index(fields=['display_order', 'is_visible']), - ] - - def __str__(self): - return f'{self.widget_type} - 顺序:{self.display_order}' - - def get_responsive(self, device, default=True): - """获取特定设备的显示设置""" - if not self.responsive: - return default - return self.responsive.get(device, default) - - def get_column_class(self): - """获取 Bootstrap 列 CSS 类""" - span_map = {1: 'col-md-3', 2: 'col-md-6', 3: 'col-md-9', 4: 'col-12'} - return span_map.get(self.column_span, 'col-md-3') diff --git a/apps/themes/templatetags/__init__.py b/apps/themes/templatetags/__init__.py deleted file mode 100755 index bed1cf1..0000000 --- a/apps/themes/templatetags/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# 模板标签包 diff --git a/apps/themes/templatetags/theme_tags.py b/apps/themes/templatetags/theme_tags.py deleted file mode 100755 index f608c0c..0000000 --- a/apps/themes/templatetags/theme_tags.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -主题系统模板标签 - -提供便捷的模板标签获取主题配置和页面内容 -""" -from django import template -from django.utils.safestring import mark_safe -from ..models import ThemeConfig, PageContent - -register = template.Library() - - -@register.simple_tag -def get_content(position, default=''): - """ - 获取指定位置的页面内容 - - 用法: - {% load theme_tags %} - {% get_content 'login_welcome' as welcome_msg %} - {{ welcome_msg }} - - 或直接输出: - {% get_content 'footer_text' '默认页脚' %} - - Args: - position: 位置标识符 - default: 默认值 - - Returns: - str: 内容文本 - """ - return PageContent.get_content(position, default) - - -@register.simple_tag -def get_content_obj(position): - """ - 获取指定位置的 PageContent 对象 - - 用法: - {% get_content_obj 'dashboard_notice' as notice %} - {% if notice %} -

{{ notice.title }}

- {{ notice.content|safe }} - {% endif %} - - Args: - position: 位置标识符 - - Returns: - PageContent 或 None - """ - contents = PageContent.get_all_enabled() - return contents.get(position) - - -@register.simple_tag -def theme_css_url(): - """ - 获取当前主题的 CSS 文件 URL - - 用法: - - - Returns: - str: CSS 文件路径 - """ - config = ThemeConfig.get_config() - return f'css/themes/{config.active_theme}.css' - - -@register.simple_tag -def theme_data_attribute(): - """ - 获取用于 HTML 标签的 data-theme 属性值 - - 用法: - - - Returns: - str: 主题标识符 - """ - config = ThemeConfig.get_config() - return config.active_theme - - -@register.simple_tag -def branding(key, default=''): - """ - 获取品牌资源路径 - - 用法: - - - Args: - key: 资源键名 (logo, logo_dark, favicon, login_bg) - default: 默认路径 - - Returns: - str: 资源路径 - """ - config = ThemeConfig.get_config() - return config.get_branding(key, default) - - -@register.simple_tag -def theme_color(key, default=''): - """ - 获取自定义颜色值 - - 用法: -
- - Args: - key: 颜色键名 - default: 默认颜色值 - - Returns: - str: 颜色值 - """ - config = ThemeConfig.get_config() - return config.get_color(key, default) - - -@register.simple_tag -def custom_css_variables(): - """ - 输出自定义 CSS 变量样式块 - - 用法: - - - - - Returns: - str: CSS 变量定义 - """ - config = ThemeConfig.get_config() - css = config.generate_css_variables() - if config.css_overrides: - css += '\n' + config.css_overrides - return mark_safe(css) - - -@register.inclusion_tag('themes/partials/theme_head.html') -def theme_head(): - """ - 包含主题相关的 内容 - - 用法: - - {% theme_head %} - - - Returns: - dict: 模板上下文 - """ - config = ThemeConfig.get_config() - return { - 'theme_config': config, - 'theme_css_url': f'css/themes/{config.active_theme}.css', - 'custom_css': config.generate_css_variables(), - 'css_overrides': config.css_overrides, - } - - -@register.filter -def split(value, delimiter=","): - return value.split(delimiter) - - -@register.filter -def trim(value): - if isinstance(value, str): - return value.strip() - return value - - -@register.filter -def is_mobile_enabled(config): - """ - 检查是否启用移动端优化 - - 用法: - {% if theme_config|is_mobile_enabled %} - - {% endif %} - - Args: - config: ThemeConfig 实例 - - Returns: - bool - """ - if config is None: - return True - return getattr(config, 'enable_mobile_optimization', True) diff --git a/apps/themes/urls_admin.py b/apps/themes/urls_admin.py deleted file mode 100644 index f80e05a..0000000 --- a/apps/themes/urls_admin.py +++ /dev/null @@ -1,53 +0,0 @@ -from django.urls import path - -from .views_admin import ( - themeconfig_edit, - themeconfig_clear_cache, - pagecontent_list, - pagecontent_create, - pagecontent_edit, - pagecontent_delete, - widgetlayout_list, - widgetlayout_create, - widgetlayout_edit, - widgetlayout_delete, -) - -app_name = 'admin_themes' - -urlpatterns = [ - path('config/', themeconfig_edit, name='themeconfig_edit'), - path( - 'config/clear-cache/', - themeconfig_clear_cache, - name='themeconfig_clear_cache', - ), - path('pages/', pagecontent_list, name='pagecontent_list'), - path('pages/create/', pagecontent_create, name='pagecontent_create'), - path( - 'pages//edit/', - pagecontent_edit, - name='pagecontent_edit', - ), - path( - 'pages//delete/', - pagecontent_delete, - name='pagecontent_delete', - ), - path('layouts/', widgetlayout_list, name='widgetlayout_list'), - path( - 'layouts/create/', - widgetlayout_create, - name='widgetlayout_create', - ), - path( - 'layouts//edit/', - widgetlayout_edit, - name='widgetlayout_edit', - ), - path( - 'layouts//delete/', - widgetlayout_delete, - name='widgetlayout_delete', - ), -] diff --git a/apps/themes/views_admin.py b/apps/themes/views_admin.py deleted file mode 100644 index ffbcb06..0000000 --- a/apps/themes/views_admin.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -主题系统超级管理员视图 - -包含: -- ThemeConfig 单例编辑 + 清除缓存 -- PageContent CRUD -- WidgetLayout CRUD -""" - -import logging - -from django.shortcuts import render, redirect, get_object_or_404 -from django.contrib import messages -from django.core.paginator import Paginator -from django.core.cache import cache -from django.views.decorators.http import require_POST - -from apps.accounts.provider_decorators import superadmin_required -from .models import ThemeConfig, PageContent, WidgetLayout -from .forms_admin import ThemeConfigForm, PageContentForm, WidgetLayoutForm - -logger = logging.getLogger('2c2a') - - -# ============================================================ -# ThemeConfig 单例编辑 + 清除缓存 -# ============================================================ - -@superadmin_required -def themeconfig_edit(request): - """主题配置编辑(单例,自动 get_or_create)""" - config, _ = ThemeConfig.objects.get_or_create(pk=1) - - if request.method == 'POST': - form = ThemeConfigForm(request.POST, instance=config) - if form.is_valid(): - form.save() - messages.success(request, '主题配置已更新,缓存已自动清除。') - return redirect('admin_themes:themeconfig_edit') - else: - form = ThemeConfigForm(instance=config) - - context = { - 'form': form, - 'config': config, - 'active_nav': 'themes_config', - } - return render( - request, 'admin_base/themes/themeconfig_edit.html', context - ) - - -@superadmin_required -@require_POST -def themeconfig_clear_cache(request): - """清除主题缓存""" - ThemeConfig.invalidate_cache() - # 尝试清除页面内容缓存 - if hasattr(cache, 'delete_pattern'): - try: - cache.delete_pattern('page_content_*') - except Exception: - pass - else: - # 手动清除已知位置的页面内容缓存 - for key, _ in PageContent.POSITION_CHOICES: - cache.delete(f'{PageContent.CACHE_KEY_PREFIX}{key}') - cache.delete(f'{PageContent.CACHE_KEY_PREFIX}all') - - messages.success(request, '主题缓存已清除。') - return redirect('admin_themes:themeconfig_edit') - - -# ============================================================ -# PageContent CRUD -# ============================================================ - -@superadmin_required -def pagecontent_list(request): - """页面内容列表""" - queryset = PageContent.objects.order_by('position') - - search = request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - title__icontains=search - ) | queryset.filter( - content__icontains=search - ) - - paginator = Paginator(queryset, 25) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'search': search, - 'active_nav': 'themes_pages', - } - return render( - request, 'admin_base/themes/pagecontent_list.html', context - ) - - -@superadmin_required -def pagecontent_create(request): - """创建页面内容""" - if request.method == 'POST': - form = PageContentForm(request.POST) - if form.is_valid(): - page = form.save() - messages.success( - request, f'页面内容「{page}」创建成功。' - ) - return redirect('admin_themes:pagecontent_list') - else: - form = PageContentForm() - - context = { - 'form': form, - 'active_nav': 'themes_pages', - 'is_create': True, - } - return render( - request, 'admin_base/themes/pagecontent_form.html', context - ) - - -@superadmin_required -def pagecontent_edit(request, pk): - """编辑页面内容""" - page = get_object_or_404(PageContent, pk=pk) - - if request.method == 'POST': - form = PageContentForm(request.POST, instance=page) - if form.is_valid(): - form.save() - messages.success( - request, f'页面内容「{page}」更新成功。' - ) - return redirect('admin_themes:pagecontent_list') - else: - form = PageContentForm(instance=page) - - context = { - 'form': form, - 'page': page, - 'active_nav': 'themes_pages', - 'is_create': False, - } - return render( - request, 'admin_base/themes/pagecontent_form.html', context - ) - - -@superadmin_required -def pagecontent_delete(request, pk): - """删除页面内容""" - page = get_object_or_404(PageContent, pk=pk) - - if request.method == 'POST': - label = str(page) - page.delete() - messages.success( - request, f'页面内容「{label}」已删除。' - ) - return redirect('admin_themes:pagecontent_list') - - context = { - 'page': page, - 'active_nav': 'themes_pages', - } - return render( - request, 'admin_base/themes/pagecontent_confirm_delete.html', context - ) - - -# ============================================================ -# WidgetLayout CRUD -# ============================================================ - -@superadmin_required -def widgetlayout_list(request): - """组件布局列表""" - queryset = WidgetLayout.objects.order_by('display_order') - - search = request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - widget_type__icontains=search - ) - - paginator = Paginator(queryset, 25) - page_number = request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context = { - 'page_obj': page_obj, - 'search': search, - 'active_nav': 'themes_layouts', - } - return render( - request, 'admin_base/themes/widgetlayout_list.html', context - ) - - -@superadmin_required -def widgetlayout_create(request): - """创建组件布局""" - if request.method == 'POST': - form = WidgetLayoutForm(request.POST) - if form.is_valid(): - layout = form.save() - messages.success( - request, f'组件布局「{layout}」创建成功。' - ) - return redirect('admin_themes:widgetlayout_list') - else: - form = WidgetLayoutForm() - - context = { - 'form': form, - 'active_nav': 'themes_layouts', - 'is_create': True, - } - return render( - request, 'admin_base/themes/widgetlayout_form.html', context - ) - - -@superadmin_required -def widgetlayout_edit(request, pk): - """编辑组件布局""" - layout = get_object_or_404(WidgetLayout, pk=pk) - - if request.method == 'POST': - form = WidgetLayoutForm(request.POST, instance=layout) - if form.is_valid(): - form.save() - messages.success( - request, f'组件布局「{layout}」更新成功。' - ) - return redirect('admin_themes:widgetlayout_list') - else: - form = WidgetLayoutForm(instance=layout) - - context = { - 'form': form, - 'layout': layout, - 'active_nav': 'themes_layouts', - 'is_create': False, - } - return render( - request, 'admin_base/themes/widgetlayout_form.html', context - ) - - -@superadmin_required -def widgetlayout_delete(request, pk): - """删除组件布局""" - layout = get_object_or_404(WidgetLayout, pk=pk) - - if request.method == 'POST': - label = str(layout) - layout.delete() - messages.success( - request, f'组件布局「{label}」已删除。' - ) - return redirect('admin_themes:widgetlayout_list') - - context = { - 'layout': layout, - 'active_nav': 'themes_layouts', - } - return render( - request, 'admin_base/themes/widgetlayout_confirm_delete.html', - context, - ) diff --git a/apps/tickets/__init__.py b/apps/tickets/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/tickets/admin.py b/apps/tickets/admin.py deleted file mode 100644 index 69d4025..0000000 --- a/apps/tickets/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (apps.tickets.views_admin) 和提供商后台 (apps.tickets.views_provider) diff --git a/apps/tickets/apps.py b/apps/tickets/apps.py deleted file mode 100644 index dac76d3..0000000 --- a/apps/tickets/apps.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.apps import AppConfig - - -class TicketsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.tickets' - verbose_name = '工单系统' - - def ready(self): - """ - 应用就绪时导入信号处理器 - """ - import apps.tickets.signals # noqa: F401 diff --git a/apps/tickets/audit_integration.py b/apps/tickets/audit_integration.py deleted file mode 100644 index f2f949c..0000000 --- a/apps/tickets/audit_integration.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -工单系统审计日志集成 - -将工单相关操作记录到审计日志系统 -""" - -from django.contrib.contenttypes.models import ContentType -from apps.audit.models import AuditLog - - -def log_ticket_action(ticket, user, action, ip_address=None, details=None, success=True, result=None): - """ - 记录工单操作到审计日志 - - Args: - ticket: 工单实例 - user: 操作用户 - action: 操作类型(需要在 AuditLog.ACTION_CHOICES 中定义) - ip_address: 操作IP地址 - details: 操作详情(字典) - success: 操作是否成功 - result: 操作结果 - """ - # 获取工单的内容类型 - ticket_content_type = ContentType.objects.get_for_model(ticket) - - # 构建操作详情 - log_details = { - 'ticket_no': ticket.ticket_no, - 'ticket_title': ticket.title, - 'ticket_status': ticket.status, - 'ticket_priority': ticket.priority, - } - - if details: - log_details.update(details) - - # 创建审计日志 - AuditLog.objects.create( - user=user, - action=action, - ip_address=ip_address, - success=success, - details=log_details, - result=result, - content_type=ticket_content_type, - object_id=ticket.pk - ) - - -def log_ticket_created(ticket, user, ip_address=None): - """记录工单创建""" - log_ticket_action( - ticket=ticket, - user=user, - action='create_ticket', - ip_address=ip_address, - details={'action': '创建工单'} - ) - - -def log_ticket_updated(ticket, user, ip_address=None, changes=None): - """记录工单更新""" - log_ticket_action( - ticket=ticket, - user=user, - action='update_ticket', - ip_address=ip_address, - details={'action': '更新工单', 'changes': changes or {}} - ) - - -def log_ticket_assigned(ticket, user, old_assignee, new_assignee, - old_assigned_group=None, new_assigned_group=None, - ip_address=None): - """记录工单分配""" - details = { - 'action': '分配工单', - 'old_assignee': str(old_assignee) if old_assignee else None, - 'new_assignee': str(new_assignee) if new_assignee else None, - 'old_assigned_group': str(old_assigned_group) if old_assigned_group else None, - 'new_assigned_group': str(new_assigned_group) if new_assigned_group else None, - } - log_ticket_action( - ticket=ticket, - user=user, - action='assign_ticket', - ip_address=ip_address, - details=details - ) - - -def log_ticket_status_changed(ticket, user, old_status, new_status, ip_address=None): - """记录工单状态变更""" - log_ticket_action( - ticket=ticket, - user=user, - action='change_ticket_status', - ip_address=ip_address, - details={ - 'action': '变更工单状态', - 'old_status': old_status, - 'new_status': new_status, - } - ) - - -def log_ticket_closed(ticket, user, ip_address=None, satisfaction=None): - """记录工单关闭""" - log_ticket_action( - ticket=ticket, - user=user, - action='close_ticket', - ip_address=ip_address, - details={ - 'action': '关闭工单', - 'satisfaction': satisfaction, - } - ) - - -def log_ticket_comment_added(comment, user, ip_address=None): - """记录工单评论添加""" - log_ticket_action( - ticket=comment.ticket, - user=user, - action='add_ticket_comment', - ip_address=ip_address, - details={ - 'action': '添加评论', - 'comment_id': comment.pk, - 'is_internal': comment.is_internal, - } - ) diff --git a/apps/tickets/forms.py b/apps/tickets/forms.py deleted file mode 100644 index 00dc2b5..0000000 --- a/apps/tickets/forms.py +++ /dev/null @@ -1,317 +0,0 @@ -""" -工单系统表单 -""" -from django import forms -from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group - -from .models import Ticket, TicketComment, TicketCategory -from apps.accounts.models import UserBan - -User = get_user_model() - - -MD3_INPUT_CLASS = ( - 'w-full bg-md-surface/50 border border-md-outline/50 rounded-md px-4 py-3 ' - 'text-md-on-surface placeholder-md-outline focus:outline-none focus:ring-2 ' - 'focus:ring-md-primary transition' -) - - -class TicketForm(forms.ModelForm): - """ - 工单创建/编辑表单 - """ - title = forms.CharField( - widget=forms.TextInput(attrs={ - 'class': MD3_INPUT_CLASS, - 'placeholder': '请输入工单标题' - }), - label=_('标题'), - help_text=_('请简要描述工单主题') - ) - - description = forms.CharField( - widget=forms.Textarea(attrs={ - 'class': MD3_INPUT_CLASS, - 'rows': 6, - 'placeholder': '请详细描述您的问题或需求' - }), - label=_('详细描述'), - help_text=_('请提供尽可能详细的信息,以便我们更好地帮助您') - ) - - category = forms.ModelChoiceField( - queryset=TicketCategory.objects.filter(is_active=True), - widget=forms.Select(attrs={'class': MD3_INPUT_CLASS}), - label=_('分类'), - help_text=_('请选择工单分类'), - required=False - ) - - priority = forms.ChoiceField( - choices=Ticket.PRIORITY_CHOICES, - widget=forms.Select(attrs={'class': MD3_INPUT_CLASS}), - label=_('优先级'), - help_text=_('请选择工单优先级'), - initial='medium' - ) - - related_cloud_computer = forms.ModelChoiceField( - queryset=None, - widget=forms.Select(attrs={'class': MD3_INPUT_CLASS}), - label=_('关联云电脑'), - help_text=_('选择您拥有的云电脑(可选)'), - required=False - ) - - related_request = forms.ModelChoiceField( - queryset=None, - widget=forms.Select(attrs={'class': MD3_INPUT_CLASS}), - label=_('关联申请'), - help_text=_('选择您提交的申请(可选)'), - required=False - ) - - class Meta: - model = Ticket - fields = ['title', 'description', 'category', 'priority', 'related_cloud_computer', 'related_request'] - - def __init__(self, *args, **kwargs): - self.user = kwargs.pop('user', None) - super().__init__(*args, **kwargs) - from apps.operations.models import CloudComputerUser, AccountOpeningRequest - - # 关联云电脑:只显示当前用户拥有的、激活状态的云电脑 - if self.user: - self.fields['related_cloud_computer'].queryset = CloudComputerUser.objects.filter( - owner=self.user, status='active' - ).select_related('product') - # 关联申请:只显示当前用户提交的申请 - self.fields['related_request'].queryset = AccountOpeningRequest.objects.filter( - applicant=self.user - ).select_related('target_product').order_by('-created_at') - else: - self.fields['related_cloud_computer'].queryset = CloudComputerUser.objects.none() - self.fields['related_request'].queryset = AccountOpeningRequest.objects.none() - - # 封禁用户只能选择允许封禁用户提交的分类 - if self.user and UserBan.objects.filter(user=self.user).exists(): - self.fields['category'].queryset = TicketCategory.objects.filter( - is_active=True, allow_banned_users=True - ) - # 封禁用户不需要关联云电脑和申请 - self.fields['related_cloud_computer'].queryset = CloudComputerUser.objects.none() - self.fields['related_request'].queryset = AccountOpeningRequest.objects.none() - - # 如果有分类,设置默认优先级 - if self.data.get('category'): - try: - category = TicketCategory.objects.get(pk=self.data['category']) - self.fields['priority'].initial = category.default_priority - except TicketCategory.DoesNotExist: - pass - - def clean(self): - cleaned_data = super().clean() - - # 如果有分类,继承分类的默认优先级(如果用户未修改) - category = cleaned_data.get('category') - priority = cleaned_data.get('priority') - - if category and priority == 'medium' and category.default_priority != 'medium': - # 如果用户没有显式修改优先级,使用分类默认值 - if not self.data.get('priority') or self.data.get('priority') == 'medium': - cleaned_data['priority'] = category.default_priority - - return cleaned_data - - -class TicketCommentForm(forms.ModelForm): - """ - 工单评论表单 - """ - content = forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': '请输入评论内容' - }), - label=_('内容'), - help_text=_('请输入您的回复') - ) - - is_internal = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), - label=_('内部备注'), - help_text=_('仅工作人员可见') - ) - - class Meta: - model = TicketComment - fields = ['content', 'is_internal'] - - -class TicketAssignForm(forms.Form): - """ - 工单分配表单 - """ - assignee = forms.ModelChoiceField( - queryset=User.objects.filter(is_active=True), - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('处理人'), - help_text=_('请选择要分配的处理人'), - required=False, - ) - - assigned_group = forms.ModelChoiceField( - queryset=Group.objects.all(), - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('处理组'), - help_text=_('请选择要分配的处理组'), - required=False, - ) - - notes = forms.CharField( - required=False, - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 2, - 'placeholder': '可选:添加分配备注' - }), - label=_('备注'), - help_text=_('可选:添加分配备注') - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['assignee'].queryset = User.objects.filter( - is_active=True - ).filter( - models.Q(is_staff=True) | models.Q(is_superuser=True) | models.Q(groups__name='主机提供商') - ).distinct() - - def clean(self): - cleaned_data = super().clean() - assignee = cleaned_data.get('assignee') - assigned_group = cleaned_data.get('assigned_group') - if not assignee and not assigned_group: - raise forms.ValidationError(_('请至少选择一个处理人或处理组')) - return cleaned_data - - -class TicketStatusForm(forms.Form): - """ - 工单状态变更表单 - """ - status = forms.ChoiceField( - choices=[ - ('pending', _('待处理')), - ('processing', _('处理中')), - ('waiting_feedback', _('待反馈')), - ('resolved', _('已解决')), - ('closed', _('已关闭')), - ('rejected', _('已驳回')), - ], - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('新状态'), - help_text=_('请选择新的工单状态') - ) - - notes = forms.CharField( - required=False, - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 2, - 'placeholder': '可选:添加状态变更备注' - }), - label=_('备注'), - help_text=_('可选:添加状态变更备注') - ) - - -class TicketCloseForm(forms.Form): - """ - 工单关闭表单(含满意度评价) - """ - satisfaction = forms.ChoiceField( - required=False, - choices=[ - ('', _('不评价')), - ('5', _('非常满意')), - ('4', _('满意')), - ('3', _('一般')), - ('2', _('不满意')), - ('1', _('非常不满意')), - ], - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('满意度评分'), - help_text=_('请对工单处理进行评价') - ) - - satisfaction_comment = forms.CharField( - required=False, - widget=forms.Textarea(attrs={ - 'class': 'form-control', - 'rows': 3, - 'placeholder': '可选:请输入您的评价内容' - }), - label=_('评价内容'), - help_text=_('可选:请输入您的评价内容') - ) - - -class TicketFilterForm(forms.Form): - """ - 工单筛选表单 - """ - status = forms.ChoiceField( - required=False, - choices=[('', _('全部'))] + Ticket.STATUS_CHOICES, - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('状态') - ) - - priority = forms.ChoiceField( - required=False, - choices=[('', _('全部'))] + Ticket.PRIORITY_CHOICES, - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('优先级') - ) - - category = forms.ModelChoiceField( - required=False, - queryset=TicketCategory.objects.filter(is_active=True), - widget=forms.Select(attrs={'class': 'form-control'}), - label=_('分类') - ) - - search = forms.CharField( - required=False, - widget=forms.TextInput(attrs={ - 'class': 'form-control', - 'placeholder': '搜索工单编号、标题或描述' - }), - label=_('搜索') - ) - - start_date = forms.DateField( - required=False, - widget=forms.DateInput(attrs={ - 'class': 'form-control', - 'type': 'date' - }), - label=_('开始日期') - ) - - end_date = forms.DateField( - required=False, - widget=forms.DateInput(attrs={ - 'class': 'form-control', - 'type': 'date' - }), - label=_('结束日期') - ) diff --git a/apps/tickets/forms_admin.py b/apps/tickets/forms_admin.py deleted file mode 100644 index 50acd56..0000000 --- a/apps/tickets/forms_admin.py +++ /dev/null @@ -1,189 +0,0 @@ -""" -工单系统 - 超管后台表单 - -超管可管理所有数据,无数据隔离。 -""" - -from django import forms -from django.contrib.auth.models import Group -from django.utils.translation import gettext_lazy as _ - -from .models import TicketCategory, TicketComment - - -class AdminTicketCategoryForm(forms.ModelForm): - """ - 工单分类表单(超管后台) - - 超管可设置 auto_assign_to 字段,created_by 在视图中自动设置。 - """ - - name = forms.CharField( - widget=forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '请输入分类名称', - }), - label=_('分类名称'), - ) - - description = forms.CharField( - required=False, - widget=forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '请输入分类描述(可选)', - }), - label=_('分类描述'), - ) - - icon = forms.CharField( - required=False, - widget=forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': 'Material Icons 图标名称,如 help_outline', - }), - label=_('图标'), - ) - - default_priority = forms.ChoiceField( - choices=TicketCategory._meta.get_field('default_priority').choices, - widget=forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer', - }), - label=_('默认优先级'), - ) - - auto_assign_to = forms.ModelChoiceField( - required=False, - queryset=None, - widget=forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer', - }), - label=_('自动分配给'), - help_text=_('该分类的工单自动分配给指定用户'), - ) - - auto_assign_to_group = forms.ModelChoiceField( - required=False, - queryset=Group.objects.all(), - widget=forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer', - }), - label=_('自动分配给用户组'), - help_text=_('该分类的工单自动分配给指定用户组'), - ) - - sla_hours = forms.IntegerField( - min_value=1, - widget=forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '24', - }), - label=_('SLA时限(小时)'), - ) - - is_active = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary', - }), - label=_('是否启用'), - ) - - allow_banned_users = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary', - }), - label=_('允许封禁用户提交'), - help_text=_('勾选后,被封禁的用户可以在此分类下提交工单'), - ) - - display_order = forms.IntegerField( - initial=0, - widget=forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '0', - }), - label=_('显示顺序'), - ) - - class Meta: - model = TicketCategory - fields = [ - 'name', 'description', 'icon', - 'default_priority', 'auto_assign_to', 'auto_assign_to_group', - 'sla_hours', - 'is_active', 'allow_banned_users', 'display_order', - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - from django.contrib.auth import get_user_model - User = get_user_model() - self.fields['auto_assign_to'].queryset = User.objects.filter( - is_staff=True - ).order_by('username') - - -class AdminTicketCommentForm(forms.ModelForm): - """ - 工单评论表单(超管后台) - - 超管可添加内部备注。 - """ - - content = forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '请输入评论内容...', - }), - label=_('评论内容'), - ) - - is_internal = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary', - }), - label=_('内部备注'), - help_text=_('仅工作人员可见'), - ) - - class Meta: - model = TicketComment - fields = ['content', 'is_internal'] diff --git a/apps/tickets/forms_provider.py b/apps/tickets/forms_provider.py deleted file mode 100644 index e2b2bc5..0000000 --- a/apps/tickets/forms_provider.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -工单系统 - 提供商后台表单 - -使用 Tailwind MD3 样式,不使用 Bootstrap。 -""" - -from django import forms -from django.utils.translation import gettext_lazy as _ - -from .models import TicketCategory, TicketComment, TicketAttachment - - -class TicketCategoryForm(forms.ModelForm): - """ - 工单分类表单(提供商后台) - - created_by 在视图中自动设置为当前用户。 - """ - - name = forms.CharField( - widget=forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '请输入分类名称', - }), - label=_('分类名称'), - ) - - description = forms.CharField( - required=False, - widget=forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '请输入分类描述(可选)', - }), - label=_('分类描述'), - ) - - icon = forms.CharField( - required=False, - widget=forms.TextInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': 'Material Icons 图标名称,如 help_outline', - }), - label=_('图标'), - ) - - default_priority = forms.ChoiceField( - choices=TicketCategory._meta.get_field('default_priority').choices, - widget=forms.Select(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface appearance-none ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer', - }), - label=_('默认优先级'), - ) - - sla_hours = forms.IntegerField( - min_value=1, - widget=forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '24', - }), - label=_('SLA时限(小时)'), - ) - - is_active = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary', - }), - label=_('是否启用'), - ) - - allow_banned_users = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary', - }), - label=_('允许封禁用户提交'), - help_text=_('勾选后,被封禁的用户可以在此分类下提交工单'), - ) - - display_order = forms.IntegerField( - initial=0, - widget=forms.NumberInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition', - 'placeholder': '0', - }), - label=_('显示顺序'), - ) - - class Meta: - model = TicketCategory - fields = [ - 'name', 'description', 'icon', - 'default_priority', 'sla_hours', - 'is_active', 'allow_banned_users', 'display_order', - ] - - -class TicketCommentForm(forms.ModelForm): - """ - 工单评论表单(提供商后台) - - 支持内部备注标记。 - """ - - content = forms.CharField( - widget=forms.Textarea(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'placeholder-md-outline focus:outline-none ' - 'focus:ring-2 focus:ring-md-primary transition resize-y', - 'rows': 3, - 'placeholder': '请输入评论内容...', - }), - label=_('评论内容'), - ) - - is_internal = forms.BooleanField( - required=False, - widget=forms.CheckboxInput(attrs={ - 'class': 'w-5 h-5 rounded border-md-outline/50 bg-md-surface/50 ' - 'text-md-primary focus:ring-md-primary focus:ring-2 ' - 'transition cursor-pointer accent-md-primary', - }), - label=_('内部备注'), - help_text=_('仅工作人员可见'), - ) - - class Meta: - model = TicketComment - fields = ['content', 'is_internal'] - - -class TicketAttachmentForm(forms.ModelForm): - """ - 工单附件上传表单(提供商后台) - """ - - file = forms.FileField( - widget=forms.FileInput(attrs={ - 'class': 'w-full bg-md-surface/50 border border-md-outline/50 ' - 'rounded-md px-4 py-3 text-md-on-surface ' - 'focus:outline-none focus:ring-2 focus:ring-md-primary ' - 'transition cursor-pointer file:mr-4 file:py-1 file:px-4 ' - 'file:rounded-md file:border-0 file:text-sm ' - 'file:font-medium file:bg-md-primary/20 ' - 'file:text-md-primary hover:file:bg-md-primary/30', - }), - label=_('选择文件'), - ) - - class Meta: - model = TicketAttachment - fields = ['file'] diff --git a/apps/tickets/migrations/0001_initial.py b/apps/tickets/migrations/0001_initial.py deleted file mode 100644 index 1e61dfd..0000000 --- a/apps/tickets/migrations/0001_initial.py +++ /dev/null @@ -1,180 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-26 15:23 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('operations', '0011_rename_operations_rdp_domain_idx_operations__domain_5c01f5_idx_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('hosts', '0010_remove_host_host_type'), - ] - - operations = [ - migrations.CreateModel( - name='Ticket', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ticket_no', models.CharField(help_text='自动生成的唯一工单编号', max_length=20, unique=True, verbose_name='工单编号')), - ('title', models.CharField(help_text='工单的简要标题', max_length=200, verbose_name='标题')), - ('description', models.TextField(help_text='工单的详细描述', verbose_name='详细描述')), - ('status', models.CharField(choices=[('pending', '待处理'), ('processing', '处理中'), ('waiting_feedback', '待反馈'), ('resolved', '已解决'), ('closed', '已关闭'), ('rejected', '已驳回')], default='pending', help_text='工单的当前状态', max_length=20, verbose_name='状态')), - ('priority', models.CharField(choices=[('urgent', '紧急'), ('high', '高'), ('medium', '中'), ('low', '低')], default='medium', help_text='工单的优先级', max_length=20, verbose_name='优先级')), - ('source', models.CharField(choices=[('web', 'Web提交'), ('api', 'API创建'), ('system', '系统自动生成'), ('email', '邮件导入')], default='web', help_text='工单的创建来源', max_length=20, verbose_name='来源')), - ('due_at', models.DateTimeField(blank=True, help_text='根据SLA计算的工单处理截止时间', null=True, verbose_name='截止时间')), - ('resolved_at', models.DateTimeField(blank=True, help_text='工单被标记为已解决的时间', null=True, verbose_name='解决时间')), - ('closed_at', models.DateTimeField(blank=True, help_text='工单被关闭的时间', null=True, verbose_name='关闭时间')), - ('satisfaction', models.PositiveSmallIntegerField(blank=True, help_text='用户对工单处理的满意度评分(1-5)', null=True, verbose_name='满意度评分')), - ('satisfaction_comment', models.TextField(blank=True, help_text='用户对工单处理的评价内容', verbose_name='满意度评价')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('assignee', models.ForeignKey(blank=True, help_text='负责处理此工单的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL, verbose_name='处理人')), - ], - options={ - 'verbose_name': '工单', - 'verbose_name_plural': '工单', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='TicketCategory', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='工单分类的名称', max_length=100, verbose_name='分类名称')), - ('description', models.TextField(blank=True, help_text='分类的详细描述', verbose_name='分类描述')), - ('icon', models.CharField(blank=True, default='help_outline', help_text='Material Icons 图标名称', max_length=50, verbose_name='图标')), - ('default_priority', models.CharField(choices=[('urgent', '紧急'), ('high', '高'), ('medium', '中'), ('low', '低')], default='medium', help_text='该分类下工单的默认优先级', max_length=20, verbose_name='默认优先级')), - ('sla_hours', models.PositiveIntegerField(default=24, help_text='工单处理的服务级别协议时限', verbose_name='SLA时限(小时)')), - ('is_active', models.BooleanField(default=True, help_text='是否在前端展示此分类', verbose_name='是否启用')), - ('display_order', models.IntegerField(default=0, help_text='分类在前端展示的顺序,数字越小越靠前', verbose_name='显示顺序')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('auto_assign_to', models.ForeignKey(blank=True, help_text='该分类的工单自动分配给指定用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_assigned_categories', to=settings.AUTH_USER_MODEL, verbose_name='自动分配给')), - ], - options={ - 'verbose_name': '工单分类', - 'verbose_name_plural': '工单分类', - 'ordering': ['display_order', 'name'], - }, - ), - migrations.CreateModel( - name='TicketAttachment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('file', models.FileField(help_text='上传的附件文件', upload_to='ticket_attachments/%Y/%m/', verbose_name='文件')), - ('filename', models.CharField(help_text='文件的原始名称', max_length=255, verbose_name='原始文件名')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('ticket', models.ForeignKey(help_text='附件所属的工单', on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='tickets.ticket', verbose_name='关联工单')), - ('uploaded_by', models.ForeignKey(help_text='上传此附件的用户', on_delete=django.db.models.deletion.CASCADE, related_name='ticket_attachments', to=settings.AUTH_USER_MODEL, verbose_name='上传人')), - ], - options={ - 'verbose_name': '工单附件', - 'verbose_name_plural': '工单附件', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='TicketActivity', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('action', models.CharField(choices=[('create', '创建'), ('assign', '分配'), ('status_change', '状态变更'), ('comment', '评论'), ('close', '关闭'), ('update', '更新')], help_text='活动的操作类型', max_length=20, verbose_name='操作类型')), - ('old_value', models.CharField(blank=True, help_text='变更前的值', max_length=255, verbose_name='旧值')), - ('new_value', models.CharField(blank=True, help_text='变更后的值', max_length=255, verbose_name='新值')), - ('description', models.TextField(blank=True, help_text='活动的详细描述', verbose_name='描述')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('actor', models.ForeignKey(blank=True, help_text='执行此操作的用户', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_activities', to=settings.AUTH_USER_MODEL, verbose_name='操作人')), - ('ticket', models.ForeignKey(help_text='活动所属的工单', on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='tickets.ticket', verbose_name='关联工单')), - ], - options={ - 'verbose_name': '工单活动', - 'verbose_name_plural': '工单活动', - 'ordering': ['-created_at'], - }, - ), - migrations.AddField( - model_name='ticket', - name='category', - field=models.ForeignKey(blank=True, help_text='工单所属的分类', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='tickets.ticketcategory', verbose_name='分类'), - ), - migrations.AddField( - model_name='ticket', - name='creator', - field=models.ForeignKey(help_text='提交工单的用户', on_delete=django.db.models.deletion.CASCADE, related_name='created_tickets', to=settings.AUTH_USER_MODEL, verbose_name='创建者'), - ), - migrations.AddField( - model_name='ticket', - name='related_host', - field=models.ForeignKey(blank=True, help_text='工单关联的主机', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='hosts.host', verbose_name='关联主机'), - ), - migrations.AddField( - model_name='ticket', - name='related_product', - field=models.ForeignKey(blank=True, help_text='工单关联的云电脑产品', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='operations.product', verbose_name='关联产品'), - ), - migrations.CreateModel( - name='TicketComment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('content', models.TextField(help_text='评论的详细内容', verbose_name='内容')), - ('is_internal', models.BooleanField(default=False, help_text='是否为仅工作人员可见的内部备注', verbose_name='内部备注')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('author', models.ForeignKey(help_text='评论的作者', on_delete=django.db.models.deletion.CASCADE, related_name='ticket_comments', to=settings.AUTH_USER_MODEL, verbose_name='作者')), - ('ticket', models.ForeignKey(help_text='评论所属的工单', on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tickets.ticket', verbose_name='关联工单')), - ], - options={ - 'verbose_name': '工单评论', - 'verbose_name_plural': '工单评论', - 'ordering': ['created_at'], - 'indexes': [models.Index(fields=['ticket', 'created_at'], name='tickets_tic_ticket__254c10_idx')], - }, - ), - migrations.AddIndex( - model_name='ticketcategory', - index=models.Index(fields=['is_active'], name='tickets_tic_is_acti_d49b55_idx'), - ), - migrations.AddIndex( - model_name='ticketcategory', - index=models.Index(fields=['display_order'], name='tickets_tic_display_3ed776_idx'), - ), - migrations.AddIndex( - model_name='ticketactivity', - index=models.Index(fields=['ticket', 'created_at'], name='tickets_tic_ticket__de2dd5_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['ticket_no'], name='tickets_tic_ticket__c7390a_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['status'], name='tickets_tic_status_0e5646_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['priority'], name='tickets_tic_priorit_0bec9b_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['assignee'], name='tickets_tic_assigne_8f75bf_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['creator'], name='tickets_tic_creator_3210bf_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['category'], name='tickets_tic_categor_3266ff_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['created_at'], name='tickets_tic_created_5dd600_idx'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['due_at'], name='tickets_tic_due_at_b8e2ab_idx'), - ), - ] diff --git a/apps/tickets/migrations/0002_add_created_by_to_ticketcategory.py b/apps/tickets/migrations/0002_add_created_by_to_ticketcategory.py deleted file mode 100644 index 2073775..0000000 --- a/apps/tickets/migrations/0002_add_created_by_to_ticketcategory.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 4.2.27 on 2026-04-30 14:07 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('tickets', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='ticketcategory', - name='created_by', - field=models.ForeignKey(blank=True, help_text='创建此分类的用户,用于提供商数据隔离', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_ticket_categories', to=settings.AUTH_USER_MODEL, verbose_name='创建者'), - ), - ] diff --git a/apps/tickets/migrations/0003_add_group_assignment.py b/apps/tickets/migrations/0003_add_group_assignment.py deleted file mode 100644 index a069f23..0000000 --- a/apps/tickets/migrations/0003_add_group_assignment.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-02 09:54 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ('tickets', '0002_add_created_by_to_ticketcategory'), - ] - - operations = [ - migrations.AddField( - model_name='ticket', - name='assigned_group', - field=models.ForeignKey(blank=True, help_text='负责处理此工单的用户组', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to='auth.group', verbose_name='处理组'), - ), - migrations.AddField( - model_name='ticketcategory', - name='auto_assign_to_group', - field=models.ForeignKey(blank=True, help_text='该分类的工单自动分配给指定用户组', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_assigned_categories', to='auth.group', verbose_name='自动分配给用户组'), - ), - migrations.AddIndex( - model_name='ticket', - index=models.Index(fields=['assigned_group'], name='tickets_tic_assigne_161ff4_idx'), - ), - ] diff --git a/apps/tickets/migrations/0004_ticketcategory_allow_banned_users.py b/apps/tickets/migrations/0004_ticketcategory_allow_banned_users.py deleted file mode 100644 index 7cc7cbf..0000000 --- a/apps/tickets/migrations/0004_ticketcategory_allow_banned_users.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-07 13:38 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("tickets", "0003_add_group_assignment"), - ] - - operations = [ - migrations.AddField( - model_name="ticketcategory", - name="allow_banned_users", - field=models.BooleanField( - default=False, - help_text="封禁用户是否可以在此分类下提交工单", - verbose_name="允许封禁用户提交", - ), - ), - ] diff --git a/apps/tickets/migrations/0005_replace_related_product_host_with_cloud_computer_request.py b/apps/tickets/migrations/0005_replace_related_product_host_with_cloud_computer_request.py deleted file mode 100644 index 13d8d7b..0000000 --- a/apps/tickets/migrations/0005_replace_related_product_host_with_cloud_computer_request.py +++ /dev/null @@ -1,49 +0,0 @@ -# Generated by Django 4.2.27 on 2026-06-09 14:46 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ("operations", "0019_alter_product_site_group_and_more"), - ("tickets", "0004_ticketcategory_allow_banned_users"), - ] - - operations = [ - migrations.RemoveField( - model_name="ticket", - name="related_host", - ), - migrations.RemoveField( - model_name="ticket", - name="related_product", - ), - migrations.AddField( - model_name="ticket", - name="related_cloud_computer", - field=models.ForeignKey( - blank=True, - help_text="工单关联的我拥有的云电脑", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="tickets", - to="operations.cloudcomputeruser", - verbose_name="关联云电脑", - ), - ), - migrations.AddField( - model_name="ticket", - name="related_request", - field=models.ForeignKey( - blank=True, - help_text="工单关联的我提交的申请", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="tickets", - to="operations.accountopeningrequest", - verbose_name="关联申请", - ), - ), - ] diff --git a/apps/tickets/migrations/__init__.py b/apps/tickets/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/tickets/models.py b/apps/tickets/models.py deleted file mode 100644 index ebaceb6..0000000 --- a/apps/tickets/models.py +++ /dev/null @@ -1,651 +0,0 @@ -""" -工单系统模型 -""" -import os -import secrets -import string -from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group -from django.utils import timezone -from django.dispatch import Signal - -User = get_user_model() - -# 定义工单信号 -ticket_created = Signal() -ticket_assigned = Signal() -ticket_status_changed = Signal() -ticket_closed = Signal() -ticket_comment_added = Signal() - - -def generate_ticket_no(): - """ - 生成工单编号 - 格式: T + 年月日 + 4位随机字符 - """ - date_str = timezone.now().strftime('%Y%m%d') - random_str = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(4)) - return f"T{date_str}{random_str}" - - -class TicketCategory(models.Model): - """ - 工单分类模型 - """ - name = models.CharField( - max_length=100, - verbose_name=_('分类名称'), - help_text=_('工单分类的名称') - ) - description = models.TextField( - blank=True, - verbose_name=_('分类描述'), - help_text=_('分类的详细描述') - ) - icon = models.CharField( - max_length=50, - blank=True, - default='help_outline', - verbose_name=_('图标'), - help_text=_('Material Icons 图标名称') - ) - default_priority = models.CharField( - max_length=20, - choices=[ - ('urgent', _('紧急')), - ('high', _('高')), - ('medium', _('中')), - ('low', _('低')), - ], - default='medium', - verbose_name=_('默认优先级'), - help_text=_('该分类下工单的默认优先级') - ) - auto_assign_to = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='auto_assigned_categories', - verbose_name=_('自动分配给'), - help_text=_('该分类的工单自动分配给指定用户') - ) - auto_assign_to_group = models.ForeignKey( - Group, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='auto_assigned_categories', - verbose_name=_('自动分配给用户组'), - help_text=_('该分类的工单自动分配给指定用户组') - ) - sla_hours = models.PositiveIntegerField( - default=24, - verbose_name=_('SLA时限(小时)'), - help_text=_('工单处理的服务级别协议时限') - ) - is_active = models.BooleanField( - default=True, - verbose_name=_('是否启用'), - help_text=_('是否在前端展示此分类') - ) - allow_banned_users = models.BooleanField( - default=False, - verbose_name=_('允许封禁用户提交'), - help_text=_('封禁用户是否可以在此分类下提交工单') - ) - display_order = models.IntegerField( - default=0, - verbose_name=_('显示顺序'), - help_text=_('分类在前端展示的顺序,数字越小越靠前') - ) - created_by = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='created_ticket_categories', - verbose_name=_('创建者'), - help_text=_('创建此分类的用户,用于提供商数据隔离') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间') - ) - - class Meta: - verbose_name = _('工单分类') - verbose_name_plural = _('工单分类') - ordering = ['display_order', 'name'] - indexes = [ - models.Index(fields=['is_active']), - models.Index(fields=['display_order']), - ] - - def __str__(self): - return self.name - - -class Ticket(models.Model): - """ - 工单模型 - """ - STATUS_CHOICES = [ - ('pending', _('待处理')), - ('processing', _('处理中')), - ('waiting_feedback', _('待反馈')), - ('resolved', _('已解决')), - ('closed', _('已关闭')), - ('rejected', _('已驳回')), - ] - - PRIORITY_CHOICES = [ - ('urgent', _('紧急')), - ('high', _('高')), - ('medium', _('中')), - ('low', _('低')), - ] - - SOURCE_CHOICES = [ - ('web', _('Web提交')), - ('api', _('API创建')), - ('system', _('系统自动生成')), - ('email', _('邮件导入')), - ] - - ticket_no = models.CharField( - max_length=20, - unique=True, - verbose_name=_('工单编号'), - help_text=_('自动生成的唯一工单编号') - ) - title = models.CharField( - max_length=200, - verbose_name=_('标题'), - help_text=_('工单的简要标题') - ) - description = models.TextField( - verbose_name=_('详细描述'), - help_text=_('工单的详细描述') - ) - category = models.ForeignKey( - TicketCategory, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='tickets', - verbose_name=_('分类'), - help_text=_('工单所属的分类') - ) - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='pending', - verbose_name=_('状态'), - help_text=_('工单的当前状态') - ) - priority = models.CharField( - max_length=20, - choices=PRIORITY_CHOICES, - default='medium', - verbose_name=_('优先级'), - help_text=_('工单的优先级') - ) - source = models.CharField( - max_length=20, - choices=SOURCE_CHOICES, - default='web', - verbose_name=_('来源'), - help_text=_('工单的创建来源') - ) - creator = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='created_tickets', - verbose_name=_('创建者'), - help_text=_('提交工单的用户') - ) - assignee = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='assigned_tickets', - verbose_name=_('处理人'), - help_text=_('负责处理此工单的用户') - ) - assigned_group = models.ForeignKey( - Group, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='assigned_tickets', - verbose_name=_('处理组'), - help_text=_('负责处理此工单的用户组') - ) - related_cloud_computer = models.ForeignKey( - 'operations.CloudComputerUser', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='tickets', - verbose_name=_('关联云电脑'), - help_text=_('工单关联的我拥有的云电脑') - ) - related_request = models.ForeignKey( - 'operations.AccountOpeningRequest', - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='tickets', - verbose_name=_('关联申请'), - help_text=_('工单关联的我提交的申请') - ) - due_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('截止时间'), - help_text=_('根据SLA计算的工单处理截止时间') - ) - resolved_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('解决时间'), - help_text=_('工单被标记为已解决的时间') - ) - closed_at = models.DateTimeField( - null=True, - blank=True, - verbose_name=_('关闭时间'), - help_text=_('工单被关闭的时间') - ) - satisfaction = models.PositiveSmallIntegerField( - null=True, - blank=True, - verbose_name=_('满意度评分'), - help_text=_('用户对工单处理的满意度评分(1-5)') - ) - satisfaction_comment = models.TextField( - blank=True, - verbose_name=_('满意度评价'), - help_text=_('用户对工单处理的评价内容') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - updated_at = models.DateTimeField( - auto_now=True, - verbose_name=_('更新时间') - ) - - class Meta: - verbose_name = _('工单') - verbose_name_plural = _('工单') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['ticket_no']), - models.Index(fields=['status']), - models.Index(fields=['priority']), - models.Index(fields=['assignee']), - models.Index(fields=['assigned_group']), - models.Index(fields=['creator']), - models.Index(fields=['category']), - models.Index(fields=['created_at']), - models.Index(fields=['due_at']), - ] - - def __str__(self): - return f"[{self.ticket_no}] {self.title}" - - def save(self, *args, **kwargs): - """ - 重写save方法,自动生成工单编号,计算SLA截止时间 - """ - is_new = not self.pk - - if is_new and not self.ticket_no: - # 确保工单编号唯一 - while True: - ticket_no = generate_ticket_no() - if not Ticket.objects.filter(ticket_no=ticket_no).exists(): - self.ticket_no = ticket_no - break - - # 计算SLA截止时间 - if is_new and self.category and self.category.sla_hours and not self.due_at: - self.due_at = timezone.now() + timezone.timedelta(hours=self.category.sla_hours) - - # 记录状态变更前的旧状态 - old_status = None - old_assignee_id = None - old_assigned_group_id = None - if self.pk: - try: - old_ticket = Ticket.objects.get(pk=self.pk) - old_status = old_ticket.status - old_assignee_id = old_ticket.assignee_id - old_assigned_group_id = old_ticket.assigned_group_id - except Ticket.DoesNotExist: - pass - - super().save(*args, **kwargs) - - # 发送信号 - if is_new: - ticket_created.send(sender=self.__class__, instance=self) - - if old_status and old_status != self.status: - ticket_status_changed.send( - sender=self.__class__, - instance=self, - old_status=old_status, - new_status=self.status - ) - - # 记录活动 - TicketActivity.objects.create( - ticket=self, - actor=self.creator if hasattr(self, '_current_user') else None, - action='status_change', - old_value=old_status, - new_value=self.status, - description=f'状态从 "{self.get_status_display_old(old_status)}" 变更为 "{self.get_status_display()}"' - ) - - if old_assignee_id != self.assignee_id or old_assigned_group_id != self.assigned_group_id: - assign_desc_parts = [] - if self.assignee: - assign_desc_parts.append(f'用户 {self.assignee.username}') - if self.assigned_group: - assign_desc_parts.append(f'用户组 {self.assigned_group.name}') - if assign_desc_parts: - ticket_assigned.send(sender=self.__class__, instance=self) - TicketActivity.objects.create( - ticket=self, - actor=self.creator if hasattr(self, '_current_user') else None, - action='assign', - new_value=' / '.join(assign_desc_parts), - description=f'工单分配给 {" / ".join(assign_desc_parts)}' - ) - - def get_status_display_old(self, status): - """获取指定状态的显示文本""" - for code, name in self.STATUS_CHOICES: - if code == status: - return name - return status - - def assign_to(self, user=None, group=None, actor=None): - """ - 分配工单给指定用户和/或用户组 - """ - if user is not None: - self.assignee = user - if group is not None: - self.assigned_group = group - if actor: - self._current_user = actor - self.save() - - def update_status(self, new_status, actor=None, notes=''): - """ - 更新工单状态 - """ - if new_status not in [s[0] for s in self.STATUS_CHOICES]: - raise ValueError(f'无效的状态: {new_status}') - - self.status = new_status - - if new_status == 'resolved': - self.resolved_at = timezone.now() - elif new_status == 'closed': - self.closed_at = timezone.now() - - if actor: - self._current_user = actor - self.save() - - def close(self, actor=None, satisfaction=None, comment=''): - """ - 关闭工单 - """ - self.status = 'closed' - self.closed_at = timezone.now() - if satisfaction is not None: - self.satisfaction = satisfaction - if comment: - self.satisfaction_comment = comment - if actor: - self._current_user = actor - self.save() - ticket_closed.send(sender=self.__class__, instance=self) - - def is_overdue(self): - """ - 检查工单是否已超时 - """ - if self.due_at and self.status not in ['resolved', 'closed', 'rejected']: - return timezone.now() > self.due_at - return False - - @property - def assignee_display(self): - """ - 获取处理人显示文本(包含用户和用户组) - """ - parts = [] - if self.assignee: - parts.append(self.assignee.username) - if self.assigned_group: - parts.append(f'{self.assigned_group.name}(组)') - return ' / '.join(parts) if parts else None - - @property - def status_badge_class(self): - """ - 获取状态对应的Bootstrap徽章样式类 - """ - badge_map = { - 'pending': 'bg-secondary', - 'processing': 'bg-primary', - 'waiting_feedback': 'bg-warning', - 'resolved': 'bg-success', - 'closed': 'bg-dark', - 'rejected': 'bg-danger', - } - return badge_map.get(self.status, 'bg-secondary') - - @property - def priority_badge_class(self): - """ - 获取优先级对应的Bootstrap徽章样式类 - """ - badge_map = { - 'urgent': 'bg-danger', - 'high': 'bg-warning', - 'medium': 'bg-info', - 'low': 'bg-success', - } - return badge_map.get(self.priority, 'bg-info') - - -class TicketComment(models.Model): - """ - 工单评论/回复模型 - """ - ticket = models.ForeignKey( - Ticket, - on_delete=models.CASCADE, - related_name='comments', - verbose_name=_('关联工单'), - help_text=_('评论所属的工单') - ) - author = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='ticket_comments', - verbose_name=_('作者'), - help_text=_('评论的作者') - ) - content = models.TextField( - verbose_name=_('内容'), - help_text=_('评论的详细内容') - ) - is_internal = models.BooleanField( - default=False, - verbose_name=_('内部备注'), - help_text=_('是否为仅工作人员可见的内部备注') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - - class Meta: - verbose_name = _('工单评论') - verbose_name_plural = _('工单评论') - ordering = ['created_at'] - indexes = [ - models.Index(fields=['ticket', 'created_at']), - ] - - def __str__(self): - return f'{self.author.username} - {self.ticket.ticket_no}' - - def save(self, *args, **kwargs): - is_new = not self.pk - super().save(*args, **kwargs) - if is_new: - ticket_comment_added.send(sender=self.__class__, instance=self) - # 记录活动 - TicketActivity.objects.create( - ticket=self.ticket, - actor=self.author, - action='comment', - description=f'{"添加内部备注" if self.is_internal else "添加评论"}' - ) - - -class TicketActivity(models.Model): - """ - 工单活动记录模型 - """ - ACTION_CHOICES = [ - ('create', _('创建')), - ('assign', _('分配')), - ('status_change', _('状态变更')), - ('comment', _('评论')), - ('close', _('关闭')), - ('update', _('更新')), - ] - - ticket = models.ForeignKey( - Ticket, - on_delete=models.CASCADE, - related_name='activities', - verbose_name=_('关联工单'), - help_text=_('活动所属的工单') - ) - actor = models.ForeignKey( - User, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='ticket_activities', - verbose_name=_('操作人'), - help_text=_('执行此操作的用户') - ) - action = models.CharField( - max_length=20, - choices=ACTION_CHOICES, - verbose_name=_('操作类型'), - help_text=_('活动的操作类型') - ) - old_value = models.CharField( - max_length=255, - blank=True, - verbose_name=_('旧值'), - help_text=_('变更前的值') - ) - new_value = models.CharField( - max_length=255, - blank=True, - verbose_name=_('新值'), - help_text=_('变更后的值') - ) - description = models.TextField( - blank=True, - verbose_name=_('描述'), - help_text=_('活动的详细描述') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - - class Meta: - verbose_name = _('工单活动') - verbose_name_plural = _('工单活动') - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['ticket', 'created_at']), - ] - - def __str__(self): - actor_name = self.actor.username if self.actor else _('系统') - return f'[{actor_name}] {self.get_action_display()} - {self.ticket.ticket_no}' - - -class TicketAttachment(models.Model): - """ - 工单附件模型 - """ - ticket = models.ForeignKey( - Ticket, - on_delete=models.CASCADE, - related_name='attachments', - verbose_name=_('关联工单'), - help_text=_('附件所属的工单') - ) - file = models.FileField( - upload_to='ticket_attachments/%Y/%m/', - verbose_name=_('文件'), - help_text=_('上传的附件文件') - ) - filename = models.CharField( - max_length=255, - verbose_name=_('原始文件名'), - help_text=_('文件的原始名称') - ) - uploaded_by = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name='ticket_attachments', - verbose_name=_('上传人'), - help_text=_('上传此附件的用户') - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name=_('创建时间') - ) - - class Meta: - verbose_name = _('工单附件') - verbose_name_plural = _('工单附件') - ordering = ['-created_at'] - - def __str__(self): - return self.filename - - def save(self, *args, **kwargs): - if self.file and not self.filename: - self.filename = os.path.basename(self.file.name) - super().save(*args, **kwargs) diff --git a/apps/tickets/notifications.py b/apps/tickets/notifications.py deleted file mode 100644 index b59ba44..0000000 --- a/apps/tickets/notifications.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -工单系统通知模块 - -支持邮件通知和站内通知(邮件通过 Celery 异步发送) -""" - - -def _get_system_config(): - """获取系统配置""" - from apps.dashboard.models import SystemConfig - try: - return SystemConfig.get_config() - except Exception: - return None - - -def _get_site_url(): - """获取站点URL""" - from django.conf import settings - return getattr(settings, 'SITE_URL', 'http://localhost:8000') - - -def send_ticket_email(subject, template_name, context, recipient_list): - """ - 异步发送工单相关邮件(通过 Celery 任务) - - Args: - subject: 邮件主题 - template_name: 邮件模板名称 - context: 模板上下文 - recipient_list: 收件人列表 - """ - if not recipient_list: - return - - from apps.accounts.tasks import send_ticket_email_task - send_ticket_email_task.delay( - subject=subject, - template_name=template_name, - context=context, - recipient_list=recipient_list, - ) - - -def notify_ticket_created(ticket): - """ - 通知工单创建 - - 通知管理员 - - 通知自动分配的处理人 - - 通知自动分配的处理组成员 - """ - context = { - 'ticket': ticket, - 'site_url': _get_site_url(), - } - - recipients = [] - - if ticket.assignee and ticket.assignee.email: - recipients.append(ticket.assignee.email) - - if ticket.assigned_group: - group_users = ticket.assigned_group.user_set.filter( - is_active=True - ).exclude(email='') - for user in group_users: - if user.email not in recipients: - recipients.append(user.email) - - if recipients: - send_ticket_email( - subject=f'[2c2a] 新工单分配 - {ticket.ticket_no}', - template_name='tickets/email/assigned.html', - context=context, - recipient_list=recipients - ) - - -def notify_ticket_assigned(ticket, old_assignee=None): - """ - 通知工单分配 - - 通知新的处理人 - - 通知处理组成员 - """ - context = { - 'ticket': ticket, - 'site_url': _get_site_url(), - 'old_assignee': old_assignee, - } - - recipients = [] - - if ticket.assignee and ticket.assignee.email: - recipients.append(ticket.assignee.email) - - if ticket.assigned_group: - group_users = ticket.assigned_group.user_set.filter( - is_active=True - ).exclude(email='') - for user in group_users: - if user.email not in recipients: - recipients.append(user.email) - - if not recipients: - return - - send_ticket_email( - subject=f'[2c2a] 工单分配通知 - {ticket.ticket_no}', - template_name='tickets/email/assigned.html', - context=context, - recipient_list=recipients - ) - - -def notify_ticket_status_changed(ticket, old_status, new_status): - """ - 通知工单状态变更 - - 通知创建者 - """ - if not ticket.creator or not ticket.creator.email: - return - - # 如果创建者自己变更状态,不发送通知 - # 这里简化处理,实际应该传入操作人参数 - - context = { - 'ticket': ticket, - 'site_url': _get_site_url(), - 'old_status': old_status, - 'new_status': new_status, - } - - send_ticket_email( - subject=f'[2c2a] 工单状态更新 - {ticket.ticket_no}', - template_name='tickets/email/status_update.html', - context=context, - recipient_list=[ticket.creator.email] - ) - - -def notify_ticket_closed(ticket): - """ - 通知工单关闭 - - 通知创建者 - - 邀请评价 - """ - if not ticket.creator or not ticket.creator.email: - return - - context = { - 'ticket': ticket, - 'site_url': _get_site_url(), - } - - send_ticket_email( - subject=f'[2c2a] 工单已关闭 - {ticket.ticket_no}', - template_name='tickets/email/closed.html', - context=context, - recipient_list=[ticket.creator.email] - ) - - -def notify_new_comment(comment): - """ - 通知新评论 - - 通知工单相关人员 - """ - ticket = comment.ticket - - # 收集需要通知的用户 - recipients = [] - - # 通知创建者(如果不是评论者自己) - if ticket.creator and ticket.creator.email and ticket.creator != comment.author: - recipients.append(ticket.creator.email) - - # 通知处理人(如果不是评论者自己) - if ticket.assignee and ticket.assignee.email and ticket.assignee != comment.author: - recipients.append(ticket.assignee.email) - - if not recipients: - return - - context = { - 'ticket': ticket, - 'comment': comment, - 'site_url': _get_site_url(), - } - - send_ticket_email( - subject=f'[2c2a] 工单新评论 - {ticket.ticket_no}', - template_name='tickets/email/new_comment.html', - context=context, - recipient_list=recipients - ) - - -def notify_overdue_ticket(ticket): - """ - 通知工单即将超时或已超时 - - 通知处理人 - - 通知处理组成员 - - 通知管理员 - """ - context = { - 'ticket': ticket, - 'site_url': _get_site_url(), - } - - recipients = [] - - if ticket.assignee and ticket.assignee.email: - recipients.append(ticket.assignee.email) - - if ticket.assigned_group: - group_users = ticket.assigned_group.user_set.filter( - is_active=True - ).exclude(email='') - for user in group_users: - if user.email not in recipients: - recipients.append(user.email) - - if not recipients: - return - - send_ticket_email( - subject=f'[2c2a] 工单即将超时 - {ticket.ticket_no}', - template_name='tickets/email/overdue.html', - context=context, - recipient_list=recipients - ) diff --git a/apps/tickets/signals.py b/apps/tickets/signals.py deleted file mode 100644 index efccc56..0000000 --- a/apps/tickets/signals.py +++ /dev/null @@ -1,78 +0,0 @@ -""" -工单系统信号处理 - -处理工单相关的信号,包括: -- 工单创建通知 -- 工单分配通知 -- 工单状态变更通知 -- 工单关闭通知 -- 评论添加通知 -- 审计日志记录 -""" - -from django.dispatch import receiver -from django.contrib.auth import get_user_model -from django.utils.translation import gettext_lazy as _ - -from .models import ( - ticket_created, ticket_assigned, ticket_status_changed, - ticket_closed, ticket_comment_added, TicketActivity -) - -User = get_user_model() - - -@receiver(ticket_created) -def on_ticket_created(sender, instance, **kwargs): - """ - 工单创建时的信号处理 - - 记录创建活动 - - 发送通知给管理员 - """ - # 创建活动记录已在模型 save 方法中处理 - # 这里可以添加额外的通知逻辑 - pass - - -@receiver(ticket_assigned) -def on_ticket_assigned(sender, instance, **kwargs): - """ - 工单分配时的信号处理 - - 发送通知给处理人 - """ - if instance.assignee: - # 可以在这里发送邮件通知或站内通知 - pass - - -@receiver(ticket_status_changed) -def on_ticket_status_changed(sender, instance, old_status, new_status, **kwargs): - """ - 工单状态变更时的信号处理 - - 发送通知给创建者 - - 检查SLA - """ - # 状态变更活动记录已在模型 save 方法中处理 - # 可以在这里添加通知逻辑 - pass - - -@receiver(ticket_closed) -def on_ticket_closed(sender, instance, **kwargs): - """ - 工单关闭时的信号处理 - - 发送满意度评价邀请 - """ - # 可以在这里发送评价邀请邮件 - pass - - -@receiver(ticket_comment_added) -def on_ticket_comment_added(sender, instance, **kwargs): - """ - 评论添加时的信号处理 - - 通知相关人员 - """ - # 评论活动记录已在模型 save 方法中处理 - # 可以在这里添加通知逻辑 - pass diff --git a/apps/tickets/urls.py b/apps/tickets/urls.py deleted file mode 100644 index 26d8426..0000000 --- a/apps/tickets/urls.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -工单系统URL配置 -""" -from django.urls import path -from . import views - -app_name = 'tickets' - -urlpatterns = [ - # 工单列表 - path('', views.TicketListView.as_view(), name='ticket_list'), - path('my/', views.MyTicketsView.as_view(), name='my_tickets'), - path('pending/', views.PendingTicketsView.as_view(), name='pending_tickets'), - - # 工单CRUD - path('create/', views.TicketCreateView.as_view(), name='ticket_create'), - path('/', views.TicketDetailView.as_view(), name='ticket_detail'), - - # 工单操作 - path('/assign/', views.ticket_assign, name='ticket_assign'), - path('/status/', views.ticket_status_update, name='ticket_status_update'), - path('/close/', views.ticket_close, name='ticket_close'), - path('/comment/', views.ticket_comment, name='ticket_comment'), - - # 仪表盘 - path('dashboard/', views.TicketDashboardView.as_view(), name='dashboard'), -] diff --git a/apps/tickets/urls_admin.py b/apps/tickets/urls_admin.py deleted file mode 100644 index fc5c09c..0000000 --- a/apps/tickets/urls_admin.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -超管后台 - 工单系统 URL 配置 - -命名空间: admin_tickets -超管可查看所有工单数据,无数据隔离。 -""" - -from django.urls import path - -from . import views_admin - -app_name = 'admin_tickets' - -urlpatterns = [ - # 工单管理 - path('', views_admin.admin_ticket_list, name='ticket_list'), - path('/', views_admin.admin_ticket_detail, name='ticket_detail'), - path('/comment/', views_admin.admin_ticket_comment_create, name='ticket_comment_create'), - - # 工单批量操作 - path('batch/processing/', views_admin.admin_ticket_batch_processing, name='ticket_batch_processing'), - path('batch/resolved/', views_admin.admin_ticket_batch_resolved, name='ticket_batch_resolved'), - path('batch/closed/', views_admin.admin_ticket_batch_closed, name='ticket_batch_closed'), - - # 工单分类管理 - path('categories/', views_admin.admin_category_list, name='category_list'), - path('categories/create/', views_admin.admin_category_create, name='category_create'), - path('categories//edit/', views_admin.admin_category_update, name='category_edit'), - path('categories//delete/', views_admin.admin_category_delete, name='category_delete'), - - # 活动日志(只读) - path('activities/', views_admin.admin_activity_list, name='activity_list'), -] diff --git a/apps/tickets/urls_provider.py b/apps/tickets/urls_provider.py deleted file mode 100644 index cfac877..0000000 --- a/apps/tickets/urls_provider.py +++ /dev/null @@ -1,87 +0,0 @@ -from django.urls import path - -from .views_provider import ( - TicketActivityListView, - TicketAttachmentDownloadView, - TicketAttachmentUploadView, - TicketBatchClosedView, - TicketBatchProcessingView, - TicketBatchResolvedView, - TicketCategoryCreateView, - TicketCategoryDeleteView, - TicketCategoryListView, - TicketCategoryUpdateView, - TicketCommentCreateView, - TicketDetailView, - TicketListView, -) - -app_name = 'provider_tickets' - -urlpatterns = [ - path( - 'categories/', - TicketCategoryListView.as_view(), - name='category_list', - ), - path( - 'categories/create/', - TicketCategoryCreateView.as_view(), - name='category_create', - ), - path( - 'categories//edit/', - TicketCategoryUpdateView.as_view(), - name='category_edit', - ), - path( - 'categories//delete/', - TicketCategoryDeleteView.as_view(), - name='category_delete', - ), - path( - '', - TicketListView.as_view(), - name='ticket_list', - ), - path( - '/', - TicketDetailView.as_view(), - name='ticket_detail', - ), - path( - 'batch-processing/', - TicketBatchProcessingView.as_view(), - name='ticket_batch_processing', - ), - path( - 'batch-resolved/', - TicketBatchResolvedView.as_view(), - name='ticket_batch_resolved', - ), - path( - 'batch-closed/', - TicketBatchClosedView.as_view(), - name='ticket_batch_closed', - ), - path( - '/comment/', - TicketCommentCreateView.as_view(), - name='ticket_comment_create', - ), - path( - 'activities/', - TicketActivityListView.as_view(), - name='activity_list', - ), - path( - '/attachments/upload/', - TicketAttachmentUploadView.as_view(), - name='attachment_upload', - ), - path( - 'attachments//download/', - TicketAttachmentDownloadView.as_view(), - name='attachment_download', - ), -] diff --git a/apps/tickets/views.py b/apps/tickets/views.py deleted file mode 100644 index 2cc2a86..0000000 --- a/apps/tickets/views.py +++ /dev/null @@ -1,478 +0,0 @@ -""" -工单系统视图 -""" -from django.shortcuts import render, get_object_or_404, redirect -from django.contrib.auth.decorators import login_required -from django.contrib import messages -from django.urls import reverse_lazy -from django.views.generic import ListView, CreateView, DetailView, UpdateView -from django.utils.decorators import method_decorator -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import JsonResponse, HttpResponseForbidden, HttpResponseRedirect -from django.views.decorators.csrf import csrf_protect -from django.views.decorators.http import require_POST -from django.utils.translation import gettext_lazy as _ -from django.db.models import Q -from django.utils import timezone -from datetime import timedelta - -from .models import Ticket, TicketComment, TicketActivity, TicketCategory -from .forms import ( - TicketForm, TicketCommentForm, TicketAssignForm, - TicketStatusForm, TicketCloseForm, TicketFilterForm -) - - -class TicketListView(LoginRequiredMixin, ListView): - """ - 工单列表视图 - """ - model = Ticket - template_name = 'tickets/ticket_list.html' - context_object_name = 'tickets' - paginate_by = 20 - - def get_queryset(self): - """获取查询集""" - queryset = Ticket.objects.all() - user = self.request.user - - # 权限过滤 - if not (user.is_staff or user.is_superuser): - # 普通用户只能看到自己创建的工单 - queryset = queryset.filter(creator=user) - - # 应用过滤条件 - form = TicketFilterForm(self.request.GET) - if form.is_valid(): - status = form.cleaned_data.get('status') - priority = form.cleaned_data.get('priority') - category = form.cleaned_data.get('category') - search = form.cleaned_data.get('search') - start_date = form.cleaned_data.get('start_date') - end_date = form.cleaned_data.get('end_date') - - if status: - queryset = queryset.filter(status=status) - if priority: - queryset = queryset.filter(priority=priority) - if category: - queryset = queryset.filter(category=category) - if search: - queryset = queryset.filter( - Q(ticket_no__icontains=search[:50]) | - Q(title__icontains=search[:50]) | - Q(description__icontains=search[:50]) - ) - if start_date: - queryset = queryset.filter(created_at__gte=start_date) - if end_date: - end_date = end_date + timedelta(days=1) - queryset = queryset.filter(created_at__lt=end_date) - - return queryset.select_related('creator', 'assignee', 'category').order_by('-created_at') - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context['filter_form'] = TicketFilterForm(self.request.GET) - context['statuses'] = Ticket.STATUS_CHOICES - context['priorities'] = Ticket.PRIORITY_CHOICES - context['categories'] = TicketCategory.objects.filter(is_active=True) - return context - - -class MyTicketsView(LoginRequiredMixin, ListView): - """ - 我的工单视图 - """ - model = Ticket - template_name = 'tickets/my_tickets.html' - context_object_name = 'tickets' - paginate_by = 20 - - def get_queryset(self): - """获取当前用户的工单""" - queryset = Ticket.objects.filter(creator=self.request.user) - - # 应用过滤条件 - form = TicketFilterForm(self.request.GET) - if form.is_valid(): - status = form.cleaned_data.get('status') - if status: - queryset = queryset.filter(status=status) - - return queryset.select_related('assignee', 'category').order_by('-created_at') - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context['filter_form'] = TicketFilterForm(self.request.GET) - context['statuses'] = Ticket.STATUS_CHOICES - return context - - -class PendingTicketsView(LoginRequiredMixin, ListView): - """ - 待处理工单视图(管理员/处理人) - """ - model = Ticket - template_name = 'tickets/pending_list.html' - context_object_name = 'tickets' - paginate_by = 20 - - def get_queryset(self): - """获取待处理工单""" - user = self.request.user - queryset = Ticket.objects.filter(status__in=['pending', 'processing', 'waiting_feedback']) - - if not (user.is_staff or user.is_superuser): - # 非管理员只能看到分配给自己或自己所在用户组的 - user_groups = user.groups.all() - queryset = queryset.filter( - Q(assignee=user) | Q(assigned_group__in=user_groups) - ) - - return queryset.select_related( - 'creator', 'assignee', 'assigned_group', 'category' - ).order_by('due_at', '-priority', '-created_at') - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - context['statuses'] = Ticket.STATUS_CHOICES - return context - - -class TicketCreateView(LoginRequiredMixin, CreateView): - """ - 创建工单视图 - """ - model = Ticket - form_class = TicketForm - template_name = 'tickets/ticket_form.html' - success_url = reverse_lazy('tickets:my_tickets') - - def get_form_kwargs(self): - """获取表单初始化参数""" - kwargs = super().get_form_kwargs() - kwargs['user'] = self.request.user - return kwargs - - def form_valid(self, form): - """表单验证成功后的处理""" - # 封禁用户只能提交允许的分类 - from apps.accounts.models import UserBan - if UserBan.objects.filter(user=self.request.user).exists(): - category = form.instance.category - if not category or not category.allow_banned_users: - messages.error(self.request, '您只能在被允许的分类下提交工单') - return self.form_invalid(form) - - form.instance.creator = self.request.user - form.instance.status = 'pending' - form.instance.source = 'web' - - # 如果有分类,自动分配 - if form.instance.category: - if form.instance.category.auto_assign_to: - form.instance.assignee = form.instance.category.auto_assign_to - if form.instance.category.auto_assign_to_group: - form.instance.assigned_group = form.instance.category.auto_assign_to_group - - response = super().form_valid(form) - - # 记录创建活动 - TicketActivity.objects.create( - ticket=self.object, - actor=self.request.user, - action='create', - description='创建工单' - ) - - messages.success(self.request, f'工单 {self.object.ticket_no} 已成功创建!') - return response - - def form_invalid(self, form): - """表单验证失败后的处理""" - messages.error(self.request, '工单信息填写有误,请检查输入信息。') - return super().form_invalid(form) - - -class TicketDetailView(LoginRequiredMixin, DetailView): - """ - 工单详情视图 - """ - model = Ticket - template_name = 'tickets/ticket_detail.html' - context_object_name = 'ticket' - - def get_queryset(self): - """获取查询集""" - return Ticket.objects.select_related( - 'creator', 'assignee', 'assigned_group', 'category', - 'related_cloud_computer', 'related_cloud_computer__product', - 'related_request', 'related_request__target_product' - ).prefetch_related('comments', 'activities') - - def get_context_data(self, **kwargs): - """获取模板上下文数据""" - context = super().get_context_data(**kwargs) - ticket = self.object - user = self.request.user - - # 权限检查 - can_view = ( - ticket.creator == user or - ticket.assignee == user or - user.is_staff or - user.is_superuser or - (ticket.assigned_group and user.groups.filter(pk=ticket.assigned_group.pk).exists()) - ) - - if not can_view: - context['forbidden'] = True - return context - - # 评论表单 - context['comment_form'] = TicketCommentForm() - - # 分配表单(仅管理员/工作人员) - if user.is_staff or user.is_superuser: - context['assign_form'] = TicketAssignForm() - context['status_form'] = TicketStatusForm() - - # 关闭表单(创建者或管理员) - if ticket.creator == user or user.is_staff or user.is_superuser: - context['close_form'] = TicketCloseForm() - - # 评论列表(过滤内部备注) - comments = ticket.comments.all() - if not (user.is_staff or user.is_superuser): - comments = comments.filter(is_internal=False) - context['comments'] = comments - - # 活动记录 - context['activities'] = ticket.activities.all()[:20] - - # 状态流转选项 - context['status_choices'] = Ticket.STATUS_CHOICES - - return context - - -@login_required -def ticket_assign(request, pk): - """ - 分配工单 - """ - ticket = get_object_or_404(Ticket, pk=pk) - - # 权限检查 - if not (request.user.is_staff or request.user.is_superuser): - return HttpResponseForbidden('无权操作') - - if request.method == 'POST': - form = TicketAssignForm(request.POST) - if form.is_valid(): - assignee = form.cleaned_data.get('assignee') - assigned_group = form.cleaned_data.get('assigned_group') - notes = form.cleaned_data['notes'] - - old_assignee = ticket.assignee - old_assigned_group = ticket.assigned_group - ticket.assign_to( - user=assignee, - group=assigned_group, - actor=request.user, - ) - - # 构建分配描述 - assign_desc_parts = [] - if assignee: - assign_desc_parts.append(f'{assignee.username}') - if assigned_group: - assign_desc_parts.append(f'{assigned_group.name}(组)') - - # 记录活动 - TicketActivity.objects.create( - ticket=ticket, - actor=request.user, - action='assign', - old_value=str(old_assignee) if old_assignee else '', - new_value=' / '.join(assign_desc_parts), - description=f'工单分配给 {" / ".join(assign_desc_parts)}' + (f',备注: {notes}' if notes else '') - ) - - messages.success(request, f'工单已分配给 {" / ".join(assign_desc_parts)}') - return redirect('tickets:ticket_detail', pk=ticket.pk) - - return redirect('tickets:ticket_detail', pk=ticket.pk) - - -@login_required -def ticket_status_update(request, pk): - """ - 更新工单状态 - """ - ticket = get_object_or_404(Ticket, pk=pk) - - # 权限检查 - if not (request.user.is_staff or request.user.is_superuser or request.user == ticket.assignee or - (ticket.assigned_group and request.user.groups.filter(pk=ticket.assigned_group.pk).exists())): - return HttpResponseForbidden('无权操作') - - if request.method == 'POST': - form = TicketStatusForm(request.POST) - if form.is_valid(): - new_status = form.cleaned_data['status'] - notes = form.cleaned_data['notes'] - - old_status = ticket.status - ticket.update_status(new_status, actor=request.user) - - # 记录活动 - TicketActivity.objects.create( - ticket=ticket, - actor=request.user, - action='status_change', - old_value=old_status, - new_value=new_status, - description=f'状态变更为 {ticket.get_status_display()}' + (f',备注: {notes}' if notes else '') - ) - - messages.success(request, f'工单状态已更新为 {ticket.get_status_display()}') - return redirect('tickets:ticket_detail', pk=ticket.pk) - - return redirect('tickets:ticket_detail', pk=ticket.pk) - - -@login_required -def ticket_close(request, pk): - """ - 关闭工单 - """ - ticket = get_object_or_404(Ticket, pk=pk) - - # 权限检查 - if not (request.user == ticket.creator or request.user.is_staff or request.user.is_superuser): - return HttpResponseForbidden('无权操作') - - if request.method == 'POST': - form = TicketCloseForm(request.POST) - if form.is_valid(): - satisfaction = form.cleaned_data['satisfaction'] - comment = form.cleaned_data['satisfaction_comment'] - - satisfaction_int = int(satisfaction) if satisfaction else None - - ticket.close( - actor=request.user, - satisfaction=satisfaction_int, - comment=comment - ) - - # 记录活动 - TicketActivity.objects.create( - ticket=ticket, - actor=request.user, - action='close', - description='关闭工单' + (f',满意度: {satisfaction}' if satisfaction else '') - ) - - messages.success(request, '工单已关闭') - return redirect('tickets:ticket_detail', pk=ticket.pk) - - return redirect('tickets:ticket_detail', pk=ticket.pk) - - -@login_required -@require_POST -def ticket_comment(request, pk): - """ - 添加工单评论 - """ - ticket = get_object_or_404(Ticket, pk=pk) - - # 权限检查 - can_comment = ( - ticket.creator == request.user or - ticket.assignee == request.user or - request.user.is_staff or - request.user.is_superuser or - (ticket.assigned_group and request.user.groups.filter(pk=ticket.assigned_group.pk).exists()) - ) - - if not can_comment: - return HttpResponseForbidden('无权评论') - - form = TicketCommentForm(request.POST) - if form.is_valid(): - comment = form.save(commit=False) - comment.ticket = ticket - comment.author = request.user - - # 非工作人员不能添加内部备注 - if comment.is_internal and not (request.user.is_staff or request.user.is_superuser): - comment.is_internal = False - - comment.save() - - messages.success(request, '评论已添加') - else: - messages.error(request, '评论内容无效') - - return redirect('tickets:ticket_detail', pk=ticket.pk) - - -class TicketDashboardView(LoginRequiredMixin, ListView): - """ - 工单仪表盘视图 - """ - model = Ticket - template_name = 'tickets/dashboard.html' - context_object_name = 'tickets' - paginate_by = 10 - - def get_queryset(self): - """获取最近工单""" - user = self.request.user - queryset = Ticket.objects.all() - - if not (user.is_staff or user.is_superuser): - queryset = queryset.filter(creator=user) - - return queryset.select_related('creator', 'assignee', 'category').order_by('-created_at')[:10] - - def get_context_data(self, **kwargs): - """获取统计信息""" - context = super().get_context_data(**kwargs) - user = self.request.user - - if user.is_staff or user.is_superuser: - # 管理员统计 - context['total_tickets'] = Ticket.objects.count() - context['pending_count'] = Ticket.objects.filter(status='pending').count() - context['processing_count'] = Ticket.objects.filter(status='processing').count() - context['resolved_count'] = Ticket.objects.filter(status='resolved').count() - context['closed_count'] = Ticket.objects.filter(status='closed').count() - context['overdue_count'] = Ticket.objects.filter( - due_at__lt=timezone.now() - ).exclude(status__in=['resolved', 'closed', 'rejected']).count() - else: - # 用户统计 - context['total_tickets'] = Ticket.objects.filter(creator=user).count() - context['pending_count'] = Ticket.objects.filter(creator=user, status='pending').count() - context['processing_count'] = Ticket.objects.filter(creator=user, status='processing').count() - context['resolved_count'] = Ticket.objects.filter(creator=user, status='resolved').count() - context['closed_count'] = Ticket.objects.filter(creator=user, status='closed').count() - - # 优先级分布 - context['priority_distribution'] = { - 'urgent': Ticket.objects.filter(priority='urgent').count() if (user.is_staff or user.is_superuser) else Ticket.objects.filter(creator=user, priority='urgent').count(), - 'high': Ticket.objects.filter(priority='high').count() if (user.is_staff or user.is_superuser) else Ticket.objects.filter(creator=user, priority='high').count(), - 'medium': Ticket.objects.filter(priority='medium').count() if (user.is_staff or user.is_superuser) else Ticket.objects.filter(creator=user, priority='medium').count(), - 'low': Ticket.objects.filter(priority='low').count() if (user.is_staff or user.is_superuser) else Ticket.objects.filter(creator=user, priority='low').count(), - } - - return context diff --git a/apps/tickets/views_admin.py b/apps/tickets/views_admin.py deleted file mode 100644 index a32d31f..0000000 --- a/apps/tickets/views_admin.py +++ /dev/null @@ -1,578 +0,0 @@ -""" -工单系统 - 超管后台视图 - -超管可查看所有数据;提供商仅可查看自己创建的分类及关联的工单。 -包含: -- AdminTicketListView: 所有工单列表,搜索、筛选、批量操作 -- AdminTicketDetailView: 工单详情,含评论和附件 -- AdminTicketCommentCreateView: 添加评论 (POST) -- AdminCategoryListView: 所有分类列表 -- AdminCategoryCreateView: 创建分类 -- AdminCategoryUpdateView: 编辑分类 -- AdminCategoryDeleteView: 删除分类 -- AdminActivityListView: 所有活动记录(只读) -""" - -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.shortcuts import get_object_or_404, redirect, render -from django.utils import timezone -from django.views.decorators.http import require_POST -from django.core.paginator import Paginator - -from apps.accounts.provider_decorators import admin_required -from utils.provider import get_provider_products - -from .forms_admin import AdminTicketCategoryForm, AdminTicketCommentForm -from .models import ( - Ticket, - TicketActivity, - TicketCategory, - TicketComment, -) - -User = get_user_model() - - -def _ticket_filter_for_user(user, site_group): - if user.is_superuser: - return Q() - if site_group and user.is_site_group_admin(site_group): - return ( - Q(related_cloud_computer__product__site_group=site_group) - | Q(creator=user) - ) - provider_products = get_provider_products(user) - return Q(related_cloud_computer__product__in=provider_products) | Q(creator=user) - - -def _category_filter_for_user(user, site_group): - if user.is_superuser: - return Q() - if site_group and user.is_site_group_admin(site_group): - return Q() - return Q(created_by=user) - - -# =========================================================================== -# 工单管理 -# =========================================================================== - - -@admin_required -def admin_ticket_list(request): - """ - 超管工单列表视图 - - - 无数据隔离,查看所有工单 - - 支持状态筛选、优先级筛选、搜索、批量操作 - """ - queryset = Ticket.objects.select_related( - "category", - "creator", - "assignee", - "assigned_group", - "related_cloud_computer", - "related_cloud_computer__product", - "related_request", - "related_request__target_product", - ) - - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - queryset = queryset.filter(ticket_filter).distinct() - - # 状态筛选 - status_filter = request.GET.get("status", "").strip() - if status_filter: - queryset = queryset.filter(status=status_filter) - - # 优先级筛选 - priority_filter = request.GET.get("priority", "").strip() - if priority_filter: - queryset = queryset.filter(priority=priority_filter) - - # 搜索 - search = request.GET.get("search", "").strip() - if search: - queryset = queryset.filter( - Q(ticket_no__icontains=search) - | Q(title__icontains=search) - | Q(description__icontains=search) - | Q(creator__username__icontains=search) - ) - - # 排序 - queryset = queryset.order_by("-created_at") - - # 分页 - paginator = Paginator(queryset, 20) - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - # 统计各状态数量 - if request.user.is_superuser: - base_qs = Ticket.objects.all() - else: - base_qs = Ticket.objects.filter(ticket_filter).distinct() - status_counts = { - "pending": base_qs.filter(status="pending").count(), - "processing": base_qs.filter(status="processing").count(), - "waiting_feedback": base_qs.filter(status="waiting_feedback").count(), - "resolved": base_qs.filter(status="resolved").count(), - "closed": base_qs.filter(status="closed").count(), - "rejected": base_qs.filter(status="rejected").count(), - } - - context = { - "page_obj": page_obj, - "tickets": page_obj, - "search": search, - "status_filter": status_filter, - "priority_filter": priority_filter, - "status_counts": status_counts, - "status_choices": Ticket.STATUS_CHOICES, - "priority_choices": Ticket.PRIORITY_CHOICES, - "page_title": "工单管理", - "active_nav": "admin_tickets", - } - - return render(request, "admin_base/tickets/ticket_list.html", context) - - -@admin_required -def admin_ticket_detail(request, pk): - """ - 超管工单详情视图 - - 显示工单信息、评论列表(含内部备注)、附件列表、活动记录。 - """ - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - ticket = get_object_or_404( - Ticket.objects.select_related( - "category", - "creator", - "assignee", - "assigned_group", - "related_cloud_computer", - "related_cloud_computer__product", - "related_request", - "related_request__target_product", - ).filter(ticket_filter), - pk=pk, - ) - else: - ticket = get_object_or_404( - Ticket.objects.select_related( - "category", - "creator", - "assignee", - "assigned_group", - "related_cloud_computer", - "related_cloud_computer__product", - "related_request", - "related_request__target_product", - ), - pk=pk, - ) - - # 评论列表(超管可见内部备注) - comments = ticket.comments.select_related("author").order_by("created_at") - - # 附件列表 - attachments = ticket.attachments.select_related("uploaded_by").order_by( - "-created_at" - ) - - # 活动记录 - activities = ticket.activities.select_related("actor").order_by("-created_at")[:10] - - context = { - "ticket": ticket, - "comments": comments, - "attachments": attachments, - "activities": activities, - "comment_form": AdminTicketCommentForm(), - "page_title": f"工单 {ticket.ticket_no}", - "active_nav": "admin_tickets", - } - - return render(request, "admin_base/tickets/ticket_detail.html", context) - - -@admin_required -@require_POST -def admin_ticket_comment_create(request, pk): - """ - 超管添加工单评论 (POST) - - 超管添加的评论自动标记作者为当前用户。 - """ - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - ticket = get_object_or_404( - Ticket.objects.filter(ticket_filter), - pk=pk, - ) - else: - ticket = get_object_or_404(Ticket, pk=pk) - - form = AdminTicketCommentForm(request.POST) - if form.is_valid(): - comment = form.save(commit=False) - comment.ticket = ticket - comment.author = request.user - comment.save() - - messages.success(request, "评论已添加。") - else: - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, error) - - return redirect("admin:admin_tickets:ticket_detail", pk=ticket.pk) - - -# =========================================================================== -# 工单批量操作 -# =========================================================================== - - -def _get_selected_ids(request): - """从 POST 请求中获取选中的工单 ID 列表""" - selected = request.POST.getlist("selected_ids") - return [int(pk) for pk in selected if pk.isdigit()] - - -@admin_required -@require_POST -def admin_ticket_batch_processing(request): - """批量标记工单为处理中""" - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, "未选择任何工单。") - return redirect("admin:admin_tickets:ticket_list") - - qs = Ticket.objects.filter( - pk__in=selected_ids, - status="pending", - ) - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - qs = qs.filter(ticket_filter).distinct() - - updated_count = 0 - for ticket in qs: - ticket.status = "processing" - ticket.assignee = request.user - ticket._current_user = request.user - ticket.save(update_fields=["status", "assignee", "updated_at"]) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f"成功将 {updated_count} 个工单标记为处理中。", - ) - else: - messages.warning(request, "没有可标记为处理中的工单。") - - return redirect("admin:admin_tickets:ticket_list") - - -@admin_required -@require_POST -def admin_ticket_batch_resolved(request): - """批量标记工单为已解决""" - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, "未选择任何工单。") - return redirect("admin:admin_tickets:ticket_list") - - qs = Ticket.objects.filter( - pk__in=selected_ids, - status__in=["pending", "processing", "waiting_feedback"], - ) - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - qs = qs.filter(ticket_filter).distinct() - - updated_count = 0 - now = timezone.now() - for ticket in qs: - ticket.status = "resolved" - ticket.resolved_at = now - ticket._current_user = request.user - ticket.save(update_fields=["status", "resolved_at", "updated_at"]) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f"成功将 {updated_count} 个工单标记为已解决。", - ) - else: - messages.warning(request, "没有可标记为已解决的工单。") - - return redirect("admin:admin_tickets:ticket_list") - - -@admin_required -@require_POST -def admin_ticket_batch_closed(request): - """批量关闭工单""" - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, "未选择任何工单。") - return redirect("admin:admin_tickets:ticket_list") - - qs = Ticket.objects.filter( - pk__in=selected_ids, - ).exclude(status="closed") - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - qs = qs.filter(ticket_filter).distinct() - - updated_count = 0 - now = timezone.now() - for ticket in qs: - ticket.status = "closed" - ticket.closed_at = now - ticket._current_user = request.user - ticket.save(update_fields=["status", "closed_at", "updated_at"]) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f"成功关闭了 {updated_count} 个工单。", - ) - else: - messages.warning(request, "没有可关闭的工单。") - - return redirect("admin:admin_tickets:ticket_list") - - -# =========================================================================== -# 工单分类管理 -# =========================================================================== - - -@admin_required -def admin_category_list(request): - """ - 超管工单分类列表视图 - - - 无数据隔离,查看所有分类 - - 支持搜索、分页 - """ - site_group = getattr(request, "site_group", None) - category_filter = _category_filter_for_user(request.user, site_group) - queryset = TicketCategory.objects.filter(category_filter).order_by( - "display_order", "name" - ) - - # 搜索 - search = request.GET.get("search", "").strip() - if search: - queryset = queryset.filter( - Q(name__icontains=search) | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(queryset, 15) - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - "categories": page_obj, - "search": search, - "page_title": "工单分类", - "active_nav": "ticket_categories", - } - - return render(request, "admin_base/tickets/category_list.html", context) - - -@admin_required -def admin_category_create(request): - """ - 超管创建工单分类 - - created_by 在视图中自动设置为当前用户。 - """ - if request.method == "POST": - form = AdminTicketCategoryForm(request.POST) - if form.is_valid(): - category = form.save(commit=False) - category.created_by = request.user - category.save() - - messages.success( - request, - f"工单分类 {category.name} 创建成功", - ) - return redirect("admin:admin_tickets:category_list") - else: - form = AdminTicketCategoryForm() - - context = { - "form": form, - "page_title": "创建工单分类", - "active_nav": "ticket_categories", - "is_create": True, - } - - return render(request, "admin_base/tickets/category_form.html", context) - - -@admin_required -def admin_category_update(request, pk): - """ - 超管编辑工单分类 - - 无数据隔离,可编辑所有分类。 - """ - site_group = getattr(request, "site_group", None) - category_filter = _category_filter_for_user(request.user, site_group) - if category_filter: - category = get_object_or_404( - TicketCategory.objects.filter(category_filter), - pk=pk, - ) - else: - category = get_object_or_404(TicketCategory, pk=pk) - - if request.method == "POST": - form = AdminTicketCategoryForm(request.POST, instance=category) - if form.is_valid(): - category = form.save() - messages.success( - request, - f"工单分类 {category.name} 更新成功", - ) - return redirect("admin:admin_tickets:category_list") - else: - form = AdminTicketCategoryForm(instance=category) - - context = { - "form": form, - "category": category, - "page_title": f"编辑分类 - {category.name}", - "active_nav": "ticket_categories", - "is_create": False, - } - - return render(request, "admin_base/tickets/category_form.html", context) - - -@admin_required -def admin_category_delete(request, pk): - """ - 超管删除工单分类 - - 无数据隔离,可删除所有分类。 - """ - site_group = getattr(request, "site_group", None) - category_filter = _category_filter_for_user(request.user, site_group) - if category_filter: - category = get_object_or_404( - TicketCategory.objects.filter(category_filter), - pk=pk, - ) - else: - category = get_object_or_404(TicketCategory, pk=pk) - - if request.method == "POST": - category_name = category.name - category.delete() - - messages.success( - request, - f"工单分类 {category_name} 已删除", - ) - return redirect("admin:admin_tickets:category_list") - - # 获取关联工单数 - ticket_count = Ticket.objects.filter(category=category).count() - - context = { - "category": category, - "ticket_count": ticket_count, - "page_title": f"删除分类 - {category.name}", - "active_nav": "ticket_categories", - } - - return render(request, "admin_base/tickets/category_confirm_delete.html", context) - - -# =========================================================================== -# 工单活动记录(只读) -# =========================================================================== - - -@admin_required -def admin_activity_list(request): - """ - 超管工单活动记录列表视图(只读) - - - 无数据隔离,查看所有活动记录 - - 支持按操作类型筛选、搜索 - """ - site_group = getattr(request, "site_group", None) - ticket_filter = _ticket_filter_for_user(request.user, site_group) - if ticket_filter: - queryset = ( - TicketActivity.objects.filter(ticket_filter) - .select_related( - "ticket", - "actor", - ) - .order_by("-created_at") - .distinct() - ) - else: - queryset = TicketActivity.objects.select_related( - "ticket", - "actor", - ).order_by("-created_at") - - # 操作类型筛选 - action_filter = request.GET.get("action", "").strip() - if action_filter: - queryset = queryset.filter(action=action_filter) - - # 搜索 - search = request.GET.get("search", "").strip() - if search: - queryset = queryset.filter( - Q(ticket__ticket_no__icontains=search) - | Q(actor__username__icontains=search) - | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(queryset, 20) - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - "activities": page_obj, - "search": search, - "action_filter": action_filter, - "action_choices": TicketActivity.ACTION_CHOICES, - "page_title": "活动日志", - "active_nav": "ticket_activities", - } - - return render(request, "admin_base/tickets/activity_list.html", context) diff --git a/apps/tickets/views_provider.py b/apps/tickets/views_provider.py deleted file mode 100644 index b98a9ff..0000000 --- a/apps/tickets/views_provider.py +++ /dev/null @@ -1,688 +0,0 @@ -""" -工单系统 - 提供商后台视图 - -包含数据隔离功能: -- TicketCategory: 按 created_by 过滤(新增提供商隔离) -- Ticket: 按关联产品/主机过滤 -- TicketComment: 按关联工单过滤 -- TicketActivity: 按关联工单过滤(只读) -- TicketAttachment: 按关联工单过滤 -""" - -import os - -from django.contrib import messages -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.http import FileResponse, Http404, HttpResponseForbidden -from django.shortcuts import get_object_or_404, redirect -from django.utils import timezone -from django.views import View -from django.views.generic import DetailView, TemplateView -from django.core.paginator import Paginator - -from utils.provider import is_provider -from apps.provider.context_mixin import ProviderContextMixin - -from .forms_provider import ( - TicketAttachmentForm, - TicketCategoryForm, - TicketCommentForm, -) -from .models import ( - Ticket, - TicketActivity, - TicketAttachment, - TicketCategory, - TicketComment, -) - -User = get_user_model() - - -# =========================================================================== -# 通用 Mixin -# =========================================================================== - - -class ProviderTicketMixin(ProviderContextMixin): - """ - 提供商工单数据隔离 Mixin - - - dispatch: 验证提供商身份 - - get_provider_ticket_queryset: 获取当前提供商可见的工单查询集 - - get_provider_category_queryset: 获取当前提供商创建的分类查询集 - """ - - provider_url_namespace = 'provider:provider_tickets' - - def dispatch(self, request, *args, **kwargs): - if not request.user.is_authenticated: - from django.contrib.auth.views import redirect_to_login - return redirect_to_login(request.get_full_path()) - if not is_provider(request.user): - return HttpResponseForbidden( - '您没有提供商权限,无法访问此页面。' - ) - return super().dispatch(request, *args, **kwargs) - - def get_provider_ticket_queryset(self): - """ - 获取当前提供商可见的工单查询集 - - 提供商可以看到: - - 关联云电脑所属产品由自己创建的工单 - """ - return Ticket.objects.filter( - Q(related_cloud_computer__product__created_by=self.request.user) - ).distinct().select_related( - 'category', 'creator', 'assignee', 'assigned_group', - 'related_cloud_computer', 'related_cloud_computer__product', - 'related_request', 'related_request__target_product', - ) - - def get_provider_category_queryset(self): - """ - 获取当前提供商创建的分类查询集 - - 新增提供商隔离:按 created_by 过滤 - """ - return TicketCategory.objects.filter( - created_by=self.request.user - ).order_by('display_order', 'name') - - def get_provider_activity_queryset(self): - """ - 获取当前提供商可见的活动记录查询集 - """ - return TicketActivity.objects.filter( - Q(ticket__related_cloud_computer__product__created_by=self.request.user) - ).distinct().select_related( - 'ticket', 'actor', - ).order_by('-created_at') - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['active_nav'] = 'tickets' - context['page_title'] = '工单管理' - return context - - -# =========================================================================== -# 工单分类管理 -# =========================================================================== - - -class TicketCategoryListView(ProviderTicketMixin, TemplateView): - """ - 工单分类列表视图 - - - 提供商数据隔离:只看到自己创建的分类(created_by=request.user) - - 支持搜索、分页 - """ - - template_name = 'admin_base/tickets/category_list.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - queryset = self.get_provider_category_queryset() - - # 搜索 - search = self.request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(name__icontains=search) - | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(queryset, 15) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context.update({ - 'page_obj': page_obj, - 'categories': page_obj, - 'search': search, - 'page_title': '工单分类', - 'active_nav': 'ticket_categories', - }) - return context - - -class TicketCategoryCreateView(ProviderTicketMixin, TemplateView): - """ - 工单分类创建视图 - - 自动设置 created_by 为当前用户。 - """ - - template_name = 'admin_base/tickets/category_form.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context.update({ - 'form': kwargs.get( - 'form', - TicketCategoryForm(), - ), - 'page_title': '创建工单分类', - 'active_nav': 'ticket_categories', - 'is_create': True, - }) - return context - - def post(self, request, *args, **kwargs): - form = TicketCategoryForm(request.POST) - if form.is_valid(): - category = form.save(commit=False) - category.created_by = request.user - category.save() - - messages.success( - request, - f'工单分类 {category.name} 创建成功', - ) - return redirect('provider_tickets:category_list') - - return self.render_to_response( - self.get_context_data(form=form) - ) - - -class TicketCategoryUpdateView(ProviderTicketMixin, TemplateView): - """ - 工单分类编辑视图 - - 提供商数据隔离:只能编辑自己创建的分类。 - """ - - template_name = 'admin_base/tickets/category_form.html' - - def get_category(self): - """获取当前编辑的分类,确保数据隔离""" - return get_object_or_404( - self.get_provider_category_queryset(), - pk=self.kwargs['pk'], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - category = self.get_category() - form = kwargs.get( - 'form', - TicketCategoryForm(instance=category), - ) - context.update({ - 'form': form, - 'category': category, - 'page_title': f'编辑分类 - {category.name}', - 'active_nav': 'ticket_categories', - 'is_create': False, - }) - return context - - def post(self, request, *args, **kwargs): - category = self.get_category() - form = TicketCategoryForm(request.POST, instance=category) - if form.is_valid(): - category = form.save() - messages.success( - request, - f'工单分类 {category.name} 更新成功', - ) - return redirect('provider_tickets:category_list') - - return self.render_to_response( - self.get_context_data(form=form) - ) - - -class TicketCategoryDeleteView(ProviderTicketMixin, TemplateView): - """ - 工单分类删除视图 - - 提供商数据隔离:只能删除自己创建的分类。 - """ - - template_name = 'admin_base/tickets/category_confirm_delete.html' - - def get_category(self): - return get_object_or_404( - self.get_provider_category_queryset(), - pk=self.kwargs['pk'], - ) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - category = self.get_category() - - # 获取关联工单数 - ticket_count = Ticket.objects.filter( - category=category - ).count() - - context.update({ - 'category': category, - 'ticket_count': ticket_count, - 'page_title': f'删除分类 - {category.name}', - 'active_nav': 'ticket_categories', - }) - return context - - def post(self, request, *args, **kwargs): - category = self.get_category() - category_name = category.name - category.delete() - - messages.success( - request, - f'工单分类 {category_name} 已删除', - ) - return redirect('provider_tickets:category_list') - - -# =========================================================================== -# 工单管理 -# =========================================================================== - - -class TicketListView(ProviderTicketMixin, TemplateView): - """ - 工单列表视图 - - - 提供商数据隔离:只看到关联自己产品/主机的工单 - - 支持状态筛选、搜索、批量操作 - """ - - template_name = 'admin_base/tickets/ticket_list.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - queryset = self.get_provider_ticket_queryset() - - # 状态筛选 - status_filter = self.request.GET.get('status', '').strip() - if status_filter: - queryset = queryset.filter(status=status_filter) - - # 优先级筛选 - priority_filter = self.request.GET.get('priority', '').strip() - if priority_filter: - queryset = queryset.filter(priority=priority_filter) - - # 搜索 - search = self.request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(ticket_no__icontains=search) - | Q(title__icontains=search) - | Q(description__icontains=search) - ) - - # 排序 - queryset = queryset.order_by('-created_at') - - # 分页 - paginator = Paginator(queryset, 20) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - # 统计各状态数量 - base_qs = self.get_provider_ticket_queryset() - status_counts = { - 'pending': base_qs.filter(status='pending').count(), - 'processing': base_qs.filter(status='processing').count(), - 'waiting_feedback': base_qs.filter( - status='waiting_feedback' - ).count(), - 'resolved': base_qs.filter(status='resolved').count(), - 'closed': base_qs.filter(status='closed').count(), - } - - context.update({ - 'page_obj': page_obj, - 'tickets': page_obj, - 'search': search, - 'status_filter': status_filter, - 'priority_filter': priority_filter, - 'status_counts': status_counts, - 'status_choices': Ticket.STATUS_CHOICES, - 'priority_choices': Ticket.PRIORITY_CHOICES, - 'page_title': '工单管理', - 'active_nav': 'tickets', - }) - return context - - -class TicketDetailView(ProviderTicketMixin, DetailView): - """ - 工单详情视图 - - 显示工单信息、评论列表、附件列表, - 支持添加评论和上传附件。 - """ - - template_name = 'admin_base/tickets/ticket_detail.html' - context_object_name = 'ticket' - pk_url_kwarg = 'pk' - - def get_queryset(self): - return self.get_provider_ticket_queryset() - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - ticket = self.object - - # 评论列表(提供商不可见内部备注) - comments = ticket.comments.filter( - is_internal=False - ).select_related('author').order_by('created_at') - - # 附件列表 - attachments = ticket.attachments.select_related( - 'uploaded_by' - ).order_by('-created_at') - - # 活动记录 - activities = ticket.activities.select_related( - 'actor' - ).order_by('-created_at')[:10] - - context.update({ - 'comments': comments, - 'attachments': attachments, - 'activities': activities, - 'comment_form': TicketCommentForm(), - 'attachment_form': TicketAttachmentForm(), - 'page_title': f'工单 {ticket.ticket_no}', - 'active_nav': 'tickets', - }) - return context - - -# =========================================================================== -# 工单批量操作 -# =========================================================================== - - -def _get_selected_ids(request): - """从 POST 请求中获取选中的工单 ID 列表""" - selected = request.POST.getlist('selected_ids') - return [int(pk) for pk in selected if pk.isdigit()] - - -class TicketBatchProcessingView(ProviderTicketMixin, View): - """ - 批量标记工单为处理中 (POST) - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何工单。') - return redirect('provider_tickets:ticket_list') - - qs = self.get_provider_ticket_queryset().filter( - pk__in=selected_ids, - status='pending', - ) - - updated_count = 0 - for ticket in qs: - ticket.status = 'processing' - ticket.assignee = request.user - ticket._current_user = request.user - ticket.save(update_fields=['status', 'assignee', 'updated_at']) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f'成功将 {updated_count} 个工单标记为处理中。', - ) - else: - messages.warning( - request, - '没有可标记为处理中的工单。', - ) - - return redirect('provider_tickets:ticket_list') - - -class TicketBatchResolvedView(ProviderTicketMixin, View): - """ - 批量标记工单为已解决 (POST) - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何工单。') - return redirect('provider_tickets:ticket_list') - - qs = self.get_provider_ticket_queryset().filter( - pk__in=selected_ids, - status__in=['pending', 'processing', 'waiting_feedback'], - ) - - updated_count = 0 - now = timezone.now() - for ticket in qs: - ticket.status = 'resolved' - ticket.resolved_at = now - ticket._current_user = request.user - ticket.save( - update_fields=['status', 'resolved_at', 'updated_at'] - ) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f'成功将 {updated_count} 个工单标记为已解决。', - ) - else: - messages.warning( - request, - '没有可标记为已解决的工单。', - ) - - return redirect('provider_tickets:ticket_list') - - -class TicketBatchClosedView(ProviderTicketMixin, View): - """ - 批量关闭工单 (POST) - """ - - def post(self, request): - selected_ids = _get_selected_ids(request) - if not selected_ids: - messages.warning(request, '未选择任何工单。') - return redirect('provider_tickets:ticket_list') - - qs = self.get_provider_ticket_queryset().filter( - pk__in=selected_ids, - ).exclude(status='closed') - - updated_count = 0 - now = timezone.now() - for ticket in qs: - ticket.status = 'closed' - ticket.closed_at = now - ticket._current_user = request.user - ticket.save( - update_fields=['status', 'closed_at', 'updated_at'] - ) - updated_count += 1 - - if updated_count > 0: - messages.success( - request, - f'成功关闭了 {updated_count} 个工单。', - ) - else: - messages.warning( - request, - '没有可关闭的工单。', - ) - - return redirect('provider_tickets:ticket_list') - - -# =========================================================================== -# 工单评论 -# =========================================================================== - - -class TicketCommentCreateView(ProviderTicketMixin, View): - """ - 添加工单评论 (POST) - - 提供商添加的评论自动标记作者为当前用户。 - """ - - def post(self, request, pk): - ticket = get_object_or_404( - self.get_provider_ticket_queryset(), - pk=pk, - ) - - form = TicketCommentForm(request.POST) - if form.is_valid(): - comment = form.save(commit=False) - comment.ticket = ticket - comment.author = request.user - comment.save() - - messages.success(request, '评论已添加。') - else: - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, error) - - return redirect('provider_tickets:ticket_detail', pk=ticket.pk) - - -# =========================================================================== -# 工单活动记录(独立只读页面) -# =========================================================================== - - -class TicketActivityListView(ProviderTicketMixin, TemplateView): - """ - 工单活动记录列表视图(独立只读页面) - - - 提供商数据隔离:只看到关联自己工单的活动 - - 支持按操作类型筛选、搜索 - - 只读,无增删改操作 - """ - - template_name = 'admin_base/tickets/activity_list.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - user = self.request.user - - queryset = self.get_provider_activity_queryset() - - # 操作类型筛选 - action_filter = self.request.GET.get('action', '').strip() - if action_filter: - queryset = queryset.filter(action=action_filter) - - # 搜索 - search = self.request.GET.get('search', '').strip() - if search: - queryset = queryset.filter( - Q(ticket__ticket_no__icontains=search) - | Q(actor__username__icontains=search) - | Q(description__icontains=search) - ) - - # 分页 - paginator = Paginator(queryset, 20) - page_number = self.request.GET.get('page', 1) - page_obj = paginator.get_page(page_number) - - context.update({ - 'page_obj': page_obj, - 'activities': page_obj, - 'search': search, - 'action_filter': action_filter, - 'action_choices': TicketActivity.ACTION_CHOICES, - 'page_title': '活动日志', - 'active_nav': 'activity_log', - }) - return context - - -# =========================================================================== -# 工单附件 -# =========================================================================== - - -class TicketAttachmentUploadView(ProviderTicketMixin, View): - """ - 上传工单附件 (POST) - """ - - def post(self, request, ticket_pk): - ticket = get_object_or_404( - self.get_provider_ticket_queryset(), - pk=ticket_pk, - ) - - form = TicketAttachmentForm(request.POST, request.FILES) - if form.is_valid(): - attachment = form.save(commit=False) - attachment.ticket = ticket - attachment.uploaded_by = request.user - if not attachment.filename: - attachment.filename = os.path.basename( - attachment.file.name - ) - attachment.save() - - messages.success(request, '附件上传成功。') - else: - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, error) - - return redirect('provider_tickets:ticket_detail', pk=ticket.pk) - - -class TicketAttachmentDownloadView(ProviderTicketMixin, View): - """ - 下载工单附件 - """ - - def get(self, request, pk): - attachment = get_object_or_404(TicketAttachment, pk=pk) - - # 验证提供商是否有权访问此附件所属工单 - ticket = get_object_or_404( - self.get_provider_ticket_queryset(), - pk=attachment.ticket_id, - ) - - if not attachment.file: - raise Http404('附件文件不存在') - - try: - file_handle = attachment.file.open('rb') - except FileNotFoundError: - raise Http404('附件文件不存在') - - response = FileResponse( - file_handle, - as_attachment=True, - filename=attachment.filename, - ) - return response diff --git a/apps/tunnel/__init__.py b/apps/tunnel/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/tunnel/admin.py b/apps/tunnel/admin.py deleted file mode 100644 index b3cef5e..0000000 --- a/apps/tunnel/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 隧道系统无需后台管理 diff --git a/apps/tunnel/apps.py b/apps/tunnel/apps.py deleted file mode 100644 index 65aed11..0000000 --- a/apps/tunnel/apps.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.apps import AppConfig - - -class TunnelConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.tunnel' - verbose_name = '隧道管理' diff --git a/apps/tunnel/migrations/__init__.py b/apps/tunnel/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/tunnel/models.py b/apps/tunnel/models.py deleted file mode 100644 index 71a8362..0000000 --- a/apps/tunnel/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/apps/tunnel/tests_dir/__init__.py b/apps/tunnel/tests_dir/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/tunnel/urls.py b/apps/tunnel/urls.py deleted file mode 100644 index c98e925..0000000 --- a/apps/tunnel/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.urls import path -from . import views - -app_name = 'tunnel' - -urlpatterns = [ - path('download/', views.download_tunnel_client, name='download'), - path('config/', views.get_tunnel_config, name='config'), - path('install/', views.install_tunnel_service, name='install'), -] diff --git a/apps/tunnel/views.py b/apps/tunnel/views.py deleted file mode 100644 index a96f346..0000000 --- a/apps/tunnel/views.py +++ /dev/null @@ -1,260 +0,0 @@ -import os -import logging -import secrets -import requests -import time -from django.http import JsonResponse, FileResponse, Http404 -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_http_methods -from django.conf import settings -from django.utils import timezone -from django.contrib.auth.decorators import login_required, permission_required -from django.core.cache import cache -from utils.helpers import get_client_ip - -logger = logging.getLogger(__name__) - -TUNNEL_RELEASES_URL = os.environ.get( - 'TUNNEL_RELEASES_URL', - 'https://api.github.com/repos/2c2a/tunnel/releases/latest' -) -TUNNEL_DOWNLOAD_DIR = os.path.join(settings.MEDIA_ROOT, 'tunnel_clients') - - -def _tunnel_rate_limit(key_prefix, rate='10/m'): - limit, period = rate.lower().split('/') - limit = int(limit) - period_map = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400} - period_seconds = period_map.get(period, 60) - - def decorator(view_func): - def wrapper(request, *args, **kwargs): - ip = get_client_ip(request) - window = int(time.time() // period_seconds) - cache_key = f'rl:{key_prefix}:{ip}:{window}' - current = cache.get(cache_key, 0) - if current >= limit: - return JsonResponse( - {'success': False, 'error': 'Too many requests'}, - status=429, - ) - cache.set(cache_key, current + 1, timeout=period_seconds + 1) - return view_func(request, *args, **kwargs) - wrapper.__name__ = view_func.__name__ - return wrapper - return decorator - - -@csrf_exempt -@require_http_methods(["GET"]) -@_tunnel_rate_limit('tunnel_download', '5/m') -def download_tunnel_client(request): - """ - 下载tunnel客户端 - 支持从GitHub Release下载或从本地存储下载 - """ - try: - arch = request.GET.get('arch', 'amd64') - if arch not in ['amd64', 'arm64']: - return JsonResponse({ - 'success': False, - 'error': 'Invalid architecture. Use amd64 or arm64' - }, status=400) - - filename = f'2c2a-tunnel-windows-{arch}.exe' - local_path = os.path.join(TUNNEL_DOWNLOAD_DIR, filename) - - if os.path.exists(local_path): - return FileResponse( - open(local_path, 'rb'), - as_attachment=True, - filename=filename - ) - - try: - response = requests.get(TUNNEL_RELEASES_URL, timeout=10) - response.raise_for_status() - release_data = response.json() - - download_url = None - for asset in release_data.get('assets', []): - if asset['name'] == filename: - download_url = asset['browser_download_url'] - break - - if not download_url: - return JsonResponse({ - 'success': False, - 'error': f'Tunnel client not found for architecture: {arch}' - }, status=404) - - download_response = requests.get(download_url, stream=True, timeout=60) - download_response.raise_for_status() - - os.makedirs(TUNNEL_DOWNLOAD_DIR, exist_ok=True) - - with open(local_path, 'wb') as f: - for chunk in download_response.iter_content(chunk_size=8192): - f.write(chunk) - - return FileResponse( - open(local_path, 'rb'), - as_attachment=True, - filename=filename - ) - - except requests.RequestException as e: - logger.error(f"Failed to download tunnel client: {str(e)}") - return JsonResponse({ - 'success': False, - 'error': 'Failed to download tunnel client from GitHub' - }, status=503) - - except Exception as e: - logger.error(f"Error in download_tunnel_client: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Internal server error' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -@_tunnel_rate_limit('tunnel_config', '10/m') -def get_tunnel_config(request): - """ - 获取tunnel配置 - 需要验证session_token,返回tunnel_token和gateway地址 - """ - try: - import json - from apps.bootstrap.models import ActiveSession - from apps.hosts.models import Host - - data = json.loads(request.body.decode('utf-8')) - session_token = data.get('session_token') - - if not session_token: - return JsonResponse({ - 'success': False, - 'error': 'session_token is required' - }, status=400) - - try: - active_session = ActiveSession.objects.get( - session_token=session_token, - expires_at__gt=timezone.now() - ) - except ActiveSession.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired session token' - }, status=401) - - client_ip = get_client_ip(request) - if active_session.bound_ip != client_ip: - return JsonResponse({ - 'success': False, - 'error': 'IP address mismatch' - }, status=403) - - host = active_session.host - - if not host.tunnel_token: - host.tunnel_token = secrets.token_urlsafe(32) - host.connection_type = 'tunnel' - host.tunnel_status = 'offline' - host.save(update_fields=[ - 'tunnel_token', 'connection_type', 'tunnel_status' - ]) - - gateway_url = os.environ.get( - 'TUNNEL_GATEWAY_URL', - 'wss://gateway.2c2a.com:9000' - ) - - return JsonResponse({ - 'success': True, - 'data': { - 'tunnel_token': host.tunnel_token, - 'gateway_url': gateway_url, - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error in get_tunnel_config: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to get tunnel config' - }, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -@_tunnel_rate_limit('tunnel_install', '5/m') -def install_tunnel_service(request): - """ - 一键安装tunnel服务 - 接收session_token,自动下载、配置并安装tunnel服务 - """ - try: - import json - import subprocess - import tempfile - from apps.bootstrap.models import ActiveSession - - data = json.loads(request.body.decode('utf-8')) - session_token = data.get('session_token') - arch = data.get('arch', 'amd64') - - if not session_token: - return JsonResponse({ - 'success': False, - 'error': 'session_token is required' - }, status=400) - - try: - active_session = ActiveSession.objects.get( - session_token=session_token, - expires_at__gt=timezone.now() - ) - except ActiveSession.DoesNotExist: - return JsonResponse({ - 'success': False, - 'error': 'Invalid or expired session token' - }, status=401) - - config_response = get_tunnel_config(request) - if config_response.status_code != 200: - return config_response - - config_data = json.loads(config_response.content) - tunnel_token = config_data['data']['tunnel_token'] - gateway_url = config_data['data']['gateway_url'] - - return JsonResponse({ - 'success': True, - 'data': { - 'message': 'Tunnel service installation initiated', - 'tunnel_token': tunnel_token, - 'gateway_url': gateway_url, - 'install_command': f'2c2a-tunnel.exe install -token {tunnel_token} -server {gateway_url}' - } - }) - - except json.JSONDecodeError: - return JsonResponse({ - 'success': False, - 'error': 'Invalid JSON in request body' - }, status=400) - except Exception as e: - logger.error(f"Error in install_tunnel_service: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': 'Failed to install tunnel service' - }, status=500) diff --git a/celerybeat-schedule b/celerybeat-schedule deleted file mode 100644 index 4abb835..0000000 Binary files a/celerybeat-schedule and /dev/null differ diff --git a/config/__init__.py b/config/__init__.py deleted file mode 100755 index 9d284a2..0000000 --- a/config/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -2c2a配置模块 -""" -from .celery import app as celery_app # noqa: E402,F401 - -__all__ = ('celery_app',) diff --git a/config/celery.py b/config/celery.py deleted file mode 100755 index 825880a..0000000 --- a/config/celery.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -from celery import Celery -from celery.schedules import crontab - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - -app = Celery('2c2a') -app.config_from_object('django.conf:settings', namespace='CELERY') - -from django.conf import settings # noqa: E402 - -_redis_url = getattr(settings, 'REDIS_URL', '') -_base_dir = str(settings.BASE_DIR) - -if not app.conf.broker_url: - _broker = getattr(settings, 'CELERY_BROKER_URL', None) - if _broker: - app.conf.broker_url = _broker - elif _redis_url: - app.conf.broker_url = _redis_url.replace('/0', '/1') - else: - app.conf.broker_url = ( - f'sqla+sqlite:///{_base_dir}/celery_broker.sqlite3' - ) - -if not app.conf.result_backend: - _result = getattr(settings, 'CELERY_RESULT_BACKEND', None) - if _result: - app.conf.result_backend = _result - elif _redis_url: - app.conf.result_backend = _redis_url.replace('/0', '/2') - else: - app.conf.result_backend = ( - f'db+sqlite:///{_base_dir}/celery_results.sqlite3' - ) - -app.autodiscover_tasks() - -app.conf.update( - task_serializer='json', - accept_content=['json'], - result_serializer='json', - timezone='UTC', - enable_utc=True, - broker_connection_retry_on_startup=True, -) - -app.conf.task_routes = { - 'certificates.tasks.*': {'queue': 'certificates'}, - 'hosts.tasks.*': {'queue': 'hosts'}, - 'operations.tasks.*': {'queue': 'operations'}, - 'bootstrap.tasks.*': {'queue': 'bootstrap'}, - 'plugins.beta_push.tasks.*': {'queue': 'beta_push'}, - 'accounts.tasks.*': {'queue': 'accounts'}, -} - -app.conf.task_default_retry_delay = 30 -app.conf.task_max_retries = 3 - -app.conf.CELERY_BEAT_SCHEDULE = { - 'cleanup-expired-provision-tokens': { - 'task': 'apps.bootstrap.tasks.cleanup_expired_provision_tokens', - 'schedule': crontab(hour='0', minute='0'), - }, - 'cleanup-unactivated-certificates': { - 'task': 'apps.bootstrap.tasks.cleanup_unactivated_certificates', - 'schedule': crontab(hour='0', minute='0'), - }, - 'cleanup-orphan-cert-dirs': { - 'task': 'apps.bootstrap.tasks.cleanup_orphan_cert_dirs', - 'schedule': crontab(hour='0', minute='0'), - }, -} diff --git a/config/demo_middleware.py b/config/demo_middleware.py deleted file mode 100755 index 4b5dec4..0000000 --- a/config/demo_middleware.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -DEMO模式中间件 -用于处理演示模式下的特殊逻辑 -""" -import os -import secrets as _secrets -from django.http import JsonResponse -from django.shortcuts import redirect -from django.contrib.auth import get_user_model -from django.contrib.auth.models import Permission -from django.contrib.contenttypes.models import ContentType -from apps.accounts.models import User -from apps.operations.models import AccountOpeningRequest, CloudComputerUser, Product -from apps.hosts.models import Host - - -class DemoModeMiddleware: - """ - DEMO模式中间件,处理演示环境的特殊逻辑 - """ - def __init__(self, get_response): - self.get_response = get_response - self.demo_mode = os.environ.get('2C2A_DEMO', '').lower() == '1' - if self.demo_mode: - self.setup_demo_users() - - def __call__(self, request): - if not self.demo_mode: - return self.get_response(request) - - # 在DEMO模式下设置特殊标志 - request.is_demo_mode = True - - response = self.get_response(request) - - return response - - def process_view(self, request, view_func, view_args, view_kwargs): - if not self.demo_mode: - return None - - # 为DEMO模式处理特定的视图逻辑 - # 检查是否是发送邮件的视图 - if hasattr(view_func, '__name__') and 'send_' in view_func.__name__ and 'email' in view_func.__name__: - # 模拟发送邮件成功,但实际上不发送 - if request.method == 'POST': - # 这里我们模拟一个成功响应 - return JsonResponse({'status': 'ok'}) - - # 检查是否是密码修改相关的视图 - if (hasattr(view_func, '__name__') and - ('password' in view_func.__name__.lower() or 'change' in view_func.__name__.lower()) and - any(pwd_keyword in request.path.lower() for pwd_keyword in ['password', 'pwd']) and - 'get-password' not in request.path.lower()): # 排除获取密码的阅后即焚功能 - # 在DEMO模式下,不允许修改密码 - if request.method == 'POST': - from django.contrib import messages - messages.error(request, 'DEMO模式下不允许修改密码') - # 重定向到profile页面或返回错误 - from django.shortcuts import redirect - referer = request.META.get('HTTP_REFERER', '/') - return redirect(referer) - - # 检查Django Admin密码更改URL - if ('/admin/password_change/' in request.path or - ('/admin/auth/user/' in request.path and 'password' in request.path)): - if request.method == 'POST': - from django.contrib import messages - messages.error(request, 'DEMO模式下不允许修改密码') - from django.shortcuts import redirect - referer = request.META.get('HTTP_REFERER', '/admin/') - return redirect(referer) - - # 检查所有可能的密码更改路径 - if ('password' in request.path.lower() and - ('change' in request.path.lower() or 'update' in request.path.lower()) and - 'get-password' not in request.path.lower()): # 排除获取密码的阅后即焚功能 - if request.method == 'POST': - from django.contrib import messages - messages.error(request, 'DEMO模式下不允许修改密码') - from django.shortcuts import redirect - referer = request.META.get('HTTP_REFERER', '/') - return redirect(referer) - - return None - - def setup_demo_users(self): - """ - 创建DEMO模式下的用户 - """ - User = get_user_model() - - # 创建User用户 - user, created = User.objects.get_or_create( - username='User', - defaults={ - 'email': 'user@example.com', - 'first_name': 'Demo', - 'last_name': 'User', - 'is_active': True, - } - ) - if created: - user.set_password(os.environ.get('2C2A_DEMO_USER_PASSWORD', _secrets.token_urlsafe(16))) - user.save() - - # 创建Admin用户 - admin, created = User.objects.get_or_create( - username='Admin', - defaults={ - 'email': 'admin@example.com', - 'first_name': 'Demo', - 'last_name': 'Admin', - 'is_staff': True, - 'is_active': True, - } - ) - if created: - admin.set_password(os.environ.get('2C2A_DEMO_ADMIN_PASSWORD', _secrets.token_urlsafe(16))) - # 分配特定权限 - self.assign_demo_permissions(admin) - admin.save() - - def assign_demo_permissions(self, user): - """ - 为DEMO模式下的Admin用户分配特定权限 - """ - permissions = [ - # View登录日志 - ('accounts', 'loginlog', 'view'), - # View开户申请 - ('operations', 'accountopeningrequest', 'view'), - # Change开户申请 - ('operations', 'accountopeningrequest', 'change'), - # View云电脑用户 - ('operations', 'cloudcomputeruser', 'view'), - # Change云电脑用户 - ('operations', 'cloudcomputeruser', 'change'), - # View产品 - ('operations', 'product', 'view'), - ] - - for app_label, model, perm_action in permissions: - try: - content_type = ContentType.objects.get(app_label=app_label, model=model) - permission_codename = f'{perm_action}_{model}' - permission = Permission.objects.get(content_type=content_type, codename=permission_codename) - user.user_permissions.add(permission) - except ContentType.DoesNotExist: - print(f"ContentType not found: {app_label}.{model}") - except Permission.DoesNotExist: - print(f"Permission not found: {app_label}.{model}.{perm_action}") - - -def is_demo_mode(): - """ - 检查是否处于DEMO模式 - """ - return os.environ.get('2C2A_DEMO', '').lower() == '1' \ No newline at end of file diff --git a/config/demo_startup.py b/config/demo_startup.py deleted file mode 100755 index b2a6cba..0000000 --- a/config/demo_startup.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -DEMO模式启动脚本 -""" -import os -from django.conf import settings -from django.core.management.color import color_style - - -def show_demo_startup_message(): - """ - 显示DEMO模式启动提示信息 - """ - if os.environ.get('2C2A_DEMO', '').lower() != '1': - return - - style = color_style() - - demo_message = """ -******************************************************************************** -* 2C2A DEMO MODE ACTIVATED * -******************************************************************************** -* * -* 当前系统运行在DEMO模式下,具有以下特性: * -* * -* 🔐 数据库: 使用 DEMO.sqlite3 (数据不会持久保存) * -* 👤 预设用户: * -* - 用户名: User, 密码: demo_user_password * -* - 用户名: Admin, 密码: demo_admin_password * -* - 用户名: SuperAdmin, 密码: DemoSuperAdmin123! (如果有创建) * -* 🛠️ 所有主机始终显示为在线状态 * -* 📧 邮件发送功能被模拟(不会实际发送邮件) * -* 🚀 WinRM指令不会实际执行(仅模拟) * -* 🔐 忽略密码复杂度要求 * -* * -* 💡 提示: 在DEMO模式下,您可以自由测试所有功能而不影响实际系统 * -******************************************************************************** -""" - - print(style.HTTP_INFO(demo_message)) \ No newline at end of file diff --git a/config/local_lock_middleware.py b/config/local_lock_middleware.py deleted file mode 100644 index 4a8e91e..0000000 --- a/config/local_lock_middleware.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -本地访问限制中间件 -当 SystemConfig.local_access_locked 启用时, -静默关闭来自 localhost/127.0.0.1 的连接 -""" -import logging -from django.http import HttpResponse - -logger = logging.getLogger('2c2a') - -LOCAL_IPS = frozenset({ - '127.0.0.1', - '::1', - '0.0.0.0', - '0000:0000:0000:0000:0000:0000:0000:0001', -}) - -LOCAL_HOSTNAMES = frozenset({ - 'localhost', -}) - - -class LocalLockMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - remote_addr = request.META.get('REMOTE_ADDR', '') - server_name = request.META.get('SERVER_NAME', '') - - is_local = ( - remote_addr in LOCAL_IPS - or remote_addr.lower() in LOCAL_HOSTNAMES - or server_name.lower() in LOCAL_HOSTNAMES - ) - - if is_local: - try: - from apps.dashboard.models import SystemConfig - config = SystemConfig.get_config() - if config.local_access_locked: - excluded_paths = ['/static/', '/media/'] - if not any( - request.path.startswith(p) - for p in excluded_paths - ): - logger.warning( - '本地访问已禁止,关闭来自 %s 的连接: %s', - remote_addr, request.path, - ) - return HttpResponse(status=403) - except Exception: - logger.exception( - 'LocalLockMiddleware 检查异常,默认拒绝' - ) - return HttpResponse(status=403) - - return self.get_response(request) diff --git a/config/maintenance_middleware.py b/config/maintenance_middleware.py deleted file mode 100755 index 9bad483..0000000 --- a/config/maintenance_middleware.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -维护模式中间件 -当REPAIRING环境变量为1时,将所有请求重定向到维护页面 -""" -import os -from django.shortcuts import render -from django.urls import reverse -from django.http import HttpResponseRedirect - - -class MaintenanceModeMiddleware: - """ - 维护模式中间件 - 当REPAIRING环境变量设置为1时,将所有请求重定向到维护页面 - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - # 检查是否处于维护模式 - repairing = os.environ.get('REPAIRING', '0') - - # 排除维护页面本身和静态资源,避免无限重定向 - excluded_paths = [ - '/maintenance/', - '/static/', - '/media/', - ] - - # 如果处于维护模式且不在排除路径中,则重定向到维护页面 - if (repairing.lower() == '1' or repairing == 'true' or repairing == 'on' or repairing == 'yes' or repairing == 'enabled') and \ - not any(request.path.startswith(path) for path in excluded_paths): - - # 检查请求是否是AJAX请求,如果是则返回JSON错误而不是重定向 - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - from django.http import JsonResponse - return JsonResponse({ - 'error': '系统正在维护中,请稍后再试', - 'maintenance': True - }, status=503) - - # 对于非AJAX请求,渲染维护页面 - return render(request, 'maintenance.html') - - response = self.get_response(request) - return response \ No newline at end of file diff --git a/config/management/commands/init_demo.py b/config/management/commands/init_demo.py deleted file mode 100755 index fc89e3f..0000000 --- a/config/management/commands/init_demo.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -初始化DEMO环境的管理命令 -""" -from django.core.management.base import BaseCommand -from django.core.management import call_command -from django.db import DEFAULT_DB_ALIAS -import os - - -class Command(BaseCommand): - help = '初始化DEMO环境,包括数据库和用户' - - def add_arguments(self, parser): - parser.add_argument( - '--force', - action='store_true', - help='强制重新初始化DEMO环境(删除现有DEMO数据库)', - ) - - def handle(self, *args, **options): - # 检查是否在DEMO模式下运行 - if os.environ.get('2C2A_DEMO', '').lower() != '1': - self.stdout.write( - self.style.ERROR('请设置 2C2A_DEMO=1 环境变量以运行DEMO模式') - ) - return - - import shutil - from pathlib import Path - from django.conf import settings - - demo_db_path = settings.BASE_DIR / 'DEMO.sqlite3' - - if demo_db_path.exists() and not options['force']: - self.stdout.write( - self.style.WARNING(f'DEMO数据库已存在: {demo_db_path}') - + '\n使用 --force 参数强制重新初始化' - ) - return - - # 删除现有的DEMO数据库 - if demo_db_path.exists(): - demo_db_path.unlink() - self.stdout.write( - self.style.SUCCESS('已删除现有DEMO数据库') - ) - - # 运行迁移 - self.stdout.write('正在运行数据库迁移...') - call_command('migrate', verbosity=1, interactive=False, database=DEFAULT_DB_ALIAS) - - # 创建DEMO用户 - self.stdout.write('正在创建DEMO用户...') - call_command('setup_demo_users', verbosity=1) - - # 创建一些示例数据 - self.create_demo_data() - - self.stdout.write( - self.style.SUCCESS('DEMO环境初始化完成!') - ) - self.stdout.write('用户名: User, 密码: demo_user_password') - self.stdout.write('管理员: Admin, 密码: demo_admin_password') - - def create_demo_data(self): - """创建DEMO环境的示例数据""" - from apps.accounts.models import LoginLog - from apps.operations.models import Product, AccountOpeningRequest - from django.contrib.auth import get_user_model - from django.utils import timezone - import random - - User = get_user_model() - - # 获取DEMO用户 - try: - user = User.objects.get(username='User') - admin = User.objects.get(username='Admin') - except User.DoesNotExist: - return # 如果用户不存在,则跳过示例数据创建 - - # 创建一些示例登录日志 - for i in range(5): - LoginLog.objects.get_or_create( - user=user, - ip_address=f'192.168.1.{random.randint(1, 100)}', - user_agent='Mozilla/5.0 (DEMO Mode)', - login_type='web', - status='success', - created_at=timezone.now() - ) - - # 创建示例产品 - from apps.hosts.models import Host - # 创建一个示例主机 - demo_host, created = Host.objects.get_or_create( - name='DEMO主机', - hostname='demo.example.com', - username='demo', - host_type='server', - defaults={ - 'port': 5985, - 'description': 'DEMO模式下的示例主机', - 'status': 'online', # 在DEMO模式下总是在线 - } - ) - demo_host.password = 'demo_password' - demo_host.save() - - # 创建示例产品 - demo_product, created = Product.objects.get_or_create( - name='DEMO产品', - display_name='DEMO云电脑', - description='DEMO模式下的示例产品', - display_description='DEMO云电脑产品', - host=demo_host, - defaults={ - 'rdp_port': 3389, - 'display_hostname': 'demo.example.com', - 'is_available': True, - } - ) - - # 创建示例开户申请 - for i in range(3): - AccountOpeningRequest.objects.get_or_create( - applicant=admin, - contact_email=f'user{i}@demo.com', - username=f'demo_user_{i}', - user_fullname=f'DEMO User {i}', - user_email=f'user{i}@demo.com', - target_product=demo_product, - defaults={ - 'status': random.choice(['pending', 'approved', 'completed']), - 'user_description': f'DEMO用户 {i} 的描述', - } - ) \ No newline at end of file diff --git a/config/security_middleware.py b/config/security_middleware.py deleted file mode 100644 index 05c7e33..0000000 --- a/config/security_middleware.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.conf import settings - -class SecurityHeadersMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - - if not settings.DEBUG: - csp_parts = [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' 'unsafe-eval' " - "https://static.2c2a.cc.cd", - "style-src 'self' 'unsafe-inline' " - "https://static.2c2a.cc.cd", - "img-src 'self' data: blob: " - "https://static.2c2a.cc.cd", - "font-src 'self' " - "https://static.2c2a.cc.cd", - "connect-src 'self' wss://rdp.2c2a.com ws://rdp.2c2a.com", - "frame-ancestors 'none'", - "base-uri 'self'", - "form-action 'self'", - ] - response['Content-Security-Policy'] = '; '.join(csp_parts) - - response['Permissions-Policy'] = ( - 'geolocation=(), microphone=(), camera=(), ' - 'payment=(), usb=(), magnetometer=(), gyroscope=(), ' - 'accelerometer=()' - ) - - return response diff --git a/config/settings.py b/config/settings.py deleted file mode 100644 index 438480b..0000000 --- a/config/settings.py +++ /dev/null @@ -1,589 +0,0 @@ -""" -Django settings for 2c2a project. - -配置加载优先级(从高到低): -1. 环境变量(os.environ)- 方便 DEMO 配置和容器部署 -2. .env 文件 - 本地开发配置 -3. 默认值 - 确保基本可用 - -DEMO 模式(2C2A_DEMO=1)会强制锁定特定配置,不受 .env 影响。 -""" - -import os -import importlib -from pathlib import Path -from django.core.exceptions import ImproperlyConfigured - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - -# ========== .env 文件加载 ========== -# 优先级:环境变量 > .env 文件 > 默认值 -# 使用 python-dotenv 加载 .env,但不覆盖已存在的环境变量 -from dotenv import load_dotenv - -ENV_FILE = BASE_DIR / '.env' -if ENV_FILE.exists(): - load_dotenv(dotenv_path=ENV_FILE, override=False) - - -def _env(key, default=None): - """ - 读取配置的统一入口。 - 优先级:环境变量 > .env 文件 > 默认值 - """ - return os.environ.get(key, default) - - -# ========== 核心配置(必须在初始化时定义) ========== - -# Database -# https://docs.djangoproject.com/en/4.2/ref/settings/#databases -DB_ENGINE = _env('DB_ENGINE', 'sqlite').lower() - -if DB_ENGINE == 'mysql': - import pymysql - pymysql.install_as_MySQLdb() - - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.mysql', - 'NAME': _env('DB_NAME', '2c2a'), - 'USER': _env('DB_USER', 'root'), - 'PASSWORD': _env('DB_PASSWORD', ''), - 'HOST': _env('DB_HOST', '127.0.0.1'), - 'PORT': _env('DB_PORT', '3306'), - 'CONN_MAX_AGE': int(_env('DB_CONN_MAX_AGE', '60')), - 'OPTIONS': { - 'charset': 'utf8mb4', - 'init_command': ( - "SET sql_mode='STRICT_TRANS_TABLES'" - ), - }, - } - } -elif DB_ENGINE == 'postgresql': - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': _env('DB_NAME', '2c2a'), - 'USER': _env('DB_USER', 'postgres'), - 'PASSWORD': _env('DB_PASSWORD', ''), - 'HOST': _env('DB_HOST', '127.0.0.1'), - 'PORT': _env('DB_PORT', '5432'), - 'CONN_MAX_AGE': int(_env('DB_CONN_MAX_AGE', '60')), - } - } -else: - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } - } - -# SECURITY WARNING: keep the secret key used in production secret! -_INSECURE_SECRET_KEY = 'django-insecure-change-this-in-production' -SECRET_KEY = _env('DJANGO_SECRET_KEY', _INSECURE_SECRET_KEY) - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = _env('DEBUG', 'False').lower() == 'true' - -if SECRET_KEY == _INSECURE_SECRET_KEY and not DEBUG: - raise ImproperlyConfigured( - 'DJANGO_SECRET_KEY 环境变量必须设置,不允许在生产环境使用默认不安全密钥' - ) - -# 允许的主机列表 -# 在DEBUG模式下,允许所有主机 -if DEBUG: - ALLOWED_HOSTS = _env('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') - CSRF_TRUSTED_ORIGINS = [ - 'http://localhost', - 'http://127.0.0.1', - 'https://localhost', - 'https://127.0.0.1', - 'https://demo.supercmd.dpdns.org', - 'https://2c2a.supercmd.dpdns.org', - ] -else: - ALLOWED_HOSTS = _env('ALLOWED_HOSTS', '').split(',') - CSRF_TRUSTED_ORIGINS = _env('CSRF_TRUSTED_ORIGINS', 'https://localhost,https://127.0.0.1').split(',') - _ALLOWED_HOSTS_ENV = _env('ALLOWED_HOSTS', '') - if not _ALLOWED_HOSTS_ENV or _ALLOWED_HOSTS_ENV == 'localhost,127.0.0.1': - raise ImproperlyConfigured( - 'ALLOWED_HOSTS 环境变量必须在生产环境中显式配置为实际域名' - ) - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - - # 第三方应用 - 'rest_framework', - 'corsheaders', - - # 模板组件框架(必须在本地应用之前,确保 cotton 模板优先发现) - 'django_cotton', - - # 本地应用 - 'django_tianai_captcha', - - 'apps.accounts', - 'apps.hosts', - 'apps.operations', - 'apps.dashboard', - 'apps.certificates', - 'apps.bootstrap', # 主机引导系统 - 'apps.audit', - 'apps.tasks', - 'apps.themes', # 主题系统 - 'apps.tunnel', # 隧道管理 - 'apps.tickets', # 工单系统 - 'apps.provider', # 提供商后台(新版 Tailwind/MD3) - 'apps.provider_backend', # 提供商后台(旧版,保留中间件和API) - 'plugins', -] - -# ========== 插件 Django App 动态注册 ========== -# 从 plugins.toml 读取需要注册为 Django App 的插件模块 -# 这样插件不存在时系统仍能正常启动(松耦合) -def _discover_plugin_apps(): - plugin_apps = [] - seen = set() - toml_path = BASE_DIR / 'plugins' / 'plugins.toml' - if not toml_path.exists(): - return plugin_apps - try: - import toml - toml_data = toml.loads(toml_path.read_text(encoding='utf-8')) - for section in ('builtin', 'third_party'): - for _key, info in toml_data.get(section, {}).items(): - if not info.get('enabled', True): - continue - if not info.get('django_app', True): - continue - module = info.get('module', '') - if not module: - continue - parts = module.split('.') - if len(parts) >= 2 and parts[0] == 'plugins': - app_module = '.'.join(parts[:2]) - else: - app_module = module - if app_module in seen: - continue - seen.add(app_module) - pkg_dir = ( - BASE_DIR / 'plugins' / app_module.split('.')[-1] - ) - if pkg_dir.is_dir() and (pkg_dir / '__init__.py').exists(): - plugin_apps.append(app_module) - except Exception: - pass - return plugin_apps - -INSTALLED_APPS += _discover_plugin_apps() - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'config.maintenance_middleware.MaintenanceModeMiddleware', - 'config.local_lock_middleware.LocalLockMiddleware', - 'config.security_middleware.SecurityHeadersMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'apps.dashboard.middleware.SiteGroupMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'apps.bootstrap.middleware.SessionValidationMiddleware', - 'config.demo_middleware.DemoModeMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'config.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - BASE_DIR / 'templates', - ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'apps.dashboard.context_processors.system_config', - ], - }, - }, -] - -WSGI_APPLICATION = 'config.wsgi.application' - -# Custom user model -AUTH_USER_MODEL = 'accounts.User' - -# Password validation -# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - -# Internationalization -# https://docs.djangoproject.com/en/4.2/topics/i18n/ - -LANGUAGE_CODE = 'zh-hans' - -TIME_ZONE = 'Asia/Shanghai' - -USE_I18N = True - -USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.2/howto/static-files/ - -STATIC_URL = 'static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' -STATICFILES_DIRS = [BASE_DIR / 'static'] - -# Media files -MEDIA_URL = 'media/' -MEDIA_ROOT = BASE_DIR / 'media' - -# Default primary key field type -# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# REST Framework settings -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.SessionAuthentication', - ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 20, - 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.UserRateThrottle', - ], - 'DEFAULT_THROTTLE_RATES': { - 'anon': '100/hour', - 'user': '1000/hour', - }, -} - -# CORS settings -CORS_ALLOW_ALL_ORIGINS = False -CORS_ALLOWED_ORIGINS = [ - origin.strip() - for origin in _env( - 'CORS_ALLOWED_ORIGINS', - 'http://localhost:8000,https://localhost,http://127.0.0.1:8000' - ).split(',') - if origin.strip() -] - - -# Winrm settings -WINRM_TIMEOUT = int(_env('WINRM_TIMEOUT', '30')) # Winrm连接超时时间(秒) -WINRM_MAX_RETRIES = int(_env('WINRM_RETRY_COUNT', '3')) # Winrm连接最大重试次数 - -# Logging settings -# 默认只输出到 stdout,方便 nohup/systemd 等收集日志 -# 如需文件日志,设置环境变量 LOG_FILE=/path/to/2c2a.log -LOG_LEVEL = _env('LOG_LEVEL', 'INFO') -LOG_FILE = _env('LOG_FILE', '') - -_logging_handlers = { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose', - }, -} -_logging_root_handlers = ['console'] -_logging_logger_handlers = ['console'] - -if LOG_FILE: - _logging_handlers['file'] = { - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': LOG_FILE, - 'maxBytes': 1024 * 1024 * 10, # 10MB - 'backupCount': 5, - 'formatter': 'verbose', - } - _logging_root_handlers.append('file') - _logging_logger_handlers.append('file') - -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '{levelname} {asctime} {module} {message}', - 'style': '{', - }, - }, - 'handlers': _logging_handlers, - 'root': { - 'handlers': _logging_root_handlers, - 'level': LOG_LEVEL, - }, - 'loggers': { - 'django': { - 'handlers': _logging_logger_handlers, - 'level': LOG_LEVEL, - 'propagate': False, - }, - '2c2a': { - 'handlers': _logging_logger_handlers, - 'level': 'DEBUG' if DEBUG else 'INFO', - 'propagate': False, - }, - }, -} - -# 安全配置 -SECURE_BROWSER_XSS_FILTER = True -SECURE_CONTENT_TYPE_NOSNIFF = True -X_FRAME_OPTIONS = 'DENY' -SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' - -SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin' - -if not DEBUG: - SECURE_CROSS_ORIGIN_EMBEDDER_POLICY = 'require-corp' - SECURE_CROSS_ORIGIN_RESOURCE_POLICY = 'same-origin' - -USE_X_FORWARDED_FOR = _env( - 'USE_X_FORWARDED_FOR', 'False' -).lower() == 'true' - -# 可信反向代理 IP 集合(nginx 等) -# 当 REMOTE_ADDR 在此集合中时,从 X-Forwarded-For / X-Real-IP 获取真实客户端 IP -# 防止代理场景下所有用户共享同一 IP 导致限流误触发 -TRUSTED_PROXY_IPS = set( - _env('TRUSTED_PROXY_IPS', '127.0.0.1,::1').split(',') -) - -SESSION_COOKIE_SECURE = _env( - 'SESSION_COOKIE_SECURE', 'True' if not DEBUG else 'False' -).lower() == 'true' -CSRF_COOKIE_SECURE = _env( - 'CSRF_COOKIE_SECURE', 'True' if not DEBUG else 'False' -).lower() == 'true' -SESSION_COOKIE_HTTPONLY = True -SESSION_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_SAMESITE = 'Lax' -CSRF_COOKIE_HTTPONLY = True -SESSION_COOKIE_AGE = int(_env('SESSION_COOKIE_AGE', '3600')) -SESSION_EXPIRE_AT_BROWSER_CLOSE = True - -# HTTPS相关安全配置 (仅在生产环境中启用) -if not DEBUG: - SECURE_SSL_REDIRECT = _env('SECURE_SSL_REDIRECT', 'True').lower() == 'true' - SECURE_HSTS_SECONDS = 31536000 # 一年 - SECURE_HSTS_INCLUDE_SUBDOMAINS = True - SECURE_HSTS_PRELOAD = True - SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') - -# ========== Redis 可选配置 ========== -# Redis 是锦上添花的增强组件,不配置时程序使用本地替代方案。 -# 配置 REDIS_URL 且 Redis 服务可达时,自动用于缓存、会话、Celery。 -# import redis 采用延迟导入:REDIS_URL 未配置时不 import,redis 包未安装也不报错。 -REDIS_URL = _env('REDIS_URL', '') - -def _check_redis_available(): - """检测 Redis 是否配置且可达(延迟导入 redis 包)""" - if not REDIS_URL: - return False - try: - import redis as _redis - client = _redis.Redis.from_url(REDIS_URL, socket_connect_timeout=3) - client.ping() - return True - except Exception: - return False - -REDIS_ENABLED = _check_redis_available() - -# ========== 缓存配置 ========== -if REDIS_ENABLED: - CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.redis.RedisCache', - 'LOCATION': REDIS_URL, - 'KEY_PREFIX': '2c2a', - 'TIMEOUT': 300, - }, - } -else: - CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': '2c2a-inmemory', - 'KEY_PREFIX': '2c2a', - 'TIMEOUT': 300, - 'OPTIONS': { - 'MAX_ENTRIES': 1000, - }, - }, - } - -# ========== 会话引擎 ========== -if REDIS_ENABLED: - SESSION_ENGINE = 'django.contrib.sessions.backends.cache' - SESSION_CACHE_ALIAS = 'default' -else: - SESSION_ENGINE = 'django.contrib.sessions.backends.db' - -# ========== Celery 配置 ========== -if REDIS_ENABLED: - CELERY_BROKER_URL = _env( - 'CELERY_BROKER_URL', - REDIS_URL.replace('/0', '/1') if REDIS_URL else 'redis://localhost:6379/1', - ) - CELERY_RESULT_BACKEND = _env( - 'CELERY_RESULT_BACKEND', - REDIS_URL.replace('/0', '/2') if REDIS_URL else 'redis://localhost:6379/2', - ) - CELERY_BROKER_TRANSPORT_OPTIONS = { - 'polling_interval': 1, - 'max_connections': 20, - } -else: - CELERY_BROKER_URL = f'sqla+sqlite:///{BASE_DIR / "celery_broker.sqlite3"}' - CELERY_RESULT_BACKEND = f'db+sqlite:///{BASE_DIR / "celery_results.sqlite3"}' - CELERY_BROKER_TRANSPORT_OPTIONS = { - 'polling_interval': 1, - } - -# ========== 限流配置 ========== -LOGIN_RATE_LIMIT = int(_env('LOGIN_RATE_LIMIT', '5')) -API_RATE_LIMIT = int(_env('API_RATE_LIMIT', '100')) - -# Gateway 控制面配置 -GATEWAY_ENABLED = _env( - 'GATEWAY_ENABLED', 'False' -).lower() in ('true', '1', 'yes') -GATEWAY_CONTROL_SOCKET = _env( - 'GATEWAY_CONTROL_SOCKET', '/run/2c2a/control.sock' -) - -GATEWAY_PAA_TOKEN_SIGNING_KEY = _env( - 'GATEWAY_PAA_TOKEN_SIGNING_KEY', 'change-me-32-chars-minimum!!' -) - -_INSECURE_GATEWAY_KEY = 'change-me-32-chars-minimum!!' -if not DEBUG and GATEWAY_ENABLED and GATEWAY_PAA_TOKEN_SIGNING_KEY == _INSECURE_GATEWAY_KEY: - raise ImproperlyConfigured( - 'GATEWAY_PAA_TOKEN_SIGNING_KEY 环境变量必须设置,不允许在生产环境使用默认不安全密钥' - ) -GATEWAY_PAA_TOKEN_EXPIRY_SECONDS = int(_env( - 'GATEWAY_PAA_TOKEN_EXPIRY_SECONDS', '600' -)) -GATEWAY_ADDRESS = _env('GATEWAY_ADDRESS', 'rdp.2c2a.com') -GATEWAY_PORT = int(_env('GATEWAY_PORT', '443')) - -# RDP 域名配置 -RDP_DOMAIN = _env('RDP_DOMAIN', '2c2a.com') - -# ========== DEMO模式配置(强制锁定,不受 .env 影响) ========== -DEMO_MODE = os.environ.get('2C2A_DEMO', '').lower() == '1' - -if DEMO_MODE: - # DEMO模式强制使用 DEMO.sqlite3,不受 DB_ENGINE 或 .env 影响 - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'DEMO.sqlite3', - } - } - - # DEMO模式保留最小长度验证,仅放宽复杂度要求 - AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - 'OPTIONS': {'min_length': 4}, - }, - ] - - ALLOWED_HOSTS = _env( - 'ALLOWED_HOSTS', - 'localhost,127.0.0.1,demo.supercmd.dpdns.org,2c2a.supercmd.dpdns.org' - ).split(',') - - # DEBUG模式开启 - DEBUG = True - - # 生成随机SECRET_KEY(每次启动不同) - import secrets as _secrets - SECRET_KEY = _secrets.token_urlsafe(50) - import logging as _logging - _logging.getLogger('2c2a').warning('DEMO模式: 使用随机生成的SECRET_KEY,重启后所有session将失效') - -# DEMO模式启动消息 -if DEMO_MODE: - from config.demo_startup import show_demo_startup_message - show_demo_startup_message() - -# Create logs directory if it doesn't exist -os.makedirs(BASE_DIR / 'logs', exist_ok=True) - -# Bootstrap认证配置 -BOOTSTRAP_SHARED_SALT = _env('BOOTSTRAP_SHARED_SALT', '') - -if not DEBUG and not BOOTSTRAP_SHARED_SALT: - import logging as _bootstrap_logging - _bootstrap_logging.getLogger('2c2a').warning( - 'BOOTSTRAP_SHARED_SALT 未设置,建议在生产环境中配置此值以增强引导认证安全性' - ) - -CAPTCHA = { - "PREFIX": "captcha", - "EXPIRE": { - "default": 120, - "WORD_IMAGE_CLICK": 180, - }, - "INIT_DEFAULT_RESOURCE": True, - "CACHE_BACKEND": "redis" if REDIS_ENABLED else "local", - "REDIS_URL": REDIS_URL if REDIS_ENABLED else "", - "DEFAULT_TYPE": "SLIDER", - "TOLERANT": 0.02, - "TRACK_VALIDATION_ENABLED": True, - "SECONDARY": { - "ENABLED": True, - "EXPIRE": 120, - "KEY_PREFIX": "captcha:secondary", - }, - "RATE_LIMIT": { - "ENABLED": True, - "RATE": 10, - "PERIOD": 60, - }, -} diff --git a/config/tests.py b/config/tests.py deleted file mode 100644 index 263e017..0000000 --- a/config/tests.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest -from django.test import RequestFactory -from config.views import static_fallback_view - - -@pytest.mark.django_db -class TestStaticFallbackView: - def setup_method(self): - self.factory = RequestFactory() - - def test_path_traversal_blocked(self): - request = self.factory.get("/static/../../etc/passwd") - response = static_fallback_view(request, "../../etc/passwd") - assert response.status_code == 400 - - def test_absolute_url_scheme_blocked(self): - request = self.factory.get("/static/https://evil.com") - response = static_fallback_view(request, "https://evil.com") - assert response.status_code == 400 - - def test_absolute_url_netloc_blocked(self): - request = self.factory.get("/static//evil.com/path") - response = static_fallback_view(request, "//evil.com/path") - assert response.status_code == 400 - - def test_backslash_url_blocked(self): - request = self.factory.get("/static/\\\\evil.com/path") - response = static_fallback_view(request, "\\\\evil.com/path") - assert response.status_code == 400 - - def test_normal_path_served_or_redirected(self): - request = self.factory.get("/static/css/base.css") - response = static_fallback_view(request, "css/base.css") - assert response.status_code in (200, 302) - - def test_normal_js_path_served_or_redirected(self): - request = self.factory.get("/static/js/base.js") - response = static_fallback_view(request, "js/base.js") - assert response.status_code in (200, 302) - - def test_nonexistent_path_redirects_to_cdn(self): - request = self.factory.get("/static/nonexistent/file.xyz") - response = static_fallback_view(request, "nonexistent/file.xyz") - assert response.status_code == 302 - assert "static.2c2a.cc.cd" in response.url diff --git a/config/urls.py b/config/urls.py deleted file mode 100755 index 1466471..0000000 --- a/config/urls.py +++ /dev/null @@ -1,42 +0,0 @@ -""" -2c2a URL Configuration -""" -from django.contrib.staticfiles.views import serve -from django.urls import path, include, re_path -from django.conf import settings -from django.conf.urls.static import static -from django.views.generic import TemplateView -from config import views - -urlpatterns = [ - path('admin/', include('apps.accounts.urls_admin')), - path('provider/', include('apps.accounts.urls_provider')), - path('api/', include('rest_framework.urls')), - path('accounts/', include('apps.accounts.urls')), - path('operations/', include('apps.operations.urls')), - path('certificates/', include('apps.certificates.urls')), - path('bootstrap/', include('apps.bootstrap.urls')), - path('audit/', include('apps.audit.urls')), - path('tasks/', include('apps.tasks.urls')), - path('tunnel/', include('apps.tunnel.urls')), - path('tickets/', include('apps.tickets.urls')), - path('captcha/', include('django_tianai_captcha.urls')), - path('docs/', views.docs_index, name='docs_index'), - path('', include('apps.dashboard.urls')), - path('404/', TemplateView.as_view(template_name='errors/404.html'), name='404'), - path('favicon.ico', views.favicon_view), - path('favicon.svg', views.favicon_svg_view), -] - -if settings.DEBUG: - urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) -else: - # 生产环境:static 文件降级逻辑 - # 本地找不到时自动重定向到 static.2c2a.cc.cd - urlpatterns += [ - re_path(r'^static/(?P.*)$', views.static_fallback_view), - ] - -handler404 = 'config.views.custom_404' -handler500 = 'config.views.custom_500' diff --git a/config/views.py b/config/views.py deleted file mode 100755 index 6b5bdc4..0000000 --- a/config/views.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -自定义错误处理视图 -""" -import re -from urllib.parse import urlparse - -from django.shortcuts import render, redirect -from django.views.static import serve -from django.conf import settings -from django.http import HttpResponse, HttpResponseBadRequest -import os - -from apps.dashboard.models import SystemConfig - - -def custom_404(request, exception): - """ - 自定义404错误页面 - - Args: - request: HTTP请求对象 - exception: 异常对象 - - Returns: - HttpResponse: 404错误页面 - """ - return render(request, 'errors/404.html', status=404) - - -def custom_500(request): - """ - 自定义500错误页面 - - Args: - request: HTTP请求对象 - - Returns: - HttpResponse: 500错误页面 - """ - return render(request, 'errors/500.html', status=500) - - -def _get_default_favicon_path(filename): - return os.path.join( - settings.STATIC_ROOT or settings.STATICFILES_DIRS[0], - 'img', filename - ) - - -def _serve_hostname_favicon(request, default_filename): - hostname = request.get_host().split(':')[0] - site_icon = None - - site_group = getattr(request, 'site_group', None) - if site_group and site_group.site_icon: - site_icon = site_group.site_icon - else: - try: - config = SystemConfig.get_config() - site_icon = config.get_site_icon_for_hostname(hostname) - except Exception: - site_icon = '/static/img/favicon.svg' - - if site_icon and site_icon != '/static/img/favicon.svg': - if site_icon.startswith(settings.MEDIA_URL): - rel_path = site_icon[len(settings.MEDIA_URL):] - file_path = os.path.join(settings.MEDIA_ROOT, rel_path) - elif site_icon.startswith('/'): - file_path = os.path.join(settings.BASE_DIR, site_icon.lstrip('/')) - else: - file_path = site_icon - - if os.path.exists(file_path) and os.path.isfile(file_path): - content_type = 'image/svg+xml' - if file_path.endswith('.ico'): - content_type = 'image/x-icon' - elif file_path.endswith('.png'): - content_type = 'image/png' - with open(file_path, 'rb') as f: - return HttpResponse(f.read(), content_type=content_type) - - favicon_path = _get_default_favicon_path(default_filename) - if not os.path.exists(favicon_path): - favicon_path = os.path.join( - settings.STATICFILES_DIRS[0], 'img', default_filename - ) - return serve( - request, os.path.basename(favicon_path), - document_root=os.path.dirname(favicon_path) - ) - - -def favicon_view(request): - return _serve_hostname_favicon(request, 'favicon.ico') - - -def favicon_svg_view(request): - return _serve_hostname_favicon(request, 'favicon.svg') - - -# Static 文件降级服务域名 -STATIC_FALLBACK_HOST = 'https://static.2c2a.cc.cd' - - -def static_fallback_view(request, path): - """ - 生产环境 static 文件降级视图 - - 逻辑: - 1. 先尝试从本地 STATIC_ROOT 或 STATICFILES_DIRS 中查找并 serve 文件 - 2. 如果本地文件不存在,则 302 重定向到外部 static 服务 - - Args: - request: HTTP请求对象 - path: static 文件路径 - - Returns: - HttpResponse: 本地文件或 302 重定向响应 - """ - document_root = None - - if settings.STATIC_ROOT and os.path.exists(settings.STATIC_ROOT): - document_root = settings.STATIC_ROOT - elif settings.STATICFILES_DIRS: - for static_dir in settings.STATICFILES_DIRS: - if os.path.exists(static_dir): - document_root = static_dir - break - - if document_root: - real_root = os.path.realpath(document_root) - file_path = os.path.realpath(os.path.join(document_root, path)) - if not file_path.startswith(real_root + os.sep) and file_path != real_root: - return HttpResponseBadRequest('Invalid path') - if os.path.exists(file_path) and os.path.isfile(file_path): - return serve(request, os.path.relpath(file_path, real_root), document_root=real_root) - - sanitized = path.replace('\\', '/') - parsed = urlparse(sanitized) - if parsed.scheme or parsed.netloc: - return HttpResponseBadRequest('Invalid path') - - redirect_url = f"{STATIC_FALLBACK_HOST}/static/{sanitized}" - return redirect(redirect_url, permanent=False) - - -USER_DOCS_FILE = settings.BASE_DIR / 'USER_DOCS.md' - - -def docs_index(request): - md_text = '' - if USER_DOCS_FILE.exists(): - with open(USER_DOCS_FILE, 'r', encoding='utf-8') as f: - md_text = f.read() - return render(request, 'docs/index.html', { - 'doc_title': '用户手册', - 'md_text': md_text, - }) \ No newline at end of file diff --git a/config/wsgi.py b/config/wsgi.py deleted file mode 100755 index 1ba51ab..0000000 --- a/config/wsgi.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -WSGI config for 2c2a project. -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - -application = get_wsgi_application() diff --git a/conftest.py b/conftest.py deleted file mode 100644 index deda15c..0000000 --- a/conftest.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest -from django.contrib.auth.models import User, Group - - -@pytest.fixture -def admin_user(db): - user = User.objects.create_superuser( - username="admin_test", - email="admin@test.com", - password="testpass123", - ) - return user - - -@pytest.fixture -def normal_user(db): - user = User.objects.create_user( - username="user_test", - email="user@test.com", - password="testpass123", - ) - return user - - -@pytest.fixture -def provider_user(db): - provider_group, _ = Group.objects.get_or_create(name="provider") - user = User.objects.create_user( - username="provider_test", - email="provider@test.com", - password="testpass123", - ) - user.groups.add(provider_group) - return user - - -@pytest.fixture -def client_logged_in(client, normal_user): - client.login(username="user_test", password="testpass123") - return client - - -@pytest.fixture -def admin_client_logged_in(client, admin_user): - client.login(username="admin_test", password="testpass123") - return client diff --git a/manage.py b/manage.py deleted file mode 100755 index 8e7ac79..0000000 --- a/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() diff --git a/plugins/README.md b/plugins/README.md deleted file mode 100755 index adb1181..0000000 --- a/plugins/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# 插件系统 - -插件系统为2c2a提供可扩展的功能模块支持,采用松耦合设计,支持动态加载和管理插件。 - -## 架构设计 - -插件系统采用分层架构,将核心接口与具体实现分离: - -- **core/**: 核心组件目录,包含接口定义和插件管理器 -- **各插件目录**: 每个插件都有独立的目录,包含其所有相关文件 - -## 核心概念 - -### PluginInterface - -所有插件必须继承 `PluginInterface` 抽象基类,并实现以下方法: - -- `initialize()`: 初始化插件 -- `shutdown()`: 关闭插件 - -### 插件管理器 - -`PluginManager` 负责插件的加载、初始化、运行和卸载。 - -## 开发新插件 - -### 1. 创建插件目录 - -为新插件创建独立的目录: - -```bash -mkdir plugins/new_plugin_name -``` - -### 2. 实现插件类 - -创建插件实现文件,继承 `PluginInterface`: - -```python -from plugins.core.base import PluginInterface - -class NewPlugin(PluginInterface): - def __init__(self): - super().__init__( - plugin_id="new_plugin", - name="新插件", - version="1.0.0", - description="新插件描述" - ) - - def initialize(self) -> bool: - # 初始化插件 - return True - - def shutdown(self) -> bool: - # 关闭插件 - return True -``` - -### 3. 注册插件 - -在 [available_plugins.py](file:///Users/Supercmd/Desktop/Python/2c2a/plugins/available_plugins.py) 文件中注册插件: - -```python -BUILTIN_PLUGINS = { - 'new_plugin': { - 'name': '新插件', - 'module': 'plugins.new_plugin.new_plugin', - 'class': 'NewPlugin', - 'description': '新插件描述', - 'version': '1.0.0', - 'enabled': True - } -} -``` - -## 现有插件 - -### QQ验证插件 - -位于 `plugins/qq_verification/` 目录,提供QQ群验证功能: - -- 检测QQ号是否在指定群中 -- 支持"只有加入了某个群才允许使用机器"模式 -- 支持"老六模式"(对已有云电脑用户进行验证) - -## 目录结构 - -参见 [STRUCTURE.md](file:///Users/Supercmd/Desktop/Python/2c2a/plugins/STRUCTURE.md) 文件了解详细的目录结构说明。 - -## 运行时管理 - -插件系统通过 `PluginManager` 实现插件的动态管理,支持: - -- 插件热加载 -- 插件状态监控 -- 事件钩子机制 \ No newline at end of file diff --git a/plugins/STRUCTURE.md b/plugins/STRUCTURE.md deleted file mode 100755 index 4435b07..0000000 --- a/plugins/STRUCTURE.md +++ /dev/null @@ -1,57 +0,0 @@ -# 插件系统目录结构 - -## 概述 -插件系统采用分层架构设计,将核心接口与具体实现分离,确保系统的可扩展性和可维护性。 - -## 目录结构 - -``` -plugins/ # 插件系统根目录 -├── core/ # 核心组件目录 -│ ├── __init__.py # 核心模块初始化 -│ ├── base.py # 插件接口定义 -│ └── plugin_manager.py # 插件管理器 -├── qq_verification/ # QQ验证插件 -│ ├── __init__.py # 插件初始化 -│ ├── qq_checker.py # QQ验证核心功能 -│ └── qq_verification_plugin.py # QQ验证插件实现 -├── sample_plugins/ # 示例插件目录 -│ ├── __init__.py # 示例插件初始化 -│ └── ... # 各种示例插件 -├── __init__.py # 插件系统初始化 -├── apps.py # Django应用配置 -├── models.py # 插件相关的Django模型 -├── admin.py # Django管理界面配置 -├── signals.py # Django信号处理器 -├── available_plugins.py # 可用插件配置 -├── README.md # 插件系统说明 -└── migrations/ # 数据库迁移文件 -``` - -## 核心组件 (core/) - -- **base.py**: 定义了[PluginInterface](file:///Users/Supercmd/Desktop/Python/2c2a/plugins/core/base.py#L8-L48)抽象基类和其他核心接口 -- **plugin_manager.py**: 实现插件的加载、管理、运行和卸载功能 -- **__init__.py**: 核心模块初始化 - -## 具体插件目录 - -每个插件都有自己的目录,包含该插件的所有相关文件: - -- **qq_verification/**: QQ验证插件 - - [qq_checker.py](file:///Users/Supercmd/Desktop/Python/2c2a/plugins/qq_verification/qq_checker.py): 实现QQ群验证的核心功能 - - [qq_verification_plugin.py](file:///Users/Supercmd/Desktop/Python/2c2a/plugins/qq_verification/qq_verification_plugin.py): 实现[PluginInterface](file:///Users/Supercmd/Desktop/Python/2c2a/plugins/core/base.py#L8-L48)的具体插件类 - - **__init__.py**: 插件初始化 - -## 配置文件 - -- **available_plugins.py**: 定义系统中所有可用的插件及其配置信息 -- **models.py**: 插件相关的数据库模型 -- **signals.py**: Django信号处理器,用于集成插件功能 - -## 设计优势 - -1. **清晰分离**: 核心接口与具体实现完全分离 -2. **易于扩展**: 添加新插件只需创建新目录和实现接口 -3. **易于维护**: 每个插件的功能封装在自己的目录中 -4. **降低耦合**: 插件之间相互独立,减少依赖关系 \ No newline at end of file diff --git a/plugins/__init__.py b/plugins/__init__.py deleted file mode 100755 index f1dfe29..0000000 --- a/plugins/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# 插件系统初始化 -default_app_config = 'plugins.apps.PluginsConfig' \ No newline at end of file diff --git a/plugins/admin.py b/plugins/admin.py deleted file mode 100755 index f6d83b8..0000000 --- a/plugins/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 已迁移至自建超管后台 (plugins.views_admin) 和提供商后台 (plugins.views_provider) diff --git a/plugins/apps.py b/plugins/apps.py deleted file mode 100755 index 3442f0e..0000000 --- a/plugins/apps.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -插件应用配置 -""" - -from django.apps import AppConfig - - -class PluginsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'plugins' - verbose_name = '插件管理' - - def ready(self): - # 导入信号处理器 - import plugins.signals - - # 初始化插件管理器并加载所有插件 - from .core.plugin_manager import get_plugin_manager - plugin_manager = get_plugin_manager() - plugin_manager.load_all_builtin_plugins() \ No newline at end of file diff --git a/plugins/available_plugins.py b/plugins/available_plugins.py deleted file mode 100755 index 6333349..0000000 --- a/plugins/available_plugins.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -可用插件配置 -定义系统中所有可用的插件 -""" -import toml -import os -from django.conf import settings - - -# 加载 TOML 配置文件 -toml_file_path = os.path.join(settings.BASE_DIR, 'plugins', 'plugins.toml') -if os.path.exists(toml_file_path): - with open(toml_file_path, 'r', encoding='utf-8') as f: - toml_data = toml.load(f) -else: - # 如果 TOML 文件不存在,使用默认配置 - toml_data = { - 'builtin': {}, - 'third_party': {} - } - - -# 系统内置插件 -BUILTIN_PLUGINS = toml_data.get('builtin', {}) - -# 第三方插件(如果有的话) -THIRD_PARTY_PLUGINS = toml_data.get('third_party', {}) - -# 合并所有插件 -ALL_AVAILABLE_PLUGINS = {**BUILTIN_PLUGINS, **THIRD_PARTY_PLUGINS} \ No newline at end of file diff --git a/plugins/beta_push/.gitignore b/plugins/beta_push/.gitignore deleted file mode 100644 index 9e4992f..0000000 --- a/plugins/beta_push/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -__pycache__/ -*.pyc -*.pyo -.mypy_cache/ -.pytest_cache/ -*.egg-info/ -dist/ -build/ -.env diff --git a/plugins/beta_push/__init__.py b/plugins/beta_push/__init__.py deleted file mode 100644 index 8fe5100..0000000 --- a/plugins/beta_push/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -import os -import logging - -from plugins.core.base import PluginInterface, URLProvider, UIExtension, UIExtensionProvider - -logger = logging.getLogger(__name__) - - -class BetaPushPlugin(PluginInterface, URLProvider, UIExtensionProvider): - - def __init__(self): - super().__init__( - plugin_id='beta_push', - name='Beta数据推送', - version='1.0.0', - description='将生产环境数据异步推送到Beta版本数据库,支持增量同步', - ) - - def initialize(self) -> bool: - return True - - def shutdown(self) -> bool: - return True - - def get_url_patterns(self): - return [ - { - 'prefix': 'beta-push/', - 'module': 'plugins.beta_push.urls', - 'namespace': 'beta_push', - 'section': URLProvider.PROVIDER, - }, - ] - - def get_ui_extensions(self): - return [ - UIExtension( - extension_type=UIExtension.NAV_ITEM, - slot='admin_sidebar_plugins', - html=( - '' - 'sync_alt' - 'Beta推送' - '' - ), - order=10, - ), - ] - - -def is_beta_db_configured(): - return bool(os.environ.get('BETA_DB_NAME', '')) diff --git a/plugins/beta_push/apps.py b/plugins/beta_push/apps.py deleted file mode 100644 index 6c3389c..0000000 --- a/plugins/beta_push/apps.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import logging - -from django.apps import AppConfig -from django.conf import settings - -logger = logging.getLogger(__name__) - - -class BetaPushConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'plugins.beta_push' - verbose_name = 'Beta数据推送' - - def ready(self): - self._configure_beta_database() - - def _configure_beta_database(self): - beta_db_name = os.environ.get('BETA_DB_NAME', '') - if not beta_db_name: - return - - if 'beta' in settings.DATABASES: - return - - default_db = settings.DATABASES.get('default', {}) - engine = default_db.get('ENGINE', '') - - if engine != 'django.db.backends.postgresql': - logger.warning( - 'Beta推送插件仅支持PostgreSQL架构,' - '当前默认数据库引擎不是PostgreSQL' - ) - return - - beta_db = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': beta_db_name, - 'USER': os.environ.get('BETA_DB_USER', default_db.get('USER', '')), - 'PASSWORD': os.environ.get('BETA_DB_PASSWORD', default_db.get('PASSWORD', '')), - 'HOST': os.environ.get('BETA_DB_HOST', default_db.get('HOST', '127.0.0.1')), - 'PORT': os.environ.get('BETA_DB_PORT', default_db.get('PORT', '5432')), - 'CONN_MAX_AGE': int(os.environ.get('BETA_DB_CONN_MAX_AGE', '60')), - } - - settings.DATABASES['beta'] = beta_db - logger.info(f'Beta数据库已配置: {beta_db_name}') diff --git a/plugins/beta_push/migrations/0001_initial.py b/plugins/beta_push/migrations/0001_initial.py deleted file mode 100644 index 0fd1d77..0000000 --- a/plugins/beta_push/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-15 15:48 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='SyncLog', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('pending', '等待中'), ('running', '执行中'), ('success', '成功'), ('failed', '失败')], default='pending', max_length=20, verbose_name='状态')), - ('task_id', models.CharField(blank=True, max_length=255, verbose_name='Celery任务ID')), - ('records_pushed', models.IntegerField(default=0, verbose_name='推送记录数')), - ('records_skipped', models.IntegerField(default=0, verbose_name='跳过记录数')), - ('records_failed', models.IntegerField(default=0, verbose_name='失败记录数')), - ('error_message', models.TextField(blank=True, verbose_name='错误信息')), - ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')), - ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='beta_push_logs', to=settings.AUTH_USER_MODEL, verbose_name='用户')), - ], - options={ - 'verbose_name': 'Beta推送日志', - 'verbose_name_plural': 'Beta推送日志', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['user'], name='beta_push_s_user_id_29a2ce_idx'), models.Index(fields=['status'], name='beta_push_s_status_7d5027_idx')], - }, - ), - ] diff --git a/plugins/beta_push/migrations/__init__.py b/plugins/beta_push/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/beta_push/models.py b/plugins/beta_push/models.py deleted file mode 100644 index fb99ea0..0000000 --- a/plugins/beta_push/models.py +++ /dev/null @@ -1,71 +0,0 @@ -from django.db import models -from django.conf import settings - - -class SyncLog(models.Model): - STATUS_CHOICES = [ - ('pending', '等待中'), - ('running', '执行中'), - ('success', '成功'), - ('failed', '失败'), - ] - - user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.CASCADE, - related_name='beta_push_logs', - verbose_name='用户', - ) - status = models.CharField( - max_length=20, - choices=STATUS_CHOICES, - default='pending', - verbose_name='状态', - ) - task_id = models.CharField( - max_length=255, - blank=True, - verbose_name='Celery任务ID', - ) - records_pushed = models.IntegerField( - default=0, - verbose_name='推送记录数', - ) - records_skipped = models.IntegerField( - default=0, - verbose_name='跳过记录数', - ) - records_failed = models.IntegerField( - default=0, - verbose_name='失败记录数', - ) - error_message = models.TextField( - blank=True, - verbose_name='错误信息', - ) - started_at = models.DateTimeField( - null=True, - blank=True, - verbose_name='开始时间', - ) - completed_at = models.DateTimeField( - null=True, - blank=True, - verbose_name='完成时间', - ) - created_at = models.DateTimeField( - auto_now_add=True, - verbose_name='创建时间', - ) - - class Meta: - verbose_name = 'Beta推送日志' - verbose_name_plural = 'Beta推送日志' - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['user']), - models.Index(fields=['status']), - ] - - def __str__(self): - return f'Beta推送({self.user.username}) - {self.get_status_display()}' diff --git a/plugins/beta_push/services.py b/plugins/beta_push/services.py deleted file mode 100644 index 28db5ee..0000000 --- a/plugins/beta_push/services.py +++ /dev/null @@ -1,554 +0,0 @@ -import logging -import os -from collections import OrderedDict - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.db import connections, models, IntegrityError -from django.utils import timezone - -import redis as redis_lib - -User = get_user_model() -logger = logging.getLogger(__name__) - -BETA_DB = "beta" - -REDIS_KEY_PREFIX = "beta_push:progress" - -ENCRYPTED_FIELDS = { - "hosts.Host._password", - "operations.CloudComputerUser._initial_password", -} - - -def _get_redis(): - url = getattr(settings, "REDIS_URL", "") - if not url: - return None - try: - client = redis_lib.Redis.from_url(url, socket_connect_timeout=3) - client.ping() - return client - except Exception: - return None - - -def set_progress(task_id, current, total, message=""): - r = _get_redis() - if not r: - return - import json - - r.setex( - f"{REDIS_KEY_PREFIX}:{task_id}", - 3600, - json.dumps( - { - "current": current, - "total": total, - "message": message, - } - ), - ) - - -def get_progress(task_id): - r = _get_redis() - if not r: - return None - import json - - data = r.get(f"{REDIS_KEY_PREFIX}:{task_id}") - if data: - return json.loads(data) - return None - - -def _get_fernet(secret_key): - try: - from cryptography.fernet import Fernet - import base64 - import hashlib - - key = hashlib.sha256(secret_key.encode()).digest() - return Fernet(base64.urlsafe_b64encode(key)) - except ImportError: - return None - - -def _re_encrypt_value(encrypted_value, model_label, field_name): - beta_secret_key = os.environ.get("BETA_SECRET_KEY", "") - if not beta_secret_key or not encrypted_value: - return encrypted_value - - field_key = f"{model_label}.{field_name}" - if field_key not in ENCRYPTED_FIELDS: - return encrypted_value - - prod_fernet = _get_fernet(settings.SECRET_KEY) - beta_fernet = _get_fernet(beta_secret_key) - if not prod_fernet or not beta_fernet: - return encrypted_value - - try: - plaintext = prod_fernet.decrypt(encrypted_value.encode()).decode() - return beta_fernet.encrypt(plaintext.encode()).decode() - except Exception as e: - logger.warning(f"重加密失败 [{field_key}]: {e}") - return encrypted_value - - -class BetaPushService: - _beta_schema_cache = {} - - def __init__(self, user_id, task_id="", site_group_id=None): - self.user_id = user_id - self.user = User.objects.get(pk=user_id) - self.task_id = task_id - self.site_group_id = site_group_id - self.site_group = self._resolve_site_group() - self.stats = { - "pushed": 0, - "skipped": 0, - "failed": 0, - "errors": [], - } - self._synced_pks = {} - self._missing_tables = set() - self.last_sync_at = self._get_last_sync_at() - - def _resolve_site_group(self): - if not self.site_group_id: - return None - try: - from apps.dashboard.models import SiteGroup - - return SiteGroup.objects.get(pk=self.site_group_id) - except Exception: - return None - - def _get_last_sync_at(self): - from .models import SyncLog - - try: - log = SyncLog.objects.filter( - user_id=self.user_id, - status="success", - ).latest("completed_at") - return log.completed_at - except SyncLog.DoesNotExist: - return None - - def push_all(self): - self.__class__._beta_schema_cache.clear() - - steps = [ - ("用户信息", self._push_user), - ("用户资料", self._push_user_profile), - ("用户组", self._push_user_groups), - ("主机", self._push_hosts), - ("主机组", self._push_host_groups), - ("产品组", self._push_product_groups), - ("产品", self._push_products), - ("云电脑用户", self._push_cloud_computer_users), - ("开户申请", self._push_account_opening_requests), - ("邀请令牌", self._push_invitation_tokens), - ("授权记录", self._push_access_grants), - ("域名路由", self._push_rdp_domain_routes), - ] - total_steps = len(steps) - for idx, (label, step_func) in enumerate(steps, 1): - if self.task_id: - set_progress(self.task_id, idx, total_steps, label) - try: - step_func() - except Exception as e: - logger.error(f"Beta推送步骤失败 [{label}]: {e}", exc_info=True) - self.stats["errors"].append(f"{label}: {str(e)}") - - if self.task_id: - set_progress(self.task_id, total_steps, total_steps, "完成") - - return self.stats - - def _is_changed(self, instance): - if self.last_sync_at is None: - return True - updated_at = getattr(instance, "updated_at", None) - if updated_at and updated_at > self.last_sync_at: - return True - created_at = getattr(instance, "created_at", None) - if created_at and created_at > self.last_sync_at: - return True - return False - - def _get_beta_table_info(self, model): - table_name = model._meta.db_table - if table_name in self._beta_schema_cache: - return self._beta_schema_cache[table_name] - - info = {"exists": False, "columns": set(), "not_null_no_default": set()} - - try: - with connections[BETA_DB].cursor() as cursor: - cursor.execute( - "SELECT column_name, is_nullable, column_default " - "FROM information_schema.columns " - "WHERE table_schema = 'public' AND table_name = %s", - [table_name], - ) - rows = cursor.fetchall() - - if rows: - info["exists"] = True - for col_name, is_nullable, col_default in rows: - info["columns"].add(col_name) - if is_nullable == "NO" and col_default is None: - info["not_null_no_default"].add(col_name) - except Exception as e: - logger.error(f"查询Beta数据库表结构失败 [{table_name}]: {e}") - - self._beta_schema_cache[table_name] = info - return info - - def _sync_instance(self, instance): - model = instance.__class__ - model_label = f"{model._meta.app_label}.{model.__name__}" - pk = instance.pk - - if model_label in self._synced_pks and pk in self._synced_pks[model_label]: - self.stats["skipped"] += 1 - return True - - table_info = self._get_beta_table_info(model) - if not table_info["exists"]: - if model._meta.db_table not in self._missing_tables: - self.stats["errors"].append( - f"{model.__name__}: Beta数据库中表 {model._meta.db_table} 不存在,已跳过" - ) - self._missing_tables.add(model._meta.db_table) - self._synced_pks.setdefault(model_label, set()).add(pk) - self.stats["skipped"] += 1 - return True - - if not self._is_changed(instance): - if model.objects.using(BETA_DB).filter(pk=pk).exists(): - self._synced_pks.setdefault(model_label, set()).add(pk) - self.stats["skipped"] += 1 - return True - - beta_columns = table_info["columns"] - field_values = {} - m2m_values = OrderedDict() - - for field in model._meta.get_fields(): - if field.many_to_many: - if field.auto_created: - continue - m2m_values[field.name] = list( - getattr(instance, field.name).values_list("pk", flat=True) - ) - continue - - if field.auto_created and not field.concrete: - continue - - if not hasattr(instance, field.attname): - continue - - if not hasattr(field, "column") or field.column not in beta_columns: - continue - - value = getattr(instance, field.attname) - - if isinstance(field, models.ForeignKey): - if value is not None: - try: - related_obj = getattr(instance, field.name) - if related_obj is not None: - self._ensure_stub_exists(related_obj) - except Exception: - pass - - value = _re_encrypt_value(value, model_label, field.name) - - field_values[field.name] = value - - try: - obj, created = model.objects.using(BETA_DB).update_or_create( - pk=pk, - defaults=field_values, - ) - except IntegrityError as e: - logger.warning(f"IntegrityError [{model.__name__}:{pk}]: {e}") - if model.objects.using(BETA_DB).filter(pk=pk).exists(): - try: - model.objects.using(BETA_DB).filter(pk=pk).update(**field_values) - obj = model.objects.using(BETA_DB).get(pk=pk) - except Exception as e2: - self.stats["failed"] += 1 - self.stats["errors"].append( - f"{model.__name__}:{pk} - 更新失败: {str(e2)}" - ) - return False - else: - prod_cols = { - f.column - for f in model._meta.concrete_fields - if hasattr(f, "column") - } - missing = table_info["not_null_no_default"] - prod_cols - hint = ( - f"Beta数据库存在额外NOT NULL无默认值列: {missing}" - if missing - else str(e) - ) - self.stats["failed"] += 1 - self.stats["errors"].append(f"{model.__name__}:{pk} - 创建失败: {hint}") - return False - - for field_name, related_pks in m2m_values.items(): - try: - m2m_field = model._meta.get_field(field_name) - m2m_through_info = self._get_beta_table_info( - m2m_field.remote_field.through - ) - if not m2m_through_info["exists"]: - logger.warning( - f"M2M中间表不存在于Beta数据库 [{model.__name__}.{field_name}]" - ) - continue - - m2m_model = m2m_field.related_model - existing_pks = set( - m2m_model.objects.using(BETA_DB) - .filter(pk__in=related_pks) - .values_list("pk", flat=True) - ) - m2m_manager = getattr(obj, field_name) - m2m_manager.set(existing_pks) - except Exception as e: - logger.warning(f"M2M同步失败 [{model.__name__}.{field_name}]: {e}") - - self._synced_pks.setdefault(model_label, set()).add(pk) - self.stats["pushed"] += 1 - return True - - def _ensure_stub_exists(self, related_instance): - model = related_instance.__class__ - model_label = f"{model._meta.app_label}.{model.__name__}" - pk = related_instance.pk - - if model._meta.db_table in self._missing_tables: - return - - if model_label in self._synced_pks and pk in self._synced_pks[model_label]: - return - - if model.objects.using(BETA_DB).filter(pk=pk).exists(): - self._synced_pks.setdefault(model_label, set()).add(pk) - return - - self._sync_instance(related_instance) - - def _push_user(self): - self._sync_instance(self.user) - - def _push_user_profile(self): - try: - profile = self.user.profile - self._sync_instance(profile) - except Exception: - pass - - def _push_user_groups(self): - for group in self.user.groups.all(): - try: - if ( - not group.__class__.objects.using(BETA_DB) - .filter(pk=group.pk) - .exists() - ): - self._sync_instance(group) - try: - gp = group.profile - self._sync_instance(gp) - except Exception: - pass - except Exception: - pass - - def _get_provider_hosts(self): - from apps.hosts.models import Host - from django.db.models import Q - - if self.user.is_superuser: - return Host.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return Host.objects.filter(site_group=self.site_group) - return Host.objects.filter(providers=self.user) - - def _push_hosts(self): - from apps.hosts.models import Host - - hosts = self._get_provider_hosts() - for host in hosts: - self._sync_instance(host) - - def _get_provider_host_groups(self): - from apps.hosts.models import HostGroup - from django.db.models import Q - - if self.user.is_superuser: - return HostGroup.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return HostGroup.objects.filter(site_group=self.site_group) - return HostGroup.objects.filter(providers=self.user) - - def _push_host_groups(self): - from apps.hosts.models import HostGroup - - host_groups = self._get_provider_host_groups() - for hg in host_groups: - self._sync_instance(hg) - - def _get_provider_product_groups(self): - from apps.operations.models import ProductGroup - from django.db.models import Q - - if self.user.is_superuser: - return ProductGroup.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return ProductGroup.objects.filter(site_group=self.site_group) - return ProductGroup.objects.filter(created_by=self.user) - - def _push_product_groups(self): - from apps.operations.models import ProductGroup - - product_groups = self._get_provider_product_groups() - for pg in product_groups: - self._sync_instance(pg) - - def _get_provider_products(self): - from apps.operations.models import Product - from django.db.models import Q - - if self.user.is_superuser: - return Product.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return Product.objects.filter(site_group=self.site_group) - return Product.objects.filter(created_by=self.user) - - def _push_products(self): - from apps.operations.models import Product - - products = self._get_provider_products() - for product in products: - self._sync_instance(product) - - def _get_provider_cloud_users(self): - from apps.operations.models import CloudComputerUser, Product - from django.db.models import Q - - if self.user.is_superuser: - return CloudComputerUser.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return CloudComputerUser.objects.filter( - product__site_group=self.site_group - ) - provider_product_ids = Product.objects.filter(created_by=self.user).values_list( - "pk", flat=True - ) - return CloudComputerUser.objects.filter(product_id__in=provider_product_ids) - - def _push_cloud_computer_users(self): - from apps.operations.models import CloudComputerUser - - cloud_users = self._get_provider_cloud_users() - for cu in cloud_users: - self._sync_instance(cu) - - def _get_provider_requests(self): - from apps.operations.models import AccountOpeningRequest, Product - from django.db.models import Q - - if self.user.is_superuser: - return AccountOpeningRequest.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return AccountOpeningRequest.objects.filter( - target_product__site_group=self.site_group - ) - provider_product_ids = Product.objects.filter(created_by=self.user).values_list( - "pk", flat=True - ) - return AccountOpeningRequest.objects.filter( - target_product_id__in=provider_product_ids - ) - - def _push_account_opening_requests(self): - from apps.operations.models import AccountOpeningRequest - - requests = self._get_provider_requests() - for req in requests: - self._sync_instance(req) - - def _get_provider_invitation_tokens(self): - from apps.operations.models import ProductInvitationToken, Product - - if self.user.is_superuser: - return ProductInvitationToken.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return ProductInvitationToken.objects.filter( - product__in=Product.objects.filter(site_group=self.site_group) - ) - return ProductInvitationToken.objects.filter(created_by=self.user) - - def _push_invitation_tokens(self): - from apps.operations.models import ProductInvitationToken - - tokens = self._get_provider_invitation_tokens() - for token in tokens: - self._sync_instance(token) - - def _get_provider_access_grants(self): - from apps.operations.models import ProductAccessGrant, Product - from django.db.models import Q - - if self.user.is_superuser: - return ProductAccessGrant.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return ProductAccessGrant.objects.filter( - product__site_group=self.site_group - ) - provider_product_ids = Product.objects.filter(created_by=self.user).values_list( - "pk", flat=True - ) - return ProductAccessGrant.objects.filter(product_id__in=provider_product_ids) - - def _push_access_grants(self): - from apps.operations.models import ProductAccessGrant - - grants = self._get_provider_access_grants() - for grant in grants: - self._sync_instance(grant) - - def _get_provider_rdp_routes(self): - from apps.operations.models import RdpDomainRoute, Product - from django.db.models import Q - - if self.user.is_superuser: - return RdpDomainRoute.objects.all() - if self.site_group and self.user.is_site_group_admin(self.site_group): - return RdpDomainRoute.objects.filter(product__site_group=self.site_group) - provider_product_ids = Product.objects.filter(created_by=self.user).values_list( - "pk", flat=True - ) - return RdpDomainRoute.objects.filter(product_id__in=provider_product_ids) - - def _push_rdp_domain_routes(self): - from apps.operations.models import RdpDomainRoute - - routes = self._get_provider_rdp_routes() - for route in routes: - self._sync_instance(route) diff --git a/plugins/beta_push/tasks.py b/plugins/beta_push/tasks.py deleted file mode 100644 index 94464a2..0000000 --- a/plugins/beta_push/tasks.py +++ /dev/null @@ -1,55 +0,0 @@ -import logging - -from celery import shared_task -from django.utils import timezone - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=1, default_retry_delay=10) -def push_to_beta(self, user_id, sync_log_id, site_group_id=None): - from .models import SyncLog - from .services import BetaPushService - - try: - sync_log = SyncLog.objects.get(pk=sync_log_id) - except SyncLog.DoesNotExist: - logger.error(f"SyncLog {sync_log_id} 不存在") - return - - sync_log.status = "running" - sync_log.started_at = timezone.now() - sync_log.task_id = self.request.id - sync_log.save(update_fields=["status", "started_at", "task_id"]) - - try: - service = BetaPushService( - user_id=user_id, - task_id=self.request.id, - site_group_id=site_group_id, - ) - stats = service.push_all() - - sync_log.status = "success" - sync_log.records_pushed = stats["pushed"] - sync_log.records_skipped = stats["skipped"] - sync_log.records_failed = stats["failed"] - if stats["errors"]: - sync_log.error_message = "\n".join(stats["errors"][:20]) - sync_log.completed_at = timezone.now() - sync_log.save() - - logger.info( - f"Beta推送完成: user={user_id}, " - f'pushed={stats["pushed"]}, ' - f'skipped={stats["skipped"]}, ' - f'failed={stats["failed"]}' - ) - - except Exception as e: - logger.error(f"Beta推送失败: user={user_id}, error={e}", exc_info=True) - sync_log.status = "failed" - sync_log.error_message = str(e)[:2000] - sync_log.completed_at = timezone.now() - sync_log.save() - raise self.retry(exc=e) diff --git a/plugins/beta_push/templates/beta_push/dashboard.html b/plugins/beta_push/templates/beta_push/dashboard.html deleted file mode 100644 index c4853a3..0000000 --- a/plugins/beta_push/templates/beta_push/dashboard.html +++ /dev/null @@ -1,293 +0,0 @@ -{% extends "admin_base/base.html" %} -{% load static %} - -{% block title %}2c2a 提供商后台 - Beta数据推送{% endblock %} - -{% block breadcrumb %} -首页 -chevron_right -Beta数据推送 -{% endblock %} - -{% block content %} -
- -
-

Beta数据推送

-

将您的生产环境数据异步推送到Beta版本数据库,仅推送变更数据

-
- - {% if not beta_configured %} -
-
- warning -
-

Beta数据库未配置

-

请在环境变量中配置以下参数后重启服务:

-
-

BETA_DB_NAME=zasca_beta

-

BETA_DB_USER=postgres

-

BETA_DB_PASSWORD=your_password

-

BETA_DB_HOST=127.0.0.1

-

BETA_DB_PORT=5432

-
-
-
-
- {% else %} - -
- -
-
-
- sync -
-
-

上次成功推送

-

- {% if last_success %} - {{ last_success.completed_at|date:"Y-m-d H:i" }} - {% else %} - 尚未推送 - {% endif %} -

-
-
- {% if last_success %} -
- 推送 {{ last_success.records_pushed }} - 跳过 {{ last_success.records_skipped }} - 失败 {{ last_success.records_failed }} -
- {% endif %} -
- -
-
-
- dns -
-
-

推送范围

-

当前用户关联数据

-
-
-

主机、产品、云电脑用户、开户申请、邀请令牌、授权记录、域名路由

-
- -
-
-
- update -
-
-

同步模式

-

增量同步

-
-
-

仅推送自上次同步以来变更的数据,已有数据自动跳过

-
-
- -
-
-
-

执行推送

-

点击按钮将数据推送到Beta数据库

-
- -
- -
-
- 准备中... - 0% -
-
-
-
-
-
- -
-

推送历史

- -
- history -

暂无推送记录

-
- -
- -
-
- - {% endif %} - -
- - -{% endblock %} diff --git a/plugins/beta_push/urls.py b/plugins/beta_push/urls.py deleted file mode 100644 index 9f85e38..0000000 --- a/plugins/beta_push/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from . import views - -app_name = 'beta_push' - -urlpatterns = [ - path('', views.dashboard, name='dashboard'), - path('push/', views.start_push, name='start_push'), - path('status/', views.push_status, name='push_status'), -] diff --git a/plugins/beta_push/views.py b/plugins/beta_push/views.py deleted file mode 100644 index b07d170..0000000 --- a/plugins/beta_push/views.py +++ /dev/null @@ -1,173 +0,0 @@ -import logging - -from django.contrib.auth import get_user_model -from django.contrib import messages -from django.http import JsonResponse -from django.shortcuts import redirect, render -from django.views.decorators.http import require_POST -from django.utils import timezone - -from apps.accounts.provider_decorators import is_provider, is_site_group_admin - -from .models import SyncLog -from .services import get_progress - -User = get_user_model() -logger = logging.getLogger(__name__) - - -def _check_permission(user, site_group=None): - if user.is_superuser: - return True - if is_site_group_admin(user, site_group): - return True - return is_provider(user) - - -def dashboard(request): - site_group = getattr(request, "site_group", None) - if not _check_permission(request.user, site_group): - from django.http import HttpResponseForbidden - - return HttpResponseForbidden("仅主机提供商及以上权限用户可使用此功能") - - from . import is_beta_db_configured - from django.conf import settings - - beta_configured = is_beta_db_configured() and "beta" in settings.DATABASES - - sync_logs = SyncLog.objects.filter(user=request.user).order_by("-created_at")[:10] - - is_running = sync_logs.filter(status="running").exists() if sync_logs else False - - last_success = None - for log in sync_logs: - if log.status == "success": - last_success = log - break - - running_task_id = "" - if is_running: - running_log = sync_logs.filter(status="running").first() - running_task_id = running_log.task_id if running_log else "" - - context = { - "page_title": "Beta数据推送", - "active_nav": "beta_push", - "beta_configured": beta_configured, - "sync_logs": sync_logs, - "is_running": is_running, - "last_success": last_success, - "running_task_id": running_task_id, - } - - return render(request, "beta_push/dashboard.html", context) - - -@require_POST -def start_push(request): - site_group = getattr(request, "site_group", None) - if not _check_permission(request.user, site_group): - return JsonResponse({"success": False, "error": "权限不足"}, status=403) - - from . import is_beta_db_configured - from django.conf import settings - - if not is_beta_db_configured() or "beta" not in settings.DATABASES: - return JsonResponse({"success": False, "error": "Beta数据库未配置"}, status=400) - - if SyncLog.objects.filter(user=request.user, status="running").exists(): - return JsonResponse( - {"success": False, "error": "已有推送任务正在执行"}, status=409 - ) - - sync_log = SyncLog.objects.create( - user=request.user, - status="pending", - ) - - try: - from .tasks import push_to_beta - - result = push_to_beta.delay( - request.user.pk, - sync_log.pk, - site_group_id=site_group.pk if site_group else None, - ) - sync_log.task_id = result.id - sync_log.save(update_fields=["task_id"]) - - return JsonResponse( - { - "success": True, - "task_id": result.id, - "sync_log_id": sync_log.pk, - } - ) - except Exception as e: - logger.error(f"启动Beta推送任务失败: {e}", exc_info=True) - sync_log.status = "failed" - sync_log.error_message = str(e) - sync_log.save() - return JsonResponse({"success": False, "error": "任务启动失败"}, status=500) - - -def push_status(request): - site_group = getattr(request, "site_group", None) - if not _check_permission(request.user, site_group): - return JsonResponse({"success": False, "error": "权限不足"}, status=403) - - task_id = request.GET.get("task_id", "") - if not task_id: - latest_log = ( - SyncLog.objects.filter(user=request.user).order_by("-created_at").first() - ) - if not latest_log: - return JsonResponse({"success": True, "status": "none"}) - return JsonResponse( - { - "success": True, - "status": latest_log.status, - "records_pushed": latest_log.records_pushed, - "records_skipped": latest_log.records_skipped, - "records_failed": latest_log.records_failed, - "error_message": latest_log.error_message, - "completed_at": ( - latest_log.completed_at.isoformat() - if latest_log.completed_at - else None - ), - } - ) - - progress = get_progress(task_id) - - sync_log = ( - SyncLog.objects.filter( - user=request.user, - task_id=task_id, - ) - .order_by("-created_at") - .first() - ) - - response_data = { - "success": True, - "progress": progress, - } - - if sync_log: - response_data.update( - { - "status": sync_log.status, - "records_pushed": sync_log.records_pushed, - "records_skipped": sync_log.records_skipped, - "records_failed": sync_log.records_failed, - "error_message": sync_log.error_message, - "completed_at": ( - sync_log.completed_at.isoformat() if sync_log.completed_at else None - ), - } - ) - - return JsonResponse(response_data) diff --git a/plugins/core/__init__.py b/plugins/core/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/plugins/core/base.py b/plugins/core/base.py deleted file mode 100755 index 212b393..0000000 --- a/plugins/core/base.py +++ /dev/null @@ -1,231 +0,0 @@ -import abc -import logging -from typing import Any, Dict, List, Optional, Type - -from django.utils.safestring import mark_safe - -logger = logging.getLogger(__name__) - - -class PluginInterface(abc.ABC): - def __init__(self, plugin_id: str, name: str, version: str, description: str = ""): - self.plugin_id = plugin_id - self.name = name - self.version = version - self.description = description - self.enabled = True - - @property - def metadata(self) -> Dict[str, Any]: - return { - 'id': self.plugin_id, - 'name': self.name, - 'version': self.version, - 'description': self.description, - 'enabled': self.enabled - } - - @abc.abstractmethod - def initialize(self) -> bool: - pass - - @abc.abstractmethod - def shutdown(self) -> bool: - pass - - -class ServiceProvider(abc.ABC): - """ - 服务提供者接口 - 插件可实现此接口以向系统注册可发现的服务 - """ - - @abc.abstractmethod - def get_service_name(self) -> str: - pass - - @abc.abstractmethod - def get_service_interface(self) -> Type: - pass - - def get_service(self) -> Any: - return self - - -class ServiceRegistry: - """ - 服务注册表 - 管理插件提供的服务实例,支持按服务名称和接口类型查找 - """ - - def __init__(self): - self._services: Dict[str, Any] = {} - self._interfaces: Dict[Type, List[str]] = {} - - def register(self, provider: ServiceProvider) -> None: - name = provider.get_service_name() - interface = provider.get_service_interface() - service = provider.get_service() - - self._services[name] = service - - if interface not in self._interfaces: - self._interfaces[interface] = [] - if name not in self._interfaces[interface]: - self._interfaces[interface].append(name) - - logger.info(f"Service registered: {name} (interface: {interface.__name__})") - - def unregister(self, service_name: str) -> None: - if service_name in self._services: - del self._services[service_name] - for interface, names in self._interfaces.items(): - if service_name in names: - names.remove(service_name) - logger.info(f"Service unregistered: {service_name}") - - def get(self, service_name: str) -> Optional[Any]: - return self._services.get(service_name) - - def get_by_interface(self, interface: Type) -> List[Any]: - names = self._interfaces.get(interface, []) - return [self._services[n] for n in names if n in self._services] - - def list_services(self) -> Dict[str, Any]: - return dict(self._services) - - -class HookInterface(abc.ABC): - @abc.abstractmethod - def execute(self, *args, **kwargs) -> Any: - pass - - -class EventHook(HookInterface): - def __init__(self, name: str): - self.name = name - self.handlers: List[callable] = [] - - def register(self, handler: callable): - if handler not in self.handlers: - self.handlers.append(handler) - - def unregister(self, handler: callable): - if handler in self.handlers: - self.handlers.remove(handler) - - def execute(self, *args, **kwargs) -> List[Any]: - results = [] - for handler in self.handlers: - try: - result = handler(*args, **kwargs) - results.append(result) - except Exception as e: - logger.error( - f"Error executing handler " - f"{handler.__name__}: {str(e)}" - ) - results.append(None) - return results - - -class UIExtension: - """ - UI 扩展点描述对象 - - 插件通过此对象声明要在某个页面位置注入的 - HTML 片段、模板路径或表单字段。 - """ - - FORM_FIELD = 'form_field' - SECTION = 'section' - NAV_ITEM = 'nav_item' - TEMPLATE = 'template' - HTML = 'html' - - def __init__( - self, - extension_type: str, - slot: str, - label: str = '', - html: str = '', - template_name: str = '', - field_name: str = '', - field_config: Optional[Dict[str, Any]] = None, - order: int = 0, - context_callback=None, - ): - self.extension_type = extension_type - self.slot = slot - self.label = label - self.html = html - self.template_name = template_name - self.field_name = field_name - self.field_config = field_config or {} - self.order = order - self.context_callback = context_callback - - def render(self, request=None) -> str: - if self.html: - return mark_safe(self.html) - - if self.template_name: - render_ctx = {} - if self.context_callback: - extra = self.context_callback() - if extra: - render_ctx.update(extra) - from django.template.loader import ( - render_to_string, - ) - return mark_safe( - render_to_string( - self.template_name, render_ctx, - request=request, - ) - ) - - return '' - - -class UIExtensionProvider(abc.ABC): - """ - UI 扩展提供者接口 - - 插件实现此接口以向前端页面注入扩展内容。 - 扩展点(slot)由核心系统在各页面模板中预定义, - 插件只需声明自己要注入到哪个 slot 即可。 - """ - - @abc.abstractmethod - def get_ui_extensions(self) -> List[UIExtension]: - pass - - -class URLProvider(abc.ABC): - """ - URL 提供者接口 - - 插件实现此接口以向系统注册 URL 路由。 - 系统在启动时收集所有插件的 URL 模式, - 并动态 include 到对应命名空间下。 - """ - - ADMIN = 'admin' - PROVIDER = 'provider' - PUBLIC = 'public' - - @abc.abstractmethod - def get_url_patterns(self) -> List[dict]: - """ - 返回 URL 模式列表。 - - 每个元素为 dict: - { - 'prefix': 'qq/', # URL 前缀 - 'module': 'plugins.qq_verification.urls_admin', - 'namespace': 'admin_plugins', - 'section': URLProvider.ADMIN, - } - """ - pass diff --git a/plugins/core/plugin_manager.py b/plugins/core/plugin_manager.py deleted file mode 100755 index a431e63..0000000 --- a/plugins/core/plugin_manager.py +++ /dev/null @@ -1,220 +0,0 @@ -import os -import sys -import importlib -import inspect -import logging -from typing import Dict, List, Type, Any, Optional, Set -from pathlib import Path -from django.conf import settings -from .base import ( - PluginInterface, - EventHook, - ServiceProvider, - ServiceRegistry, - UIExtension, - UIExtensionProvider, - URLProvider, -) - -logger = logging.getLogger(__name__) - - -class PluginManager: - def __init__(self): - self.plugins: Dict[str, PluginInterface] = {} - self.hooks: Dict[str, EventHook] = {} - self.loaded_modules: Set[str] = set() - self.service_registry = ServiceRegistry() - self._ui_extensions: Dict[ - str, List[UIExtension] - ] = {} - - def discover_builtin_plugins(self) -> Dict[str, dict]: - from ..available_plugins import ALL_AVAILABLE_PLUGINS - return ALL_AVAILABLE_PLUGINS - - def load_builtin_plugin(self, plugin_key: str, plugin_info: dict) -> Optional[PluginInterface]: - if not plugin_info.get('enabled', True): - logger.info(f"插件 {plugin_key} 已禁用,跳过加载") - return None - - try: - module_path = plugin_info['module'] - class_name = plugin_info['class'] - - module = importlib.import_module(module_path) - plugin_class = getattr(module, class_name) - - if (inspect.isclass(plugin_class) and - issubclass(plugin_class, PluginInterface) and - plugin_class != PluginInterface): - - plugin_instance = plugin_class() - self.plugins[plugin_instance.plugin_id] = plugin_instance - - if isinstance(plugin_instance, ServiceProvider): - self.service_registry.register(plugin_instance) - - logger.info(f"成功加载插件: {plugin_instance.name} (ID: {plugin_instance.plugin_id})") - return plugin_instance - else: - logger.error(f"模块 {module_path} 中的 {class_name} 不是一个有效的插件类") - return None - - except ImportError as e: - logger.error(f"无法导入插件模块 {plugin_info['module']}: {str(e)}") - return None - except AttributeError as e: - logger.error(f"模块 {plugin_info['module']} 中找不到类 {plugin_info['class']}: {str(e)}") - return None - except Exception as e: - logger.error(f"加载插件 {plugin_key} 时发生错误: {str(e)}") - return None - - def load_all_builtin_plugins(self): - builtin_plugins = self.discover_builtin_plugins() - - for plugin_key, plugin_info in builtin_plugins.items(): - plugin = self.load_builtin_plugin(plugin_key, plugin_info) - if plugin: - try: - if not plugin.initialize(): - logger.warning(f"插件 {plugin.name} 初始化失败") - except Exception as e: - logger.error(f"插件 {plugin.name} 初始化时发生错误: {str(e)}") - - def unload_plugin(self, plugin_id: str) -> bool: - if plugin_id not in self.plugins: - logger.warning(f"插件 {plugin_id} 不存在") - return False - - plugin = self.plugins[plugin_id] - - try: - if isinstance(plugin, ServiceProvider): - self.service_registry.unregister(plugin.get_service_name()) - - if not plugin.shutdown(): - logger.warning(f"插件 {plugin.name} 关闭时返回失败状态") - - del self.plugins[plugin_id] - - logger.info(f"插件 {plugin.name} (ID: {plugin_id}) 已卸载") - return True - except Exception as e: - logger.error(f"卸载插件 {plugin_id} 时发生错误: {str(e)}") - return False - - def get_plugin(self, plugin_id: str) -> Optional[PluginInterface]: - return self.plugins.get(plugin_id) - - def get_all_plugins(self) -> Dict[str, PluginInterface]: - return self.plugins.copy() - - def get_plugin_metadata(self) -> List[Dict[str, Any]]: - metadata_list = [] - for plugin in self.plugins.values(): - metadata_list.append(plugin.metadata) - return metadata_list - - def get_service(self, service_name: str) -> Optional[Any]: - return self.service_registry.get(service_name) - - def get_services_by_interface(self, interface: Type) -> List[Any]: - return self.service_registry.get_by_interface(interface) - - def list_services(self) -> Dict[str, Any]: - return self.service_registry.list_services() - - def register_hook(self, hook_name: str) -> EventHook: - if hook_name not in self.hooks: - self.hooks[hook_name] = EventHook(hook_name) - return self.hooks[hook_name] - - def get_hook(self, hook_name: str) -> Optional[EventHook]: - return self.hooks.get(hook_name) - - def trigger_hook(self, hook_name: str, *args, **kwargs) -> List[Any]: - hook = self.get_hook(hook_name) - if hook: - return hook.execute(*args, **kwargs) - return [] - - def start_all_plugins(self): - for plugin_id, plugin in self.plugins.items(): - try: - if not plugin.initialize(): - logger.warning( - f"插件 {plugin.name} 启动失败" - ) - except Exception as e: - logger.error( - f"启动插件 {plugin.name} " - f"时发生错误: {str(e)}" - ) - - def _collect_ui_extensions(self): - self._ui_extensions.clear() - for plugin in self.plugins.values(): - if isinstance(plugin, UIExtensionProvider): - try: - exts = plugin.get_ui_extensions() - for ext in exts: - slot = ext.slot - if slot not in self._ui_extensions: - self._ui_extensions[slot] = [] - self._ui_extensions[slot].append(ext) - except Exception as e: - logger.error( - f"收集插件 {plugin.name} " - f"UI扩展失败: {str(e)}" - ) - for slot in self._ui_extensions: - self._ui_extensions[slot].sort( - key=lambda e: e.order - ) - - def get_ui_extensions( - self, slot: str - ) -> List[UIExtension]: - if not self._ui_extensions: - self._collect_ui_extensions() - return self._ui_extensions.get(slot, []) - - def get_all_ui_slots(self) -> List[str]: - if not self._ui_extensions: - self._collect_ui_extensions() - return list(self._ui_extensions.keys()) - - def get_plugin_url_patterns( - self, section: str - ) -> List[dict]: - patterns = [] - for plugin in self.plugins.values(): - if isinstance(plugin, URLProvider): - try: - for p in plugin.get_url_patterns(): - if p.get('section') == section: - patterns.append(p) - except Exception as e: - logger.error( - f"收集插件 {plugin.name} " - f"URL失败: {str(e)}" - ) - return patterns - - def stop_all_plugins(self): - for plugin_id in reversed(list(self.plugins.keys())): - plugin = self.plugins[plugin_id] - try: - if not plugin.shutdown(): - logger.warning(f"插件 {plugin.name} 停止时返回失败状态") - except Exception as e: - logger.error(f"停止插件 {plugin.name} 时发生错误: {str(e)}") - - -plugin_manager = PluginManager() - - -def get_plugin_manager() -> PluginManager: - return plugin_manager diff --git a/plugins/django_integration.py b/plugins/django_integration.py deleted file mode 100755 index 3930b69..0000000 --- a/plugins/django_integration.py +++ /dev/null @@ -1,205 +0,0 @@ -""" -Django项目插件系统集成 -展示如何将插件系统集成到Django项目中 -""" - -from django.conf import settings -from django.http import JsonResponse -from django.views.decorators.http import require_http_methods -from django.contrib.auth.decorators import login_required -import json -import logging - -from . import plugin_manager - -logger = logging.getLogger(__name__) - - -def initialize_plugins(): - """ - 初始化项目插件 - 应该在Django应用启动时调用 - """ - # 添加项目插件目录 - plugin_dirs = [ - "./plugins/custom_plugins", # 自定义插件目录 - "./plugins/third_party", # 第三方插件目录 - ] - - # 从设置中读取额外的插件目录 - if hasattr(settings, 'PLUGIN_DIRS'): - plugin_dirs.extend(settings.PLUGIN_DIRS) - - for directory in plugin_dirs: - plugin_manager.add_plugin_directory(directory) - - # 加载所有插件 - loaded_plugins = plugin_manager.load_all_plugins() - print(f"Django项目已加载插件: {loaded_plugins}") - - return loaded_plugins - - -def get_plugin(plugin_id): - """ - 获取插件实例 - :param plugin_id: 插件ID - :return: 插件实例或None - """ - return plugin_manager.get_plugin(plugin_id) - - -def trigger_hook(hook_name, *args, **kwargs): - """ - 触发钩子 - :param hook_name: 钩子名称 - :param args: 参数 - :param kwargs: 关键字参数 - :return: 钩子执行结果 - """ - return plugin_manager.trigger_hook(hook_name, *args, **kwargs) - - -def register_hook(hook_name, handler): - """ - 注册钩子处理器 - :param hook_name: 钩子名称 - :param handler: 处理器函数 - """ - plugin_manager.register_hook(hook_name, handler) - - -def plugin_api_view(request, plugin_id, action): - """ - 插件API视图 - 提供对插件功能的HTTP访问 - """ - plugin = get_plugin(plugin_id) - - if not plugin: - return JsonResponse({ - 'error': f'Plugin {plugin_id} not found', - 'success': False - }, status=404) - - if not plugin.enabled: - return JsonResponse({ - 'error': f'Plugin {plugin_id} is disabled', - 'success': False - }, status=400) - - if request.method == 'POST': - try: - data = json.loads(request.body) - except json.JSONDecodeError: - data = {} - else: - data = request.GET.dict() - - # 根据动作调用插件方法 - if hasattr(plugin, action): - try: - method = getattr(plugin, action) - if callable(method): - result = method(**data) - return JsonResponse({ - 'result': result, - 'success': True - }) - else: - return JsonResponse({ - 'error': f'{action} is not callable', - 'success': False - }, status=400) - except Exception as e: - logger.error("Plugin action execution failed", exc_info=True) - return JsonResponse({ - 'error': 'Internal server error', - 'success': False - }, status=500) - else: - return JsonResponse({ - 'error': f'Action {action} not found in plugin {plugin_id}', - 'success': False - }, status=400) - - -@require_http_methods(["GET", "POST"]) -@login_required -def plugin_management_api(request): - """ - 插件管理API - 用于管理插件的启用/禁用、加载/卸载等 - """ - if request.method == 'GET': - # 返回所有插件信息 - plugins = [] - for plugin in plugin_manager.get_all_plugins(): - plugins.append(plugin.metadata) - return JsonResponse({'plugins': plugins}) - - elif request.method == 'POST': - try: - data = json.loads(request.body) - action = data.get('action') - plugin_id = data.get('plugin_id') - - if action == 'enable': - success = plugin_manager.enable_plugin(plugin_id) - return JsonResponse({'success': success}) - elif action == 'disable': - success = plugin_manager.disable_plugin(plugin_id) - return JsonResponse({'success': success}) - elif action == 'reload': - # 重新加载插件(在实际实现中可能需要更复杂的逻辑) - plugin_manager.unregister_plugin(plugin_id) - # 重新从目录加载 - for directory in plugin_manager.plugin_dirs: - plugin_manager.load_plugins_from_directory(directory) - return JsonResponse({'success': True}) - else: - return JsonResponse({ - 'error': f'Unknown action: {action}', - 'success': False - }, status=400) - - except Exception as e: - logger.error("Plugin management action failed", exc_info=True) - return JsonResponse({ - 'error': 'Internal server error', - 'success': False - }, status=500) - - -class PluginMiddleware: - """ - 插件中间件 - 在请求处理过程中执行插件钩子 - """ - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - # 在请求处理前触发钩子 - trigger_hook('before_request', request=request) - - response = self.get_response(request) - - # 在请求处理后触发钩子 - trigger_hook('after_request', request=request, response=response) - - return response - - def process_view(self, request, view_func, view_args, view_kwargs): - """在视图函数调用前触发钩子""" - result = trigger_hook('before_view', - request=request, - view_func=view_func, - view_args=view_args, - view_kwargs=view_kwargs) - # 如果任何插件返回了HttpResponse,则使用它 - for res in result: - if res and hasattr(res, 'status_code'): - return res - return None \ No newline at end of file diff --git a/plugins/dynamic_urls.py b/plugins/dynamic_urls.py deleted file mode 100644 index c7c8b64..0000000 --- a/plugins/dynamic_urls.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.urls import path, include - -from plugins.core.plugin_manager import get_plugin_manager -from plugins.core.base import URLProvider - - -def _build_url_list(section): - pm = get_plugin_manager() - patterns = pm.get_plugin_url_patterns(section) - result = [] - for p in patterns: - namespace = p.get('namespace', '') - if namespace: - result.append( - path( - p['prefix'], - include(p['module'], namespace), - ) - ) - else: - result.append( - path( - p['prefix'], - include(p['module']), - ) - ) - return result - - -def get_plugin_admin_urls(): - return _build_url_list(URLProvider.ADMIN) - - -def get_plugin_provider_urls(): - return _build_url_list(URLProvider.PROVIDER) diff --git a/plugins/forms_admin.py b/plugins/forms_admin.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/forms_provider.py b/plugins/forms_provider.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/management/commands/plugin.py b/plugins/management/commands/plugin.py deleted file mode 100755 index a4768ec..0000000 --- a/plugins/management/commands/plugin.py +++ /dev/null @@ -1,1482 +0,0 @@ -""" -插件管理命令 -提供类似 pip 的插件管理功能,支持安装、卸载、搜索、登录等操作 -支持从文件安装:将插件目录解压到 plugins/ 目录后,使用 scan 发现并 install 安装 -""" -import os -import sys -import subprocess -import json -import re -import toml -import inspect -import zipfile -import tempfile -from django.core.management.base import BaseCommand, CommandError -from django.conf import settings -from plugins.core.plugin_manager import get_plugin_manager -from plugins.models import PluginRecord -import importlib -import importlib.util -from plugins.available_plugins import ALL_AVAILABLE_PLUGINS -import shutil -import urllib.request -import urllib.error - -PLUGIN_REGISTRY_URL = "https://raw.githubusercontent.com/2c2a/2c2a-plugin-registry/main/plugins.json" -PLUGIN_REGISTRY_RAW_API = "https://api.github.com/repos/2c2a/2c2a-plugin-registry/contents/plugins.json" -PLUGIN_REGISTRY_GITEE_URL = "https://raw.giteeusercontent.com/Bilibili-Supercmd/plugin-registry/raw/main/plugins.json" - - -class Command(BaseCommand): - help = '插件管理命令,类似 pip 的功能' - - def add_arguments(self, parser): - parser.add_argument('action', type=str, help='操作类型: install, upgrade, uninstall, list, info, search, scan, login, enable, disable') - parser.add_argument('plugin_name', nargs='?', type=str, help='插件名称、本地路径或zip文件路径') - parser.add_argument('--source', type=str, help='插件源地址或本地路径') - parser.add_argument('--force', action='store_true', help='强制执行操作') - parser.add_argument('--no-migrate', action='store_true', help='跳过数据库迁移') - parser.add_argument('--debug', action='store_true', help='输出调试信息') - parser.add_argument('--registry', type=str, default=PLUGIN_REGISTRY_URL, help='插件仓库地址') - parser.add_argument('--force-github', action='store_true', help='强制使用 GitHub 插件仓库') - parser.add_argument('--force-gitee', action='store_true', help='强制使用 Gitee 插件仓库镜像') - parser.add_argument('--install-all', action='store_true', help='与 scan 配合使用,安装所有发现的未注册插件') - - def handle(self, *args, **options): - action = options['action'] - plugin_name = options.get('plugin_name') - no_migrate = options.get('no_migrate', False) - self.debug = options.get('debug', False) - - registry_url = options.get('registry') - if options.get('force_gitee'): - registry_url = PLUGIN_REGISTRY_GITEE_URL - elif options.get('force_github'): - registry_url = PLUGIN_REGISTRY_URL - - if action == 'list': - self.list_plugins() - elif action == 'scan': - self.scan_plugins( - install_all=options.get('install_all', False), - no_migrate=no_migrate, - ) - elif action == 'install': - if not plugin_name: - raise CommandError('安装插件需要指定插件名称、路径或zip文件') - self.install_plugin( - plugin_name, - options.get('source'), - options.get('force'), - registry_url, - no_migrate=no_migrate, - ) - elif action == 'upgrade': - if not plugin_name: - raise CommandError('升级插件需要指定插件名称') - self.upgrade_plugin(plugin_name, registry_url) - elif action == 'uninstall': - if not plugin_name: - raise CommandError('卸载插件需要指定插件名称') - self.uninstall_plugin( - plugin_name, - options.get('force'), - no_migrate=no_migrate, - ) - elif action == 'info': - if not plugin_name: - raise CommandError('查看插件信息需要指定插件名称') - self.plugin_info(plugin_name) - elif action == 'search': - keyword = plugin_name or '' - self.search_plugins(keyword, registry_url) - elif action == 'login': - self.login_github() - elif action == 'enable': - if not plugin_name: - raise CommandError('启用插件需要指定插件名称') - self.enable_plugin(plugin_name) - elif action == 'disable': - if not plugin_name: - raise CommandError('禁用插件需要指定插件名称') - self.disable_plugin(plugin_name) - else: - raise CommandError( - f'未知的操作: {action}. ' - f'支持的操作: install, upgrade, uninstall, ' - f'list, info, search, scan, login, enable, disable' - ) - - def _fetch_registry(self, registry_url=None): - url = registry_url or PLUGIN_REGISTRY_URL - urls_to_try = [] - - if url == PLUGIN_REGISTRY_GITEE_URL: - urls_to_try = [PLUGIN_REGISTRY_GITEE_URL, PLUGIN_REGISTRY_URL] - elif url == PLUGIN_REGISTRY_URL: - urls_to_try = [PLUGIN_REGISTRY_URL, PLUGIN_REGISTRY_GITEE_URL] - else: - urls_to_try = [url, PLUGIN_REGISTRY_GITEE_URL, PLUGIN_REGISTRY_URL] - - last_error = None - for try_url in urls_to_try: - try: - req = urllib.request.Request(try_url, headers={'User-Agent': '2c2a-PluginManager/1.0'}) - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read().decode('utf-8')) - return data.get('plugins', {}) - except Exception as e: - last_error = e - continue - - try: - result = subprocess.run( - ['gh', 'api', '-H', 'Accept: application/vnd.github.v3.raw', - 'repos/2c2a/2c2a-plugin-registry/contents/plugins.json'], - capture_output=True, text=True, timeout=15 - ) - if result.returncode == 0 and result.stdout.strip(): - data = json.loads(result.stdout) - return data.get('plugins', {}) - except FileNotFoundError: - pass - except subprocess.TimeoutExpired: - pass - except json.JSONDecodeError: - pass - except Exception: - pass - - raise CommandError( - '无法访问插件仓库。\n' - '请检查网络连接或使用 "uv run manage.py plugin login" 登录 GitHub CLI 后重试。' - ) - - def search_plugins(self, keyword='', registry_url=None): - remote_plugins = self._fetch_registry(registry_url) - if not remote_plugins: - self.stdout.write('远程插件仓库中没有插件') - return - - results = {} - for plugin_id, info in remote_plugins.items(): - if not keyword: - results[plugin_id] = info - else: - kw = keyword.lower() - if (kw in plugin_id.lower() or - kw in info.get('name', '').lower() or - kw in info.get('description', '').lower()): - results[plugin_id] = info - - if not results: - self.stdout.write(f'未找到与 "{keyword}" 匹配的插件') - return - - self.stdout.write(self.style.SUCCESS(f'找到 {len(results)} 个插件:')) - self.stdout.write('') - for plugin_id, info in results.items(): - self.stdout.write(f' {self.style.SUCCESS(plugin_id)}') - self.stdout.write(f' 名称: {info.get("name", "N/A")}') - self.stdout.write(f' 简介: {info.get("description", "N/A")}') - self.stdout.write(f' 仓库: {info.get("repository", "N/A")}') - self.stdout.write(f' 版本: {info.get("version", "N/A")}') - self.stdout.write('') - - def scan_plugins(self, install_all=False, no_migrate=False): - plugins_base = os.path.join(settings.BASE_DIR, 'plugins') - if not os.path.isdir(plugins_base): - raise CommandError(f'插件目录不存在: {plugins_base}') - - registered_ids = set(ALL_AVAILABLE_PLUGINS.keys()) - db_ids = set( - PluginRecord.objects.values_list('plugin_id', flat=True) - ) - loaded_plugins = get_plugin_manager().get_all_plugins() - loaded_ids = set(loaded_plugins.keys()) - - skip_dirs = {'core', 'management', 'templatetags', 'migrations', - '__pycache__', '.git'} - - discovered = [] - - for entry in sorted(os.listdir(plugins_base)): - entry_path = os.path.join(plugins_base, entry) - if not os.path.isdir(entry_path): - continue - if entry in skip_dirs: - continue - if entry.startswith('.') or entry.startswith('_'): - continue - init_file = os.path.join(entry_path, '__init__.py') - if not os.path.exists(init_file): - continue - - has_plugin_class = self._detect_plugin_class(entry, entry_path) - if not has_plugin_class: - continue - - is_registered = entry in registered_ids - is_in_db = entry in db_ids - is_loaded = entry in loaded_ids - - if is_registered or is_in_db or is_loaded: - continue - - plugin_class, plugin_module_name = self._load_plugin_class_from_package( - entry, entry_path - ) - plugin_name = entry - plugin_version = '0.0.0' - plugin_desc = '' - if plugin_class: - try: - inst = plugin_class() - plugin_name = inst.name - plugin_version = inst.version - plugin_desc = inst.description - except Exception as exc: - self.stderr.write( - f"Warning: failed to read metadata from plugin '{entry}': {exc}" - ) - - discovered.append({ - 'dir_name': entry, - 'path': entry_path, - 'name': plugin_name, - 'version': plugin_version, - 'description': plugin_desc, - 'plugin_class': plugin_class, - 'plugin_module_name': plugin_module_name, - }) - - if not discovered: - self.stdout.write(self.style.SUCCESS('没有发现未注册的插件')) - return - - self.stdout.write(self.style.SUCCESS( - f'发现 {len(discovered)} 个未注册的插件:' - )) - self.stdout.write('') - for info in discovered: - self.stdout.write(f' {self.style.SUCCESS(info["dir_name"])}') - self.stdout.write(f' 名称: {info["name"]}') - self.stdout.write(f' 版本: {info["version"]}') - self.stdout.write(f' 描述: {info["description"]}') - self.stdout.write(f' 路径: {info["path"]}') - self.stdout.write('') - - if install_all: - self.stdout.write('正在安装所有发现的插件...') - for info in discovered: - try: - app_label = self._register_discovered_plugin( - info, no_migrate=no_migrate - ) - self.stdout.write(self.style.SUCCESS( - f' ✓ {info["name"]} 安装完成' - )) - except Exception as e: - self.stdout.write(self.style.ERROR( - f' ✗ {info["dir_name"]} 安装失败: {str(e)}' - )) - else: - self.stdout.write( - '使用 "uv run python manage.py plugin install <目录名>" ' - '安装单个插件\n' - '使用 "uv run python manage.py plugin scan --install-all" ' - '安装所有发现的插件' - ) - - def _detect_plugin_class(self, dir_name, dir_path): - init_file = os.path.join(dir_path, '__init__.py') - if os.path.exists(init_file): - try: - with open(init_file, 'r', encoding='utf-8') as f: - content = f.read() - if re.search( - r'class\s+\w+\s*\([^)]*PluginInterface', - content, re.DOTALL - ): - return True - if 'PLUGIN_INFO' in content: - return True - except Exception as e: - self.stdout.write( - self.style.WARNING( - f'读取插件 {dir_name} 的 __init__.py 失败: {e}' - ) - ) - - for item in os.listdir(dir_path): - if not item.endswith('.py') or item == '__init__.py': - continue - fp = os.path.join(dir_path, item) - try: - with open(fp, 'r', encoding='utf-8') as f: - content = f.read() - if re.search( - r'class\s+\w+\s*\([^)]*PluginInterface', - content, re.DOTALL - ): - return True - except Exception: - continue - - return False - - def _register_discovered_plugin(self, info, no_migrate=False): - plugin_class = info['plugin_class'] - plugin_module_name = info['plugin_module_name'] - dir_name = info['dir_name'] - - if not plugin_class: - plugin_class, plugin_module_name = self._load_plugin_class_from_package( - dir_name, info['path'] - ) - - if not plugin_class: - raise CommandError( - f'在 {info["path"]} 中未找到有效的插件类' - ) - - plugin_instance = plugin_class() - plugin_manager = get_plugin_manager() - plugin_manager.plugins[plugin_instance.plugin_id] = plugin_instance - - try: - plugin_instance.initialize() - except Exception as e: - self.stdout.write( - self.style.WARNING( - f'插件 {plugin_instance.plugin_id} 初始化失败,已继续安装流程: {e}' - ) - ) - - PluginRecord.objects.update_or_create( - plugin_id=plugin_instance.plugin_id, - defaults={ - 'name': plugin_instance.name, - 'version': plugin_instance.version, - 'description': plugin_instance.description, - 'is_active': True, - } - ) - - self.add_plugin_to_toml_config(plugin_instance.plugin_id, { - 'name': plugin_instance.name, - 'module': plugin_module_name, - 'class': plugin_class.__name__, - 'description': plugin_instance.description, - 'version': plugin_instance.version, - 'enabled': True - }) - - app_label = self._get_app_label_from_module(plugin_module_name) - if app_label and not no_migrate: - self._run_migrate(app_label) - - return app_label - - def login_github(self): - self.stdout.write('正在检查 GitHub CLI 认证状态...') - try: - result = subprocess.run( - ['gh', 'auth', 'status'], - capture_output=True, text=True, timeout=10 - ) - if result.returncode == 0: - self.stdout.write(self.style.SUCCESS('GitHub CLI 已认证')) - self.stdout.write(result.stdout.strip()) - return - except FileNotFoundError: - raise CommandError( - '未找到 gh CLI,请先安装 GitHub CLI: https://cli.github.com/' - ) - except subprocess.TimeoutExpired: - raise CommandError('检查认证状态超时') - - self.stdout.write('GitHub CLI 未认证,正在启动登录流程...') - try: - result = subprocess.run( - ['gh', 'auth', 'login'], - stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr - ) - if result.returncode == 0: - self.stdout.write(self.style.SUCCESS('GitHub 登录成功!')) - else: - raise CommandError('GitHub 登录失败') - except FileNotFoundError: - raise CommandError( - '未找到 gh CLI,请先安装 GitHub CLI: https://cli.github.com/' - ) - - def install_plugin(self, plugin_name, source=None, force=False, registry_url=None, no_migrate=False): - self.stdout.write(f'正在安装插件: {plugin_name}') - - app_label = None - - if plugin_name.endswith('.zip'): - if not os.path.exists(plugin_name): - raise CommandError(f'zip 文件不存在: {plugin_name}') - app_label = self.install_from_zip(plugin_name, force=force, no_migrate=no_migrate) - elif os.path.exists(plugin_name) and os.path.isdir(plugin_name): - app_label = self.install_from_path(plugin_name) - else: - plugin_path = os.path.join( - settings.BASE_DIR, 'plugins', plugin_name - ) - if os.path.exists(plugin_path) and os.path.isdir(plugin_path): - app_label = self.install_from_path(plugin_path) - elif plugin_name in ALL_AVAILABLE_PLUGINS: - plugin_info = ALL_AVAILABLE_PLUGINS[plugin_name] - app_label = self.install_builtin_plugin( - plugin_id=plugin_name, plugin_info=plugin_info - ) - else: - found = False - for pid, pinfo in ALL_AVAILABLE_PLUGINS.items(): - if pinfo['name'].lower() == plugin_name.lower(): - app_label = self.install_builtin_plugin( - plugin_id=pid, plugin_info=pinfo - ) - found = True - break - if not found: - if source and source.endswith('.zip') and os.path.exists(source): - app_label = self.install_from_zip(source, force=force, no_migrate=no_migrate) - elif source and os.path.exists(source) and os.path.isdir(source): - app_label = self.install_from_path(source) - else: - app_label = self.install_from_registry( - plugin_name, registry_url, force - ) - - if app_label and not no_migrate: - self._run_migrate(app_label) - - def install_from_zip(self, zip_path, force=False, no_migrate=False): - if not zipfile.is_zipfile(zip_path): - raise CommandError(f'不是有效的 zip 文件: {zip_path}') - - plugins_base = os.path.join(settings.BASE_DIR, 'plugins') - zip_basename = os.path.basename(zip_path) - plugin_dir_name = os.path.splitext(zip_basename)[0] - - with zipfile.ZipFile(zip_path, 'r') as zf: - top_dirs = set() - for name in zf.namelist(): - parts = name.split('/') - if len(parts) > 1 and parts[0]: - top_dirs.add(parts[0]) - - if len(top_dirs) == 1: - single_dir = top_dirs.pop() - if single_dir == plugin_dir_name or single_dir.replace('-', '_') == plugin_dir_name: - plugin_dir_name = single_dir - - if len(top_dirs) == 1: - single_dir = list(top_dirs)[0] if not plugin_dir_name else plugin_dir_name - has_init = any( - n == f'{single_dir}/__init__.py' or n.startswith(f'{single_dir}/') - for n in zf.namelist() - if n.endswith('__init__.py') - ) - if not has_init: - nested = [n for n in zf.namelist() - if n.startswith(f'{single_dir}/') and n.endswith('/')] - if nested: - for sub in nested: - sub_name = sub.rstrip('/').split('/')[-1] - sub_init = f'{single_dir}/{sub_name}/__init__.py' - if sub_init in zf.namelist(): - plugin_dir_name = sub_name - break - - target_dir = os.path.join(plugins_base, plugin_dir_name) - if os.path.exists(target_dir): - if force: - shutil.rmtree(target_dir) - else: - raise CommandError( - f'插件目录已存在: {target_dir}\n' - f'使用 --force 强制重新安装' - ) - - self.stdout.write(f'正在从 zip 文件解压插件: {zip_path}') - - with tempfile.TemporaryDirectory() as tmp_dir: - with zipfile.ZipFile(zip_path, 'r') as zf: - zf.extractall(tmp_dir) - - extracted_items = os.listdir(tmp_dir) - if len(extracted_items) == 1 and os.path.isdir( - os.path.join(tmp_dir, extracted_items[0]) - ): - src_dir = os.path.join(tmp_dir, extracted_items[0]) - inner_items = os.listdir(src_dir) - has_init = '__init__.py' in inner_items - if has_init: - plugin_dir_name = extracted_items[0] - target_dir = os.path.join(plugins_base, plugin_dir_name) - if os.path.exists(target_dir): - if force: - shutil.rmtree(target_dir) - else: - raise CommandError( - f'插件目录已存在: {target_dir}\n' - f'使用 --force 强制重新安装' - ) - shutil.copytree(src_dir, target_dir) - else: - sub_dirs = [ - d for d in inner_items - if os.path.isdir(os.path.join(src_dir, d)) - and os.path.exists(os.path.join(src_dir, d, '__init__.py')) - ] - if len(sub_dirs) == 1: - plugin_dir_name = sub_dirs[0] - target_dir = os.path.join(plugins_base, plugin_dir_name) - if os.path.exists(target_dir): - if force: - shutil.rmtree(target_dir) - else: - raise CommandError( - f'插件目录已存在: {target_dir}\n' - f'使用 --force 强制重新安装' - ) - shutil.copytree( - os.path.join(src_dir, sub_dirs[0]), target_dir - ) - else: - shutil.copytree(src_dir, target_dir) - else: - shutil.copytree(tmp_dir, target_dir) - - self.stdout.write(self.style.SUCCESS( - f'插件已解压到: {target_dir}' - )) - - app_label = self.install_from_path(target_dir) - return app_label - - def install_from_registry(self, plugin_name, registry_url=None, force=False): - remote_plugins = self._fetch_registry(registry_url) - - plugin_info = None - for pid, info in remote_plugins.items(): - if pid == plugin_name or info.get('name', '').lower() == plugin_name.lower(): - plugin_info = info - plugin_name = pid - break - - if not plugin_info: - available = list(remote_plugins.keys()) - raise CommandError( - f'在远程仓库中找不到插件: {plugin_name}\n' - f'可用的远程插件: {available}' - ) - - repository_url = plugin_info.get('repository') - if not repository_url: - raise CommandError(f'插件 {plugin_name} 没有提供仓库地址') - - target_dir = os.path.join(settings.BASE_DIR, 'plugins', plugin_name) - if os.path.exists(target_dir): - if force: - shutil.rmtree(target_dir) - else: - raise CommandError( - f'插件目录已存在: {target_dir}\n' - f'使用 --force 强制重新安装' - ) - - self.stdout.write(f'正在从 {repository_url} 克隆插件...') - - clone_url = repository_url - if repository_url.startswith('https://github.com/'): - clone_url = repository_url.replace('https://github.com/', 'git@github.com:') - - try: - result = subprocess.run( - ['git', 'clone', '--depth', '1', clone_url, target_dir], - capture_output=True, text=True, timeout=120 - ) - if result.returncode != 0: - self.stdout.write('SSH 克隆失败,尝试 HTTPS...') - result = subprocess.run( - ['git', 'clone', '--depth', '1', repository_url, target_dir], - capture_output=True, text=True, timeout=120 - ) - if result.returncode != 0: - raise CommandError(f'克隆插件仓库失败: {result.stderr.strip()}') - except subprocess.TimeoutExpired: - raise CommandError('克隆插件仓库超时') - except FileNotFoundError: - raise CommandError('未找到 git,请先安装 git') - - self.stdout.write(self.style.SUCCESS(f'插件仓库已克隆到: {target_dir}')) - - git_dir = os.path.join(target_dir, '.git') - if os.path.exists(git_dir): - shutil.rmtree(git_dir) - self.stdout.write('已移除 .git 目录') - - plugin_record, created = PluginRecord.objects.update_or_create( - plugin_id=plugin_name, - defaults={ - 'name': plugin_info.get('name', plugin_name), - 'version': plugin_info.get('version', '0.0.0'), - 'description': plugin_info.get('description', ''), - 'is_active': True, - } - ) - if created: - self.stdout.write('已创建插件数据库记录') - else: - self.stdout.write('已更新插件数据库记录') - - self._try_register_cloned_plugin(plugin_name, target_dir, plugin_info) - - app_label = plugin_name - self.stdout.write(self.style.SUCCESS( - f'插件 {plugin_info.get("name", plugin_name)} 安装完成!' - )) - return app_label - - def _try_register_cloned_plugin(self, plugin_id, plugin_path, registry_info): - plugin_class = None - plugin_module_name = None - - plugin_class, plugin_module_name = self._load_plugin_class_from_package( - plugin_id, plugin_path - ) - - if plugin_class: - try: - plugin_instance = plugin_class() - plugin_manager = get_plugin_manager() - plugin_manager.plugins[plugin_instance.plugin_id] = plugin_instance - - try: - plugin_instance.initialize() - except Exception: - pass - - plugin_record, _ = PluginRecord.objects.update_or_create( - plugin_id=plugin_instance.plugin_id, - defaults={ - 'name': plugin_instance.name, - 'version': plugin_instance.version, - 'description': plugin_instance.description, - 'is_active': True, - } - ) - - self.add_plugin_to_toml_config(plugin_instance.plugin_id, { - 'name': plugin_instance.name, - 'module': plugin_module_name, - 'class': plugin_class.__name__, - 'description': plugin_instance.description, - 'version': plugin_instance.version, - 'enabled': True - }) - except Exception as e: - self.stdout.write(self.style.WARNING( - f'插件类注册失败(插件文件已下载): {str(e)}' - )) - else: - self.stdout.write(self.style.WARNING( - '未找到 PluginInterface 子类,插件已下载但未自动注册到 TOML 配置' - )) - self.stdout.write('你可能需要手动在 plugins.toml 中添加插件配置') - - def _load_plugin_class_from_package(self, plugin_id, plugin_path): - plugin_class = None - plugin_module_name = None - mod_name = f'plugins.{plugin_id}' - - init_file = os.path.join(plugin_path, '__init__.py') - if os.path.exists(init_file): - try: - init_module = importlib.import_module(mod_name) - if hasattr(init_module, 'PLUGIN_INFO'): - pinfo = getattr(init_module, 'PLUGIN_INFO') - if 'main_class' in pinfo and hasattr(init_module, pinfo['main_class']): - plugin_class = getattr(init_module, pinfo['main_class']) - plugin_module_name = mod_name - if self.debug: - self.stdout.write(f'[DEBUG] 从 PLUGIN_INFO 找到插件类: {plugin_class.__name__}') - - if not plugin_class: - for attr_name in dir(init_module): - attr = getattr(init_module, attr_name) - if (inspect.isclass(attr) and - hasattr(attr, '__mro__') and - attr.__name__ != 'PluginInterface' and - any(hasattr(b, '__name__') and b.__name__ == 'PluginInterface' - for b in attr.__mro__)): - plugin_class = attr - plugin_module_name = mod_name - break - except ImportError as e: - if self.debug: - self.stdout.write(f'[DEBUG] import_module({mod_name}) 失败: {e}') - - try: - spec = importlib.util.spec_from_file_location( - mod_name, init_file - ) - if spec is not None and spec.loader is not None: - init_module = importlib.util.module_from_spec(spec) - sys.modules[mod_name] = init_module - spec.loader.exec_module(init_module) - - if hasattr(init_module, 'PLUGIN_INFO'): - pinfo = getattr(init_module, 'PLUGIN_INFO') - if 'main_class' in pinfo and hasattr(init_module, pinfo['main_class']): - plugin_class = getattr(init_module, pinfo['main_class']) - plugin_module_name = mod_name - - if not plugin_class: - for attr_name in dir(init_module): - attr = getattr(init_module, attr_name) - if (inspect.isclass(attr) and - hasattr(attr, '__mro__') and - attr.__name__ != 'PluginInterface' and - any(hasattr(b, '__name__') and b.__name__ == 'PluginInterface' - for b in attr.__mro__)): - plugin_class = attr - plugin_module_name = mod_name - break - except Exception as e2: - if self.debug: - self.stdout.write(f'[DEBUG] spec_from_file_location 也失败: {e2}') - - if not plugin_class: - plugin_class, plugin_module_name = self._scan_py_files_for_plugin( - plugin_id, plugin_path - ) - - if self.debug: - if plugin_class: - self.stdout.write(f'[DEBUG] 找到插件类: {plugin_class.__name__} from {plugin_module_name}') - else: - self.stdout.write(f'[DEBUG] 未找到插件类 in {plugin_path}') - - return plugin_class, plugin_module_name - - def _scan_py_files_for_plugin(self, plugin_id, plugin_path): - py_files = [ - f for f in os.listdir(plugin_path) - if f.endswith('.py') and f != '__init__.py' - ] - for pf in py_files: - fp = os.path.join(plugin_path, pf) - try: - with open(fp, 'r', encoding='utf-8') as f: - content = f.read() - if not re.search( - r'class\s+\w+\s*\([^)]*PluginInterface', - content, re.DOTALL - ): - continue - - mod_name = f'plugins.{plugin_id}.{pf[:-3]}' - try: - module = importlib.import_module(mod_name) - except ImportError: - spec = importlib.util.spec_from_file_location( - f"external_plugin_{plugin_id}", fp - ) - if spec is None or spec.loader is None: - continue - module = importlib.util.module_from_spec(spec) - sys.modules[mod_name] = module - spec.loader.exec_module(module) - - for attr_name in dir(module): - attr = getattr(module, attr_name) - if not inspect.isclass(attr) or not hasattr(attr, '__mro__'): - continue - if attr.__name__ == 'PluginInterface': - continue - if any( - hasattr(base, '__name__') and base.__name__ == 'PluginInterface' - for base in attr.__mro__ - ): - return attr, mod_name - except Exception: - continue - - return None, None - - def install_from_path(self, plugin_path): - if not os.path.exists(plugin_path): - raise CommandError(f'插件路径不存在: {plugin_path}') - - plugin_dir_name = os.path.basename(os.path.abspath(plugin_path)) - plugins_base = os.path.join(settings.BASE_DIR, 'plugins') - is_under_plugins = ( - os.path.dirname(os.path.abspath(plugin_path)) == - os.path.abspath(plugins_base) - ) - - plugin_manager = get_plugin_manager() - loaded_plugins = plugin_manager.get_all_plugins() - - plugin_class = None - plugin_module_name = None - - if is_under_plugins: - plugin_class, plugin_module_name = ( - self._load_plugin_class_from_package( - plugin_dir_name, plugin_path - ) - ) - else: - plugin_class, plugin_module_name = ( - self._load_plugin_class_from_external(plugin_path) - ) - - if not plugin_class: - raise CommandError( - f'在 {plugin_path} 中未找到有效的插件类\n' - f'请确保插件目录中包含继承自 PluginInterface 的类' - ) - - try: - plugin_instance = plugin_class() - - if plugin_instance.plugin_id in loaded_plugins: - self.stdout.write(self.style.WARNING( - f'插件 {plugin_instance.name} (ID: {plugin_instance.plugin_id}) 已加载,跳过重复安装' - )) - return self._get_app_label_from_module(plugin_module_name) - - plugin_manager.plugins[plugin_instance.plugin_id] = ( - plugin_instance - ) - - from plugins.core.base import ServiceProvider - if isinstance(plugin_instance, ServiceProvider): - plugin_manager.service_registry.register(plugin_instance) - - try: - if plugin_instance.initialize(): - self.stdout.write(self.style.SUCCESS( - f'成功从路径安装插件: {plugin_instance.name}' - )) - else: - self.stdout.write(self.style.WARNING( - f'插件 {plugin_instance.name} 安装成功但初始化失败' - )) - except Exception as e: - self.stdout.write(self.style.WARNING( - f'插件 {plugin_instance.name} 初始化出错: {str(e)}' - )) - - plugin_record, created = PluginRecord.objects.update_or_create( - plugin_id=plugin_instance.plugin_id, - defaults={ - 'name': plugin_instance.name, - 'version': plugin_instance.version, - 'description': plugin_instance.description, - 'is_active': True, - } - ) - - if created: - self.stdout.write('已创建插件数据库记录') - else: - self.stdout.write('已更新插件数据库记录') - - self.add_plugin_to_toml_config(plugin_instance.plugin_id, { - 'name': plugin_instance.name, - 'module': plugin_module_name, - 'class': plugin_class.__name__, - 'description': plugin_instance.description, - 'version': plugin_instance.version, - 'enabled': True - }) - - self.stdout.write(self.style.SUCCESS( - f'插件 {plugin_instance.name} (v{plugin_instance.version}) 安装完成!' - )) - self.stdout.write( - '提示: 需要重启服务以使 Django App 注册生效' - ) - - return self._get_app_label_from_module(plugin_module_name) - except CommandError: - raise - except Exception as e: - raise CommandError(f'从路径安装插件失败: {str(e)}') - - def _load_plugin_class_from_external(self, plugin_path): - plugin_class = None - plugin_module_name = None - plugin_dir_name = os.path.basename(plugin_path) - - init_file = os.path.join(plugin_path, '__init__.py') - if os.path.exists(init_file): - try: - spec = importlib.util.spec_from_file_location( - f"plugin_init_{plugin_dir_name}", init_file - ) - if spec is not None and spec.loader is not None: - init_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(init_module) - if hasattr(init_module, 'PLUGIN_INFO'): - pinfo = getattr(init_module, 'PLUGIN_INFO') - mc = pinfo.get('main_class') - if mc and hasattr(init_module, mc): - plugin_class = getattr(init_module, mc) - plugin_module_name = ( - f'plugins.{plugin_dir_name}' - ) - except Exception: - pass - - if not plugin_class: - py_files = [ - f for f in os.listdir(plugin_path) - if f.endswith('.py') and f != '__init__.py' - ] - for pf in py_files: - fp = os.path.join(plugin_path, pf) - try: - with open(fp, 'r', encoding='utf-8') as f: - content = f.read() - if not re.search( - r'class\s+\w+\s*\([^)]*PluginInterface', - content, re.DOTALL - ): - continue - - mod_name = f'plugins.{plugin_dir_name}.{pf[:-3]}' - spec = importlib.util.spec_from_file_location( - mod_name, fp - ) - if spec is None or spec.loader is None: - continue - module = importlib.util.module_from_spec(spec) - sys.modules[mod_name] = module - spec.loader.exec_module(module) - - for attr_name in dir(module): - attr = getattr(module, attr_name) - if not inspect.isclass(attr): - continue - if attr.__name__ == 'PluginInterface': - continue - if not hasattr(attr, '__mro__'): - continue - if any( - hasattr(b, '__name__') and - b.__name__ == 'PluginInterface' - for b in attr.__mro__ - ): - return attr, mod_name - except Exception: - continue - - return plugin_class, plugin_module_name - - def install_builtin_plugin(self, plugin_id, plugin_info): - plugin_manager = get_plugin_manager() - - if plugin_id in plugin_manager.get_all_plugins(): - self.stdout.write( - self.style.WARNING(f'插件 {plugin_info["name"]} 已经加载.') - ) - return self._get_app_label_from_module( - plugin_info.get('module', '') - ) - - plugin = plugin_manager.load_builtin_plugin(plugin_id, plugin_info) - if not plugin: - raise CommandError(f'加载插件 {plugin_info["name"]} 失败') - - try: - if plugin.initialize(): - self.stdout.write(self.style.SUCCESS( - f'成功安装并初始化插件: {plugin.name}' - )) - else: - self.stdout.write(self.style.WARNING( - f'插件 {plugin.name} 安装成功但初始化失败' - )) - except Exception as e: - self.stdout.write(self.style.WARNING( - f'插件 {plugin.name} 安装成功但初始化出错: {str(e)}' - )) - - plugin_record, created = PluginRecord.objects.update_or_create( - plugin_id=plugin.plugin_id, - defaults={ - 'name': plugin.name, - 'version': plugin.version, - 'description': plugin.description, - 'is_active': True, - } - ) - - if created: - self.stdout.write(f'已创建插件数据库记录') - - if plugin_id not in ALL_AVAILABLE_PLUGINS: - self.add_plugin_to_toml_config(plugin_id, plugin_info) - - return self._get_app_label_from_module( - plugin_info.get('module', '') - ) - - def uninstall_plugin(self, plugin_name, force=False, no_migrate=False): - self.stdout.write(f'正在卸载插件: {plugin_name}') - - plugin_manager = get_plugin_manager() - - plugin = None - loaded_plugins = plugin_manager.get_all_plugins() - app_label = None - for pid, p in loaded_plugins.items(): - if p.plugin_id == plugin_name or p.name.lower() == plugin_name.lower(): - plugin = p - app_label = pid - break - - if not plugin: - db_record = PluginRecord.objects.filter(plugin_id=plugin_name).first() - if db_record: - if force or input(f'插件 {plugin_name} 未加载但数据库中有记录。是否删除数据库记录?(y/N): ').lower() == 'y': - if not no_migrate: - self._run_migrate_reverse(plugin_name) - PluginRecord.objects.filter(plugin_id=plugin_name).delete() - self.remove_plugin_from_toml_config(plugin_name) - plugin_dir = os.path.join(settings.BASE_DIR, 'plugins', plugin_name) - if os.path.exists(plugin_dir): - shutil.rmtree(plugin_dir) - self.stdout.write(f'已删除插件目录: {plugin_dir}') - self.stdout.write(self.style.SUCCESS(f'已从数据库中删除插件记录: {plugin_name}')) - return - else: - self.stdout.write(f'操作已取消') - return - else: - raise CommandError(f'找不到已加载的插件: {plugin_name}') - - try: - if hasattr(plugin, 'shutdown'): - plugin.shutdown() - - success = plugin_manager.unload_plugin(plugin.plugin_id) - - if success: - if not no_migrate and app_label: - self._run_migrate_reverse(app_label) - PluginRecord.objects.filter(plugin_id=plugin.plugin_id).update(is_active=False) - self.remove_plugin_from_toml_config(plugin.plugin_id) - plugin_dir = os.path.join(settings.BASE_DIR, 'plugins', plugin.plugin_id) - if os.path.exists(plugin_dir): - shutil.rmtree(plugin_dir) - self.stdout.write(f'已删除插件目录: {plugin_dir}') - self.stdout.write(self.style.SUCCESS(f'成功卸载插件: {plugin.name}')) - else: - raise CommandError(f'卸载插件 {plugin.name} 失败') - except Exception as e: - if force: - if not no_migrate and app_label: - self._run_migrate_reverse(app_label) - PluginRecord.objects.filter(plugin_id=plugin.plugin_id).delete() - self.remove_plugin_from_toml_config(plugin.plugin_id) - plugin_dir = os.path.join(settings.BASE_DIR, 'plugins', plugin.plugin_id) - if os.path.exists(plugin_dir): - shutil.rmtree(plugin_dir) - self.stdout.write(self.style.WARNING(f'强制卸载插件: {plugin.name}')) - else: - raise CommandError(f'卸载插件失败: {str(e)}') - - def list_plugins(self): - self.stdout.write(self.style.SUCCESS('已安装的插件:')) - - plugin_manager = get_plugin_manager() - loaded_plugins = plugin_manager.get_all_plugins() - - if not loaded_plugins: - self.stdout.write(' 没有加载任何插件') - else: - for plugin_id, plugin in loaded_plugins.items(): - status = '✓' if hasattr(plugin, 'enabled') and plugin.enabled else '✓' - self.stdout.write(f' [{status}] {plugin.name} (v{plugin.version}) - {plugin.description}') - - db_records = PluginRecord.objects.all() - if db_records.exists(): - self.stdout.write('\n数据库中的插件记录:') - for record in db_records: - status = '✓' if record.is_active else '✗' - is_available = record.plugin_id in ALL_AVAILABLE_PLUGINS - availability_indicator = '●' if is_available else '○' - self.stdout.write(f' [{status}{availability_indicator}] {record.name} (v{record.version}) - {record.plugin_id}') - - self.stdout.write('\n可用插件:') - installed_plugin_ids = {p.plugin_id for p in loaded_plugins.values()} - for plugin_id, plugin_info in ALL_AVAILABLE_PLUGINS.items(): - status = '✓' if plugin_id in installed_plugin_ids else '○' - self.stdout.write(f' [{status}] {plugin_info["name"]} - {plugin_info["description"]}') - - self.stdout.write('\n远程仓库插件 (使用 plugins search 查看更多):') - try: - remote_plugins = self._fetch_registry() - for plugin_id, info in remote_plugins.items(): - installed = '✓' if plugin_id in installed_plugin_ids else '○' - self.stdout.write(f' [{installed}] {info.get("name", plugin_id)} - {info.get("description", "N/A")}') - except Exception: - self.stdout.write(self.style.WARNING(' 无法连接远程仓库')) - - def plugin_info(self, plugin_name): - plugin_manager = get_plugin_manager() - - loaded_plugins = plugin_manager.get_all_plugins() - plugin = None - for pid, p in loaded_plugins.items(): - if p.plugin_id == plugin_name or p.name.lower() == plugin_name.lower(): - plugin = p - break - - if not plugin: - for plugin_id, plugin_info in ALL_AVAILABLE_PLUGINS.items(): - if plugin_id == plugin_name or plugin_info['name'].lower() == plugin_name.lower(): - self.stdout.write(f'插件信息: {plugin_info["name"]}') - self.stdout.write(f' ID: {plugin_id}') - self.stdout.write(f' 版本: {plugin_info["version"]}') - self.stdout.write(f' 描述: {plugin_info["description"]}') - self.stdout.write(f' 模块: {plugin_info["module"]}') - self.stdout.write(f' 类: {plugin_info["class"]}') - self.stdout.write(f' 状态: {"已启用" if plugin_info["enabled"] else "已禁用"}') - return - - if plugin: - self.stdout.write(f'插件信息: {plugin.name}') - self.stdout.write(f' ID: {plugin.plugin_id}') - self.stdout.write(f' 版本: {plugin.version}') - self.stdout.write(f' 描述: {plugin.description}') - if hasattr(plugin, 'enabled'): - self.stdout.write(f' 状态: {"已启用" if plugin.enabled else "已禁用"}') - else: - self.stdout.write(f' 状态: 已加载') - else: - try: - remote_plugins = self._fetch_registry() - for plugin_id, info in remote_plugins.items(): - if plugin_id == plugin_name or info.get('name', '').lower() == plugin_name.lower(): - self.stdout.write(f'远程插件信息: {info.get("name", plugin_id)}') - self.stdout.write(f' ID: {plugin_id}') - self.stdout.write(f' 版本: {info.get("version", "N/A")}') - self.stdout.write(f' 描述: {info.get("description", "N/A")}') - self.stdout.write(f' 仓库: {info.get("repository", "N/A")}') - return - except Exception: - pass - - db_record = PluginRecord.objects.filter(plugin_id=plugin_name).first() - if db_record: - self.stdout.write(f'插件信息: {db_record.name}') - self.stdout.write(f' ID: {db_record.plugin_id}') - self.stdout.write(f' 版本: {db_record.version}') - self.stdout.write(f' 描述: {db_record.description}') - self.stdout.write(f' 状态: {"已激活" if db_record.is_active else "未激活"}') - self.stdout.write(f' 注意: 此插件在数据库中有记录,但当前版本中不可用(可能是私有插件)') - else: - raise CommandError(f'找不到插件: {plugin_name}') - - def add_plugin_to_toml_config(self, plugin_id, plugin_info): - config_file_path = os.path.join(settings.BASE_DIR, 'plugins', 'plugins.toml') - - if os.path.exists(config_file_path): - with open(config_file_path, 'r', encoding='utf-8') as f: - toml_data = toml.load(f) - else: - toml_data = { - 'builtin': {}, - 'third_party': {} - } - - if plugin_id in toml_data.get('builtin', {}) or plugin_id in toml_data.get('third_party', {}): - self.stdout.write(f'插件 {plugin_id} 已存在于配置中') - return - - toml_data.setdefault('third_party', {})[plugin_id] = { - 'name': plugin_info['name'], - 'module': plugin_info['module'], - 'class': plugin_info['class'], - 'description': plugin_info['description'], - 'version': plugin_info['version'], - 'enabled': plugin_info['enabled'] - } - - with open(config_file_path, 'w', encoding='utf-8') as f: - toml.dump(toml_data, f) - - self.stdout.write(f'已将插件 {plugin_id} 添加到 TOML 配置文件') - - def remove_plugin_from_toml_config(self, plugin_id): - config_file_path = os.path.join(settings.BASE_DIR, 'plugins', 'plugins.toml') - - if not os.path.exists(config_file_path): - self.stdout.write(f'TOML 配置文件不存在: {config_file_path}') - return - - with open(config_file_path, 'r', encoding='utf-8') as f: - toml_data = toml.load(f) - - plugin_removed = False - if 'builtin' in toml_data and plugin_id in toml_data['builtin']: - del toml_data['builtin'][plugin_id] - plugin_removed = True - self.stdout.write(f'已从 builtin 部分移除插件 {plugin_id}') - - if 'third_party' in toml_data and plugin_id in toml_data['third_party']: - del toml_data['third_party'][plugin_id] - plugin_removed = True - self.stdout.write(f'已从 third_party 部分移除插件 {plugin_id}') - - if plugin_removed: - with open(config_file_path, 'w', encoding='utf-8') as f: - toml.dump(toml_data, f) - - self.stdout.write(f'已从 TOML 配置文件中移除插件 {plugin_id}') - else: - self.stdout.write(f'插件 {plugin_id} 在 TOML 配置文件中未找到') - - def _resolve_plugin_id(self, plugin_name): - plugin_manager = get_plugin_manager() - loaded_plugins = plugin_manager.get_all_plugins() - - for pid, p in loaded_plugins.items(): - if p.plugin_id == plugin_name or p.name.lower() == plugin_name.lower(): - return p.plugin_id - - for plugin_id, plugin_info in ALL_AVAILABLE_PLUGINS.items(): - if plugin_id == plugin_name or plugin_info.get('name', '').lower() == plugin_name.lower(): - return plugin_id - - db_record = PluginRecord.objects.filter(plugin_id=plugin_name).first() - if not db_record: - db_record = PluginRecord.objects.filter( - name__iexact=plugin_name - ).first() - if db_record: - return db_record.plugin_id - - return None - - def enable_plugin(self, plugin_name): - plugin_id = self._resolve_plugin_id(plugin_name) - if not plugin_id: - raise CommandError(f'找不到插件: {plugin_name}') - - plugin_manager = get_plugin_manager() - loaded_plugins = plugin_manager.get_all_plugins() - - if plugin_id in loaded_plugins: - self.stdout.write(self.style.WARNING( - f'插件 {plugin_id} 已加载且处于启用状态' - )) - self.update_plugin_enabled_in_toml(plugin_id, True) - PluginRecord.objects.filter(plugin_id=plugin_id).update( - is_active=True - ) - return - - plugin_info = ALL_AVAILABLE_PLUGINS.get(plugin_id) - if plugin_info: - plugin = plugin_manager.load_builtin_plugin(plugin_id, plugin_info) - if plugin: - try: - plugin.initialize() - self.stdout.write(self.style.SUCCESS( - f'成功启用并初始化插件: {plugin.name}' - )) - except Exception as e: - self.stdout.write(self.style.WARNING( - f'插件 {plugin.name} 启用成功但初始化失败: {str(e)}' - )) - else: - raise CommandError(f'加载插件 {plugin_id} 失败') - else: - self.stdout.write(self.style.WARNING( - f'插件 {plugin_id} 不在可用插件列表中,仅更新配置和数据库状态' - )) - - self.update_plugin_enabled_in_toml(plugin_id, True) - PluginRecord.objects.update_or_create( - plugin_id=plugin_id, - defaults={'is_active': True} - ) - self.stdout.write(self.style.SUCCESS(f'插件 {plugin_id} 已启用')) - - def disable_plugin(self, plugin_name): - plugin_id = self._resolve_plugin_id(plugin_name) - if not plugin_id: - raise CommandError(f'找不到插件: {plugin_name}') - - plugin_manager = get_plugin_manager() - loaded_plugins = plugin_manager.get_all_plugins() - - if plugin_id in loaded_plugins: - plugin = loaded_plugins[plugin_id] - try: - if hasattr(plugin, 'shutdown'): - plugin.shutdown() - plugin_manager.unload_plugin(plugin_id) - self.stdout.write(f'已卸载插件: {plugin.name}') - except Exception as e: - raise CommandError(f'卸载插件 {plugin.name} 失败: {str(e)}') - - self.update_plugin_enabled_in_toml(plugin_id, False) - PluginRecord.objects.filter(plugin_id=plugin_id).update( - is_active=False - ) - self.stdout.write(self.style.SUCCESS(f'插件 {plugin_id} 已禁用')) - - def update_plugin_enabled_in_toml(self, plugin_id, enabled): - config_file_path = os.path.join( - settings.BASE_DIR, 'plugins', 'plugins.toml' - ) - - if not os.path.exists(config_file_path): - self.stdout.write(self.style.WARNING( - f'TOML 配置文件不存在: {config_file_path}' - )) - return False - - with open(config_file_path, 'r', encoding='utf-8') as f: - toml_data = toml.load(f) - - updated = False - for section in ('builtin', 'third_party'): - if section in toml_data and plugin_id in toml_data[section]: - toml_data[section][plugin_id]['enabled'] = enabled - updated = True - break - - if not updated: - self.stdout.write(self.style.WARNING( - f'插件 {plugin_id} 在 TOML 配置文件中未找到,将添加配置' - )) - toml_data.setdefault('third_party', {})[plugin_id] = { - 'enabled': enabled - } - updated = True - - if updated: - with open(config_file_path, 'w', encoding='utf-8') as f: - toml.dump(toml_data, f) - state = '启用' if enabled else '禁用' - self.stdout.write( - f'已更新 TOML 配置: 插件 {plugin_id} -> {state}' - ) - - return updated - - def _get_app_label_from_module(self, module_name): - if not module_name: - return None - parts = module_name.rsplit('.', 1) - if len(parts) == 2 and parts[0].startswith('plugins.'): - return parts[0].split('.')[1] - return None - - def _run_migrate(self, app_label): - import subprocess - import sys - self.stdout.write(f'正在执行数据库迁移: {app_label}') - if self.debug: - from django.apps import apps - installed = [a.label for a in apps.get_app_configs()] - self.stdout.write(f'[DEBUG] 已注册 App labels: {installed}') - self.stdout.write(f'[DEBUG] {app_label} is_installed: {apps.is_installed(app_label)}') - try: - result = subprocess.run( - [sys.executable, 'manage.py', 'migrate', app_label, '--verbosity=2' if self.debug else '--verbosity=0'], - capture_output=True, text=True, - cwd=str(settings.BASE_DIR), - ) - if self.debug and result.stdout: - self.stdout.write(f'[DEBUG] migrate stdout:\n{result.stdout}') - if self.debug and result.stderr: - self.stdout.write(f'[DEBUG] migrate stderr:\n{result.stderr}') - if result.returncode == 0: - self.stdout.write(self.style.SUCCESS( - f'数据库迁移完成: {app_label}' - )) - else: - self.stdout.write(self.style.WARNING( - f'数据库迁移失败: {result.stderr.strip()}' - )) - self.stdout.write( - '你可以手动执行: ' - f'python manage.py migrate {app_label}' - ) - except Exception as e: - self.stdout.write(self.style.WARNING( - f'数据库迁移失败: {str(e)}' - )) - - def _run_migrate_reverse(self, app_label): - import subprocess - import sys - self.stdout.write(f'正在回滚数据库迁移: {app_label}') - try: - result = subprocess.run( - [ - sys.executable, 'manage.py', - 'migrate', app_label, 'zero', - ], - capture_output=True, text=True, - cwd=str(settings.BASE_DIR), - ) - if result.returncode == 0: - self.stdout.write(self.style.SUCCESS( - f'数据库迁移已回滚: {app_label}' - )) - else: - self.stdout.write(self.style.WARNING( - f'数据库迁移回滚失败: {result.stderr.strip()}' - )) - except Exception as e: - self.stdout.write(self.style.WARNING( - f'数据库迁移回滚失败: {str(e)}' - )) - - def upgrade_plugin(self, plugin_name, registry_url=None): - self.stdout.write(f'正在升级插件: {plugin_name}') - - plugin_info = ALL_AVAILABLE_PLUGINS.get(plugin_name) - if not plugin_info: - for pid, pinfo in ALL_AVAILABLE_PLUGINS.items(): - if pinfo['name'].lower() == plugin_name.lower(): - plugin_info = pinfo - plugin_name = pid - break - - if not plugin_info: - self.stdout.write( - '插件未在本地配置中找到,尝试从远程仓库更新...' - ) - self.install_from_registry( - plugin_name, registry_url, force=True - ) - return - - app_label = self._get_app_label_from_module( - plugin_info.get('module', '') - ) - if app_label: - self._run_migrate(app_label) - - self.stdout.write(self.style.SUCCESS( - f'插件 {plugin_name} 升级完成' - )) diff --git a/plugins/migrations/0001_initial.py b/plugins/migrations/0001_initial.py deleted file mode 100755 index 6b9cb21..0000000 --- a/plugins/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 4.2+ on migration creation - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='PluginRecord', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('plugin_id', models.CharField(help_text='插件的唯一标识符', max_length=255, unique=True, verbose_name='插件ID')), - ('name', models.CharField(help_text='插件的显示名称', max_length=255, verbose_name='插件名称')), - ('version', models.CharField(default='1.0.0', help_text='插件的版本号', max_length=50, verbose_name='版本号')), - ('description', models.TextField(blank=True, help_text='插件的功能描述', verbose_name='描述')), - ('is_active', models.BooleanField(default=True, help_text='插件是否处于启用状态', verbose_name='是否启用')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ], - options={ - 'verbose_name': '插件', - 'verbose_name_plural': '插件', - 'db_table': 'plugin_records', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='PluginConfiguration', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('key', models.CharField(help_text='配置项的键名', max_length=255, verbose_name='配置键')), - ('value', models.TextField(help_text='配置项的值', verbose_name='配置值')), - ('description', models.TextField(blank=True, help_text='配置项的描述信息', verbose_name='描述')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('plugin', models.ForeignKey(on_delete=models.CASCADE, related_name='configurations', to='plugins.pluginrecord', verbose_name='关联插件')), - ], - options={ - 'verbose_name': '插件配置', - 'verbose_name_plural': '插件配置', - 'db_table': 'plugin_configurations', - }, - ), - migrations.AddConstraint( - model_name='pluginconfiguration', - constraint=models.UniqueConstraint(fields=('plugin', 'key'), name='unique_plugin_key'), - ), - ] \ No newline at end of file diff --git a/plugins/migrations/0002_qqverifyconfig_and_more.py b/plugins/migrations/0002_qqverifyconfig_and_more.py deleted file mode 100755 index 20e0aa5..0000000 --- a/plugins/migrations/0002_qqverifyconfig_and_more.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-29 09:47 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('plugins', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='QQVerifyConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('host', models.CharField(help_text='QQ机器人服务器的主机地址', max_length=255, verbose_name='机器人服务器地址')), - ('port', models.CharField(help_text='QQ机器人服务器的端口', max_length=10, verbose_name='机器人服务器端口')), - ('token', models.CharField(help_text='访问QQ机器人API的令牌', max_length=255, verbose_name='访问令牌')), - ('group_id', models.CharField(help_text='需要验证的QQ群号', max_length=20, verbose_name='目标群号')), - ('enable_status', models.CharField(choices=[('disabled', '禁用'), ('enabled_require_group', '启用-要求入群'), ('enabled_old_six_mode', '启用-老六模式'), ('enabled_both', '启用-两种模式')], default='disabled', help_text='QQ验证功能的启用状态和模式', max_length=25, verbose_name='启用状态')), - ], - ), - migrations.RemoveConstraint( - model_name='pluginconfiguration', - name='unique_plugin_key', - ), - migrations.AlterUniqueTogether( - name='pluginconfiguration', - unique_together={('plugin', 'key')}, - ), - ] diff --git a/plugins/migrations/0003_auto_20260129_1808.py b/plugins/migrations/0003_auto_20260129_1808.py deleted file mode 100755 index bc2540b..0000000 --- a/plugins/migrations/0003_auto_20260129_1808.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.2.27 on 2026-01-29 10:08 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('operations', '0002_publichostinfo'), - ('plugins', '0002_qqverifyconfig_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='QQVerificationConfig', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('host', models.CharField(help_text='QQ机器人服务器的主机地址', max_length=255, verbose_name='机器人服务器地址')), - ('port', models.CharField(help_text='QQ机器人服务器的端口号', max_length=20, verbose_name='机器人服务器端口')), - ('token', models.CharField(help_text='用于认证的访问令牌', max_length=255, verbose_name='访问令牌')), - ('group_id', models.CharField(help_text='用于验证QQ号是否在群内的群号', max_length=20, verbose_name='验证群号')), - ('enable_status', models.CharField(choices=[('disabled', '禁用'), ('enabled_require_group', '启用-要求入群'), ('enabled_old_six_mode', '启用-老六模式'), ('enabled_both', '启用-两种模式')], default='disabled', help_text='QQ验证功能的启用状态', max_length=25, verbose_name='启用状态')), - ('non_qq_email_handling', models.CharField(choices=[('default_allow', '默认符合'), ('default_deny', '默认不符合'), ('manual_review', '等待人工处理')], default='default_deny', help_text='当用户使用非QQ邮箱时的处理策略', max_length=20, verbose_name='非QQ邮箱处理策略')), - ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), - ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), - ('product', models.OneToOneField(help_text='此QQ验证配置关联的产品', on_delete=django.db.models.deletion.CASCADE, related_name='qq_verification_config', to='operations.product', verbose_name='关联产品')), - ], - options={ - 'verbose_name': 'QQ验证配置', - 'verbose_name_plural': 'QQ验证配置', - 'unique_together': {('product',)}, - }, - ), - ] \ No newline at end of file diff --git a/plugins/migrations/0005_auto_20260130_1041.py b/plugins/migrations/0005_auto_20260130_1041.py deleted file mode 100755 index 0808ca0..0000000 --- a/plugins/migrations/0005_auto_20260130_1041.py +++ /dev/null @@ -1,14 +0,0 @@ -# 修复依赖问题的占位迁移文件 -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('plugins', '0003_auto_20260129_1808'), - ] - - operations = [ - # 占位操作,不实际改变数据库 - migrations.RunSQL(migrations.RunSQL.noop, reverse_sql=migrations.RunSQL.noop), - ] \ No newline at end of file diff --git a/plugins/migrations/0006_delete_qqverifyconfig_alter_pluginrecord_options_and_more.py b/plugins/migrations/0006_delete_qqverifyconfig_alter_pluginrecord_options_and_more.py deleted file mode 100644 index e51dd0c..0000000 --- a/plugins/migrations/0006_delete_qqverifyconfig_alter_pluginrecord_options_and_more.py +++ /dev/null @@ -1,78 +0,0 @@ -# Generated by Django 4.2.27 on 2026-02-02 15:15 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('plugins', '0005_auto_20260130_1041'), - ] - - operations = [ - migrations.DeleteModel( - name='QQVerifyConfig', - ), - migrations.AlterModelOptions( - name='pluginrecord', - options={'ordering': ['-created_at'], 'verbose_name': '插件记录', 'verbose_name_plural': '插件记录'}, - ), - migrations.AlterField( - model_name='pluginconfiguration', - name='description', - field=models.TextField(blank=True, help_text='配置项的描述', verbose_name='描述'), - ), - migrations.AlterField( - model_name='pluginconfiguration', - name='key', - field=models.CharField(help_text='配置参数的键名', max_length=200, verbose_name='配置键'), - ), - migrations.AlterField( - model_name='pluginconfiguration', - name='plugin', - field=models.ForeignKey(help_text='配置所属的插件', on_delete=django.db.models.deletion.CASCADE, to='plugins.pluginrecord', verbose_name='插件'), - ), - migrations.AlterField( - model_name='pluginconfiguration', - name='value', - field=models.TextField(help_text='配置参数的值', verbose_name='配置值'), - ), - migrations.AlterField( - model_name='pluginrecord', - name='description', - field=models.TextField(blank=True, help_text='插件的详细描述', verbose_name='描述'), - ), - migrations.AlterField( - model_name='pluginrecord', - name='is_active', - field=models.BooleanField(default=True, help_text='插件是否处于激活状态', verbose_name='是否激活'), - ), - migrations.AlterField( - model_name='pluginrecord', - name='name', - field=models.CharField(help_text='插件的显示名称', max_length=200, verbose_name='插件名称'), - ), - migrations.AlterField( - model_name='pluginrecord', - name='plugin_id', - field=models.CharField(help_text='插件的唯一标识符', max_length=100, unique=True, verbose_name='插件ID'), - ), - migrations.AlterField( - model_name='pluginrecord', - name='version', - field=models.CharField(help_text='插件的版本号', max_length=50, verbose_name='版本号'), - ), - migrations.AddConstraint( - model_name='pluginconfiguration', - constraint=models.UniqueConstraint(fields=('plugin', 'key'), name='unique_plugin_key'), - ), - migrations.AlterModelTable( - name='pluginconfiguration', - table=None, - ), - migrations.AlterModelTable( - name='pluginrecord', - table=None, - ), - ] diff --git a/plugins/migrations/0007_add_use_default_bot_and_group_ids.py b/plugins/migrations/0007_add_use_default_bot_and_group_ids.py deleted file mode 100644 index b6a73bb..0000000 --- a/plugins/migrations/0007_add_use_default_bot_and_group_ids.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 4.2.27 on 2026-05-02 09:15 - -from django.db import migrations, models - - -def migrate_group_id_to_group_ids(apps, schema_editor): - QQVerificationConfig = apps.get_model( - 'plugins', 'QQVerificationConfig' - ) - for config in QQVerificationConfig.objects.all(): - old_group_id = getattr(config, 'group_id', None) - if old_group_id: - config.group_ids = old_group_id - config.save(update_fields=['group_ids']) - - -class Migration(migrations.Migration): - - dependencies = [ - ('plugins', '0006_delete_qqverifyconfig_alter_pluginrecord_options_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='qqverificationconfig', - name='group_ids', - field=models.TextField(blank=True, help_text='用于验证QQ号是否在群内的群号,每行一个QQ群号', null=True, verbose_name='验证群号'), - ), - migrations.AddField( - model_name='qqverificationconfig', - name='use_default_bot', - field=models.BooleanField(default=True, help_text='启用后使用系统配置中的默认机器人服务器地址、端口和令牌', verbose_name='使用系统默认机器人服务器'), - ), - migrations.AlterField( - model_name='qqverificationconfig', - name='host', - field=models.CharField(blank=True, help_text='QQ机器人服务器的主机地址', max_length=255, null=True, verbose_name='机器人服务器地址'), - ), - migrations.AlterField( - model_name='qqverificationconfig', - name='port', - field=models.CharField(blank=True, help_text='QQ机器人服务器的端口号', max_length=20, null=True, verbose_name='机器人服务器端口'), - ), - migrations.AlterField( - model_name='qqverificationconfig', - name='token', - field=models.CharField(blank=True, help_text='用于认证的访问令牌', max_length=255, null=True, verbose_name='访问令牌'), - ), - migrations.RunPython( - migrate_group_id_to_group_ids, - migrations.RunPython.noop, - ), - migrations.RemoveField( - model_name='qqverificationconfig', - name='group_id', - ), - ] diff --git a/plugins/migrations/0008_move_qqverificationconfig_to_own_app.py b/plugins/migrations/0008_move_qqverificationconfig_to_own_app.py deleted file mode 100644 index f8885ce..0000000 --- a/plugins/migrations/0008_move_qqverificationconfig_to_own_app.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('plugins', '0007_add_use_default_bot_and_group_ids'), - ] - - operations = [ - migrations.SeparateDatabaseAndState( - state_operations=[ - migrations.DeleteModel( - name='QQVerificationConfig', - ), - ], - database_operations=[], - ), - ] diff --git a/plugins/migrations/__init__.py b/plugins/migrations/__init__.py deleted file mode 100755 index e69de29..0000000 diff --git a/plugins/models.py b/plugins/models.py deleted file mode 100755 index fd143df..0000000 --- a/plugins/models.py +++ /dev/null @@ -1,89 +0,0 @@ -from django.db import models -import logging - -logger = logging.getLogger(__name__) - - -class PluginRecord(models.Model): - plugin_id = models.CharField( - max_length=100, - unique=True, - verbose_name='插件ID', - help_text='插件的唯一标识符', - ) - name = models.CharField( - max_length=200, - verbose_name='插件名称', - help_text='插件的显示名称', - ) - version = models.CharField( - max_length=50, - verbose_name='版本号', - help_text='插件的版本号', - ) - description = models.TextField( - blank=True, verbose_name='描述', - help_text='插件的详细描述', - ) - is_active = models.BooleanField( - default=True, - verbose_name='是否激活', - help_text='插件是否处于激活状态', - ) - created_at = models.DateTimeField( - auto_now_add=True, verbose_name='创建时间' - ) - updated_at = models.DateTimeField( - auto_now=True, verbose_name='更新时间' - ) - - class Meta: - verbose_name = '插件记录' - verbose_name_plural = '插件记录' - ordering = ['-created_at'] - - def __str__(self): - return f"{self.name} v{self.version}" - - -class PluginConfiguration(models.Model): - plugin = models.ForeignKey( - PluginRecord, - on_delete=models.CASCADE, - verbose_name='插件', - help_text='配置所属的插件', - ) - key = models.CharField( - max_length=200, - verbose_name='配置键', - help_text='配置参数的键名', - ) - value = models.TextField( - verbose_name='配置值', - help_text='配置参数的值', - ) - description = models.TextField( - blank=True, - verbose_name='描述', - help_text='配置项的描述', - ) - created_at = models.DateTimeField( - auto_now_add=True, verbose_name='创建时间' - ) - updated_at = models.DateTimeField( - auto_now=True, verbose_name='更新时间' - ) - - class Meta: - verbose_name = '插件配置' - verbose_name_plural = '插件配置' - unique_together = [['plugin', 'key']] - constraints = [ - models.UniqueConstraint( - fields=['plugin', 'key'], - name='unique_plugin_key', - ) - ] - - def __str__(self): - return f"{self.plugin.name}: {self.key}" diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py deleted file mode 100755 index f53318f..0000000 --- a/plugins/plugin_manager.py +++ /dev/null @@ -1,393 +0,0 @@ -""" -插件管理系统管理器 -负责插件的加载、卸载、管理和执行 -""" - -import os -import sys -import importlib -import importlib.util -import logging -from typing import Any, Dict, List, Optional, Type - -from plugins.core.base import PluginInterface, EventHook - -logger = logging.getLogger(__name__) - - -class PluginManager: - """ - 插件管理器 - 负责管理所有插件的生命周期 - """ - - def __init__(self): - self.plugins: Dict[str, PluginInterface] = {} - self.hooks: Dict[str, EventHook] = {} - self.plugin_dirs: List[str] = [] - - def _get_plugin_model(self): - """延迟导入PluginRecord模型""" - try: - from django.conf import settings - # 检查Django是否已配置 - if settings.configured: - from .models import PluginRecord - return PluginRecord - except (ImportError, AttributeError): - pass - return None - - def add_plugin_directory(self, directory: str): - """添加插件目录""" - if os.path.isdir(directory) and directory not in self.plugin_dirs: - self.plugin_dirs.append(directory) - - def load_plugins_from_directory(self, directory: str) -> List[str]: - """ - 从指定目录加载插件 - :param directory: 插件目录路径 - :return: 成功加载的插件ID列表 - """ - loaded_plugins = [] - - if not os.path.isdir(directory): - logger.warning(f"Plugin directory does not exist: {directory}") - return loaded_plugins - - NON_PLUGIN_FILES = frozenset([ - 'base.py', 'models.py', 'admin.py', 'views.py', - 'urls.py', 'signals.py', 'django_integration.py', - 'apps.py', 'forms_admin.py', 'forms_provider.py', - 'available_plugins.py', - ]) - NON_PLUGIN_SUBDIRS = frozenset([ - 'core', 'migrations', 'management', 'sample_plugins', - ]) - - for item in os.listdir(directory): - item_path = os.path.join(directory, item) - if not os.path.isfile(item_path): - continue - if not item.endswith('.py') or item == '__init__.py': - continue - if item in NON_PLUGIN_FILES: - continue - - plugin_filename = item[:-3] - module_name = f"plugins.{plugin_filename}" - - try: - spec = importlib.util.spec_from_file_location( - module_name, item_path - ) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - loaded_plugins.extend( - self._extract_and_register_plugins( - module, plugin_filename - ) - ) - except ImportError: - pass - except Exception as e: - logger.error(f"Error loading plugin from {item_path}: {str(e)}") - - for subdir in os.listdir(directory): - subdir_path = os.path.join(directory, subdir) - if not os.path.isdir(subdir_path): - continue - if subdir.startswith('_') or subdir in NON_PLUGIN_SUBDIRS: - continue - init_path = os.path.join(subdir_path, '__init__.py') - if not os.path.isfile(init_path): - continue - - loaded_plugins.extend( - self._load_subdir_package(directory, subdir) - ) - - return loaded_plugins - - def _load_subdir_package( - self, parent_dir: str, subdir: str - ) -> List[str]: - loaded_plugins = [] - package_name = f"plugins.{subdir}" - - try: - if package_name in sys.modules: - module = sys.modules[package_name] - else: - module = importlib.import_module(package_name) - - loaded_plugins.extend( - self._extract_and_register_plugins(module, subdir) - ) - except ImportError as e: - logger.error( - f"Error importing plugin package " - f"{package_name}: {str(e)}" - ) - except Exception as e: - logger.error( - f"Error loading plugin package " - f"{package_name}: {str(e)}" - ) - - return loaded_plugins - - def _extract_and_register_plugins( - self, module, default_id_prefix: str - ) -> List[str]: - loaded = [] - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - isinstance(attr, type) - and issubclass(attr, PluginInterface) - and attr != PluginInterface - ): - try: - plugin_instance = attr() - if not hasattr(plugin_instance, 'plugin_id'): - plugin_instance.plugin_id = ( - f"{default_id_prefix}_" - f"{attr.__name__.lower()}" - ) - if self.register_plugin(plugin_instance): - loaded.append(plugin_instance.plugin_id) - except Exception as e: - logger.error( - f"Error instantiating plugin " - f"{attr_name}: {str(e)}" - ) - return loaded - - def register_plugin(self, plugin: PluginInterface) -> bool: - """ - 注册插件 - :param plugin: 插件实例 - :return: 注册是否成功 - """ - if plugin.plugin_id in self.plugins: - logger.warning(f"Plugin with ID {plugin.plugin_id} already exists") - return False - - try: - # 初始化插件 - if plugin.initialize(): - self.plugins[plugin.plugin_id] = plugin - logger.info(f"Successfully registered plugin: {plugin.name} ({plugin.plugin_id})") - - # 同步到数据库(如果Django可用) - plugin_model = self._get_plugin_model() - if plugin_model: - try: - from django.db import transaction - # 使用事务和原子操作 - with transaction.atomic(): - plugin_record, created = plugin_model.objects.get_or_create( - plugin_id=plugin.plugin_id, - defaults={ - 'name': plugin.name, - 'version': plugin.version, - 'description': plugin.description, - 'is_active': plugin.enabled - } - ) - except Exception as db_error: - logger.error(f"Error syncing plugin to database: {str(db_error)}") - - return True - else: - logger.warning(f"Failed to initialize plugin: {plugin.name}") - return False - except Exception as e: - logger.error(f"Error initializing plugin {plugin.name}: {str(e)}") - return False - - def unregister_plugin(self, plugin_id: str) -> bool: - """ - 卸载插件 - :param plugin_id: 插件ID - :return: 卸载是否成功 - """ - if plugin_id not in self.plugins: - logger.warning(f"Plugin with ID {plugin_id} does not exist") - return False - - plugin = self.plugins[plugin_id] - - try: - # 关闭插件 - if plugin.shutdown(): - del self.plugins[plugin_id] - logger.info(f"Successfully unregistered plugin: {plugin.name}") - - return True - else: - logger.warning(f"Failed to shutdown plugin: {plugin.name}") - return False - except Exception as e: - logger.error(f"Error shutting down plugin {plugin.name}: {str(e)}") - return False - - def enable_plugin(self, plugin_id: str) -> bool: - """启用插件""" - if plugin_id in self.plugins: - self.plugins[plugin_id].enabled = True - - # 同步到数据库(如果Django可用) - plugin_model = self._get_plugin_model() - if plugin_model: - try: - # 使用 update 方法直接更新数据库,避免触发信号 - rows_updated = plugin_model.objects.filter(plugin_id=plugin_id).update(is_active=True) - if rows_updated > 0: - logger.info(f"Database updated for plugin {plugin_id} (enabled)") - except Exception as db_error: - logger.error(f"Error updating plugin status in database: {str(db_error)}") - - return True - return False - - def disable_plugin(self, plugin_id: str) -> bool: - """禁用插件""" - if plugin_id in self.plugins: - self.plugins[plugin_id].enabled = False - - # 同步到数据库(如果Django可用) - plugin_model = self._get_plugin_model() - if plugin_model: - try: - # 使用 update 方法直接更新数据库,避免触发信号 - rows_updated = plugin_model.objects.filter(plugin_id=plugin_id).update(is_active=False) - if rows_updated > 0: - logger.info(f"Database updated for plugin {plugin_id} (disabled)") - except Exception as db_error: - logger.error(f"Error updating plugin status in database: {str(db_error)}") - - return True - return False - - def get_plugin(self, plugin_id: str) -> Optional[PluginInterface]: - """获取插件实例""" - return self.plugins.get(plugin_id) - - def get_all_plugins(self) -> List[PluginInterface]: - """获取所有插件""" - return list(self.plugins.values()) - - def get_enabled_plugins(self) -> List[PluginInterface]: - """获取所有启用的插件""" - return [plugin for plugin in self.plugins.values() if plugin.enabled] - - def load_all_plugins(self) -> List[str]: - """ - 从所有已注册的目录加载插件 - :return: 成功加载的插件ID列表 - """ - all_loaded = [] - for directory in self.plugin_dirs: - loaded = self.load_all_plugins_from_directory(directory) - all_loaded.extend(loaded) - return all_loaded - - def load_all_plugins_from_directory(self, directory: str) -> List[str]: - """ - 从指定目录加载所有插件 - :param directory: 插件目录路径 - :return: 成功加载的插件ID列表 - """ - loaded_plugins = [] - - if not os.path.isdir(directory): - logger.warning(f"Plugin directory does not exist: {directory}") - return loaded_plugins - - for item in os.listdir(directory): - item_path = os.path.join(directory, item) - - # 检查是否是Python文件且不是__init__.py - if os.path.isfile(item_path) and item.endswith('.py') and item != '__init__.py': - plugin_filename = item[:-3] # 移除.py后缀 - module_name = f"sample_plugins_{plugin_filename}" # 使用不同的模块名避免冲突 - - try: - # 使用 importlib.util.spec_from_file_location 动态加载模块 - spec = importlib.util.spec_from_file_location(module_name, item_path) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - # 添加到 sys.modules 以支持相对导入 - sys.modules[spec.name] = module - spec.loader.exec_module(module) - - # 查找插件类(继承自PluginInterface的类) - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - isinstance(attr, type) and - issubclass(attr, PluginInterface) and - attr != PluginInterface - ): - plugin_class: Type[PluginInterface] = attr - - # 创建插件实例并初始化 - plugin_instance = plugin_class() - - # 如果插件实例没有设置ID,则使用默认值 - if not hasattr(plugin_instance, 'plugin_id'): - plugin_instance.plugin_id = f"{plugin_filename}_{plugin_class.__name__.lower()}" - - if self.register_plugin(plugin_instance): - loaded_plugins.append(plugin_instance.plugin_id) - - except ImportError as e: - logger.error(f"Failed to import plugin module from {item_path}: {str(e)}") - except Exception as e: - logger.error(f"Error loading plugin from {item_path}: {str(e)}") - - return loaded_plugins - - def register_hook(self, hook_name: str, handler: callable): - """ - 注册钩子处理器 - :param hook_name: 钩子名称 - :param handler: 处理器函数 - """ - if hook_name not in self.hooks: - self.hooks[hook_name] = EventHook(hook_name) - self.hooks[hook_name].register(handler) - - def unregister_hook(self, hook_name: str, handler: callable): - """ - 注销钩子处理器 - :param hook_name: 钩子名称 - :param handler: 处理器函数 - """ - if hook_name in self.hooks: - self.hooks[hook_name].unregister(handler) - - def trigger_hook(self, hook_name: str, *args, **kwargs) -> List[Any]: - """ - 触发钩子 - :param hook_name: 钩子名称 - :param args: 传递给处理器的位置参数 - :param kwargs: 传递给处理器的关键字参数 - :return: 所有处理器的返回值列表 - """ - if hook_name in self.hooks: - return self.hooks[hook_name].execute(*args, **kwargs) - return [] - - def get_hook(self, hook_name: str) -> Optional[EventHook]: - """获取钩子实例""" - return self.hooks.get(hook_name) - - def shutdown_all_plugins(self): - """关闭所有插件""" - for plugin_id in list(self.plugins.keys()): - self.unregister_plugin(plugin_id) \ No newline at end of file diff --git a/plugins/signals.py b/plugins/signals.py deleted file mode 100755 index 6c957f9..0000000 --- a/plugins/signals.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -插件系统信号处理器 -""" -from django.db.models.signals import post_save -from django.dispatch import receiver -from apps.operations.models import AccountOpeningRequest, CloudComputerUser -from .core.plugin_manager import get_plugin_manager -import logging - -logger = logging.getLogger(__name__) - - -@receiver(post_save, sender=AccountOpeningRequest) -def handle_account_opening_request(sender, instance, created, **kwargs): - """ - 处理开户申请的信号处理器 - 通过插件管理器调用相关插件 - """ - logger.info(f"[Post Save Signal] 处理开户申请: {instance.id}, 创建: {created}, 状态: {instance.status}") - - # 检查是否已处理过,避免无限循环 - if getattr(instance, '_plugin_processed', False): - return - - # 通过插件管理器获取所有可用的验证插件并执行验证 - plugin_manager = get_plugin_manager() - all_plugins = plugin_manager.get_all_plugins() - - validation_performed = False - validation_results = [] - - for plugin_id, plugin in all_plugins.items(): - if hasattr(plugin, 'validate_for_account_opening'): - try: - logger.info(f"[Post Save Signal] 使用插件 {plugin.name} 验证开户申请") - is_valid, reason = plugin.validate_for_account_opening( - account_request=instance - ) - - validation_performed = True - validation_results.append((plugin.name, is_valid, reason)) - - if not is_valid: - from django.contrib.auth import get_user_model - from django.utils import timezone - User = get_user_model() - - # 验证失败,自动拒绝申请 - instance.status = 'rejected' - instance.approval_notes = f'{plugin.name}验证失败:{reason}' - instance.approved_by = User.objects.filter(is_superuser=True).first() - instance.approval_date = timezone.now() - - # 标记已处理,避免无限循环 - instance._plugin_processed = True - instance.save(update_fields=['status', 'approval_notes', 'approved_by', 'approval_date']) - - logger.warning(f"[Post Save Signal] 申请 {instance.id} {plugin.name}验证失败,已拒绝:{reason}") - return # 一旦有任何验证失败就拒绝申请 - except Exception as e: - logger.error(f"[Post Save Signal] 插件 {plugin.name} 验证过程中出错: {str(e)}") - - if validation_performed: - logger.info(f"[Post Save Signal] 验证完成,结果: {validation_results}") - else: - logger.info(f"[Post Save Signal] 没有找到可执行验证的插件") - - -@receiver(post_save, sender=CloudComputerUser) -def handle_cloud_computer_user_update(sender, instance, **kwargs): - """ - 处理云电脑用户更新的信号处理器 - 通过插件管理器调用相关插件 - """ - logger.info(f"[Post Save Signal] 云电脑用户更新: {instance.username}, 状态: {instance.status}") - - # 通过插件管理器获取所有可用的验证插件并执行验证 - plugin_manager = get_plugin_manager() - all_plugins = plugin_manager.get_all_plugins() - - for plugin_id, plugin in all_plugins.items(): - if hasattr(plugin, 'validate_cloud_user'): - try: - logger.info(f"[Post Save Signal] 使用插件 {plugin.name} 验证云电脑用户") - is_valid, reason = plugin.validate_cloud_user( - cloud_user=instance - ) - - if not is_valid: - # 验证失败,根据插件策略处理用户 - if hasattr(plugin, 'should_disable_user_on_failure') and plugin.should_disable_user_on_failure(): - if instance.status != 'disabled': - instance.status = 'disabled' - instance.save(update_fields=['status']) - logger.warning(f"[Post Save Signal] {plugin.name}验证失败,禁用用户: {instance.username} - {reason}") - else: - logger.info(f"[Post Save Signal] {plugin.name}验证失败,但不执行禁用操作: {instance.username} - {reason}") - except Exception as e: - logger.error(f"[Post Save Signal] 插件 {plugin.name} 验证云用户时出错: {str(e)}") \ No newline at end of file diff --git a/plugins/templatetags/__init__.py b/plugins/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/templatetags/plugin_extensions.py b/plugins/templatetags/plugin_extensions.py deleted file mode 100644 index ecc04d4..0000000 --- a/plugins/templatetags/plugin_extensions.py +++ /dev/null @@ -1,91 +0,0 @@ -import logging - -from django import template -from django.utils.safestring import mark_safe - -from plugins.core.plugin_manager import get_plugin_manager - -register = template.Library() -logger = logging.getLogger(__name__) - - -@register.simple_tag(takes_context=True) -def plugin_extensions(context, slot): - """ - 在模板中渲染指定 slot 的所有插件 UI 扩展。 - - 用法: - {% load plugin_extensions %} - {% plugin_extensions "host_form_after_auth" %} - """ - pm = get_plugin_manager() - extensions = pm.get_ui_extensions(slot) - - if not extensions: - return '' - - request = context.get('request') - - parts = [] - for ext in extensions: - try: - rendered = ext.render(request=request) - if rendered: - parts.append(rendered) - except Exception as e: - logger.error( - f"渲染 UI 扩展失败 " - f"(slot={slot}, " - f"type={ext.extension_type}): {e}" - ) - - return mark_safe(''.join(parts)) - - -@register.simple_tag(takes_context=True) -def plugin_nav_items(context): - """ - 渲染所有插件注册的导航项扩展。 - - 用法: - {% load plugin_extensions %} - {% plugin_nav_items %} - """ - pm = get_plugin_manager() - extensions = pm.get_ui_extensions( - 'admin_sidebar_plugins' - ) - - if not extensions: - return '' - - request = context.get('request') - parts = [] - for ext in extensions: - try: - rendered = ext.render(request=request) - if rendered: - parts.append(rendered) - except Exception as e: - logger.error( - f"渲染导航扩展失败: {e}" - ) - - return mark_safe(''.join(parts)) - - -@register.simple_tag(takes_context=True) -def plugin_has_extensions(context, slot): - """ - 判断指定 slot 是否有插件注册了扩展。 - - 用法: - {% load plugin_extensions %} - {% plugin_has_extensions "host_form_after_auth" as has_ext %} - {% if has_ext %} - ... - {% endif %} - """ - pm = get_plugin_manager() - extensions = pm.get_ui_extensions(slot) - return len(extensions) > 0 diff --git a/plugins/test_demo_plugin/__init__.py b/plugins/test_demo_plugin/__init__.py deleted file mode 100644 index 3c07b3e..0000000 --- a/plugins/test_demo_plugin/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -from plugins.core.base import PluginInterface - -class TestDemoPlugin(PluginInterface): - def __init__(self): - super().__init__( - plugin_id='test_demo', - name='Test Demo Plugin', - version='0.1.0', - description='A test plugin for verifying zip installation', - ) - - def initialize(self) -> bool: - return True - - def shutdown(self) -> bool: - return True diff --git a/plugins/tests.py b/plugins/tests.py deleted file mode 100644 index 3d0b104..0000000 --- a/plugins/tests.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest -from django.test import RequestFactory -from django.contrib.auth import get_user_model -from unittest.mock import MagicMock, patch - -User = get_user_model() - - -@pytest.mark.django_db -class TestPluginApiViewNoInfoLeak: - def setup_method(self): - self.factory = RequestFactory() - - def test_exception_returns_generic_error(self): - from plugins.django_integration import plugin_api_view - - plugin = MagicMock() - plugin.enabled = True - plugin.some_action.side_effect = RuntimeError("secret internal error") - - with patch("plugins.django_integration.get_plugin", return_value=plugin): - request = self.factory.post( - "/api/plugins/test/some_action", - data="{}", - content_type="application/json", - ) - response = plugin_api_view(request, "test", "some_action") - - assert response.status_code == 500 - assert b"Internal server error" in response.content - assert b"secret internal error" not in response.content - - -@pytest.mark.django_db -class TestPluginManagementApiNoInfoLeak: - def setup_method(self): - self.factory = RequestFactory() - - def test_exception_returns_generic_error(self): - from plugins.django_integration import plugin_management_api - - user = User.objects.create_user("testuser", password="testpass") - request = self.factory.post( - "/api/plugins/manage", - data='{"action":"enable","plugin_id":"nonexist"}', - content_type="application/json", - ) - request.user = user - - with patch( - "plugins.plugin_manager.PluginManager.enable_plugin", - side_effect=RuntimeError("db connection lost"), - ): - response = plugin_management_api(request) - - assert response.status_code == 500 - assert b"Internal server error" in response.content - assert b"db connection lost" not in response.content diff --git a/plugins/urls.py b/plugins/urls.py deleted file mode 100755 index 1a16967..0000000 --- a/plugins/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -插件系统URL配置 -""" - -from django.urls import path -from . import views - -app_name = 'plugins' - -urlpatterns = [ - # 插件管理相关视图 - path('', views.plugin_list, name='plugin_list'), - path('/', views.plugin_detail, name='plugin_detail'), - path('/toggle/', views.toggle_plugin, name='toggle_plugin'), - path('sync/', views.sync_plugins, name='sync_plugins'), -] \ No newline at end of file diff --git a/plugins/urls_admin.py b/plugins/urls_admin.py deleted file mode 100644 index 637600f..0000000 --- a/plugins/urls_admin.py +++ /dev/null @@ -1 +0,0 @@ -urlpatterns = [] diff --git a/plugins/urls_provider.py b/plugins/urls_provider.py deleted file mode 100644 index 637600f..0000000 --- a/plugins/urls_provider.py +++ /dev/null @@ -1 +0,0 @@ -urlpatterns = [] diff --git a/plugins/views.py b/plugins/views.py deleted file mode 100755 index 5767d5c..0000000 --- a/plugins/views.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -插件系统视图 -""" - -from django.shortcuts import render, get_object_or_404, redirect -from django.contrib import messages -from django.http import JsonResponse -from django.contrib.auth.decorators import login_required, user_passes_test -from django.views.decorators.http import require_POST -from .models import PluginRecord -from . import plugin_manager - - -@login_required -def plugin_list(request): - """ - 插件列表视图 - """ - plugins = PluginRecord.objects.all().order_by('-created_at') - context = { - 'plugins': plugins - } - return render(request, 'plugins/list.html', context) - - -@login_required -def plugin_detail(request, plugin_id): - """ - 插件详情视图 - """ - plugin_record = get_object_or_404(PluginRecord, plugin_id=plugin_id) - plugin_instance = plugin_manager.get_plugin(plugin_id) - - context = { - 'plugin_record': plugin_record, - 'plugin_instance': plugin_instance - } - return render(request, 'plugins/detail.html', context) - - -@user_passes_test(lambda u: u.is_staff) -@login_required -@require_POST -def toggle_plugin(request, plugin_id): - """ - 切换插件启用/禁用状态 - """ - try: - plugin_record = get_object_or_404(PluginRecord, plugin_id=plugin_id) - - # 切换状态 - new_status = not plugin_record.is_active - if new_status: - plugin_manager.enable_plugin(plugin_id) - messages.success(request, f'插件 "{plugin_record.name}" 已启用') - else: - plugin_manager.disable_plugin(plugin_id) - messages.warning(request, f'插件 "{plugin_record.name}" 已禁用') - - # 更新数据库记录 - plugin_record.is_active = new_status - plugin_record.save() - - return JsonResponse({ - 'success': True, - 'new_status': new_status, - 'message': f'插件状态已更新为 {"启用" if new_status else "禁用"}' - }) - except Exception as e: - logger = __import__('logging').getLogger(__name__) - logger.error(f"Error toggling plugin: {str(e)}", exc_info=True) - return JsonResponse({ - 'success': False, - 'error': '操作失败,请稍后重试' - }, status=400) - - -@user_passes_test(lambda u: u.is_staff) -@login_required -def sync_plugins(request): - """ - 同步插件状态视图 - """ - try: - # 从插件管理器同步插件到数据库 - for plugin in plugin_manager.get_all_plugins(): - plugin_record, created = PluginRecord.objects.get_or_create( - plugin_id=plugin.plugin_id, - defaults={ - 'name': plugin.name, - 'version': plugin.version, - 'description': plugin.description, - 'is_active': plugin.enabled - } - ) - - if not created: - # 更新现有记录 - plugin_record.name = plugin.name - plugin_record.version = plugin.version - plugin_record.description = plugin.description - plugin_record.is_active = plugin.enabled - plugin_record.save() - - messages.success(request, f'成功同步了 {len(plugin_manager.get_all_plugins())} 个插件') - return redirect('plugins:plugin_list') - except Exception as e: - messages.error(request, f'同步插件时出错: {str(e)}') - return redirect('plugins:plugin_list') \ No newline at end of file diff --git a/plugins/views_admin.py b/plugins/views_admin.py deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/views_provider.py b/plugins/views_provider.py deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index a23ce07..bec85ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,81 +1,75 @@ [project] name = "2c2a" -version = "1.0.0" -description = "2c2a - Django Web Application" -license = "AGPL-3.0-only" +version = "2.0.0" +description = "Zero Agent Security Control Architecture - async rewrite (Granian + FastAPI + SQLAlchemy 2.0 Async + HTMX OOB + RedisHuey + JinjaX + aiohttp)" +requires-python = ">=3.12" readme = "README.md" -requires-python = ">=3.10" +license = { text = "AGPL-3.0" } + dependencies = [ - "celery==5.4.0", - "cryptography==46.0.3", - "django-cors-headers==4.3.1", - "django-formtools>=2.5.1", - "Django==4.2.27", - "djangorestframework==3.15.2", - "idna==3.15", - "kombu==5.6.2", - "Markdown==3.10.1", - "pillow==12.1.0", - "PyJWT>=2.8.0", - "pyotp", - "python-dotenv==1.2.1", - "pywinrm==0.4.3", - "redis>=5.0.0", - "requests==2.32.3", - "toml", - "django-cotton @ git+https://github.com/2c2a/django-cotton.git@feature/x-prefix-tag-support", - "djlint>=1.36.4", - "heroicons>=2.14.0", - "cotton-icons>=0.2.0", - "whitenoise>=6.12.0", - "django-tianai-captcha", + # ASGI 服务器 + "granian>=1.6.0", + # Web 框架 + "fastapi>=0.115.0", + "starlette>=0.38.0", + # ORM (SQLAlchemy 2.0 异步) + "sqlalchemy[asyncio]>=2.0.32", + "aiosqlite>=0.20.0", + "asyncpg>=0.29.0", + "aiomysql>=0.2.0", + # 迁移 + "alembic>=1.13.0", + # 模板 (JinjaX) + "jinja2>=3.1.4", + "jinjax>=0.5.0", + # HTMX 服务端无直接依赖,仅前端 JS + # Redis (异步 + Huey) + "redis[hiredis]>=5.0.0", + "huey[redis]>=2.5.0", + # 异步 HTTP (替代同步 winrm) + "aiohttp>=3.10.0", + # 安全 / 加密 + "argon2-cffi>=23.1.0", + "pyjwt>=2.9.0", + "cryptography>=43.0.0", + # 配置 + "pydantic>=2.9.0", + "pydantic-settings>=2.5.0", + # 工具 + "python-multipart>=0.0.9", + "itsdangerous>=2.2.0", + "httpx>=0.27.0", + "structlog>=24.4.0", + "python-dotenv>=1.0.1", + # CLI 管理工具 + "typer>=0.12.0", + "rich>=13.7.0", ] +[project.scripts] +2c2a = "app.cli.main:app" + [project.optional-dependencies] -kerberos = [ - "gssapi>=1.11.1", - "krb5>=0.9.0", +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.27.0", + "ruff>=0.6.0", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -[dependency-groups] -dev = [ - "pytest>=9.0.3", - "pytest-django", - "black", - "django-stubs>=6.0.2", - "flake8", - "pyrefly>=0.60.0", - "pytest", - "pytest-django", - "redis>=7.4.0", -] - -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.build.targets.wheel] -packages = ["."] - -[tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "config.settings" -pythonpath = ["."] -python_files = ["tests.py", "test_*.py", "*_tests.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -addopts = "-v --tb=short --import-mode=importlib" +packages = ["app"] -[tool.pyright] -venvPath = "." -venv = ".venv" -pythonVersion = "3.13" -typeCheckingMode = "basic" +[tool.ruff] +target-version = "py312" +line-length = 110 -[tool.pyrefly] -python-version = "3.13" +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] -[tool.uv.sources] -django-tianai-captcha = { git = "https://github.com/trustedinster/django-tianai-captcha.git" } +[tool.granian] +# 仅文档用,实际参数由命令行传入 diff --git a/scripts/deploy.py b/scripts/deploy.py deleted file mode 100755 index 2039406..0000000 --- a/scripts/deploy.py +++ /dev/null @@ -1,1553 +0,0 @@ -#!/usr/bin/env python3 -""" -2c2a 交互式部署脚本 -根据部署环境动态生成 pyproject.toml,仅安装所需依赖,避免冗余库 - -用法: - python3 scripts/deploy.py -""" - -import sys -import shutil -import subprocess -import curses -from pathlib import Path -import secrets -import string -from datetime import datetime - -BASE_DIR = Path(__file__).resolve().parent.parent - -DEPS_CORE = { - "Django": "Django==4.2.27", - "djangorestframework": "djangorestframework==3.15.2", - "django-cors-headers": "django-cors-headers==4.3.1", - "django-cotton": "django-cotton @ git+https://github.com/2c2a/django-cotton.git@feature/x-prefix-tag-support", - "django-formtools": "django-formtools>=2.5.1", - "python-dotenv": "python-dotenv==1.2.1", - "PyJWT": "PyJWT>=2.8.0", - "cryptography": "cryptography==46.0.3", - "requests": "requests==2.32.3", - "pillow": "pillow==12.1.0", - "toml": "toml", -} - -DEPS_CELERY = { - "celery": "celery==5.4.0", -} - -DEPS_MYSQL = { - "pymysql": "pymysql>=1.1.2", -} - -DEPS_POSTGRESQL = { - "psycopg": "psycopg[binary]>=3.0", -} - -DEPS_REDIS = { - "redis": "redis>=5.0.0", -} - -DEPS_SQLITE_BROKER = { - "sqlalchemy": "sqlalchemy>=2.0.0", -} - -DEPS_WINRM = { - "pywinrm": "pywinrm==0.4.3", -} - -DEPS_KERBEROS = { - "gssapi": "gssapi>=1.11.1", - "krb5": "krb5>=0.9.0", -} - -DEPS_MARKDOWN = { - "Markdown": "Markdown==3.10.1", -} - -DEPS_2FA = { - "pyotp": "pyotp", -} - -DEPS_DEV = { - "pytest": "pytest", - "pytest-django": "pytest-django", - "black": "black", - "flake8": "flake8", - "django-stubs": "django-stubs>=6.0.2", - "pyrefly": "pyrefly>=0.60.0", -} - -DB_OPTIONS = [ - ("sqlite", "SQLite", "零配置本地文件数据库,适合开发/小规模部署"), - ("mysql", "MySQL / MariaDB", "生产级关系数据库,适合中大规模部署"), - ("postgresql", "PostgreSQL", "生产级关系数据库,功能最丰富"), -] - -FEATURE_OPTIONS = [ - ("celery", "Celery 异步任务队列", "后台任务处理(主机操作、工单通知等)", True), - ("redis", "Redis 缓存/消息队列", "高性能缓存、会话存储、Celery Broker", False), - ("winrm", "WinRM Windows 远程管理", "远程管理 Windows 服务器", False), - ("kerberos", "Kerberos 域认证", "Windows 域环境集成认证", False), - ("markdown", "Markdown 渲染", "仪表盘/工单中的 Markdown 内容渲染", True), - ("2fa", "双因素认证 (TOTP)", "基于时间的一次性密码二次验证", True), -] - -DEV_OPTION = ("dev", "开发/测试工具", "pytest, black, flake8, django-stubs 等", False) - -C_TITLE = 1 -C_SUBTITLE = 2 -C_HIGHLIGHT = 3 -C_SELECTED = 4 -C_UNSELECTED = 5 -C_RADIO_ON = 6 -C_RADIO_OFF = 7 -C_CHECK_ON = 8 -C_CHECK_OFF = 9 -C_DESC = 10 -C_HINT = 11 -C_BORDER = 12 -C_SUCCESS = 13 -C_WARN = 14 -C_ERROR = 15 -C_DEP = 16 -C_DEP_DEV = 17 -C_BANNER = 18 - - -def _safe_addstr(stdscr, y, x, text, attr=0): - max_y, max_x = stdscr.getmaxyx() - if y < 0 or y >= max_y or x < 0 or x >= max_x: - return - available = max_x - x - 1 - if available <= 0: - return - text = text[:available] - try: - stdscr.addstr(y, x, text, attr) - except curses.error: - pass - - -def _init_colors(): - curses.start_color() - curses.use_default_colors() - curses.init_pair(C_TITLE, curses.COLOR_CYAN, -1) - curses.init_pair(C_SUBTITLE, curses.COLOR_WHITE, -1) - curses.init_pair(C_HIGHLIGHT, curses.COLOR_BLACK, curses.COLOR_CYAN) - curses.init_pair(C_SELECTED, curses.COLOR_CYAN, -1) - curses.init_pair(C_UNSELECTED, curses.COLOR_WHITE, -1) - curses.init_pair(C_RADIO_ON, curses.COLOR_GREEN, -1) - curses.init_pair(C_RADIO_OFF, curses.COLOR_WHITE, -1) - curses.init_pair(C_CHECK_ON, curses.COLOR_GREEN, -1) - curses.init_pair(C_CHECK_OFF, curses.COLOR_WHITE, -1) - curses.init_pair(C_DESC, 8, -1) - curses.init_pair(C_HINT, curses.COLOR_YELLOW, -1) - curses.init_pair(C_BORDER, curses.COLOR_CYAN, -1) - curses.init_pair(C_SUCCESS, curses.COLOR_GREEN, -1) - curses.init_pair(C_WARN, curses.COLOR_YELLOW, -1) - curses.init_pair(C_ERROR, curses.COLOR_RED, -1) - curses.init_pair(C_DEP, curses.COLOR_GREEN, -1) - curses.init_pair(C_DEP_DEV, curses.COLOR_YELLOW, -1) - curses.init_pair(C_BANNER, curses.COLOR_CYAN, -1) - - -def _draw_banner(stdscr, y, max_x): - title = " ZASCA 交互式部署管理脚本 " - x = max(0, (max_x - len(title)) // 2) - _safe_addstr(stdscr, y, x, title, curses.color_pair(C_BANNER) | curses.A_BOLD) - - -def _draw_box(stdscr, y, x, h, w): - _safe_addstr(stdscr, y, x, "┌" + "─" * (w - 2) + "┐", curses.color_pair(C_BORDER)) - for i in range(1, h - 1): - _safe_addstr(stdscr, y + i, x, "│", curses.color_pair(C_BORDER)) - _safe_addstr(stdscr, y + i, x + w - 1, "│", curses.color_pair(C_BORDER)) - _safe_addstr(stdscr, y + h - 1, x, "└" + "─" * (w - 2) + "┘", curses.color_pair(C_BORDER)) - - -def _draw_radio_item(stdscr, y, x, selected, active, label, desc, max_w): - if selected: - marker = "◉" - m_color = C_RADIO_ON | curses.A_BOLD - else: - marker = "○" - m_color = C_RADIO_OFF - - if active: - bg = curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD - _safe_addstr(stdscr, y, x, f" {marker} {label}", bg) - else: - _safe_addstr(stdscr, y, x, " ", curses.color_pair(C_UNSELECTED)) - _safe_addstr(stdscr, y, x + 2, marker, curses.color_pair(m_color)) - _safe_addstr(stdscr, y, x + 4, label, curses.color_pair(C_SELECTED if selected else C_UNSELECTED) | curses.A_BOLD) - - if desc: - desc_x = x + 6 - remaining = max_w - (desc_x - x) - 1 - if remaining > 3: - _safe_addstr(stdscr, y + 1, desc_x, desc[:remaining], curses.color_pair(C_DESC) if not active else curses.color_pair(C_HIGHLIGHT)) - - -def _draw_check_item(stdscr, y, x, checked, active, label, desc, max_w): - if checked: - marker = "☑" - m_color = C_CHECK_ON | curses.A_BOLD - else: - marker = "☐" - m_color = C_CHECK_OFF - - if active: - bg = curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD - _safe_addstr(stdscr, y, x, f" {marker} {label}", bg) - else: - _safe_addstr(stdscr, y, x, " ", curses.color_pair(C_UNSELECTED)) - _safe_addstr(stdscr, y, x + 2, marker, curses.color_pair(m_color)) - _safe_addstr(stdscr, y, x + 4, label, curses.color_pair(C_SELECTED if checked else C_UNSELECTED) | curses.A_BOLD) - - if desc: - desc_x = x + 6 - remaining = max_w - (desc_x - x) - 1 - if remaining > 3: - _safe_addstr(stdscr, y + 1, desc_x, desc[:remaining], curses.color_pair(C_DESC) if not active else curses.color_pair(C_HIGHLIGHT)) - - -def _draw_hint(stdscr, y, x, hint_text): - _safe_addstr(stdscr, y, x, hint_text, curses.color_pair(C_HINT)) - - -def _page_db(stdscr, selected): - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - box_w = min(72, max_x - 4) - box_h = 5 + len(DB_OPTIONS) * 3 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = " 数据库引擎 (单选) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - for i, (key, label, desc) in enumerate(DB_OPTIONS): - iy = box_y + 2 + i * 3 - _draw_radio_item(stdscr, iy, box_x + 2, i == selected, i == selected, label, desc, box_w - 4) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 移动 Enter 确认 Esc 退出") - - stdscr.refresh() - - -def _select_db(stdscr): - selected = 0 - while True: - _page_db(stdscr, selected) - key = stdscr.getch() - if key == curses.KEY_UP: - selected = (selected - 1) % len(DB_OPTIONS) - elif key == curses.KEY_DOWN: - selected = (selected + 1) % len(DB_OPTIONS) - elif key in (curses.KEY_ENTER, 10, 13): - return DB_OPTIONS[selected][0] - elif key == 27: - return None - elif key in (ord('1'), ord('2'), ord('3')): - idx = key - ord('1') - if 0 <= idx < len(DB_OPTIONS): - return DB_OPTIONS[idx][0] - - -def _page_features(stdscr, cursor, features_state, dev_state, page): - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - if page == 0: - items = FEATURE_OPTIONS - total = len(items) - box_w = min(76, max_x - 4) - box_h = 5 + total * 3 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = " 功能模块 (多选) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - for i, (key, label, desc, default) in enumerate(items): - iy = box_y + 2 + i * 3 - _draw_check_item(stdscr, iy, box_x + 2, features_state[key], i == cursor, label, desc, box_w - 4) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 移动 Space 切换 Enter 下一步 Esc 返回") - else: - box_w = min(76, max_x - 4) - box_h = 7 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = " 开发环境 " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - key, label, desc, default = DEV_OPTION - iy = box_y + 2 - _draw_check_item(stdscr, iy, box_x + 2, dev_state, True, label, desc, box_w - 4) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "Space 切换 Enter 确认 Esc 返回") - - stdscr.refresh() - - -def _select_features(stdscr): - features_state = {key: default for key, _, _, default in FEATURE_OPTIONS} - dev_state = DEV_OPTION[3] - cursor = 0 - page = 0 - - while True: - _page_features(stdscr, cursor, features_state, dev_state, page) - key = stdscr.getch() - - if page == 0: - if key == curses.KEY_UP: - cursor = (cursor - 1) % len(FEATURE_OPTIONS) - elif key == curses.KEY_DOWN: - cursor = (cursor + 1) % len(FEATURE_OPTIONS) - elif key == ord(' '): - fk = FEATURE_OPTIONS[cursor][0] - features_state[fk] = not features_state[fk] - elif key in (curses.KEY_ENTER, 10, 13): - page = 1 - cursor = 0 - elif key == 27: - return None - else: - if key == ord(' '): - dev_state = not dev_state - elif key in (curses.KEY_ENTER, 10, 13): - return features_state, dev_state - elif key == 27: - page = 0 - cursor = 0 - - -def _page_summary(stdscr, answers, deps, dev_deps, cursor, scroll=0): - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - db_names = {"sqlite": "SQLite", "mysql": "MySQL / MariaDB", "postgresql": "PostgreSQL"} - feature_names = { - "celery": "Celery", - "redis": "Redis", - "winrm": "WinRM", - "kerberos": "Kerberos", - "markdown": "Markdown", - "2fa": "2FA", - } - - lines = [] - lines.append(("title", " 部署摘要 ")) - lines.append(("blank", "")) - lines.append(("kv", f" 数据库: {db_names[answers['db']]}")) - - active_features = [feature_names[k] for k in ("celery", "redis", "winrm", "kerberos", "markdown", "2fa") if answers.get(k)] - if active_features: - lines.append(("kv", f" 功能模块: {', '.join(active_features)}")) - else: - lines.append(("kv", " 功能模块: 无(仅核心框架)")) - - if answers.get("dev"): - lines.append(("kv", " 开发工具: 已启用")) - else: - lines.append(("kv", " 开发工具: 未启用")) - - lines.append(("blank", "")) - lines.append(("dep_title", f" 生产依赖 ({len(deps)} 个):")) - for name, spec in deps.items(): - lines.append(("dep", f" + {spec}")) - - if dev_deps: - lines.append(("blank", "")) - lines.append(("dep_dev_title", f" 开发依赖 ({len(dev_deps)} 个):")) - for name, spec in dev_deps.items(): - lines.append(("dep_dev", f" + {spec}")) - - total = len(deps) + len(dev_deps) - lines.append(("blank", "")) - lines.append(("total", f" 合计: {total} 个直接依赖(传递依赖由 uv 自动解析)")) - - box_w = min(76, max_x - 4) - box_h = min(len(lines) + 4, max_y - 6) - box_x = max(0, (max_x - box_w) // 2) - box_y = 1 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - max_visible = box_h - 3 - total_lines = len(lines) - max_scroll = max(0, total_lines - max_visible) - scroll = min(scroll, max_scroll) - scroll = max(0, scroll) - - visible = lines[scroll:scroll + max_visible] - for i, (kind, text) in enumerate(visible): - ry = box_y + 1 + i - rx = box_x + 2 - remaining = box_w - 5 - t = text[:remaining] - - if kind == "title": - tx = box_x + max(0, (box_w - len(t)) // 2) - _safe_addstr(stdscr, ry, tx, t, curses.color_pair(C_TITLE) | curses.A_BOLD) - elif kind == "blank": - pass - elif kind == "kv": - _safe_addstr(stdscr, ry, rx, t, curses.color_pair(C_SUBTITLE) | curses.A_BOLD) - elif kind == "dep_title": - _safe_addstr(stdscr, ry, rx, t, curses.color_pair(C_DEP) | curses.A_BOLD) - elif kind == "dep": - _safe_addstr(stdscr, ry, rx, t, curses.color_pair(C_DEP)) - elif kind == "dep_dev_title": - _safe_addstr(stdscr, ry, rx, t, curses.color_pair(C_DEP_DEV) | curses.A_BOLD) - elif kind == "dep_dev": - _safe_addstr(stdscr, ry, rx, t, curses.color_pair(C_DEP_DEV)) - elif kind == "total": - _safe_addstr(stdscr, ry, rx, t, curses.color_pair(C_TITLE) | curses.A_BOLD) - - if max_scroll > 0: - scroll_info = f" [{scroll + 1}-{min(scroll + max_visible, total_lines)}/{total_lines}] " - _safe_addstr(stdscr, box_y + box_h - 2, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC)) - - btn_y = box_y + box_h + 1 - btn_labels = ["[✔ 确认生成]", "[✘ 取消]"] - btn_x = box_x + 4 - for i, label in enumerate(btn_labels): - if i == cursor: - _safe_addstr(stdscr, btn_y, btn_x, label, curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD) - else: - _safe_addstr(stdscr, btn_y, btn_x, label, curses.color_pair(C_UNSELECTED)) - btn_x += len(label) + 3 - - hint_y = btn_y + 2 - hint = "←→ 选择 Enter 确认 Esc 返回" - if max_scroll > 0: - hint = "↑↓ 滚动 " + hint - _draw_hint(stdscr, hint_y, box_x + 2, hint) - - stdscr.refresh() - return scroll - - -def _confirm_summary(stdscr, answers, deps, dev_deps): - cursor = 0 - scroll = 0 - while True: - scroll = _page_summary(stdscr, answers, deps, dev_deps, cursor, scroll) - key = stdscr.getch() - if key == curses.KEY_LEFT: - cursor = 0 - elif key == curses.KEY_RIGHT: - cursor = 1 - elif key == curses.KEY_UP: - scroll = max(0, scroll - 1) - elif key == curses.KEY_DOWN: - scroll += 1 - elif key in (curses.KEY_ENTER, 10, 13): - return cursor == 0 - elif key == 27: - return None - elif key == ord('1'): - return True - elif key == ord('2'): - return False - - -def _page_progress(stdscr, step, total_steps, message): - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - _draw_banner(stdscr, 0, max_x) - - box_w = min(60, max_x - 4) - box_h = 5 - box_x = max(0, (max_x - box_w) // 2) - box_y = 3 - - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = f" Step {step}/{total_steps} " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - msg_x = box_x + 3 - _safe_addstr(stdscr, box_y + 2, msg_x, message[:box_w - 6], curses.color_pair(C_SUBTITLE)) - - stdscr.refresh() - - -def _page_result(stdscr, success, answers, backup_name=None, env_configured=False, env_backup_name=None, migrate_success=False): - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - _draw_banner(stdscr, 0, max_x) - - box_w = min(72, max_x - 4) - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - if success: - title = " ✔ 部署完成 " - steps = [] - n = 1 - if env_configured: - steps.append(f"{n}. .env 已配置 (可手动编辑调整)") - else: - steps.append(f"{n}. 配置 .env 文件 (参考 .env.example)") - n += 1 - if migrate_success: - steps.append(f"{n}. 数据库迁移 已完成") - else: - steps.append(f"{n}. 初始化数据库 uv run python manage.py migrate") - n += 1 - steps.append(f"{n}. 创建管理员 uv run python manage.py createsuperuser") - n += 1 - steps.append(f"{n}. 启动服务 uv run python manage.py runserver") - if answers.get("celery"): - n += 1 - steps.append(f"{n}. 启动 Celery uv run celery -A config worker -l info") - - box_h = 4 + len(steps) + (2 if not answers.get("redis") and answers.get("celery") else 0) + (1 if backup_name else 0) + (1 if env_backup_name else 0) - box_h = max(box_h, 8) - - _draw_box(stdscr, box_y, box_x, box_h, box_w) - _safe_addstr(stdscr, box_y, box_x + max(0, (box_w - len(title)) // 2), title, curses.color_pair(C_SUCCESS) | curses.A_BOLD) - - for i, step in enumerate(steps): - ry = box_y + 2 + i - _safe_addstr(stdscr, ry, box_x + 3, step[:box_w - 6], curses.color_pair(C_SUBTITLE)) - - row = box_y + 2 + len(steps) - if not answers.get("redis") and answers.get("celery"): - _safe_addstr(stdscr, row, box_x + 3, "⚠ Celery 使用 SQLite Broker,生产环境建议启用 Redis", curses.color_pair(C_WARN)) - row += 2 - - if backup_name: - _safe_addstr(stdscr, row, box_x + 3, f"备份: {backup_name}", curses.color_pair(C_DESC)) - row += 1 - if env_backup_name: - _safe_addstr(stdscr, row, box_x + 3, f".env 备份: {env_backup_name}", curses.color_pair(C_DESC)) - else: - box_h = 8 - _draw_box(stdscr, box_y, box_x, box_h, box_w) - title = " ✘ 部署失败 " - _safe_addstr(stdscr, box_y, box_x + max(0, (box_w - len(title)) // 2), title, curses.color_pair(C_ERROR) | curses.A_BOLD) - _safe_addstr(stdscr, box_y + 2, box_x + 3, "依赖同步失败,请检查错误信息", curses.color_pair(C_ERROR)) - if backup_name: - _safe_addstr(stdscr, box_y + 4, box_x + 3, f"恢复: cp {backup_name} pyproject.toml", curses.color_pair(C_WARN)) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "按任意键退出") - - stdscr.refresh() - stdscr.getch() - - -def _tui_main(stdscr): - curses.curs_set(0) - curses.noecho() - stdscr.keypad(True) - _init_colors() - - db = _select_db(stdscr) - if db is None: - return - - result = _select_features(stdscr) - if result is None: - return - features_state, dev_state = result - - answers = {"db": db} - answers.update(features_state) - answers["dev"] = dev_state - - deps, dev_deps = compute_dependencies(answers) - - confirmed = _confirm_summary(stdscr, answers, deps, dev_deps) - if confirmed is None or not confirmed: - return - - toml_path = BASE_DIR / "pyproject.toml" - backup = backup_file(toml_path) - backup_name = backup.name if backup else None - - _page_progress(stdscr, 1, 3, "正在生成 pyproject.toml ...") - content = generate_pyproject_toml(answers, deps, dev_deps) - toml_path.write_text(content, encoding="utf-8") - - _page_progress(stdscr, 2, 3, "正在执行 uv sync ...") - try: - proc = subprocess.run( - ["uv", "sync"], - cwd=BASE_DIR, - capture_output=True, - text=True, - timeout=300, - ) - success = proc.returncode == 0 - except FileNotFoundError: - success = False - except subprocess.TimeoutExpired: - success = False - - env_configured = False - env_backup_name = None - migrate_success = False - if success: - env_values = _configure_env(stdscr, answers) - if env_values is not None: - env_path = BASE_DIR / ".env" - backup_env = backup_file(env_path) - env_backup_name = backup_env.name if backup_env else None - env_content = generate_env_content(answers, env_values) - env_path.write_text(env_content, encoding="utf-8") - env_configured = True - - _page_progress(stdscr, 3, 3, "正在执行数据库迁移 ...") - try: - migrate_proc = subprocess.run( - ["uv", "run", "python", "manage.py", "migrate"], - cwd=BASE_DIR, - capture_output=True, - text=True, - timeout=120, - ) - migrate_success = migrate_proc.returncode == 0 - except FileNotFoundError: - migrate_success = False - except subprocess.TimeoutExpired: - migrate_success = False - - _page_result(stdscr, success, answers, backup_name, env_configured, env_backup_name, migrate_success) - - -def _generate_secret(length=50): - alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+" - return ''.join(secrets.choice(alphabet) for _ in range(length)) - - -def _load_existing_env(env_path): - values = {} - if env_path.exists(): - for line in env_path.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if not line or line.startswith('#'): - continue - if '=' in line: - key, _, val = line.partition('=') - values[key.strip()] = val.strip() - return values - - -def _get_env_items(answers): - db = answers.get("db", "sqlite") - db_port_default = {"mysql": "3306", "postgresql": "5432"}.get(db, "3306") - db_user_default = {"mysql": "root", "postgresql": "postgres"}.get(db, "root") - - items = [] - - items.append(("group", "核心配置")) - items.append(("field", {"key": "DEBUG", "default": "True", "desc": "调试模式(生产环境必须 False)", "type": "bool"})) - items.append(("field", {"key": "DJANGO_SECRET_KEY", "default": "", "desc": "Django 密钥", "type": "secret"})) - items.append(("field", {"key": "ALLOWED_HOSTS", "default": "localhost,127.0.0.1", "desc": "允许访问的主机", "type": "list"})) - items.append(("field", {"key": "CSRF_TRUSTED_ORIGINS", "default": "https://localhost,https://127.0.0.1", "desc": "CSRF 可信来源", "type": "list"})) - - items.append(("group", "数据库配置")) - items.append(("field", {"key": "DB_ENGINE", "default": db, "desc": "数据库引擎(根据之前的选择自动设置)", "type": "auto"})) - if db != "sqlite": - items.append(("field", {"key": "DB_HOST", "default": "127.0.0.1", "desc": "数据库主机", "type": "text"})) - items.append(("field", {"key": "DB_PORT", "default": db_port_default, "desc": "数据库端口", "type": "text"})) - items.append(("field", {"key": "DB_NAME", "default": "zasca", "desc": "数据库名称", "type": "text"})) - items.append(("field", {"key": "DB_USER", "default": db_user_default, "desc": "数据库用户", "type": "text"})) - items.append(("field", {"key": "DB_PASSWORD", "default": "", "desc": "数据库密码", "type": "secret"})) - - if answers.get("redis"): - items.append(("group", "Redis 配置")) - items.append(("field", {"key": "REDIS_URL", "default": "redis://localhost:6379/0", "desc": "Redis 连接地址", "type": "text"})) - - if answers.get("celery"): - items.append(("group", "Celery 配置")) - items.append(("field", {"key": "CELERY_BROKER_URL", "default": "", "desc": "Celery Broker(留空自动选择)", "type": "text"})) - items.append(("field", {"key": "CELERY_RESULT_BACKEND", "default": "", "desc": "Celery 结果后端(留空自动选择)", "type": "text"})) - - items.append(("group", "演示模式")) - items.append(("field", {"key": "ZASCA_DEMO", "default": "0", "desc": "演示模式(1=启用)", "type": "text"})) - - items.append(("group", "安全配置")) - items.append(("field", {"key": "SECURE_SSL_REDIRECT", "default": "False", "desc": "SSL 重定向", "type": "bool"})) - items.append(("field", {"key": "SESSION_COOKIE_SECURE", "default": "False", "desc": "会话 Cookie 安全", "type": "bool"})) - items.append(("field", {"key": "CSRF_COOKIE_SECURE", "default": "False", "desc": "CSRF Cookie 安全", "type": "bool"})) - - items.append(("group", "日志配置")) - items.append(("field", {"key": "LOG_LEVEL", "default": "DEBUG", "desc": "日志级别", "type": "text"})) - items.append(("field", {"key": "LOG_FILE", "default": "/var/log/2c2a/application.log", "desc": "日志文件路径", "type": "text"})) - - if answers.get("winrm"): - items.append(("group", "WinRM 配置")) - items.append(("field", {"key": "WINRM_TIMEOUT", "default": "30", "desc": "WinRM 超时(秒)", "type": "text"})) - items.append(("field", {"key": "WINRM_RETRY_COUNT", "default": "3", "desc": "WinRM 重试次数", "type": "text"})) - - items.append(("group", "Gateway 配置")) - items.append(("field", {"key": "GATEWAY_ENABLED", "default": "False", "desc": "Gateway 开关", "type": "bool"})) - items.append(("field", {"key": "GATEWAY_CONTROL_SOCKET", "default": "/run/2c2a/control.sock", "desc": "Gateway 控制套接字", "type": "text"})) - - items.append(("group", "Beta 数据库配置(可选)")) - items.append(("field", {"key": "BETA_DB_NAME", "default": "", "desc": "Beta 数据库名称(留空跳过)", "type": "text"})) - items.append(("field", {"key": "BETA_DB_USER", "default": "", "desc": "Beta 数据库用户", "type": "text"})) - items.append(("field", {"key": "BETA_DB_PASSWORD", "default": "", "desc": "Beta 数据库密码", "type": "secret"})) - items.append(("field", {"key": "BETA_DB_HOST", "default": "", "desc": "Beta 数据库主机", "type": "text"})) - items.append(("field", {"key": "BETA_DB_PORT", "default": "", "desc": "Beta 数据库端口", "type": "text"})) - - items.append(("group", "Bootstrap 认证配置")) - items.append(("field", {"key": "BOOTSTRAP_SHARED_SALT", "default": "", "desc": "Bootstrap 共享盐值", "type": "secret"})) - - return items - - -def _step_bool(stdscr, field_data, current_val, step, total): - key = field_data["key"] - desc = field_data.get("desc", "") - selected = 0 if current_val == "True" else 1 - - while True: - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - box_w = min(72, max_x - 4) - box_h = 10 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = f" 环境变量配置 ({step}/{total}) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - ry = box_y + 2 - _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) - - ry += 1 - if desc: - _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) - - ry += 2 - options = [("True", "启用"), ("False", "禁用")] - for i, (val, label) in enumerate(options): - is_sel = (i == selected) - marker = "◉" if is_sel else "○" - m_color = C_RADIO_ON | curses.A_BOLD if is_sel else C_RADIO_OFF - ox = box_x + 6 + i * 22 - _safe_addstr(stdscr, ry, ox, marker, curses.color_pair(m_color)) - _safe_addstr(stdscr, ry, ox + 2, f" {label} ({val})", curses.color_pair(C_SELECTED if is_sel else C_UNSELECTED) | curses.A_BOLD) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "←→/Space 切换 Enter 确认 Esc 返回") - - stdscr.refresh() - - ch = stdscr.getch() - if ch == curses.KEY_LEFT: - selected = 0 - elif ch == curses.KEY_RIGHT: - selected = 1 - elif ch == ord(' '): - selected = 1 - selected - elif ch in (curses.KEY_ENTER, 10, 13): - return "True" if selected == 0 else "False" - elif ch == 27: - return None - - -def _step_secret(stdscr, field_data, current_val, is_auto, step, total): - key = field_data["key"] - desc = field_data.get("desc", "") - - if is_auto or not current_val: - radio = 0 - else: - radio = 1 - - while True: - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - box_w = min(72, max_x - 4) - box_h = 12 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = f" 环境变量配置 ({step}/{total}) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - ry = box_y + 2 - _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) - - ry += 1 - if desc: - _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) - - ry += 2 - is_sel0 = (radio == 0) - marker0 = "◉" if is_sel0 else "○" - m_color0 = C_RADIO_ON | curses.A_BOLD if is_sel0 else C_RADIO_OFF - _safe_addstr(stdscr, ry, box_x + 4, marker0, curses.color_pair(m_color0)) - _safe_addstr(stdscr, ry, box_x + 7, "随机生成(推荐)", curses.color_pair(C_SELECTED if is_sel0 else C_UNSELECTED) | curses.A_BOLD) - - ry += 1 - sub_desc = "自动生成包含字母、数字和符号的50位密钥" - _safe_addstr(stdscr, ry, box_x + 7, sub_desc[:box_w - 10], curses.color_pair(C_DESC)) - - if is_sel0 and is_auto and current_val: - ry += 1 - preview = current_val[:8] + "..." + current_val[-4:] if len(current_val) > 12 else current_val - _safe_addstr(stdscr, ry, box_x + 7, f"预览: {preview}", curses.color_pair(C_DESC)) - - ry += 2 - is_sel1 = (radio == 1) - marker1 = "◉" if is_sel1 else "○" - m_color1 = C_RADIO_ON | curses.A_BOLD if is_sel1 else C_RADIO_OFF - _safe_addstr(stdscr, ry, box_x + 4, marker1, curses.color_pair(m_color1)) - _safe_addstr(stdscr, ry, box_x + 7, "手动输入", curses.color_pair(C_SELECTED if is_sel1 else C_UNSELECTED) | curses.A_BOLD) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 选择 Enter 确认 Esc 返回") - - stdscr.refresh() - - ch = stdscr.getch() - if ch == curses.KEY_UP: - radio = 0 - elif ch == curses.KEY_DOWN: - radio = 1 - elif ch in (curses.KEY_ENTER, 10, 13): - if radio == 0: - if not current_val or not is_auto: - current_val = _generate_secret(50) - return current_val, True - else: - result = _step_text(stdscr, field_data, current_val, step, total) - if result is not None: - return result, False - continue - elif ch == 27: - return None, None - - -def _step_text(stdscr, field_data, current_val, step, total): - key = field_data["key"] - desc = field_data.get("desc", "") - curses.curs_set(1) - buf = list(current_val) - pos = len(buf) - - while True: - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - box_w = min(72, max_x - 4) - box_h = 10 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = f" 环境变量配置 ({step}/{total}) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - ry = box_y + 2 - _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) - - ry += 1 - if desc: - _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) - - ry += 2 - input_x = box_x + 4 - input_w = box_w - 8 - _safe_addstr(stdscr, ry, input_x, " " * input_w, curses.color_pair(C_HIGHLIGHT)) - - text = "".join(buf) - if len(text) > input_w: - start = max(0, pos - input_w + 1) - visible_text = text[start:start + input_w] - cursor_offset = pos - start - else: - visible_text = text - cursor_offset = pos - - _safe_addstr(stdscr, ry, input_x, visible_text[:input_w], curses.color_pair(C_HIGHLIGHT)) - - try: - stdscr.move(ry, input_x + min(cursor_offset, input_w - 1)) - except curses.error: - # Cursor move can fail when terminal is resized or too small; ignore and continue rendering. - pass - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "Enter 确认 Esc 返回 Ctrl+U 清空") - - stdscr.refresh() - - ch = stdscr.getch() - if ch in (curses.KEY_ENTER, 10, 13): - curses.curs_set(0) - return "".join(buf) - elif ch == 27: - curses.curs_set(0) - return None - elif ch in (curses.KEY_BACKSPACE, 127, 8): - if pos > 0: - buf.pop(pos - 1) - pos -= 1 - elif ch == curses.KEY_DC: - if pos < len(buf): - buf.pop(pos) - elif ch == curses.KEY_LEFT: - if pos > 0: - pos -= 1 - elif ch == curses.KEY_RIGHT: - if pos < len(buf): - pos += 1 - elif ch == curses.KEY_HOME: - pos = 0 - elif ch == curses.KEY_END: - pos = len(buf) - elif ch == 21: - buf.clear() - pos = 0 - elif 32 <= ch < 127: - buf.insert(pos, chr(ch)) - pos += 1 - - -def _step_auto(stdscr, field_data, current_val, step, total): - key = field_data["key"] - desc = field_data.get("desc", "") - - while True: - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - box_w = min(72, max_x - 4) - box_h = 10 - box_x = max(0, (max_x - box_w) // 2) - box_y = 2 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = f" 环境变量配置 ({step}/{total}) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - ry = box_y + 2 - _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) - - ry += 1 - if desc: - _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) - - ry += 2 - _safe_addstr(stdscr, ry, box_x + 4, f"▸ {current_val}", curses.color_pair(C_SUCCESS) | curses.A_BOLD) - - ry += 1 - _safe_addstr(stdscr, ry, box_x + 4, "(此值由之前的选择自动确定)", curses.color_pair(C_DESC)) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "Enter 继续 Esc 返回") - - stdscr.refresh() - - ch = stdscr.getch() - if ch in (curses.KEY_ENTER, 10, 13): - return current_val - elif ch == 27: - return None - - -def _step_list(stdscr, field_data, current_val, step, total): - key = field_data["key"] - desc = field_data.get("desc", "") - items = [x.strip() for x in current_val.split(",") if x.strip()] if current_val else [] - cursor = 0 - scroll = 0 - - while True: - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - box_w = min(72, max_x - 4) - box_h = max(14, min(20, max_y - 4)) - box_x = max(0, (max_x - box_w) // 2) - box_y = 1 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = f" 环境变量配置 ({step}/{total}) " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - ry = box_y + 2 - _safe_addstr(stdscr, ry, box_x + 4, key, curses.color_pair(C_SELECTED) | curses.A_BOLD) - - ry += 1 - if desc: - _safe_addstr(stdscr, ry, box_x + 4, desc[:box_w - 8], curses.color_pair(C_DESC)) - - ry += 2 - max_list_visible = box_h - 8 - max_scroll = max(0, len(items) - max_list_visible) - if cursor < scroll: - scroll = cursor - elif cursor >= scroll + max_list_visible: - scroll = cursor - max_list_visible + 1 - scroll = max(0, min(scroll, max_scroll)) - - if not items: - _safe_addstr(stdscr, ry, box_x + 6, "(空列表,按 + 添加项)", curses.color_pair(C_DESC)) - else: - visible_items = items[scroll:scroll + max_list_visible] - for i, item in enumerate(visible_items): - actual_idx = scroll + i - iy = ry + i - is_active = (actual_idx == cursor) - marker = "▸" if is_active else " " - line = f" {marker} {item}" - if is_active: - _safe_addstr(stdscr, iy, box_x + 4, line[:box_w - 8], curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD) - else: - _safe_addstr(stdscr, iy, box_x + 4, line[:box_w - 8], curses.color_pair(C_UNSELECTED)) - - count_y = box_y + box_h - 3 - _safe_addstr(stdscr, count_y, box_x + 4, f"共 {len(items)} 项", curses.color_pair(C_DESC)) - - if max_scroll > 0: - scroll_info = f" [{scroll + 1}-{min(scroll + max_list_visible, len(items))}/{len(items)}] " - _safe_addstr(stdscr, count_y, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC)) - - hint_y = box_y + box_h + 1 - _draw_hint(stdscr, hint_y, box_x + 2, "↑↓ 切换 + 添加 - 删除 Enter 编辑 Ctrl+D 完成 Esc 返回") - - stdscr.refresh() - - ch = stdscr.getch() - if ch == curses.KEY_UP: - if items: - cursor = max(0, cursor - 1) - elif ch == curses.KEY_DOWN: - if items: - cursor = min(len(items) - 1, cursor + 1) - elif ch in (ord('+'), ord('=')): - new_item = _step_text(stdscr, {"key": f"{key}[新项]", "desc": "输入新项的值"}, "", step, total) - if new_item is not None and new_item.strip(): - items.append(new_item.strip()) - cursor = len(items) - 1 - elif ch in (ord('-'), ord('_')): - if items and 0 <= cursor < len(items): - items.pop(cursor) - if cursor >= len(items) and len(items) > 0: - cursor = len(items) - 1 - elif ch in (curses.KEY_ENTER, 10, 13): - if items and 0 <= cursor < len(items): - new_item = _step_text(stdscr, {"key": f"{key}[{cursor}]", "desc": "编辑当前项"}, items[cursor], step, total) - if new_item is not None: - items[cursor] = new_item.strip() if new_item.strip() else items[cursor] - elif ch == 4: - return ",".join(items) - elif ch == 27: - return None - - -def _env_preview(stdscr, items, values, secret_auto): - field_indices = [i for i, (kind, _) in enumerate(items) if kind == "field"] - cursor = 0 - scroll = 0 - - while True: - _init_colors() - stdscr.clear() - max_y, max_x = stdscr.getmaxyx() - - box_w = min(76, max_x - 4) - box_h = max_y - 5 - box_x = max(0, (max_x - box_w) // 2) - box_y = 1 - - _draw_banner(stdscr, 0, max_x) - _draw_box(stdscr, box_y, box_x, box_h, box_w) - - title = " .env 配置预览 " - tx = box_x + max(0, (box_w - len(title)) // 2) - _safe_addstr(stdscr, box_y, tx, title, curses.color_pair(C_TITLE) | curses.A_BOLD) - - cursor_item_idx = field_indices[cursor] if cursor < len(field_indices) else 0 - - max_visible = box_h - 3 - total_items = len(items) - max_scroll = max(0, total_items - max_visible) - - if cursor_item_idx < scroll: - scroll = cursor_item_idx - elif cursor_item_idx >= scroll + max_visible: - scroll = cursor_item_idx - max_visible + 1 - scroll = max(0, min(scroll, max_scroll)) - - visible = items[scroll:scroll + max_visible] - current_visible_idx = cursor_item_idx - scroll - - for i, (kind, data) in enumerate(visible): - ry = box_y + 1 + i - rx = box_x + 2 - remaining = box_w - 5 - - if kind == "group": - _safe_addstr(stdscr, ry, rx, f" {data}", curses.color_pair(C_SUBTITLE) | curses.A_BOLD) - elif kind == "field": - is_active = (i == current_visible_idx) - key = data["key"] - val = values.get(key, data["default"]) - ftype = data.get("type", "text") - - if ftype == "secret": - if key in secret_auto: - if val: - preview = val[:6] + "..." + val[-4:] if len(val) > 10 else val - display = f"(随机: {preview})" - else: - display = "(随机生成)" - elif val: - display = "******" - else: - display = "(未设置)" - elif ftype == "auto": - display = f"{val} (自动)" - elif ftype == "bool": - display = val - elif ftype == "list": - list_items = [x.strip() for x in val.split(",") if x.strip()] if val else [] - if list_items: - display = ", ".join(list_items) - else: - display = "(空列表)" - else: - display = val if val else "(空)" - - line = f" {key} = {display}" - - if is_active: - _safe_addstr(stdscr, ry, rx, line[:remaining], curses.color_pair(C_HIGHLIGHT) | curses.A_BOLD) - else: - color = C_DESC if ftype == "auto" else C_UNSELECTED - _safe_addstr(stdscr, ry, rx, line[:remaining], curses.color_pair(color)) - - desc_y = box_y + box_h - 2 - if 0 <= current_visible_idx < len(visible) and visible[current_visible_idx][0] == "field": - field_data = visible[current_visible_idx][1] - desc = field_data.get("desc", "") - if desc: - _safe_addstr(stdscr, desc_y, box_x + 3, f" {desc}"[:box_w - 6], curses.color_pair(C_DESC)) - - if max_scroll > 0: - scroll_info = f" [{scroll + 1}-{min(scroll + max_visible, total_items)}/{total_items}] " - _safe_addstr(stdscr, desc_y, box_x + box_w - len(scroll_info) - 2, scroll_info, curses.color_pair(C_DESC)) - - hint_y = box_y + box_h + 1 - hint = "↑↓ 移动 Enter 编辑 S 保存 Esc 取消" - if max_scroll > 0: - hint = "↑↓/PgUp/PgDn 滚动 " + hint - _draw_hint(stdscr, hint_y, box_x + 2, hint) - - stdscr.refresh() - - ch = stdscr.getch() - if ch == curses.KEY_UP: - cursor = max(0, cursor - 1) - elif ch == curses.KEY_DOWN: - cursor = min(len(field_indices) - 1, cursor + 1) - elif ch == curses.KEY_PPAGE: - cursor = max(0, cursor - 5) - elif ch == curses.KEY_NPAGE: - cursor = min(len(field_indices) - 1, cursor + 5) - elif ch in (curses.KEY_ENTER, 10, 13): - field_data = items[field_indices[cursor]][1] - ftype = field_data.get("type", "text") - key = field_data["key"] - current = values.get(key, field_data["default"]) - - if ftype == "bool": - result = _step_bool(stdscr, field_data, current, cursor + 1, len(field_indices)) - if result is not None: - values[key] = result - elif ftype == "secret": - is_auto_flag = key in secret_auto - result, auto = _step_secret(stdscr, field_data, current, is_auto_flag, cursor + 1, len(field_indices)) - if result is not None: - values[key] = result - if auto: - secret_auto.add(key) - else: - secret_auto.discard(key) - elif ftype == "list": - result = _step_list(stdscr, field_data, current, cursor + 1, len(field_indices)) - if result is not None: - values[key] = result - else: - result = _step_text(stdscr, field_data, current, cursor + 1, len(field_indices)) - if result is not None: - values[key] = result - elif ch in (ord('s'), ord('S')): - return values - elif ch == 27: - return None - - -def _configure_env(stdscr, answers): - items = _get_env_items(answers) - field_items = [(i, data) for i, (kind, data) in enumerate(items) if kind == "field"] - total = len(field_items) - - existing = _load_existing_env(BASE_DIR / ".env") - values = {} - secret_auto = set() - - for idx, data in field_items: - key = data["key"] - if data.get("type") == "auto": - values[key] = data["default"] - elif key in existing: - values[key] = existing[key] - else: - values[key] = data["default"] - - step = 0 - while 0 <= step < total: - idx, data = field_items[step] - ftype = data.get("type", "text") - key = data["key"] - current = values.get(key, data["default"]) - - if ftype == "bool": - result = _step_bool(stdscr, data, current, step + 1, total) - if result is None: - step -= 1 - continue - values[key] = result - step += 1 - elif ftype == "secret": - is_auto_flag = key in secret_auto - result, auto = _step_secret(stdscr, data, current, is_auto_flag, step + 1, total) - if result is None: - step -= 1 - continue - values[key] = result - if auto: - secret_auto.add(key) - else: - secret_auto.discard(key) - step += 1 - elif ftype == "auto": - result = _step_auto(stdscr, data, current, step + 1, total) - if result is None: - step -= 1 - continue - step += 1 - elif ftype == "list": - result = _step_list(stdscr, data, current, step + 1, total) - if result is None: - step -= 1 - continue - values[key] = result - step += 1 - else: - result = _step_text(stdscr, data, current, step + 1, total) - if result is None: - step -= 1 - continue - values[key] = result - step += 1 - - if step < 0: - return None - - result = _env_preview(stdscr, items, values, secret_auto) - if result is None: - return None - - return result - - -def generate_env_content(answers, values): - lines = [] - lines.append("# ZASCA 环境配置文件") - lines.append("# 由 deploy.py 自动生成") - lines.append("") - - lines.append("# ========== 核心配置 ==========") - lines.append(f"DEBUG={values.get('DEBUG', 'True')}") - - secret_key = values.get('DJANGO_SECRET_KEY', '') - if not secret_key: - secret_key = _generate_secret(50) - lines.append(f"DJANGO_SECRET_KEY={secret_key}") - - lines.append(f"ALLOWED_HOSTS={values.get('ALLOWED_HOSTS', 'localhost,127.0.0.1')}") - lines.append(f"CSRF_TRUSTED_ORIGINS={values.get('CSRF_TRUSTED_ORIGINS', 'https://localhost,https://127.0.0.1')}") - lines.append("") - - db = answers.get("db", "sqlite") - lines.append("# ========== 数据库配置 ==========") - lines.append(f"DB_ENGINE={values.get('DB_ENGINE', db)}") - if db != "sqlite": - lines.append(f"DB_HOST={values.get('DB_HOST', '127.0.0.1')}") - lines.append(f"DB_PORT={values.get('DB_PORT', '3306')}") - lines.append(f"DB_NAME={values.get('DB_NAME', 'zasca')}") - lines.append(f"DB_USER={values.get('DB_USER', 'root')}") - db_pass = values.get('DB_PASSWORD', '') - if not db_pass: - db_pass = _generate_secret(50) - lines.append(f"DB_PASSWORD={db_pass}") - lines.append("") - - if answers.get("redis"): - lines.append("# ========== Redis 配置 ==========") - lines.append(f"REDIS_URL={values.get('REDIS_URL', 'redis://localhost:6379/0')}") - lines.append("") - - if answers.get("celery"): - lines.append("# ========== Celery 配置 ==========") - broker = values.get('CELERY_BROKER_URL', '') - backend = values.get('CELERY_RESULT_BACKEND', '') - if not broker and answers.get("redis"): - redis_url = values.get('REDIS_URL', 'redis://localhost:6379/0') - broker = redis_url.replace('/0', '/1') - if not backend and answers.get("redis"): - redis_url = values.get('REDIS_URL', 'redis://localhost:6379/0') - backend = redis_url.replace('/0', '/2') - if broker: - lines.append(f"CELERY_BROKER_URL={broker}") - if backend: - lines.append(f"CELERY_RESULT_BACKEND={backend}") - lines.append("") - - lines.append("# ========== 演示模式 ==========") - lines.append(f"ZASCA_DEMO={values.get('ZASCA_DEMO', '0')}") - lines.append("") - - lines.append("# ========== 安全配置 ==========") - lines.append(f"SECURE_SSL_REDIRECT={values.get('SECURE_SSL_REDIRECT', 'False')}") - lines.append(f"SESSION_COOKIE_SECURE={values.get('SESSION_COOKIE_SECURE', 'False')}") - lines.append(f"CSRF_COOKIE_SECURE={values.get('CSRF_COOKIE_SECURE', 'False')}") - lines.append("") - - lines.append("# ========== 日志配置 ==========") - lines.append(f"LOG_LEVEL={values.get('LOG_LEVEL', 'DEBUG')}") - lines.append(f"LOG_FILE={values.get('LOG_FILE', '/var/log/2c2a/application.log')}") - lines.append("") - - if answers.get("winrm"): - lines.append("# ========== WinRM 配置 ==========") - lines.append(f"WINRM_TIMEOUT={values.get('WINRM_TIMEOUT', '30')}") - lines.append(f"WINRM_RETRY_COUNT={values.get('WINRM_RETRY_COUNT', '3')}") - lines.append("") - - lines.append("# ========== Gateway 配置 ==========") - lines.append(f"GATEWAY_ENABLED={values.get('GATEWAY_ENABLED', 'False')}") - lines.append(f"GATEWAY_CONTROL_SOCKET={values.get('GATEWAY_CONTROL_SOCKET', '/run/2c2a/control.sock')}") - lines.append("") - - beta_fields = ['BETA_DB_NAME', 'BETA_DB_USER', 'BETA_DB_PASSWORD', 'BETA_DB_HOST', 'BETA_DB_PORT'] - has_beta = any(values.get(f, '') for f in beta_fields) - if has_beta: - lines.append("# ========== Beta 数据库配置 ==========") - for f in beta_fields: - if f == 'BETA_DB_PASSWORD': - bp = values.get(f, '') - if not bp: - bp = _generate_secret(50) - lines.append(f"{f}={bp}") - else: - lines.append(f"{f}={values.get(f, '')}") - lines.append("") - - lines.append("# ========== Bootstrap 认证配置 ==========") - salt = values.get('BOOTSTRAP_SHARED_SALT', '') - if not salt: - salt = _generate_secret(50) - lines.append(f"BOOTSTRAP_SHARED_SALT={salt}") - lines.append("") - - return "\n".join(lines) - - -def compute_dependencies(answers): - deps = dict(DEPS_CORE) - - if answers.get("celery"): - deps.update(DEPS_CELERY) - - if answers["db"] == "mysql": - deps.update(DEPS_MYSQL) - elif answers["db"] == "postgresql": - deps.update(DEPS_POSTGRESQL) - - if answers.get("redis"): - deps.update(DEPS_REDIS) - - if answers.get("celery") and not answers.get("redis"): - deps.update(DEPS_SQLITE_BROKER) - - if answers.get("winrm"): - deps.update(DEPS_WINRM) - - if answers.get("kerberos"): - deps.update(DEPS_KERBEROS) - - if answers.get("markdown"): - deps.update(DEPS_MARKDOWN) - - if answers.get("2fa"): - deps.update(DEPS_2FA) - - dev_deps = dict(DEPS_DEV) if answers.get("dev") else {} - - return deps, dev_deps - - -def generate_pyproject_toml(answers, deps, dev_deps): - lines = [] - - lines.append("[project]") - lines.append('name = "2c2a"') - lines.append('version = "1.0.0"') - lines.append('description = "2c2a - Django Web Application"') - lines.append('license = "AGPL-3.0-only"') - lines.append('readme = "README.md"') - lines.append('requires-python = ">=3.10"') - - sorted_deps = sorted(deps.values(), key=_dep_sort_key) - lines.append("dependencies = [") - for spec in sorted_deps: - lines.append(f' "{spec}",') - lines.append("]") - - optional_groups = {} - if not answers.get("redis"): - optional_groups["redis"] = sorted(DEPS_REDIS.values()) - if not answers.get("kerberos"): - optional_groups["kerberos"] = sorted(DEPS_KERBEROS.values()) - if not answers.get("winrm"): - optional_groups["winrm"] = sorted(DEPS_WINRM.values()) - - if optional_groups: - lines.append("") - lines.append("[project.optional-dependencies]") - for group_name, group_deps in optional_groups.items(): - lines.append(f"{group_name} = [") - for spec in group_deps: - lines.append(f' "{spec}",') - lines.append("]") - - lines.append("") - lines.append("[build-system]") - lines.append('requires = ["hatchling"]') - lines.append('build-backend = "hatchling.build"') - - if dev_deps: - lines.append("") - lines.append("[dependency-groups]") - lines.append("dev = [") - for spec in sorted(dev_deps.values()): - lines.append(f' "{spec}",') - lines.append("]") - - lines.append("") - lines.append("[tool.hatch.metadata]") - lines.append("allow-direct-references = true") - - lines.append("") - lines.append("[tool.hatch.build.targets.wheel]") - lines.append('packages = ["."]') - - lines.append("") - lines.append("[tool.pyright]") - lines.append('venvPath = "."') - lines.append('venv = ".venv"') - lines.append('pythonVersion = "3.13"') - lines.append('typeCheckingMode = "basic"') - - lines.append("") - lines.append("[tool.pyrefly]") - lines.append('python-version = "3.13"') - - lines.append("") - return "\n".join(lines) - - -def _dep_sort_key(spec): - if "@" in spec: - return (1, spec.lower()) - return (0, spec.lower()) - - -def backup_file(path): - if not path.exists(): - return None - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - backup = path.with_name(f"{path.stem}.bak.{ts}{path.suffix}") - shutil.copy2(path, backup) - return backup - - -def main(): - if not sys.stdin.isatty(): - print("错误: 此脚本需要交互式终端") - sys.exit(1) - - try: - curses.wrapper(_tui_main) - except KeyboardInterrupt: - pass - - -if __name__ == "__main__": - main() diff --git a/scripts/tailwind-build.sh b/scripts/tailwind-build.sh deleted file mode 100755 index bd0736e..0000000 --- a/scripts/tailwind-build.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================ -# 2c2a - Tailwind CSS v4 Build Script -# ============================================================================ -# Usage: -# ./scripts/tailwind-build.sh # One-time build -# ./scripts/tailwind-build.sh --watch # Watch mode (auto-rebuild on changes) -# ============================================================================ - -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$PROJECT_ROOT" - -TAILWIND_BIN="./static/vendor/tailwindcss" -INPUT_CSS="./static/src/tailwind.css" -OUTPUT_CSS="./static/css/provider.css" -TAILWIND_VERSION="v4.2.4" - -detect_platform() { - local os arch - os="$(uname -s | tr '[:upper:]' '[:lower:]')" - arch="$(uname -m)" - case "$os" in - linux) os="linux" ;; - darwin) os="macos" ;; - *) echo "[ERROR] Unsupported OS: $os"; exit 1 ;; - esac - case "$arch" in - x86_64|amd64) arch="x64" ;; - aarch64|arm64) arch="arm64" ;; - *) echo "[ERROR] Unsupported architecture: $arch"; exit 1 ;; - esac - echo "tailwindcss-${os}-${arch}" -} - -download_tailwind() { - local platform filename url - platform="$(detect_platform)" - filename="$platform" - url="https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/${filename}" - - echo "[INFO] Downloading Tailwind CSS CLI ${TAILWIND_VERSION}..." - echo " Platform: $platform" - echo " URL: $url" - - mkdir -p "$(dirname "$TAILWIND_BIN")" - if command -v curl &>/dev/null; then - curl -fSL -o "$TAILWIND_BIN" "$url" - elif command -v wget &>/dev/null; then - wget -O "$TAILWIND_BIN" "$url" - else - echo "[ERROR] Neither curl nor wget found. Please install one and retry." - exit 1 - fi - - chmod +x "$TAILWIND_BIN" - echo "[INFO] Tailwind CSS CLI downloaded successfully." -} - -if [[ ! -x "$TAILWIND_BIN" ]]; then - echo "[WARN] Tailwind CSS CLI not found at: $TAILWIND_BIN" - read -rp "Download it now from GitHub? [Y/n] " answer - case "${answer:-Y}" in - [yY]|[yY][eE][sS]|"") download_tailwind ;; - *) echo "[ERROR] Cannot build without Tailwind CSS CLI. Exiting."; exit 1 ;; - esac -fi - -mkdir -p "$(dirname "$OUTPUT_CSS")" - -if [[ "${1:-}" == "--watch" ]]; then - echo "[INFO] Starting Tailwind CSS in watch mode..." - echo " Input: $INPUT_CSS" - echo " Output: $OUTPUT_CSS" - "$TAILWIND_BIN" -i "$INPUT_CSS" -o "$OUTPUT_CSS" --watch -else - echo "[INFO] Building Tailwind CSS (one-time)..." - echo " Input: $INPUT_CSS" - echo " Output: $OUTPUT_CSS" - "$TAILWIND_BIN" -i "$INPUT_CSS" -o "$OUTPUT_CSS" - echo "[INFO] Build complete." -fi diff --git a/static/admin/css/bootstrap-deploy-button.css b/static/admin/css/bootstrap-deploy-button.css deleted file mode 100755 index d728d73..0000000 --- a/static/admin/css/bootstrap-deploy-button.css +++ /dev/null @@ -1,871 +0,0 @@ -/* 三步骤部署流程模态框样式 */ -#deploy-flow-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - z-index: 10000; - display: none; -} - -#deploy-flow-modal-content { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: white; - padding: 25px; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - min-width: 600px; - max-width: 800px; - max-height: 85vh; - overflow-y: auto; -} - -#deploy-flow-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 25px; - padding-bottom: 15px; - border-bottom: 1px solid #eee; -} - -#deploy-flow-title { - margin: 0; - font-size: 1.4em; - font-weight: bold; - color: #333; -} - -#close-deploy-flow-modal { - background: none; - border: none; - font-size: 1.8em; - cursor: pointer; - padding: 0; - width: 35px; - height: 35px; - display: flex; - align-items: center; - justify-content: center; - color: #999; - transition: color 0.2s; -} - -#close-deploy-flow-modal:hover { - color: #333; -} - -/* 步骤指示器 */ -#step-indicator { - display: flex; - justify-content: space-between; - margin-bottom: 30px; - position: relative; -} - -.step-item { - display: flex; - flex-direction: column; - align-items: center; - flex: 1; - position: relative; - z-index: 2; -} - -.step-item:not(:last-child)::after { - content: ''; - position: absolute; - top: 20px; - left: 50%; - right: -50%; - height: 2px; - background-color: #ddd; - z-index: 1; -} - -.step-item.active::after, -.step-item.completed::after { - background-color: #007cba; -} - -.step-number { - width: 40px; - height: 40px; - border-radius: 50%; - background-color: #ddd; - display: flex; - align-items: center; - justify-content: center; - font-weight: bold; - color: #666; - margin-bottom: 8px; - transition: all 0.3s ease; -} - -.step-item.active .step-number { - background-color: #007cba; - color: white; -} - -.step-item.completed .step-number { - background-color: #28a745; - color: white; -} - -.step-text { - font-size: 0.9em; - color: #666; - text-align: center; -} - -.step-item.active .step-text { - color: #007cba; - font-weight: 500; -} - -.step-item.completed .step-text { - color: #28a745; -} - -/* 步骤面板 */ -.step-panel { - display: none; - animation: fadeIn 0.3s ease; -} - -.step-panel.active { - display: block; -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -.step-panel h4 { - margin-top: 0; - margin-bottom: 20px; - color: #333; - font-size: 1.2em; -} - -.step-instructions { - margin-bottom: 25px; -} - -.step-instructions p { - margin: 0 0 15px 0; - line-height: 1.5; -} - -/* 下载部分 */ -.download-section { - margin: 20px 0; - text-align: center; -} - -.download-btn { - display: inline-block; - background-color: #007cba; - color: white; - padding: 12px 24px; - text-decoration: none; - border-radius: 4px; - font-weight: 500; - transition: background-color 0.2s; - margin-bottom: 10px; -} - -.download-btn:hover { - background-color: #005a87; - color: white; -} - -.download-hint { - display: block; - font-size: 0.9em; - color: #666; -} - -.note { - font-size: 0.9em; - color: #666; - background-color: #f8f9fa; - padding: 12px; - border-radius: 4px; - border-left: 3px solid #007cba; -} - -/* 命令部分 */ -.command-section { - margin: 20px 0; -} - -.command-display { - background-color: #f8f9fa; - border: 1px solid #ddd; - border-radius: 4px; - padding: 15px; - margin-bottom: 12px; - font-family: 'Courier New', monospace; - font-size: 0.95em; - white-space: pre-wrap; - word-break: break-all; - min-height: 60px; - display: flex; - align-items: center; -} - -.copy-btn { - background-color: #007cba; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 0.9em; - transition: background-color 0.2s; -} - -.copy-btn:hover { - background-color: #005a87; -} - -.copy-btn.copied { - background-color: #28a745; -} - -.command-info ul { - margin: 10px 0; - padding-left: 20px; -} - -.command-info li { - margin-bottom: 5px; - line-height: 1.4; -} - -/* 配对码部分 */ -.pairing-section { - margin: 20px 0; - text-align: center; -} - -.pairing-code { - font-size: 2.5em; - font-weight: bold; - letter-spacing: 5px; - color: #007cba; - background-color: #f8f9fa; - padding: 20px; - border-radius: 8px; - border: 2px dashed #007cba; - margin: 15px 0; - font-family: monospace; -} - -.pairing-info { - margin: 15px 0; -} - -.pairing-info p { - margin: 5px 0; -} - -.warning { - color: #dc3545; - font-weight: 500; -} - -/* 状态部分 */ -.status-section { - margin: 20px 0; - padding: 15px; - background-color: #f8f9fa; - border-radius: 4px; - text-align: center; -} - -#pairing-status { - font-weight: 500; - color: #666; -} - -.loading { - color: #666; - font-style: italic; -} - -.error { - color: #dc3545; -} - -/* 按钮区域 */ -.step-actions { - display: flex; - justify-content: space-between; - gap: 15px; - margin-top: 25px; - padding-top: 20px; - border-top: 1px solid #eee; -} - -.prev-btn, .next-btn, .finish-btn { - padding: 10px 20px; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 0.95em; - transition: all 0.2s; -} - -.prev-btn { - background-color: #6c757d; - color: white; -} - -.prev-btn:hover { - background-color: #5a6268; -} - -.next-btn, .finish-btn { - background-color: #007cba; - color: white; -} - -.next-btn:hover, .finish-btn:hover { - background-color: #005a87; -} - -.finish-btn { - background-color: #28a745; -} - -.finish-btn:hover { - background-color: #218838; -} - -/* 深色模式支持 */ -@media (prefers-color-scheme: dark) { - #deploy-flow-modal-content { - background-color: #2d2d2d; - color: #fff; - } - - #deploy-flow-title, - .step-panel h4, - .step-text { - color: #fff; - } - - #close-deploy-flow-modal { - color: #aaa; - } - - #close-deploy-flow-modal:hover { - color: #fff; - } - - .step-number { - background-color: #555; - color: #ccc; - } - - .step-item.active .step-number { - background-color: #007cba; - color: white; - } - - .step-item.completed .step-number { - background-color: #28a745; - color: white; - } - - .step-text, - .step-item .step-text { - color: #aaa; - } - - .step-item.active .step-text { - color: #007cba; - } - - .step-item.completed .step-text { - color: #28a745; - } - - .command-display, - .pairing-code { - background-color: #333; - border-color: #555; - color: #fff; - } - - .note, - .status-section { - background-color: #333; - color: #fff; - } - - .download-btn:hover { - background-color: #005a87; - } -} - -/* Django Admin 深色模式支持 */ -body[data-admin-theme="dark"] #deploy-flow-modal-content, -.theme-dark #deploy-flow-modal-content { - background-color: #2d2d2d; - color: #fff; -} - -body[data-admin-theme="dark"] #deploy-flow-title, -.theme-dark #deploy-flow-title, -body[data-admin-theme="dark"] .step-panel h4, -.theme-dark .step-panel h4 { - color: #fff; -} - -body[data-admin-theme="dark"] .step-number, -.theme-dark .step-number { - background-color: #555; - color: #ccc; -} - -body[data-admin-theme="dark"] .command-display, -.theme-dark .command-display, -body[data-admin-theme="dark"] .pairing-code, -.theme-dark .pairing-code { - background-color: #333; - border-color: #555; - color: #fff; -} - -/* 快速部署对话框样式 */ -#quick-deploy-dialog, -#quick-register-dialog, -#verify-host-dialog { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - z-index: 10001; - display: none; -} - -#quick-deploy-dialog-content, -#quick-register-dialog-content, -#verify-host-dialog-content { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background-color: white; - padding: 25px; - border-radius: 8px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - min-width: 500px; - max-width: 900px; - max-height: 80vh; - overflow-y: auto; -} - -#quick-deploy-dialog-header, -#quick-register-dialog-header, -#verify-host-dialog-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 15px; - border-bottom: 1px solid #eee; -} - -#quick-deploy-dialog-header h3, -#quick-register-dialog-header h3, -#verify-host-dialog-header h3 { - margin: 0; - font-size: 1.3em; - color: #333; -} - -#close-quick-deploy-dialog, -#close-quick-register-dialog, -#close-verify-host-dialog { - background: none; - border: none; - font-size: 1.8em; - cursor: pointer; - padding: 0; - width: 35px; - height: 35px; - display: flex; - align-items: center; - justify-content: center; - color: #999; - transition: color 0.2s; -} - -#close-quick-deploy-dialog:hover, -#close-quick-register-dialog:hover, -#close-verify-host-dialog:hover { - color: #333; -} - -/* 验证对话框样式 */ -.verify-section { - margin-bottom: 20px; -} - -.verify-section h4 { - margin: 0 0 15px 0; - color: #333; -} - -.pending-hosts-grid { - display: grid; - gap: 10px; -} - -.pending-host-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 15px; - background-color: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; -} - -.pending-host-item:hover { - background-color: #e9ecef; - border-color: #007cba; -} - -.pending-host-item .host-info { - flex: 1; -} - -.pending-host-item .host-info strong { - display: block; - margin-bottom: 4px; - color: #333; -} - -.pending-host-item .host-time { - font-size: 0.85em; - color: #666; -} - -.verify-btn-small { - background-color: #007cba; - color: white; - border: none; - padding: 6px 12px; - border-radius: 3px; - cursor: pointer; - font-size: 0.85em; - transition: background-color 0.2s; -} - -.verify-btn-small:hover { - background-color: #005a87; -} - -.revoke-btn-small { - background-color: #dc3545; - color: white; - border: none; - padding: 6px 12px; - border-radius: 3px; - cursor: pointer; - font-size: 0.85em; - transition: background-color 0.2s; - margin-left: 8px; -} - -.revoke-btn-small:hover { - background-color: #c82333; -} - -.host-actions { - display: flex; - gap: 5px; -} - -.verify-form { - padding: 20px; - background-color: #f8f9fa; - border-radius: 4px; - border: 1px solid #e9ecef; -} - -.verify-form h4 { - margin: 0 0 15px 0; - color: #333; -} - -.verify-form p { - margin: 0 0 15px 0; - color: #666; -} - -.verify-form .form-group { - margin-bottom: 15px; -} - -.verify-form label { - display: block; - margin-bottom: 5px; - font-weight: 500; - color: #333; -} - -.verify-form input[type="text"] { - width: 100%; - padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 1em; - font-family: monospace; - letter-spacing: 2px; -} - -.verify-form input[type="text"]:focus { - outline: none; - border-color: #007cba; - box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2); -} - -/* 注册说明 */ -.register-instructions { - margin-bottom: 20px; -} - -.register-instructions h4 { - margin: 0 0 10px 0; - color: #333; -} - -.register-instructions p { - margin: 0; - color: #666; - line-height: 1.5; -} - -.register-info { - margin-top: 20px; - padding: 15px; - background-color: #f8f9fa; - border-radius: 4px; - border-left: 3px solid #007cba; -} - -.register-info p { - margin: 0 0 10px 0; -} - -.register-info ul { - margin: 0; - padding-left: 20px; -} - -.register-info li { - margin-bottom: 5px; - line-height: 1.4; -} - -/* 主机选择列表 */ -.host-select-list { - max-height: 400px; - overflow-y: auto; -} - -.host-select-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 15px; - margin-bottom: 8px; - background-color: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; -} - -.host-select-item:hover { - background-color: #e9ecef; - border-color: #007cba; -} - -.host-info { - flex: 1; -} - -.host-info strong { - display: block; - margin-bottom: 4px; - color: #333; -} - -.host-detail { - font-size: 0.9em; - color: #666; -} - -.host-status { - margin: 0 15px; - padding: 4px 8px; - border-radius: 3px; - font-size: 0.85em; - background-color: #fff; - border: 1px solid #ddd; -} - -.deploy-btn-small { - background-color: #007cba; - color: white; - border: none; - padding: 6px 12px; - border-radius: 3px; - cursor: pointer; - font-size: 0.85em; - transition: background-color 0.2s; -} - -.deploy-btn-small:hover { - background-color: #005a87; -} - -/* 内联部署按钮 */ -.inline-deploy-btn { - display: inline-block; - margin-left: 10px; - padding: 3px 8px; - background-color: #007cba; - color: white; - text-decoration: none; - border-radius: 3px; - font-size: 0.85em; - transition: background-color 0.2s; -} - -.inline-deploy-btn:hover { - background-color: #005a87; - color: white; - text-decoration: none; -} - -/* 深色模式支持 */ -@media (prefers-color-scheme: dark) { - #quick-deploy-dialog-content, - #quick-register-dialog-content { - background-color: #2d2d2d; - color: #fff; - } - - #quick-deploy-dialog-header h3, - #quick-register-dialog-header h3 { - color: #fff; - } - - #close-quick-deploy-dialog, - #close-quick-register-dialog { - color: #aaa; - } - - #close-quick-deploy-dialog:hover, - #close-quick-register-dialog:hover { - color: #fff; - } - - .host-select-item { - background-color: #333; - border-color: #555; - } - - .host-select-item:hover { - background-color: #444; - border-color: #007cba; - } - - .host-info strong { - color: #fff; - } - - .host-detail { - color: #aaa; - } - - .host-status { - background-color: #444; - border-color: #666; - color: #fff; - } - - .register-instructions h4 { - color: #fff; - } - - .register-instructions p { - color: #aaa; - } - - .register-info { - background-color: #333; - border-color: #007cba; - } -} - -/* Django Admin 深色模式 */ -body[data-admin-theme="dark"] #quick-deploy-dialog-content, -.theme-dark #quick-deploy-dialog-content, -body[data-admin-theme="dark"] #quick-register-dialog-content, -.theme-dark #quick-register-dialog-content { - background-color: #2d2d2d; - color: #fff; -} - -body[data-admin-theme="dark"] #quick-deploy-dialog-header h3, -.theme-dark #quick-deploy-dialog-header h3, -body[data-admin-theme="dark"] #quick-register-dialog-header h3, -.theme-dark #quick-register-dialog-header h3 { - color: #fff; -} - -body[data-admin-theme="dark"] .host-select-item, -.theme-dark .host-select-item { - background-color: #333; - border-color: #555; -} - -body[data-admin-theme="dark"] .host-info strong, -.theme-dark .host-info strong { - color: #fff; -} - -body[data-admin-theme="dark"] .register-instructions h4, -.theme-dark .register-instructions h4 { - color: #fff; -} - -body[data-admin-theme="dark"] .register-instructions p, -.theme-dark .register-instructions p { - color: #aaa; -} - -body[data-admin-theme="dark"] .register-info, -.theme-dark .register-info { - background-color: #333; - border-color: #007cba; -} \ No newline at end of file diff --git a/static/admin/css/bootstrap_admin.css b/static/admin/css/bootstrap_admin.css deleted file mode 100644 index 52d9198..0000000 --- a/static/admin/css/bootstrap_admin.css +++ /dev/null @@ -1,394 +0,0 @@ -/** - * Bootstrap Admin样式 - 配对码认证版本 - */ - -/* 配对码显示样式 */ -.pairing-code-display { - background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); - border: 2px solid #90caf9; - border-radius: 8px; - padding: 12px 16px; - margin: 8px 0; - display: inline-block; - min-width: 120px; - text-align: center; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - transition: all 0.3s ease; -} - -.pairing-code-display:hover { - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.15); -} - -.pairing-code-display .code-number { - font-size: 1.8em; - font-weight: bold; - color: #1976d2; - letter-spacing: 2px; - font-family: 'Courier New', monospace; -} - -.pairing-code-display .code-label { - font-size: 0.85em; - color: #666; - margin-top: 4px; -} - -.pairing-code-display .code-expiry { - font-size: 0.75em; - color: #888; - margin-top: 2px; -} - -/* 过期警告样式 */ -.pairing-code-expiring { - background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%); - border-color: #ffd54f; -} - -.pairing-code-expired { - background: linear-gradient(135deg, #ffebee 0%, #ffcdd2 100%); - border-color: #ef9a9a; - opacity: 0.8; -} - -.pairing-code-expiring .code-number { - color: #f57f17; - animation: pulse 2s infinite; -} - -.pairing-code-expired .code-number { - color: #c62828; - text-decoration: line-through; -} - -@keyframes pulse { - 0% { opacity: 1; } - 50% { opacity: 0.7; } - 100% { opacity: 1; } -} - -/* 操作按钮样式 */ -.action-buttons { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 12px; -} - -.action-buttons .btn { - font-size: 0.85em; - padding: 6px 12px; - border-radius: 4px; -} - -.btn-copy-config { - background: #4caf50; - border-color: #4caf50; - color: white; -} - -.btn-copy-config:hover { - background: #45a049; - border-color: #45a049; -} - -.btn-refresh-code { - background: #2196f3; - border-color: #2196f3; - color: white; -} - -.btn-refresh-code:hover { - background: #1976d2; - border-color: #1976d2; -} - -.btn-verify-status { - background: #ff9800; - border-color: #ff9800; - color: white; -} - -.btn-verify-status:hover { - background: #f57c00; - border-color: #f57c00; -} - -/* 状态指示器 */ -.status-indicator { - display: inline-block; - width: 12px; - height: 12px; - border-radius: 50%; - margin-right: 8px; -} - -.status-issued { - background-color: #ff9800; -} - -.status-paired { - background-color: #4caf50; -} - -.status-consumed { - background-color: #2196f3; -} - -.status-expired { - background-color: #f44336; -} - -/* 配对码信息卡片 */ -.pairing-info-card { - background: white; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 16px; - margin: 16px 0; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); -} - -.pairing-info-card .card-title { - font-size: 1.1em; - font-weight: bold; - color: #333; - margin-bottom: 12px; - padding-bottom: 8px; - border-bottom: 2px solid #e0e0e0; -} - -.pairing-info-card .info-item { - display: flex; - justify-content: space-between; - padding: 8px 0; - border-bottom: 1px solid #f0f0f0; -} - -.pairing-info-card .info-item:last-child { - border-bottom: none; -} - -.pairing-info-card .info-label { - font-weight: 500; - color: #666; -} - -.pairing-info-card .info-value { - font-weight: bold; - color: #333; -} - -/* 倒计时动画 */ -.countdown-timer { - font-family: 'Courier New', monospace; - font-size: 1.2em; - font-weight: bold; - color: #f44336; - animation: countdown-pulse 1s infinite; -} - -@keyframes countdown-pulse { - 0% { transform: scale(1); } - 50% { transform: scale(1.1); } - 100% { transform: scale(1); } -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .pairing-code-display { - min-width: 100px; - padding: 8px 12px; - } - - .pairing-code-display .code-number { - font-size: 1.5em; - } - - .action-buttons { - flex-direction: column; - } - - .action-buttons .btn { - width: 100%; - } -} - -/* 深色模式适配 */ -@media (prefers-color-scheme: dark) { - .pairing-code-display { - background: linear-gradient(135deg, #1a237e 0%, #283593 100%); - border-color: #303f9f; - } - - .pairing-code-display .code-number { - color: #64b5f6; - } - - .pairing-code-display .code-label, - .pairing-code-display .code-expiry { - color: #bbbbbb; - } - - .pairing-info-card { - background: #2d2d2d; - border-color: #444; - color: #ffffff; - } - - .pairing-info-card .card-title { - color: #ffffff; - border-bottom-color: #444; - } - - .pairing-info-card .info-item { - border-bottom-color: #444; - } - - .pairing-info-card .info-label { - color: #bbbbbb; - } - - .pairing-info-card .info-value { - color: #ffffff; - } -} - -/* 快速操作面板 */ -.quick-actions-panel { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: 12px; - padding: 20px; - margin: 20px 0; - color: white; - box-shadow: 0 4px 15px rgba(0,0,0,0.2); -} - -.quick-actions-panel h4 { - margin: 0 0 15px 0; - font-size: 1.3em; - font-weight: 600; -} - -.quick-actions-panel .action-buttons { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - -.quick-actions-panel .btn { - background: rgba(255,255,255,0.2); - border: 2px solid rgba(255,255,255,0.3); - color: white; - padding: 10px 20px; - border-radius: 8px; - font-weight: 500; - transition: all 0.3s ease; -} - -.quick-actions-panel .btn:hover { - background: rgba(255,255,255,0.3); - transform: translateY(-2px); - box-shadow: 0 4px 8px rgba(0,0,0,0.2); -} - -/* 统计卡片增强 */ -.stats-panel { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 16px; - margin: 20px 0; -} - -.stat-card { - background: white; - border-radius: 12px; - padding: 20px; - text-align: center; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - transition: transform 0.3s ease; -} - -.stat-card:hover { - transform: translateY(-5px); -} - -.stat-value { - font-size: 2.5em; - font-weight: bold; - color: #2196f3; - margin-bottom: 8px; -} - -.stat-label { - font-size: 1em; - color: #666; - font-weight: 500; -} - -/* 加载动画 */ -.loading-spinner { - display: inline-block; - width: 20px; - height: 20px; - border: 3px solid #f3f3f3; - border-top: 3px solid #2196f3; - border-radius: 50%; - animation: spin 1s linear infinite; - margin-right: 8px; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* 通知样式 */ -.admin-notification { - position: fixed; - top: 20px; - right: 20px; - z-index: 9999; - min-width: 300px; - padding: 16px; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - animation: slideIn 0.3s ease-out; -} - -@keyframes slideIn { - from { - transform: translateX(100%); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } -} - -.admin-notification.success { - background: #d4edda; - border: 1px solid #c3e6cb; - color: #155724; -} - -.admin-notification.error { - background: #f8d7da; - border: 1px solid #f5c6cb; - color: #721c24; -} - -.admin-notification.info { - background: #d1ecf1; - border: 1px solid #bee5eb; - color: #0c5460; -} - -.admin-notification.warning { - background: #fff3cd; - border: 1px solid #ffeaa7; - color: #856404; -} \ No newline at end of file diff --git a/static/admin/js/bootstrap-deploy-button.js b/static/admin/js/bootstrap-deploy-button.js deleted file mode 100644 index d8e6895..0000000 --- a/static/admin/js/bootstrap-deploy-button.js +++ /dev/null @@ -1,656 +0,0 @@ -// 三步骤部署流程模态框 -var deployFlowModule = (function() { - var $; - var currentStep = 1; - var deployData = {}; - - // 等待django.jQuery可用 - function init() { - if (typeof django !== 'undefined' && django.jQuery) { - $ = django.jQuery; - setupDeployButton(); - setupQuickDeployButton(); - } else { - setTimeout(init, 100); - } - } - - function setupDeployButton() { - $(document).ready(function() { - // 添加部署按钮到页面顶部工具栏(修改页面) - var header = $('.object-tools'); - if (header.length > 0) { - var heading = $('#content h1').text(); - if (heading.includes('主机') || heading.includes('Host')) { - var objectIdMatch = window.location.pathname.match(/\/(\d+)\/change\//); - if (objectIdMatch && objectIdMatch[1]) { - var hostId = objectIdMatch[1]; - var hostName = $('input[name="name"]').val() || 'Unknown'; - - var deployButtonHtml = ` -
  • - - 开始部署 - -
  • - `; - - header.prepend(deployButtonHtml); - } - } - } - - // 创建三步骤模态框 - createDeployModal(); - bindModalEvents(); - }); - } - - function setupQuickDeployButton() { - $(document).ready(function() { - // 检查是否在主机列表页面 - var heading = $('#content h1').text(); - var isHostListPage = heading.includes('选择要修改的主机') || - heading.includes('Select host to change') || - window.location.pathname.includes('/admin/hosts/host/'); - - if (isHostListPage && !window.location.pathname.includes('/change/')) { - // 在列表页面添加一键注册按钮到右上角 - var objectTools = $('.object-tools'); - if (objectTools.length > 0) { - // 添加验证主机按钮 - var verifyButton = ` -
  • - - 验证主机 - -
  • - `; - objectTools.prepend(verifyButton); - - // 添加一键注册按钮 - var quickRegisterButton = ` -
  • - - 一键注册主机 - -
  • - `; - objectTools.prepend(quickRegisterButton); - } - - // 创建快速注册对话框 - createQuickRegisterDialog(); - - // 创建验证对话框 - createVerifyDialog(); - } - }); - } - - function createVerifyDialog() { - var dialogHtml = ` - - `; - - $('body').append(dialogHtml); - - // 绑定关闭事件 - $('#close-verify-host-dialog').click(function() { - $('#verify-host-dialog').hide(); - }); - - $('#verify-host-dialog').click(function(e) { - if (e.target === this) { - $(this).hide(); - } - }); - - // 绑定验证按钮事件 - $(document).on('click', '#submit-verify-btn', function() { - submitVerification(); - }); - - // TOTP输入框自动格式化 - $(document).on('input', '#totp-code', function() { - this.value = this.value.replace(/[^0-9]/g, '').substring(0, 6); - }); - } - - window.showVerifyDialog = function() { - loadPendingHosts(); - $('#verify-host-dialog').show(); - }; - - function loadPendingHosts() { - var baseUrl = window.location.origin; - $.ajax({ - url: baseUrl + '/bootstrap/api/pending-hosts/', - method: 'GET', - success: function(response) { - if (response.success) { - displayPendingHosts(response.data.hosts); - } else { - $('#pending-hosts-list').html('

    加载失败: ' + response.error + '

    '); - } - }, - error: function() { - $('#pending-hosts-list').html('

    加载失败

    '); - } - }); - } - - function displayPendingHosts(hosts) { - if (hosts.length === 0) { - $('#pending-hosts-list').html('

    当前没有待验证的主机

    '); - return; - } - - var html = '
    '; - hosts.forEach(function(host) { - html += ` -
    -
    - ${host.hostname} - ${host.created_at} -
    -
    - - -
    -
    - `; - }); - html += '
    '; - - $('#pending-hosts-list').html(html); - } - - window.selectHostForVerify = function(token, hostname) { - $('#verify-hostname').text(hostname); - $('#verify-form').data('token', token); - $('#verify-form').show(); - $('#totp-code').val('').focus(); - }; - - function submitVerification() { - var token = $('#verify-form').data('token'); - var totpCode = $('#totp-code').val(); - - if (!totpCode || totpCode.length !== 6) { - alert('请输入6位数字验证码'); - return; - } - - var baseUrl = window.location.origin; - $.ajax({ - url: baseUrl + '/bootstrap/api/verify-pairing-code/', - method: 'POST', - data: JSON.stringify({ - token: token, - pairing_code: totpCode - }), - contentType: 'application/json', - success: function(response) { - if (response.success) { - alert('验证成功!主机已激活'); - $('#verify-host-dialog').hide(); - location.reload(); - } else { - alert('验证失败: ' + response.error); - } - }, - error: function() { - alert('验证请求失败'); - } - }); - } - - window.revokePendingHost = function(token, hostname) { - if (!confirm('确定要吊销主机 "' + hostname + '" 吗?\n\n吊销后将删除该主机记录及其令牌,此操作不可恢复。')) { - return; - } - - var baseUrl = window.location.origin; - $.ajax({ - url: baseUrl + '/bootstrap/api/revoke-pending-host/', - method: 'POST', - data: JSON.stringify({ - token: token - }), - contentType: 'application/json', - success: function(response) { - if (response.success) { - alert('吊销成功!\n' + response.message); - loadPendingHosts(); - $('#verify-form').hide(); - } else { - alert('吊销失败: ' + response.error); - } - }, - error: function(xhr) { - var errorMsg = '吊销请求失败'; - if (xhr.responseJSON && xhr.responseJSON.error) { - errorMsg = xhr.responseJSON.error; - } - alert(errorMsg); - } - }); - }; - - function createQuickRegisterDialog() { - var dialogHtml = ` - - `; - - $('body').append(dialogHtml); - - // 绑定关闭事件 - $('#close-quick-register-dialog').click(function() { - $('#quick-register-dialog').hide(); - }); - - $('#quick-register-dialog').click(function(e) { - if (e.target === this) { - $(this).hide(); - } - }); - - // 绑定复制按钮事件 - $(document).on('click', '#copy-register-command-btn', function() { - var commandText = $('#register-command-display').text(); - copyToClipboard(commandText, $(this)); - }); - } - - window.showQuickRegisterDialog = function() { - // 生成一键注册命令 - generateRegisterCommand(); - $('#quick-register-dialog').show(); - }; - - function generateRegisterCommand() { - // 获取当前站点URL - var baseUrl = window.location.origin; - - // 生成极简的一行命令 - var psCommand = `iex (irm ${baseUrl}/bootstrap/api/auto-register/?hostname=$env:COMPUTERNAME).data.script`; - - $('#register-command-display').text(psCommand); - } - - function createDeployModal() { - var modalHtml = ` - - `; - - $('body').append(modalHtml); - } - - function bindModalEvents() { - // 关闭按钮事件 - $('#close-deploy-flow-modal').click(function() { - closeDeployFlow(); - }); - - // 点击背景关闭 - $('#deploy-flow-modal').click(function(e) { - if (e.target === this) { - closeDeployFlow(); - } - }); - - // 复制命令按钮事件 - $(document).on('click', '#copy-command-btn', function() { - var commandText = $('#deploy-command-display').text(); - copyToClipboard(commandText, $(this)); - }); - - // ESC键关闭 - $(document).keydown(function(e) { - if (e.keyCode === 27 && $('#deploy-flow-modal').is(':visible')) { - closeDeployFlow(); - } - }); - } - - function copyToClipboard(text, button) { - navigator.clipboard.writeText(text).then(function() { - var originalText = button.text(); - button.text('已复制!').addClass('copied'); - setTimeout(function() { - button.text(originalText).removeClass('copied'); - }, 2000); - }).catch(function(err) { - console.error('复制失败: ', err); - // 备选方案 - fallbackCopyTextToClipboard(text, button); - }); - } - - function fallbackCopyTextToClipboard(text, button) { - var textArea = document.createElement("textarea"); - textArea.value = text; - textArea.style.cssText = 'position: fixed; top: -1000px; left: -1000px; opacity: 0;'; - - document.body.appendChild(textArea); - textArea.select(); - - try { - var successful = document.execCommand('copy'); - if (successful) { - var originalText = button.text(); - button.text('已复制!').addClass('copied'); - setTimeout(function() { - button.text(originalText).removeClass('copied'); - }, 2000); - } else { - alert('复制失败,请手动选择文本并复制'); - } - } catch (err) { - console.error('复制命令失败: ', err); - alert('复制失败,请手动选择文本并复制'); - } - - document.body.removeChild(textArea); - } - - // 全局函数 - window.startDeployFlow = function(hostId, hostName) { - if (!$) { - if (typeof django !== 'undefined' && django.jQuery) { - $ = django.jQuery; - } else { - alert('页面加载中,请稍后重试'); - return; - } - } - - // 重置状态 - currentStep = 1; - deployData = {}; - updateStepIndicator(1); - showStepPanel(1); - - // 显示模态框 - $('#deploy-flow-modal').show(); - - // 获取部署数据 - fetchDeployData(hostId); - }; - - window.nextStep = function() { - if (currentStep < 3) { - currentStep++; - updateStepIndicator(currentStep); - showStepPanel(currentStep); - } - }; - - window.prevStep = function() { - if (currentStep > 1) { - currentStep--; - updateStepIndicator(currentStep); - showStepPanel(currentStep); - } - }; - - window.finishDeploy = function() { - closeDeployFlow(); - alert('部署流程已完成!'); - }; - - function closeDeployFlow() { - $('#deploy-flow-modal').hide(); - currentStep = 1; - deployData = {}; - updateStepIndicator(1); - showStepPanel(1); - } - - function fetchDeployData(hostId) { - // 显示加载状态 - $('#deploy-command-display').html('
    正在生成部署信息...
    '); - $('#pairing-code-display').html('
    正在生成配对码...
    '); - - // 构建API URL - 支持列表页面和修改页面 - var apiUrl; - if (window.location.pathname.includes('/change/')) { - // 修改页面:使用相对路径 - apiUrl = window.location.pathname.replace(/\/change\/?$/, '') + '/generate-deploy-command/'; - } else { - // 列表页面:使用绝对路径 - apiUrl = '/admin/hosts/host/quick-deploy/' + hostId + '/'; - } - - $.ajax({ - url: apiUrl, - method: 'GET', - headers: { - 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || - document.querySelector('input[name="csrfmiddlewaretoken"]')?.value || '' - }, - success: function(response) { - if (response.success) { - deployData = response; - updateDeployInfo(); - } else { - showError('生成部署信息失败: ' + response.error); - } - }, - error: function(xhr) { - var errorMsg = xhr.responseJSON ? xhr.responseJSON.error : '请求失败'; - showError('获取部署信息失败: ' + errorMsg); - } - }); - } - - function updateDeployInfo() { - // 更新部署命令 - $('#deploy-command-display').text(deployData.deploy_command); - - // 更新配对码 - $('#pairing-code-display').text(deployData.pairing_code); - - // 更新配对码过期时间 - var expiryTime = new Date(deployData.pairing_code_expiry); - $('#pairing-expiry').text(expiryTime.toLocaleString('zh-CN')); - - // 启动配对状态检查 - startPairingCheck(); - } - - function showError(message) { - $('#deploy-command-display').html(`${message}`); - $('#pairing-code-display').html(`${message}`); - } - - function updateStepIndicator(step) { - $('.step-item').removeClass('active completed'); - $('.step-item').each(function() { - var itemStep = parseInt($(this).data('step')); - if (itemStep < step) { - $(this).addClass('completed'); - } else if (itemStep === step) { - $(this).addClass('active'); - } - }); - } - - function showStepPanel(step) { - $('.step-panel').removeClass('active'); - $('#step-panel-' + step).addClass('active'); - } - - function startPairingCheck() { - // 每5秒检查一次配对状态 - setInterval(function() { - if ($('#deploy-flow-modal').is(':visible') && currentStep === 3) { - checkPairingStatus(); - } - }, 5000); - } - - function checkPairingStatus() { - // 这里可以添加实际的配对状态检查逻辑 - // 目前只是示例 - var statusElement = $('#pairing-status'); - var currentTime = new Date(); - var expiryTime = new Date(deployData.pairing_code_expiry); - - if (currentTime > expiryTime) { - statusElement.html('配对码已过期,请重新开始部署流程'); - } else { - // 模拟检查状态 - var timeLeft = Math.floor((expiryTime - currentTime) / 1000 / 60); - statusElement.text(`等待配对... (${timeLeft}分钟有效)`); - } - } - - // 初始化模块 - init(); -})(); \ No newline at end of file diff --git a/static/admin/js/bootstrap_admin.js b/static/admin/js/bootstrap_admin.js deleted file mode 100644 index 782c203..0000000 --- a/static/admin/js/bootstrap_admin.js +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Bootstrap Admin前端功能 - 配对码认证版本 - */ - -document.addEventListener('DOMContentLoaded', function() { - // 初始化所有功能 - initCopyButtons(); - initRefreshPairingCode(); - initAutoRefresh(); -}); - -/** - * 初始化复制按钮功能 - */ -function initCopyButtons() { - // 为复制按钮添加点击事件 - document.addEventListener('click', function(e) { - if (e.target.classList.contains('copy-btn')) { - copyToClipboard(e.target); - } - }); -} - -/** - * 复制到剪贴板功能 - */ -function copyToClipboard(button) { - const value = button.getAttribute('data-value'); - if (!value) return; - - // 创建临时textarea元素 - const textarea = document.createElement('textarea'); - textarea.value = value; - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - textarea.style.top = '-9999px'; - document.body.appendChild(textarea); - - // 选中并复制 - textarea.select(); - textarea.setSelectionRange(0, 99999); // 移动端兼容 - - try { - const successful = document.execCommand('copy'); - if (successful) { - // 显示成功提示 - showNotification('配置信息已复制到剪贴板', 'success'); - // 更改按钮状态 - const originalText = button.textContent; - button.textContent = '已复制!'; - button.classList.add('btn-success'); - setTimeout(() => { - button.textContent = originalText; - button.classList.remove('btn-success'); - }, 2000); - } else { - showNotification('复制失败,请手动复制', 'error'); - } - } catch (err) { - console.error('复制失败:', err); - showNotification('复制失败,请手动复制', 'error'); - } - - // 清理 - document.body.removeChild(textarea); -} - -/** - * 初始化刷新配对码功能 - */ -function initRefreshPairingCode() { - window.refreshPairingCode = function(tokenId) { - if (!confirm('确定要刷新配对码吗?旧的配对码将失效。')) { - return; - } - - // 发送AJAX请求 - const xhr = new XMLHttpRequest(); - const url = `/admin/bootstrap/initialtoken/${tokenId}/refresh-pairing-code/`; - - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); - - xhr.onreadystatechange = function() { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - try { - const response = JSON.parse(xhr.responseText); - if (response.success) { - showNotification(`配对码已刷新为: ${response.pairing_code}`, 'success'); - // 刷新页面以显示新配对码 - setTimeout(() => { - location.reload(); - }, 1500); - } else { - showNotification(`刷新失败: ${response.error}`, 'error'); - } - } catch (e) { - showNotification('响应解析失败', 'error'); - } - } else { - showNotification(`请求失败: ${xhr.status}`, 'error'); - } - } - }; - - xhr.send(JSON.stringify({})); - }; -} - -/** - * 初始化自动刷新功能 - */ -function initAutoRefresh() { - // 每30秒检查一次配对码状态 - setInterval(function() { - const pairingCodeElements = document.querySelectorAll('[id^="pairing_code_"]'); - pairingCodeElements.forEach(element => { - const tokenId = element.id.replace('pairing_code_', ''); - updatePairingCodeStatus(tokenId, element); - }); - - // 同时更新倒计时显示 - updateAllCountdowns(); - }, 30000); -} - -/** - * 更新所有倒计时显示 - */ -function updateAllCountdowns() { - document.querySelectorAll('.countdown-timer').forEach(timer => { - const parent = timer.closest('.pairing-code-display'); - if (parent && parent.dataset.expiry) { - const expiryTime = parent.dataset.expiry; - const timeRemaining = formatTimeRemaining(expiryTime); - timer.textContent = timeRemaining; - } - }); -} - -/** - * 更新配对码状态 - */ -function updatePairingCodeStatus(tokenId, element) { - // 这里可以实现配对码状态的实时更新 - // 目前作为预留功能 - console.log(`检查令牌 ${tokenId} 的配对码状态`); -} - -/** - * 显示通知消息 - */ -function showNotification(message, type = 'info') { - // 创建通知元素 - const notification = document.createElement('div'); - notification.className = `alert alert-${type} alert-dismissible fade show`; - notification.style.position = 'fixed'; - notification.style.top = '20px'; - notification.style.right = '20px'; - notification.style.zIndex = '9999'; - notification.style.minWidth = '300px'; - notification.innerHTML = ` - ${message} - - `; - - // 添加到页面 - document.body.appendChild(notification); - - // 3秒后自动消失 - setTimeout(() => { - if (notification.parentNode) { - notification.parentNode.removeChild(notification); - } - }, 3000); -} - -/** - * 获取Cookie值 - */ -function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} - -/** - * 格式化时间显示 - */ -function formatTimeRemaining(expiryTime) { - const now = new Date(); - const expiry = new Date(expiryTime); - const diffMs = expiry - now; - - if (diffMs <= 0) { - return '已过期'; - } - - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); - const diffSeconds = Math.floor((diffMs % (1000 * 60)) / 1000); - - if (diffHours > 0) { - return `${diffHours}小时${diffMinutes}分钟`; - } else if (diffMinutes > 0) { - return `${diffMinutes}分钟${diffSeconds}秒`; - } else { - return `${diffSeconds}秒`; - } -} - -/** - * 批量刷新配对码 - */ -window.batchRefreshPairingCodes = function(selectedIds) { - if (!selectedIds || selectedIds.length === 0) { - showNotification('请选择要刷新的令牌', 'warning'); - return; - } - - if (!confirm(`确定要刷新选中的 ${selectedIds.length} 个令牌的配对码吗?`)) { - return; - } - - let successCount = 0; - let failCount = 0; - - selectedIds.forEach((tokenId, index) => { - setTimeout(() => { - refreshSinglePairingCode(tokenId, () => { - successCount++; - if (successCount + failCount === selectedIds.length) { - showNotification(`批量刷新完成: 成功${successCount}个,失败${failCount}个`, - successCount > 0 ? 'success' : 'error'); - if (successCount > 0) { - setTimeout(() => location.reload(), 2000); - } - } - }, () => { - failCount++; - if (successCount + failCount === selectedIds.length) { - showNotification(`批量刷新完成: 成功${successCount}个,失败${failCount}个`, - successCount > 0 ? 'success' : 'error'); - if (successCount > 0) { - setTimeout(() => location.reload(), 2000); - } - } - }); - }, index * 500); // 间隔500ms发送请求 - }); -}; - -/** - * 刷新单个配对码 - */ -function refreshSinglePairingCode(tokenId, onSuccess, onError) { - const xhr = new XMLHttpRequest(); - const url = `/admin/bootstrap/initialtoken/${tokenId}/refresh-pairing-code/`; - - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); - - xhr.onreadystatechange = function() { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - try { - const response = JSON.parse(xhr.responseText); - if (response.success) { - onSuccess && onSuccess(response); - } else { - onError && onError(response); - } - } catch (e) { - onError && onError({error: '响应解析失败'}); - } - } else { - onError && onError({error: `请求失败: ${xhr.status}`}); - } - } - }; - - xhr.send(JSON.stringify({})); -} - -/** - * 高亮显示即将过期的配对码 - */ -function highlightExpiringCodes() { - const codeElements = document.querySelectorAll('.pairing-code-display'); - codeElements.forEach(element => { - const expiryAttr = element.getAttribute('data-expiry'); - if (expiryAttr) { - const expiryTime = new Date(expiryAttr); - const now = new Date(); - const minutesRemaining = (expiryTime - now) / (1000 * 60); - - if (minutesRemaining <= 1) { - element.style.backgroundColor = '#ffebee'; - element.style.borderColor = '#ffcdd2'; - element.style.color = '#c62828'; - } else if (minutesRemaining <= 3) { - element.style.backgroundColor = '#fff8e1'; - element.style.borderColor = '#ffecb3'; - element.style.color = '#f57f17'; - } - } - }); -} - -// 页面加载完成后执行高亮 -document.addEventListener('DOMContentLoaded', highlightExpiringCodes); \ No newline at end of file diff --git a/static/css/accounts.css b/static/css/accounts.css deleted file mode 100755 index 813da35..0000000 --- a/static/css/accounts.css +++ /dev/null @@ -1,702 +0,0 @@ -/** - * 2c2a 用户账户样式 - 现代毛玻璃风格 - * 特性:简洁优雅、响应式设计、深浅模式支持 - */ - -/* ==================== 页面基础布局 (Page Layout) ==================== */ -html, body { - margin: 0; - padding: 0; - overflow-x: hidden; - width: 100%; - height: 100%; - min-height: 100vh; - font-family: var(--font-family); -} - -body.login-page, -body.register-page { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - position: relative; - overflow: hidden; - margin: 0; - padding: 0; - width: 100%; - min-width: 100vw; - - /* 浅色模式背景 */ - background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 50%, #f1f5f9 100%); - - /* 深色模式背景 */ - [data-theme="dark"] & { - background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%); - } -} - -/* 背景装饰元素 */ -body.login-page::before, -body.register-page::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: - radial-gradient(circle at 20% 30%, rgba(16, 185, 129, 0.08) 0%, transparent 50%), - radial-gradient(circle at 80% 70%, rgba(30, 58, 95, 0.08) 0%, transparent 50%); - z-index: 0; - pointer-events: none; -} - -[data-theme="dark"] body.login-page::before, -[data-theme="dark"] body.register-page::before { - background: - radial-gradient(circle at 20% 30%, rgba(16, 185, 129, 0.15) 0%, transparent 50%), - radial-gradient(circle at 80% 70%, rgba(96, 165, 250, 0.15) 0%, transparent 50%); -} - -/* 背景装饰圆圈 */ -.background-decor { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - pointer-events: none; -} - -.bg-circle { - position: absolute; - border-radius: 50%; - opacity: 0.4; - animation: floatGentle 12s infinite ease-in-out; -} - -.bg-circle:nth-child(1) { - width: 400px; - height: 400px; - top: -150px; - left: -150px; - background: radial-gradient(circle, rgba(16, 185, 129, 0.12) 0%, transparent 70%); - animation-delay: 0s; -} - -.bg-circle:nth-child(2) { - width: 300px; - height: 300px; - bottom: -120px; - right: 10%; - background: radial-gradient(circle, rgba(30, 58, 95, 0.12) 0%, transparent 70%); - animation-delay: 4s; -} - -.bg-circle:nth-child(3) { - width: 200px; - height: 200px; - top: 40%; - right: -80px; - background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, transparent 70%); - animation-delay: 8s; -} - -@keyframes floatGentle { - 0%, 100% { transform: translate(0, 0) scale(1); } - 25% { transform: translate(-15px, -15px) scale(1.05); } - 50% { transform: translate(15px, 15px) scale(0.95); } - 75% { transform: translate(-10px, 10px) scale(1.02); } -} - -/* ==================== 认证容器 (Auth Container) ==================== */ -.auth-container { - display: flex; - align-items: center; - justify-content: center; - padding: var(--spacing-6); - position: relative; - z-index: 10; - margin: 0; - width: 100%; -} - -/* ==================== 认证卡片 (Auth Card) - 毛玻璃效果 ==================== */ -.auth-card { - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: var(--radius-xl); - padding: var(--spacing-10) var(--spacing-8); - width: 100%; - max-width: 440px; - position: relative; - overflow: hidden; - border: var(--glass-border); - box-shadow: var(--glass-shadow-lg); - - /* 内部光晕效果 */ - &::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.3), - transparent - ); - transition: left 0.6s ease; - } - - &:hover::before { - left: 100%; - } - - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - transform: translateY(-4px); - box-shadow: 0 20px 60px rgba(31, 38, 135, 0.25); - } -} - -/* ==================== 认证头部 (Auth Header) ==================== */ -.auth-header { - text-align: center; - margin-bottom: var(--margin-8); -} - -.auth-logo { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - margin-bottom: var(--margin-4); - letter-spacing: 2px; - text-transform: uppercase; - display: inline-block; - transition: all var(--duration-normal) var(--ease-default); -} - -.auth-logo:hover { - transform: scale(1.05); - filter: drop-shadow(0 4px 12px rgba(var(--primary-color-rgb), 0.3)); -} - -.auth-title { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-semibold); - margin: 0; - color: var(--text-primary); - letter-spacing: -0.02em; -} - -.auth-subtitle { - font-size: var(--font-size-sm); - color: var(--text-secondary); - margin-top: var(--margin-2); - line-height: var(--line-height-relaxed); -} - -/* ==================== 表单样式 (Form Styles) ==================== */ -.auth-form { - margin-bottom: var(--margin-6); -} - -.form-group { - margin-bottom: var(--margin-5); - position: relative; -} - -.form-label { - display: block; - margin-bottom: var(--margin-2); - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - font-size: var(--font-size-sm); - letter-spacing: 0.01em; -} - -.form-control { - width: 100%; - padding: var(--input-padding-y) var(--input-padding-x); - font-size: var(--font-size-base); - line-height: var(--line-height-normal); - color: var(--text-primary); - background: var(--bg-glass-heavy); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - background-clip: padding-box; - border: 2px solid var(--border-color); - border-radius: var(--radius-md); - transition: all var(--duration-normal) var(--ease-default); - outline: none; -} - -.form-control:hover { - border-color: var(--primary-color-light); - box-shadow: 0 0 0 4px rgba(var(--primary-color-rgb), 0.05); -} - -.form-control:focus { - color: var(--text-primary); - background: var(--surface-color-elevated); - border-color: var(--primary-color); - box-shadow: 0 0 0 4px rgba(var(--primary-color-rgb), 0.15), var(--shadow-sm); -} - -.form-control::placeholder { - color: var(--text-tertiary); - opacity: 0.7; -} - -/* 输入框验证状态 */ -.form-control.is-invalid { - border-color: var(--danger-color); - background-color: rgba(239, 68, 68, 0.05); - padding-right: calc(1.5em + 0.75rem); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23ef4444' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23ef4444' stroke='none'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} - -.invalid-feedback { - display: none; - width: 100%; - margin-top: var(--margin-2); - font-size: var(--font-size-xs); - color: var(--danger-color); - font-weight: var(--font-weight-medium); -} - -.form-control.is-invalid ~ .invalid-feedback { - display: block; -} - -.form-control.is-valid { - border-color: var(--success-color); - background-color: rgba(16, 185, 129, 0.05); - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2310b981' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-position: right calc(0.375em + 0.1875rem) center; - background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); -} - -/* ==================== 按钮样式 (Button Styles) ==================== */ -.btn-block { - display: flex; - width: 100%; - padding: var(--spacing-4) var(--spacing-6); - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - border: none; - border-radius: var(--radius-md); - cursor: pointer; - transition: all var(--duration-normal) var(--ease-default); - position: relative; - overflow: hidden; - justify-content: center; - align-items: center; - gap: var(--spacing-2); - letter-spacing: 0.02em; - - /* 渐变背景 */ - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - color: white; - box-shadow: var(--shadow-primary); - - /* 波纹效果 */ - &::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); - transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; - } - - &:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg), 0 10px 25px rgba(var(--primary-color-rgb), 0.3); - } - - &:hover::after { - width: 400px; - height: 400px; - } - - &:active { - transform: translateY(0); - } -} - -.btn-primary { - color: var(--text-inverse); - background-color: transparent; - border: none; -} - -.btn-link { - color: var(--primary-color); - text-decoration: none; - font-weight: var(--font-weight-medium); - transition: all var(--duration-fast) var(--ease-default); - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 0; - height: 2px; - background: var(--primary-color); - transition: width var(--duration-fast) var(--ease-default); - } - - &:hover { - color: var(--secondary-color); - } - - &:hover::after { - width: 100%; - } -} - -/* ==================== 用户资料页面 (Profile Page) ==================== */ -.profile-container { - background: var(--surface-color); - border-radius: var(--radius-xl); - padding: var(--spacing-8); - box-shadow: var(--shadow-md); - border: 1px solid var(--border-color); - max-width: 900px; - margin: var(--margin-8) auto; -} - -.profile-header { - display: flex; - align-items: center; - margin-bottom: var(--margin-8); - padding-bottom: var(--margin-6); - border-bottom: 2px solid var(--border-color); - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 120px; - height: 2px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); - } -} - -.profile-avatar { - width: 120px; - height: 120px; - border-radius: 50%; - object-fit: cover; - margin-right: var(--margin-6); - border: 4px solid var(--bg-glass); - backdrop-filter: blur(8px); - box-shadow: var(--shadow-lg); - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - transform: scale(1.05); - box-shadow: var(--shadow-xl), 0 0 30px rgba(var(--primary-color-rgb), 0.2); - } -} - -.profile-info { - flex: 1; -} - -.profile-name { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - margin-bottom: var(--margin-2); - color: var(--text-primary); -} - -.profile-email { - color: var(--text-secondary); - font-size: var(--font-size-sm); - display: flex; - align-items: center; - gap: var(--spacing-2); -} - -.profile-actions { - display: flex; - gap: var(--spacing-3); -} - -/* ==================== 资料表单 (Profile Form) ==================== */ -.profile-form { - max-width: 700px; -} - -.form-section { - margin-bottom: var(--margin-8); - padding: var(--spacing-6); - background: var(--bg-secondary); - border-radius: var(--radius-lg); - border: 1px solid var(--border-color); - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - border-color: var(--primary-color-light); - box-shadow: var(--shadow-sm); - } -} - -.form-section-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-semibold); - margin-bottom: var(--margin-5); - padding-bottom: var(--margin-3); - border-bottom: 2px solid var(--border-color); - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-3); - - &::before { - content: ''; - width: 4px; - height: 24px; - background: linear-gradient(180deg, var(--primary-color), var(--secondary-color)); - border-radius: var(--radius-full); - } -} - -.form-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--spacing-5); - margin-bottom: var(--margin-4); -} - -/* ==================== 登录历史 (Login History) ==================== */ -.login-history { - background: var(--bg-secondary); - border-radius: var(--radius-lg); - padding: var(--spacing-6); - border: 1px solid var(--border-color); -} - -.login-history-item { - padding: var(--spacing-5); - border: 2px solid var(--border-color); - border-radius: var(--radius-md); - margin-bottom: var(--margin-4); - display: flex; - justify-content: space-between; - align-items: center; - transition: all var(--duration-fast) var(--ease-default); - background: var(--bg-primary); - - &:last-child { - margin-bottom: 0; - } - - &:hover { - border-color: var(--primary-color-light); - transform: translateX(4px); - box-shadow: var(--shadow-sm); - } -} - -.login-history-info { - flex: 1; -} - -.login-history-time { - font-weight: var(--font-weight-semibold); - margin-bottom: var(--margin-1); - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-2); -} - -.login-history-details { - font-size: var(--font-size-sm); - color: var(--text-secondary); -} - -.login-history-status { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - padding: var(--spacing-2) var(--spacing-4); - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.login-history-status.success { - background: rgba(16, 185, 129, 0.15); - color: #059669; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.login-history-status.failed { - background: rgba(239, 68, 68, 0.15); - color: #dc2626; - border: 1px solid rgba(239, 68, 68, 0.3); -} - -/* ==================== 消息提示样式 (Alert Messages) ==================== */ -.alert { - border-radius: var(--radius-lg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - background: var(--glass-bg); - border: var(--glass-border); - color: var(--text-primary); - box-shadow: var(--glass-shadow); - display: flex; - align-items: flex-start; - gap: var(--spacing-3); - animation: slideInDown 0.3s ease-out; -} - -@keyframes slideInDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.alert-success { - background: rgba(16, 185, 129, 0.12); - border-left: 4px solid var(--success-color); - color: #065f46; -} - -.alert-danger { - background: rgba(239, 68, 68, 0.12); - border-left: 4px solid var(--danger-color); - color: #991b1b; -} - -.alert-warning { - background: rgba(245, 158, 11, 0.12); - border-left: 4px solid var(--warning-color); - color: #92400e; -} - -.alert-info { - background: rgba(59, 130, 246, 0.12); - border-left: 4px solid var(--info-color); - color: #1e40af; -} - -.alert-dismissible .btn-close { - filter: grayscale(100%) brightness(0.7); - opacity: 0.7; - transition: all var(--duration-fast) var(--ease-default); - - &:hover { - filter: grayscale(0%) brightness(1); - opacity: 1; - } -} - -/* ==================== 页脚样式 (Footer) ==================== */ -footer { - background: var(--glass-bg); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-top: var(--glass-border); - color: var(--text-secondary); - margin: 0; - padding: var(--spacing-6) 0; - position: relative; - width: 100%; - text-align: center; - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.05); -} - -footer p { - color: var(--text-tertiary); - margin: 0; - font-size: var(--font-size-sm); -} - -/* ==================== 响应式设计 (Responsive Design) ==================== */ -@media (max-width: 768px) { - .auth-card { - padding: var(--spacing-8) var(--spacing-6); - margin: var(--margin-4); - max-width: calc(100% - 2rem); - } - - .auth-logo { - font-size: var(--font-size-3xl); - } - - .auth-title { - font-size: var(--font-size-xl); - } - - .background-decor { - display: none; - } - - footer { - position: relative; - } - - .profile-header { - flex-direction: column; - text-align: center; - } - - .profile-avatar { - margin-right: 0; - margin-bottom: var(--margin-4); - } - - .profile-actions { - justify-content: center; - } - - .form-row { - grid-template-columns: 1fr; - } - - .login-history-item { - flex-direction: column; - align-items: flex-start; - gap: var(--margin-3); - } - - .profile-container { - margin: var(--margin-4); - padding: var(--spacing-6); - } -} diff --git a/static/css/base.css b/static/css/base.css deleted file mode 100755 index 97081ff..0000000 --- a/static/css/base.css +++ /dev/null @@ -1,947 +0,0 @@ - /** - * 2c2a 基础样式文件 - * 特性:毛玻璃效果、现代化设计、响应式布局 - */ - -/* ==================== 全局样式重置与基础 ==================== */ -:root { - /* 兼容性颜色变量映射到新主题系统 */ - --primary-color: var(--primary-color); - --secondary-color: var(--secondary-color); - --success-color: var(--success-color); - --danger-color: var(--danger-color); - --warning-color: var(--warning-color); - --info-color: var(--info-color); - --light-color: var(--surface-color); - --dark-color: var(--text-primary); -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; - color-scheme: dark; -} - -select option { - background-color: #1e293b; - color: #ffffff; -} - -select option:hover, -select option:checked { - background-color: #334155; - color: #ffffff; -} - -body { - font-family: var(--font-family); - font-size: var(--font-size-base); - line-height: var(--line-height-normal); - color: var(--text-primary); - background-color: var(--bg-secondary); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - transition: background-color var(--duration-normal) var(--ease-default), - color var(--duration-normal) var(--ease-default); -} - -/* ==================== 毛玻璃基础类 (Glassmorphism Utilities) ==================== */ - -/* 标准毛玻璃效果 */ -.glass { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: var(--glass-border); - box-shadow: var(--glass-shadow); -} - -/* 强毛玻璃效果 */ -.glass-heavy { - background: var(--glass-heavy-bg); - backdrop-filter: var(--glass-heavy-blur); - -webkit-backdrop-filter: var(--glass-heavy-blur); - border: var(--glass-border); - box-shadow: var(--glass-shadow-lg); -} - -/* 轻量毛玻璃效果 */ -.glass-light { - background: var(--glass-light-bg); - backdrop-filter: var(--glass-light-blur); - -webkit-backdrop-filter: var(--glass-light-blur); - border: 1px solid rgba(255, 255, 255, 0.1); -} - -/* ==================== 导航栏样式 (Glassmorphism Navbar) ==================== */ -.navbar, -.md-navbar { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-bottom: var(--glass-border); - box-shadow: var(--shadow-md); - transition: all var(--duration-normal) var(--ease-default); - position: sticky; - top: 0; - z-index: 1000; -} - -.navbar-brand, -.md-navbar-brand { - font-weight: var(--font-weight-bold); - letter-spacing: 0.5px; - color: var(--primary-color) !important; - text-decoration: none !important; - display: flex; - align-items: center; - gap: var(--spacing-2); - transition: all var(--duration-fast) var(--ease-default); -} - -.navbar-brand:hover, -.md-navbar-brand:hover { - transform: translateY(-1px); - opacity: 0.9; -} - -/* ==================== 卡片样式 (Glass Card) ==================== */ -.card { - border: none; - border-radius: var(--card-radius); - background: var(--surface-color); - box-shadow: var(--card-shadow); - margin-bottom: var(--margin-6); - overflow: hidden; - transition: all var(--duration-normal) var(--ease-default); - position: relative; -} - -.card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - overflow: hidden; -} - -/* 毛玻璃卡片变体 */ -.card-glass { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: var(--glass-border); - box-shadow: var(--glass-shadow); -} - -.card-header { - background: transparent; - border-bottom: 1px solid var(--border-color); - font-weight: var(--font-weight-medium); - padding: var(--spacing-5) var(--spacing-6); - color: var(--text-primary); -} - -.card-body { - padding: var(--spacing-6); -} - -/* ==================== 表格样式 (Modern Table) ==================== */ -.table { - margin-bottom: 0; - width: 100%; - border-collapse: separate; - border-spacing: 0; -} - -.table th { - border-top: none; - font-weight: var(--font-weight-semibold); - color: var(--text-secondary); - background-color: var(--bg-tertiary); - padding: var(--spacing-3) var(--spacing-4); - white-space: nowrap; - position: sticky; - top: 0; - z-index: 10; -} - -.table td { - padding: var(--spacing-3) var(--spacing-4); - border-top: 1px solid var(--border-color); - vertical-align: middle; -} - -.table tbody tr { - transition: all var(--duration-fast) var(--ease-default); -} - -.table tbody tr:hover { - background-color: var(--bg-tertiary); - transform: scale(1.01); -} - -/* ==================== 按钮样式 (Modern Buttons) ==================== */ -.btn { - border-radius: var(--button-radius); - padding: var(--button-padding-y) var(--button-padding-x); - font-weight: var(--font-weight-medium); - font-size: var(--font-size-sm); - transition: all var(--duration-normal) var(--ease-default); - cursor: pointer; - outline: none; - border: none; - position: relative; - overflow: hidden; - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--spacing-2); -} - -.btn::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); - transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; -} - -.btn:hover::before { - width: 300px; - height: 300px; -} - -.btn:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-primary); -} - -.btn:active { - transform: translateY(0); -} - -/* 主要按钮 */ -.btn-primary { - background: linear-gradient(135deg, var(--primary-color), var(--primary-color-light)); - color: var(--text-inverse); - box-shadow: var(--shadow-primary); -} - -/* 次要按钮(绿色) */ -.btn-secondary { - background: linear-gradient(135deg, var(--secondary-color), var(--secondary-color-light)); - color: var(--text-inverse); - box-shadow: var(--shadow-secondary); -} - -/* 成功按钮 */ -.btn-success { - background: linear-gradient(135deg, var(--success-color), var(--secondary-color-light)); - color: var(--text-inverse); -} - -/* 危险按钮 */ -.btn-danger { - background: linear-gradient(135deg, var(--danger-color), #f87171); - color: var(--text-inverse); -} - -/* 警告按钮 */ -.btn-warning { - background: linear-gradient(135deg, var(--warning-color), #fbbf24); - color: #000; -} - -/* 信息按钮 */ -.btn-info { - background: linear-gradient(135deg, var(--info-color), var(--accent-color)); - color: var(--text-inverse); -} - -/* 毛玻璃按钮 */ -.btn-glass { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: var(--glass-border); - color: var(--text-primary); - box-shadow: var(--glass-shadow); -} - -.btn-glass:hover { - background: var(--glass-heavy-bg); - box-shadow: var(--glass-shadow-lg); -} - -/* ==================== 表单样式 (Modern Form Controls) ==================== */ -.form-control, -.form-select { - border-radius: var(--input-radius); - border: 2px solid var(--border-color); - padding: var(--input-padding-y) var(--input-padding-x); - font-size: var(--font-size-base); - line-height: var(--line-height-normal); - color: var(--text-primary); - background-color: var(--surface-color); - transition: all var(--duration-normal) var(--ease-default); - outline: none; - width: 100%; -} - -.form-control:focus, -.form-select:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 4px rgba(var(--primary-color-rgb), 0.15); - background-color: var(--surface-color-elevated); -} - -.form-control::placeholder { - color: var(--text-tertiary); -} - -/* 毛玻璃输入框 */ -.form-control-glass { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border: var(--glass-border); -} - -.form-control-glass:focus { - background: var(--glass-heavy-bg); - box-shadow: var(--glass-shadow-lg); -} - -/* 表单标签 */ -.form-label { - display: block; - margin-bottom: var(--spacing-2); - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - font-size: var(--font-size-sm); -} - -/* ==================== 徽章样式 (Modern Badges) ==================== */ -.badge { - padding: var(--spacing-1) var(--spacing-3); - font-weight: var(--font-weight-semibold); - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - text-transform: uppercase; - letter-spacing: 0.5px; - display: inline-flex; - align-items: center; - gap: var(--spacing-1); -} - -.badge-success { - background: linear-gradient(135deg, var(--success-color), var(--secondary-color-light)); - color: var(--text-inverse); - box-shadow: 0 2px 8px rgba(var(--success-color-rgb), 0.25); -} - -.badge-danger { - background: linear-gradient(135deg, var(--danger-color), #f87171); - color: var(--text-inverse); - box-shadow: 0 2px 8px rgba(var(--danger-color-rgb), 0.25); -} - -.badge-warning { - background: linear-gradient(135deg, var(--warning-color), #fbbf24); - color: #000; - box-shadow: 0 2px 8px rgba(var(--warning-color-rgb), 0.25); -} - -.badge-info { - background: linear-gradient(135deg, var(--info-color), var(--accent-color)); - color: var(--text-inverse); - box-shadow: 0 2px 8px rgba(var(--info-color-rgb), 0.25); -} - -.badge-secondary { - background: var(--bg-tertiary); - color: var(--text-secondary); -} - -/* ==================== 统计卡片样式 (Stat Cards with Glass Effect) ==================== */ -.stat-card { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-radius: var(--card-radius); - padding: var(--spacing-8); - box-shadow: var(--glass-shadow); - border: var(--glass-border); - transition: all var(--duration-normal) var(--ease-default); - position: relative; - overflow: hidden; -} - -.stat-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color), var(--accent-color)); - opacity: 0; - transition: opacity var(--duration-normal) var(--ease-default); -} - -.stat-card:hover { - transform: translateY(-4px); - box-shadow: var(--glass-shadow-lg); -} - -.stat-card:hover::before { - opacity: 1; -} - -.stat-card .stat-value { - font-size: var(--font-size-3xl); - font-weight: var(--font-weight-bold); - margin: var(--margin-4) 0; - color: var(--primary-color); - line-height: var(--line-height-tight); -} - -.stat-card .stat-label { - font-size: var(--font-size-sm); - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: var(--font-weight-medium); -} - -.stat-card .stat-icon { - font-size: var(--font-size-3xl); - margin-bottom: var(--margin-3); - opacity: 0.8; -} - -/* ==================== 加载动画 (Loading States) ==================== */ -.spinner-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - display: flex; - justify-content: center; - align-items: center; - z-index: 9999; -} - -.loading-spinner { - width: 48px; - height: 48px; - border: 4px solid var(--border-color); - border-top-color: var(--primary-color); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* ==================== 提示框/警告框样式 (Alert Boxes) ==================== */ -.alert { - border-radius: var(--radius-md); - padding: var(--spacing-4) var(--spacing-6); - margin-bottom: var(--margin-4); - border: none; - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - display: flex; - align-items: flex-start; - gap: var(--spacing-3); - animation: slideIn 0.3s ease-out; -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.alert-success { - background: rgba(16, 185, 129, 0.15); - border-left: 4px solid var(--success-color); - color: var(--secondary-color-dark); -} - -.alert-danger { - background: rgba(239, 68, 68, 0.15); - border-left: 4px solid var(--danger-color); - color: #dc2626; -} - -.alert-warning { - background: rgba(245, 158, 11, 0.15); - border-left: 4px solid var(--warning-color); - color: #d97706; -} - -.alert-info { - background: rgba(59, 130, 246, 0.15); - border-left: 4px solid var(--info-color); - color: #2563eb; -} - -.alert-dismissible .btn-close { - filter: grayscale(100%); - opacity: 0.7; - transition: all var(--duration-fast) var(--ease-default); -} - -.alert-dismissible .btn-close:hover { - opacity: 1; - filter: grayscale(0%); -} - -/* ==================== 页面标题 (Page Header) ==================== */ -.page-header { - margin-bottom: var(--margin-8); - padding-bottom: var(--margin-6); - border-bottom: 2px solid var(--border-color); - position: relative; -} - -.page-header::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 80px; - height: 2px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); -} - -.page-header h1 { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - margin: 0; - color: var(--text-primary); -} - -/* ==================== 操作按钮组 (Action Buttons) ==================== */ -.action-buttons { - display: flex; - gap: var(--spacing-2); - align-items: center; - flex-wrap: wrap; -} - -.action-buttons .btn { - padding: var(--spacing-2) var(--spacing-4); - font-size: var(--font-size-sm); -} - -/* ==================== 状态指示器 (Status Indicators) ==================== */ -.status-indicator { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - margin-right: var(--spacing-2); - animation: pulse 2s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -.status-indicator.online { - background: var(--success-color); - box-shadow: 0 0 8px rgba(var(--success-color-rgb), 0.5); -} - -.status-indicator.offline { - background: var(--danger-color); - box-shadow: 0 0 8px rgba(var(--danger-color-rgb), 0.5); -} - -.status-indicator.unknown { - background: var(--text-tertiary); -} - -.status-indicator.warning { - background: var(--warning-color); - box-shadow: 0 0 8px rgba(var(--warning-color-rgb), 0.5); -} - -/* ==================== 菜单样式 (Menu Components) ==================== */ -.md-menu { - position: relative; - display: inline-block; -} - -.md-menu__trigger { - background: transparent; - border: none; - padding: var(--spacing-2) var(--spacing-4); - color: var(--text-secondary); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - border-radius: var(--radius-md); - cursor: pointer; - transition: all var(--duration-fast) var(--ease-default); -} - -.md-menu__trigger:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.md-menu__content { - position: absolute; - top: 100%; - right: 0; - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-radius: var(--radius-md); - box-shadow: var(--glass-shadow-lg); - border: var(--glass-border); - min-width: 200px; - padding: var(--spacing-2) 0; - margin-top: var(--spacing-2); - z-index: 1001; - display: none; - animation: fadeInDown 0.2s ease-out; -} - -@keyframes fadeInDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.md-menu:hover .md-menu__content { - display: block; -} - -.md-menu__item { - display: block; - padding: var(--spacing-3) var(--spacing-6); - color: var(--text-primary); - text-decoration: none; - font-size: var(--font-size-sm); - transition: all var(--duration-fast) var(--ease-default); -} - -.md-menu__item:hover { - background: var(--bg-tertiary); - padding-left: var(--spacing-7); -} - -.md-divider { - margin: var(--spacing-2) 0; - border: none; - border-top: 1px solid var(--border-color); -} - -/* ==================== 主题切换按钮 (Theme Toggle) ==================== */ -.theme-toggle { - position: relative; - width: 56px; - height: 28px; - background: var(--bg-tertiary); - border-radius: var(--radius-full); - cursor: pointer; - transition: all var(--duration-normal) var(--ease-default); - border: 2px solid var(--border-color); - display: flex; - align-items: center; - padding: 0 4px; -} - -.theme-toggle:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); -} - -.theme-toggle-knob { - width: 20px; - height: 20px; - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - border-radius: 50%; - transition: all var(--duration-normal) var(--ease-bounce); - display: flex; - align-items: center; - justify-content: center; - box-shadow: var(--shadow-md); -} - -.theme-toggle-knob i { - font-size: 12px; - color: white; -} - -[data-theme="dark"] .theme-toggle { - background: var(--surface-color-elevated); -} - -[data-theme="dark"] .theme-toggle-knob { - transform: translateX(28px); - background: linear-gradient(135deg, #fbbf24, #f59e0b); -} - -/* ==================== 响应式设计 (Responsive Design) ==================== */ -@media (max-width: 640px) { - :root { - --navbar-height: var(--navbar-height-mobile); - } - - .card { - margin-bottom: var(--margin-4); - } - - .action-buttons { - flex-direction: column; - width: 100%; - } - - .action-buttons .btn { - width: 100%; - } - - .stat-card { - padding: var(--spacing-5); - } - - .stat-card .stat-value { - font-size: var(--font-size-2xl); - } - - .page-header h1 { - font-size: var(--font-size-xl); - } - - .table { - font-size: var(--font-size-sm); - } - - .table th, - .table td { - padding: var(--spacing-2) var(--spacing-3); - } -} - -@media (max-width: 768px) { - .card { - margin-bottom: var(--margin-4); - } - - .action-buttons { - flex-direction: column; - } - - .md-main-content { - padding: var(--spacing-4); - } -} - -/* ==================== 工具类 (Utility Classes) ==================== */ - -/* 文本工具类 */ -.text-gradient { - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.text-glow { - text-shadow: 0 0 10px rgba(var(--primary-color-rgb), 0.5); -} - -/* 阴影工具类 */ -.shadow-glow { - box-shadow: var(--shadow-primary); -} - -.shadow-glow-secondary { - box-shadow: var(--shadow-secondary); -} - -/* 动画工具类 */ -.hover-lift { - transition: transform var(--duration-normal) var(--ease-default); -} - -.hover-lift:hover { - transform: translateY(-4px); -} - -.hover-scale { - transition: transform var(--duration-normal) var(--ease-default); -} - -.hover-scale:hover { - transform: scale(1.05); -} - -/* 渐入动画 */ -.fade-in { - animation: fadeIn 0.3s ease-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* 从下方滑入 */ -.slide-up { - animation: slideUp 0.4s ease-out; -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* ==================== 页脚样式 (Footer - Glassmorphism) ==================== */ -.footer-glass { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-top: var(--glass-border); - color: var(--text-secondary); - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.05); - transition: all var(--duration-normal) var(--ease-default); -} - -.footer-glass p { - color: var(--text-tertiary); - margin: 0; - font-size: var(--font-size-sm); -} - -[data-theme="dark"] .footer-glass { - background: rgba(15, 23, 42, 0.8); - border-top-color: rgba(255, 255, 255, 0.1); - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.2); -} - -/* ==================== 模态框样式 (Modal) ==================== */ -.modal-content { - background-color: var(--surface-color); - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.modal-header { - border-bottom-color: var(--border-color); - background-color: var(--surface-color); -} - -.modal-title { - color: var(--text-primary); -} - -.modal-footer { - border-top-color: var(--border-color); - background-color: var(--surface-color); -} - -.btn-close { - filter: var(--btn-close-filter, none); -} - -[data-theme="dark"] .modal-content { - background-color: var(--surface-color-elevated); - box-shadow: var(--shadow-xl); -} - -[data-theme="dark"] .modal-header { - background-color: var(--surface-color-elevated); - border-bottom-color: var(--border-color); -} - -[data-theme="dark"] .modal-title { - color: var(--text-primary); -} - -[data-theme="dark"] .modal-footer { - background-color: var(--surface-color-elevated); - border-top-color: var(--border-color); -} - -[data-theme="dark"] .btn-close { - --btn-close-filter: invert(1) grayscale(100%) brightness(200%); -} - -[data-theme="dark"] .form-control, -[data-theme="dark"] .form-select { - background-color: var(--bg-secondary); - color: var(--text-primary); - border-color: var(--border-color); -} - -[data-theme="dark"] .form-control:focus, -[data-theme="dark"] .form-select:focus { - background-color: var(--surface-color); - color: var(--text-primary); -} - -[data-theme="dark"] .form-label { - color: var(--text-secondary); -} - -/* ==================== 主题感知按钮 (Theme Aware Buttons) ==================== */ -.theme-aware-btn.btn-primary { - background: linear-gradient(135deg, var(--primary-color), var(--primary-color-light)); - color: var(--text-inverse); - box-shadow: var(--shadow-primary); - border: 1px solid var(--primary-color); -} - -[data-theme="dark"] .theme-aware-btn.btn-primary { - background: linear-gradient(135deg, var(--primary-color), var(--primary-color-light)); - color: #0f172a; - box-shadow: 0 4px 14px 0 rgba(var(--primary-color-rgb), 0.45); -} - -.theme-aware-btn.btn-primary:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-primary); -} diff --git a/static/css/dashboard.css b/static/css/dashboard.css deleted file mode 100755 index cc00309..0000000 --- a/static/css/dashboard.css +++ /dev/null @@ -1,497 +0,0 @@ -/** - * 2c2a 仪表盘样式 - * 特性:毛玻璃效果、渐变色彩、现代化设计 - */ - -/* ==================== 统计卡片容器 (Stats Container) ==================== */ -.stats-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--spacing-6); - margin-bottom: var(--margin-8); -} - -/* ==================== 统计卡片 (Stat Cards) ==================== */ -.stat-card { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-radius: var(--radius-xl); - padding: var(--spacing-8); - border: var(--glass-border); - box-shadow: var(--glass-shadow); - transition: all var(--duration-normal) var(--ease-default); - position: relative; - overflow: hidden; - cursor: pointer; -} - -/* 卡片顶部装饰条 */ -.stat-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: linear-gradient(90deg, var(--card-accent, var(--primary-color)), transparent); - opacity: 0; - transition: opacity var(--duration-normal) var(--ease-default); -} - -/* 悬停时的光晕效果 */ -.stat-card::after { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(var(--card-accent-rgb, var(--primary-color-rgb)), 0.1) 0%, transparent 70%); - opacity: 0; - transition: opacity var(--duration-slow) var(--ease-default); - pointer-events: none; -} - -.stat-card:hover { - transform: translateY(-6px) scale(1.02); - box-shadow: var(--glass-shadow-lg), 0 20px 40px rgba(var(--card-accent-rgb, var(--primary-color-rgb)), 0.15); -} - -.stat-card:hover::before { - opacity: 1; -} - -.stat-card:hover::after { - opacity: 1; -} - -/* 不同颜色的统计卡片变体 */ -.stat-card.primary { - --card-accent: #1e3a5f; - --card-accent-rgb: 30, 58, 95; - background: linear-gradient(135deg, rgba(30, 58, 95, 0.08) 0%, rgba(16, 185, 129, 0.05) 100%); -} - -.stat-card.success { - --card-accent: #10b981; - --card-accent-rgb: 16, 185, 129; - background: linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(6, 182, 212, 0.05) 100%); -} - -.stat-card.info { - --card-accent: #3b82f6; - --card-accent-rgb: 59, 130, 246; - background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(139, 92, 246, 0.05) 100%); -} - -.stat-card.warning { - --card-accent: #f59e0b; - --card-accent-rgb: 245, 158, 11; - background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(251, 191, 36, 0.05) 100%); -} - -/* 图标样式 */ -.stat-card .stat-icon { - font-size: var(--font-size-4xl); - margin-bottom: var(--margin-4); - display: inline-flex; - align-items: center; - justify-content: center; - width: 64px; - height: 64px; - border-radius: var(--radius-lg); - background: var(--bg-glass); - backdrop-filter: blur(8px); - color: var(--card-accent, var(--primary-color)); - box-shadow: 0 4px 12px rgba(var(--card-accent-rgb, var(--primary-color-rgb)), 0.15); - transition: all var(--duration-normal) var(--ease-default); -} - -.stat-card:hover .stat-icon { - transform: scale(1.1) rotate(5deg); - box-shadow: 0 8px 20px rgba(var(--card-accent-rgb, var(--primary-color-rgb)), 0.25); -} - -/* 数值样式 */ -.stat-card .stat-value { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - margin: var(--margin-4) 0 var(--margin-2); - color: var(--text-primary); - line-height: var(--line-height-tight); - letter-spacing: -0.02em; -} - -/* 标签样式 */ -.stat-card .stat-label { - font-size: var(--font-size-sm); - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 1px; - font-weight: var(--font-weight-semibold); - opacity: 0.8; -} - -/* ==================== 图表容器 (Chart Container) ==================== */ -.chart-container { - position: relative; - height: 350px; - width: 100%; - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-radius: var(--radius-xl); - padding: var(--spacing-6); - border: var(--glass-border); - box-shadow: var(--glass-shadow); - overflow: hidden; -} - -.chart-wrapper { - padding: var(--spacing-4); - height: 100%; - position: relative; -} - -/* ==================== 操作记录表格 (Operation Table) ==================== */ -.operation-table { - background: var(--surface-color); - border-radius: var(--radius-xl); - overflow: hidden; - box-shadow: var(--shadow-md); - border: 1px solid var(--border-color); -} - -.operation-table thead { - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - border-bottom: 2px solid var(--border-color); -} - -.operation-table thead th { - font-weight: var(--font-weight-semibold); - color: var(--text-secondary); - text-transform: uppercase; - font-size: var(--font-size-xs); - letter-spacing: 0.5px; - padding: var(--spacing-4) var(--spacing-5); - white-space: nowrap; -} - -.operation-table tbody tr { - transition: all var(--duration-fast) var(--ease-default); - border-bottom: 1px solid var(--border-color-light); -} - -.operation-table tbody tr:last-child { - border-bottom: none; -} - -.operation-table tbody tr:hover { - background: var(--bg-tertiary); - transform: scale(1.01); - box-shadow: inset 0 0 0 2px var(--primary-color); -} - -.operation-table tbody td { - padding: var(--spacing-4) var(--spacing-5); - vertical-align: middle; -} - -/* ==================== 组件配置页面 (Widget Configuration) ==================== */ -.widget-config-list { - background: var(--surface-color); - border-radius: var(--radius-xl); - padding: var(--spacing-8); - box-shadow: var(--shadow-md); - border: 1px solid var(--border-color); -} - -.widget-item { - padding: var(--spacing-5); - border: 2px solid var(--border-color); - border-radius: var(--radius-lg); - margin-bottom: var(--margin-4); - transition: all var(--duration-normal) var(--ease-default); - background: var(--bg-primary); - position: relative; - overflow: hidden; -} - -.widget-item::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 4px; - height: 100%; - background: linear-gradient(180deg, var(--primary-color), var(--secondary-color)); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-default); -} - -.widget-item:last-child { - margin-bottom: 0; -} - -.widget-item:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-lg), 0 0 30px rgba(var(--primary-color-rgb), 0.1); - transform: translateX(4px); -} - -.widget-item:hover::before { - opacity: 1; -} - -.widget-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--margin-3); -} - -.widget-type { - font-weight: var(--font-weight-semibold); - font-size: var(--font-size-lg); - color: var(--primary-color); - display: flex; - align-items: center; - gap: var(--spacing-2); -} - -.widget-controls { - display: flex; - gap: var(--spacing-4); - align-items: center; -} - -/* ==================== 开关样式 (Toggle Switch) ==================== */ -.form-switch .form-check-input { - width: 48px; - height: 24px; - cursor: pointer; - background-color: var(--bg-tertiary); - border: none; - position: relative; - transition: all var(--duration-normal) var(--ease-default); -} - -.form-switch .form-check-input:checked { - background-color: var(--secondary-color); - box-shadow: 0 0 12px rgba(var(--success-color-rgb), 0.4); -} - -.form-switch .form-check-input::before { - width: 20px; - height: 20px; - background: white; - border-radius: 50%; - box-shadow: var(--shadow-md); - transition: all var(--duration-normal) var(--ease-bounce); -} - -.form-switch .form-check-input:checked::before { - transform: translateX(24px); -} - -/* ==================== 加载状态 (Loading States) ==================== */ -.loading-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--glass-bg); - backdrop-filter: var(--glass-blur); - -webkit-backdrop-filter: var(--glass-blur); - display: flex; - justify-content: center; - align-items: center; - z-index: 9999; -} - -.loading-spinner { - width: 56px; - height: 56px; - border: 4px solid var(--border-color); - border-top-color: var(--primary-color); - border-radius: 50%; - animation: spin 0.8s linear infinite; - position: relative; -} - -.loading-spinner::before, -.loading-spinner::after { - content: ''; - position: absolute; - border-radius: 50%; - border: 4px solid transparent; -} - -.loading-spinner::before { - top: 4px; - left: 4px; - right: 4px; - bottom: 4px; - border-top-color: var(--secondary-color); - animation: spin 1.5s linear infinite reverse; -} - -.loading-spinner::after { - top: 10px; - left: 10px; - right: 10px; - bottom: 10px; - border-top-color: var(--accent-color); - animation: spin 2s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* ==================== 动画效果 (Animations) ==================== */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.fade-in { - animation: fadeInUp 0.4s ease-out; -} - -/* 渐入动画延迟(用于卡片依次出现) */ -.stats-container .stat-card:nth-child(1) { animation-delay: 0ms; } -.stats-container .stat-card:nth-child(2) { animation-delay: 100ms; } -.stats-container .stat-card:nth-child(3) { animation-delay: 200ms; } -.stats-container .stat-card:nth-child(4) { animation-delay: 300ms; } - -.stats-container .stat-card { - animation: fadeInUp 0.5s ease-out both; -} - -/* ==================== 状态徽章 (Status Badges) ==================== */ -.status-badge { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - padding: var(--spacing-2) var(--spacing-4); - border-radius: var(--radius-full); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.5px; - position: relative; - overflow: hidden; -} - -.status-badge::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 50%; - animation: pulse 2s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(0.9); } -} - -.status-badge.success { - background: rgba(16, 185, 129, 0.15); - color: #059669; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.status-badge.success::before { - background: #10b981; - box-shadow: 0 0 8px rgba(16, 185, 129, 0.6); -} - -.status-badge.failed { - background: rgba(239, 68, 68, 0.15); - color: #dc2626; - border: 1px solid rgba(239, 68, 68, 0.3); -} - -.status-badge.failed::before { - background: #ef4444; - box-shadow: 0 0 8px rgba(239, 68, 68, 0.6); -} - -.status-badge.pending { - background: rgba(245, 158, 11, 0.15); - color: #d97706; - border: 1px solid rgba(245, 158, 11, 0.3); -} - -.status-badge.pending::before { - background: #f59e0b; - box-shadow: 0 0 8px rgba(245, 158, 11, 0.6); -} - -/* ==================== 响应式设计 (Responsive Design) ==================== */ -@media (max-width: 768px) { - .stats-container { - grid-template-columns: 1fr; - gap: var(--spacing-4); - } - - .stat-card { - padding: var(--spacing-6); - } - - .stat-card .stat-value { - font-size: var(--font-size-3xl); - } - - .chart-container { - height: 280px; - padding: var(--spacing-4); - } - - .widget-controls { - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-3); - } - - .operation-table { - font-size: var(--font-size-sm); - } - - .operation-table th, - .operation-table td { - padding: var(--spacing-3); - } - - .widget-config-list { - padding: var(--spacing-5); - } -} - -@media (max-width: 480px) { - .stats-container { - grid-template-columns: 1fr; - } - - .stat-card .stat-icon { - width: 48px; - height: 48px; - font-size: var(--font-size-2xl); - } - - .stat-card .stat-value { - font-size: var(--font-size-2xl); - } -} diff --git a/static/css/operations.css b/static/css/operations.css deleted file mode 100755 index 551e772..0000000 --- a/static/css/operations.css +++ /dev/null @@ -1,859 +0,0 @@ -/** - * 2c2a 操作记录相关页面样式 - * 特性:毛玻璃效果、现代化设计、响应式布局 - */ - -/* ==================== 页面基础 (Page Base) ==================== */ -.operations-page { - padding: var(--spacing-6) 0; -} - -.page-header { - margin-bottom: var(--margin-8); - padding-bottom: var(--margin-6); - border-bottom: 2px solid var(--border-color); - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 100px; - height: 2px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); - } -} - -/* ==================== 卡片样式 (Cards) ==================== */ -.card { - background: var(--surface-color); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-md); - border: 1px solid var(--border-color); - overflow: hidden; - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - border-color: var(--primary-color-light); - overflow: hidden; - } -} - -.card-title { - color: var(--text-primary); - font-weight: var(--font-weight-semibold); - font-size: var(--font-size-xl); - padding-bottom: var(--margin-4); - margin-bottom: var(--margin-5); - border-bottom: 2px solid var(--border-color); - display: flex; - align-items: center; - gap: var(--spacing-3); - - &::before { - content: ''; - width: 4px; - height: 24px; - background: linear-gradient(180deg, var(--primary-color), var(--secondary-color)); - border-radius: var(--radius-full); - } -} - -.card-body { - padding: var(--spacing-6); -} - -/* ==================== 任务列表 (Task List) ==================== */ -.task-list { - margin-top: var(--margin-5); -} - -.task-item { - padding: var(--spacing-5); - border: 2px solid var(--border-color); - border-radius: var(--radius-lg); - margin-bottom: var(--margin-4); - transition: all var(--duration-normal) var(--ease-default); - background: var(--bg-primary); - position: relative; - overflow: hidden; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: linear-gradient(180deg, var(--primary-color), var(--secondary-color)); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-default); - } - - &:last-child { - margin-bottom: 0; - } - - &:hover { - border-color: var(--primary-color); - transform: translateX(4px); - box-shadow: var(--shadow-md); - - &::before { - opacity: 1; - } - } -} - -.task-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--margin-3); -} - -.task-name { - font-weight: var(--font-weight-semibold); - font-size: var(--font-size-lg); - margin: 0; - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-2); -} - -.task-type { - font-size: var(--font-size-sm); - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: var(--spacing-1) var(--spacing-3); - border-radius: var(--radius-full); - font-weight: var(--font-weight-medium); -} - -/* 进度条 */ -.task-progress { - height: 8px; - background: var(--bg-tertiary); - border-radius: var(--radius-full); - overflow: hidden; - margin: var(--margin-3) 0; - position: relative; -} - -.task-progress-bar { - height: 100%; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); - border-radius: var(--radius-full); - transition: width var(--duration-slow) var(--ease-default); - position: relative; - overflow: hidden; - - /* 动态光效 */ - &::after { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.4), - transparent - ); - animation: shimmer 2s infinite; - } -} - -@keyframes shimmer { - to { left: 100%; } -} - -/* 状态徽章 */ -.task-status { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - padding: var(--spacing-2) var(--spacing-4); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-bold); - line-height: 1; - text-align: center; - border-radius: var(--radius-full); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.task-status.pending { - background: rgba(245, 158, 11, 0.15); - color: #d97706; - border: 1px solid rgba(245, 158, 11, 0.3); -} - -.task-status.running { - background: rgba(6, 182, 212, 0.15); - color: #0891b2; - border: 1px solid rgba(6, 182, 212, 0.3); - animation: pulseGlow 2s ease-in-out infinite; -} - -@keyframes pulseGlow { - 0%, 100% { box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.4); } - 50% { box-shadow: 0 0 0 8px rgba(6, 182, 212, 0); } -} - -.task-status.success { - background: rgba(16, 185, 129, 0.15); - color: #059669; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.task-status.failed { - background: rgba(239, 68, 68, 0.15); - color: #dc2626; - border: 1px solid rgba(239, 68, 68, 0.3); -} - -.task-status.cancelled { - background: rgba(107, 114, 128, 0.15); - color: #4b5563; - border: 1px solid rgba(107, 114, 128, 0.3); -} - -/* ==================== 申请列表 (Application List) ==================== */ -.application-list { - margin-top: var(--margin-5); -} - -.application-item { - padding: var(--spacing-5); - border: 2px solid var(--border-color); - border-radius: var(--radius-lg); - margin-bottom: var(--margin-4); - transition: all var(--duration-normal) var(--ease-default); - background: var(--bg-primary); - - &:last-child { - margin-bottom: 0; - } - - &:hover { - border-color: var(--secondary-color); - box-shadow: var(--shadow-md); - transform: translateY(-2px); - } -} - -.application-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--margin-3); -} - -.application-user { - font-weight: var(--font-weight-semibold); - font-size: var(--font-size-lg); - margin: 0; - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-2); -} - -.application-status { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); -} - -/* ==================== 表单样式 (Form Styles) ==================== */ -.form-label { - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - margin-bottom: var(--margin-2); - font-size: var(--font-size-sm); - letter-spacing: 0.01em; -} - -.form-control { - border: 2px solid var(--border-color); - border-radius: var(--radius-md); - padding: var(--input-padding-y) var(--input-padding-x); - font-size: var(--font-size-base); - line-height: var(--line-height-normal); - transition: all var(--duration-normal) var(--ease-default); - background: var(--surface-color); - color: var(--text-primary); - outline: none; -} - -.form-control:hover { - border-color: var(--primary-color-light); -} - -.form-control:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 4px rgba(var(--primary-color-rgb), 0.12); - background: var(--surface-color-elevated); -} - -.form-text { - color: var(--text-tertiary); - font-size: var(--font-size-sm); - margin-top: var(--margin-2); -} - -/* ==================== 按钮样式 (Button Styles) ==================== */ -.btn { - padding: var(--button-padding-y) var(--button-padding-x); - font-size: var(--font-size-base); - border-radius: var(--button-radius); - cursor: pointer; - transition: all var(--duration-normal) var(--ease-default); - font-weight: var(--font-weight-medium); - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - border: none; - outline: none; - position: relative; - overflow: hidden; - - &::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - border-radius: 50%; - background: rgba(255, 255, 255, 0.3); - transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; - } - - &:hover::before { - width: 300px; - height: 300px; - } - - &:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - } - - &:active { - transform: translateY(0); - } -} - -.btn-primary { - background: linear-gradient(135deg, var(--primary-color), var(--primary-color-light)); - color: white; - box-shadow: var(--shadow-primary); -} - -.btn-secondary { - background: linear-gradient(135deg, var(--secondary-color), var(--secondary-color-light)); - color: white; - box-shadow: var(--shadow-secondary); -} - -.btn-success { - background: linear-gradient(135deg, var(--success-color), var(--secondary-color-light)); - color: white; -} - -.btn-danger { - background: linear-gradient(135deg, var(--danger-color), #f87171); - color: white; -} - -.btn-warning { - background: linear-gradient(135deg, var(--warning-color), #fbbf24); - color: #000; -} - -.btn-info { - background: linear-gradient(135deg, var(--info-color), var(--accent-color)); - color: white; -} - -/* ==================== 过滤器样式 (Filter Section) ==================== */ -.filter-section { - margin-bottom: var(--margin-6); - padding: var(--spacing-5); - background: var(--glass-bg); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border-radius: var(--radius-lg); - border: var(--glass-border); - box-shadow: var(--glass-shadow); -} - -.filter-form { - margin-bottom: 0; -} - -.filter-label { - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - font-size: var(--font-size-sm); - margin-bottom: var(--margin-2); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -/* ==================== 表格样式 (Table Styles) ==================== */ -.table { - width: 100%; - margin-bottom: 0; - border-collapse: separate; - border-spacing: 0; -} - -.table th { - border-top: none; - background: var(--glass-bg); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - font-weight: var(--font-weight-semibold); - color: var(--text-secondary); - text-transform: uppercase; - font-size: var(--font-size-xs); - letter-spacing: 0.5px; - padding: var(--spacing-4) var(--spacing-5); - white-space: nowrap; - border-bottom: 2px solid var(--border-color); -} - -.table td { - padding: var(--spacing-4) var(--spacing-5); - vertical-align: middle; - border-top: 1px solid var(--border-color-light); - color: var(--text-primary); -} - -.table tbody tr { - transition: all var(--duration-fast) var(--ease-default); -} - -.table tbody tr:hover { - background: var(--bg-tertiary); - transform: scale(1.005); -} - -/* ==================== 状态徽章 (Status Badges) ==================== */ -.status-badge { - padding: var(--spacing-2) var(--spacing-4); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-bold); - border-radius: var(--radius-full); - text-transform: uppercase; - letter-spacing: 0.5px; - display: inline-flex; - align-items: center; - gap: var(--spacing-2); -} - -.status-pending { - background: rgba(245, 158, 11, 0.15); - color: #d97706; - border: 1px solid rgba(245, 158, 11, 0.3); -} - -.status-approved, -.status-completed { - background: rgba(16, 185, 129, 0.15); - color: #059669; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.status-rejected, -.status-failed { - background: rgba(239, 68, 68, 0.15); - color: #dc2626; - border: 1px solid rgba(239, 68, 68, 0.3); -} - -.status-processing, -.status-running { - background: rgba(6, 182, 212, 0.15); - color: #0891b2; - border: 1px solid rgba(6, 182, 212, 0.3); -} - -/* ==================== 操作按钮组 (Action Buttons) ==================== */ -.action-buttons { - display: flex; - gap: var(--spacing-2); - align-items: center; - flex-wrap: wrap; -} - -.action-buttons .btn { - padding: var(--spacing-2) var(--spacing-4); - font-size: var(--font-size-sm); -} - -/* ==================== 响应式表格 (Responsive Table) ==================== */ -.table-responsive { - overflow-x: auto; - border-radius: var(--radius-lg); - -ms-overflow-style: none; - scrollbar-width: none; -} - -/* 隐藏滚动条但保留功能 */ -.table-responsive::-webkit-scrollbar { - height: 0; - width: 0; -} - -/* ==================== 云电脑用户列表 (User Cards) ==================== */ -.user-card { - border: 2px solid var(--border-color); - border-radius: var(--radius-lg); - padding: var(--spacing-5); - margin-bottom: var(--margin-4); - transition: all var(--duration-normal) var(--ease-default); - background: var(--bg-primary); - position: relative; - overflow: hidden; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-default); - } - - &:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-lg); - transform: translateY(-4px); - - &::before { - opacity: 1; - } - } -} - -.user-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--margin-4); -} - -.user-name { - font-weight: var(--font-weight-semibold); - font-size: var(--font-size-lg); - margin: 0; - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-2); -} - -.user-status { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); -} - -/* ==================== 信息组样式 (Info Groups) ==================== */ -.info-group { - margin-bottom: var(--margin-5); - padding-bottom: var(--margin-5); - border-bottom: 1px solid var(--border-color); - - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border-bottom: none; - } -} - -.info-label { - font-weight: var(--font-weight-semibold); - color: var(--text-secondary); - font-size: var(--font-size-sm); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: var(--margin-2); -} - -.info-value { - font-size: var(--font-size-base); - color: var(--text-primary); - word-break: break-word; - font-weight: var(--font-weight-medium); -} - -/* ==================== 确认页面样式 (Confirmation Page) ==================== */ -.confirmation-section { - background: var(--glass-bg); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - padding: var(--spacing-6); - border-radius: var(--radius-xl); - border: var(--glass-border); - margin: var(--margin-6) 0; - box-shadow: var(--glass-shadow); -} - -.confirmation-header { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - margin-bottom: var(--margin-5); - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-3); - - &::before { - content: '✓'; - width: 32px; - height: 32px; - background: linear-gradient(135deg, var(--success-color), var(--secondary-color-light)); - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-sm); - } -} - -.confirmation-details { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--spacing-5); - margin-bottom: var(--margin-6); -} - -.confirmation-item { - padding: var(--spacing-4); - background: var(--surface-color-elevated); - border: 2px solid var(--border-color); - border-radius: var(--radius-md); - transition: all var(--duration-fast) var(--ease-default); - - &:hover { - border-color: var(--primary-color-light); - box-shadow: var(--shadow-sm); - } -} - -.confirmation-label { - font-weight: var(--font-weight-medium); - color: var(--text-secondary); - font-size: var(--font-size-sm); - margin-bottom: var(--margin-2); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.confirmation-value { - font-size: var(--font-size-lg); - color: var(--text-primary); - font-weight: var(--font-weight-semibold); -} - -/* ==================== 产品卡片样式 (Product Cards) ==================== */ -.product-card { - border: 2px solid var(--border-color); - border-radius: var(--radius-xl); - padding: var(--spacing-6); - margin-bottom: var(--margin-5); - transition: all var(--duration-normal) var(--ease-default); - background: var(--bg-primary); - position: relative; - overflow: hidden; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color), var(--accent-color)); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-default); - } - - &:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-lg), 0 10px 30px rgba(var(--primary-color-rgb), 0.15); - transform: translateY(-4px); - - &::before { - opacity: 1; - } - } -} - -.product-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--margin-5); -} - -.product-name { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - margin: 0; - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-3); -} - -.product-status { - display: inline-flex; - align-items: center; - gap: var(--spacing-2); - padding: var(--spacing-2) var(--spacing-4); - border-radius: var(--radius-full); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); -} - -.product-status.online { - background: rgba(16, 185, 129, 0.15); - color: #059669; - border: 1px solid rgba(16, 185, 129, 0.3); -} - -.product-status.offline { - background: rgba(239, 68, 68, 0.15); - color: #dc2626; - border: 1px solid rgba(239, 68, 68, 0.3); -} - -.product-status.error { - background: rgba(245, 158, 11, 0.15); - color: #d97706; - border: 1px solid rgba(245, 158, 11, 0.3); -} - -.product-info { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: var(--spacing-5); - margin-bottom: var(--margin-5); -} - -.product-info-item { - display: flex; - flex-direction: column; - gap: var(--spacing-1); -} - -.product-info-label { - font-size: var(--font-size-xs); - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: var(--font-weight-medium); -} - -.product-info-value { - font-size: var(--font-size-base); - color: var(--text-primary); - font-weight: var(--font-weight-semibold); -} - -.product-description { - margin-top: var(--margin-5); - padding-top: var(--margin-5); - border-top: 1px solid var(--border-color); - color: var(--text-secondary); - line-height: var(--line-height-relaxed); -} - -/* 隐藏目标产品字段 */ -#id_target_product { - display: none; -} - -.target-product-display { - background: var(--bg-tertiary); - padding: var(--spacing-4); - border-radius: var(--radius-md); - margin-bottom: var(--margin-4); - border-left: 4px solid var(--info-color); -} - -/* 密码复杂度要求 */ -.password-requirements ul { - margin-bottom: 0; - padding-left: 1.5em; -} - -.password-requirements li { - margin-bottom: var(--margin-2); - font-size: var(--font-size-sm); - color: var(--text-secondary); -} - -/* ==================== 响应式设计 (Responsive Design) ==================== */ -@media (max-width: 768px) { - .row { - margin-left: calc(var(--spacing-4) * -1); - margin-right: calc(var(--spacing-4) * -1); - } - - .col-md-6 { - padding-left: var(--spacing-4); - padding-right: var(--spacing-4); - } - - .confirmation-details { - grid-template-columns: 1fr; - } - - .action-buttons { - flex-direction: column; - width: 100%; - } - - .action-buttons .btn { - width: 100%; - justify-content: center; - } - - .product-info { - grid-template-columns: 1fr; - } - - .user-header { - flex-direction: column; - align-items: flex-start; - gap: var(--margin-3); - } - - .task-header { - flex-direction: column; - align-items: flex-start; - gap: var(--margin-3); - } - - .application-header { - flex-direction: column; - align-items: flex-start; - gap: var(--margin-3); - } -} diff --git a/static/css/profile.css b/static/css/profile.css deleted file mode 100755 index 2b751f9..0000000 --- a/static/css/profile.css +++ /dev/null @@ -1,449 +0,0 @@ -/** - * 2c2a 用户资料页面样式 - 现代毛玻璃风格 - * 特性:优雅简洁、响应式设计、深浅模式支持 - */ - -/* ==================== 用户资料容器 (Profile Container) ==================== */ -.profile-container { - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: var(--radius-xl); - padding: var(--spacing-8); - border: var(--glass-border); - box-shadow: var(--glass-shadow-lg); - margin: var(--margin-8) auto; - max-width: 1000px; - position: relative; - overflow: hidden; - - /* 背景装饰 */ - &::before { - content: ''; - position: absolute; - top: -50%; - right: -50%; - width: 100%; - height: 100%; - background: radial-gradient(circle, rgba(var(--primary-color-rgb), 0.05) 0%, transparent 70%); - pointer-events: none; - } -} - -.profile-content { - display: flex; - gap: var(--spacing-8); - position: relative; - z-index: 1; -} - -/* ==================== 侧边栏 (Sidebar) ==================== */ -.profile-sidebar { - flex: 0 0 280px; - text-align: center; -} - -/* ==================== 主内容区 (Main Content) ==================== */ -.profile-main { - flex: 1; - min-width: 0; /* 防止flex子项溢出 */ -} - -/* ==================== 头像样式 (Avatar) ==================== */ -.profile-avatar { - width: 140px; - height: 140px; - border-radius: 50%; - object-fit: cover; - margin-bottom: var(--margin-5); - border: 4px solid var(--bg-glass-heavy); - backdrop-filter: blur(12px); - box-shadow: - var(--shadow-lg), - 0 0 30px rgba(var(--primary-color-rgb), 0.15), - inset 0 2px 10px rgba(255, 255, 255, 0.3); - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - transform: scale(1.08) rotate(2deg); - box-shadow: - var(--shadow-xl), - 0 0 40px rgba(var(--primary-color-rgb), 0.25), - inset 0 2px 15px rgba(255, 255, 255, 0.4); - } -} - -/* ==================== 用户信息 (User Info) ==================== */ -.profile-info { - text-align: center; - margin-bottom: var(--margin-6); -} - -.profile-name { - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-bold); - margin-bottom: var(--margin-2); - color: var(--text-primary); - letter-spacing: -0.02em; - background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.profile-email { - color: var(--text-secondary); - font-size: var(--font-size-sm); - margin-bottom: var(--margin-1); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-2); - - i { - opacity: 0.7; - } -} - -.profile-username { - color: var(--text-tertiary); - font-size: var(--font-size-xs); - font-style: italic; - margin-bottom: var(--margin-4); -} - -/* ==================== 操作按钮 (Action Buttons) ==================== */ -.profile-actions { - display: flex; - gap: var(--spacing-3); - justify-content: center; - flex-wrap: wrap; - - .btn { - padding: var(--spacing-3) var(--spacing-5); - font-size: var(--font-size-sm); - border-radius: var(--radius-full); - background: var(--glass-bg); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: var(--glass-border); - color: var(--text-primary); - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - background: var(--glass-heavy-bg); - transform: translateY(-2px); - box-shadow: var(--shadow-md); - border-color: var(--primary-color-light); - } - - i { - font-size: var(--font-size-base); - } - } -} - -/* ==================== 表单区域 (Form Sections) ==================== */ -.profile-form { - max-width: 100%; -} - -.form-section { - margin-bottom: var(--margin-8); - padding: var(--spacing-6); - background: var(--glass-bg); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border-radius: var(--radius-lg); - border: var(--glass-border); - position: relative; - overflow: hidden; - transition: all var(--duration-normal) var(--ease-default); - - /* 左侧装饰条 */ - &::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: linear-gradient(180deg, var(--primary-color), var(--secondary-color)); - opacity: 0; - transition: opacity var(--duration-fast) var(--ease-default); - } - - &:hover { - border-color: var(--primary-color-light); - box-shadow: var(--shadow-md); - - &::before { - opacity: 1; - } - } -} - -.form-section-title { - font-size: var(--font-size-xl); - font-weight: var(--font-weight-bold); - margin-bottom: var(--margin-5); - padding-bottom: var(--margin-4); - border-bottom: 2px solid var(--border-color); - color: var(--text-primary); - display: flex; - align-items: center; - gap: var(--spacing-3); - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 60px; - height: 2px; - background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); - } -} - -.form-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--spacing-5); - margin-bottom: var(--margin-5); -} - -/* ==================== 密码修改表单 (Password Change Section) ==================== */ -.password-change-section { - margin-top: var(--margin-8); - padding: var(--spacing-6); - background: var(--glass-bg); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border-radius: var(--radius-lg); - border: var(--glass-border); - box-shadow: var(--glass-shadow); - transition: all var(--duration-normal) var(--ease-default); - - &:hover { - border-color: var(--warning-color); - box-shadow: var(--shadow-md); - } -} - -/* ==================== 头像上传 (Avatar Upload) ==================== */ -.avatar-upload { - position: relative; - display: block; - margin: 0 auto var(--margin-5) auto; -} - -.avatar-wrapper { - position: relative; - display: inline-block; - cursor: pointer; - border-radius: 50%; - - &:hover .profile-avatar { - filter: brightness(0.85); - } -} - -.avatar-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - border-radius: 50%; - opacity: 0; - transition: all var(--duration-normal) var(--ease-default); - pointer-events: none; -} - -.avatar-wrapper:hover .avatar-overlay { - opacity: 1; -} - -.avatar-overlay i { - font-size: var(--font-size-3xl); - color: white; - margin-bottom: var(--margin-2); - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); -} - -.avatar-overlay span { - color: white; - font-size: var(--font-size-sm); - text-align: center; - font-weight: var(--font-weight-medium); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.avatar-upload input[type="file"] { - position: absolute; - left: -9999px; -} - -/* ==================== 选项卡样式 (Tabs) ==================== */ -.profile-tabs { - display: flex; - gap: var(--spacing-2); - border-bottom: 2px solid var(--border-color); - margin-bottom: var(--margin-6); - padding-bottom: 0; - overflow-x: auto; - - /* 隐藏滚动条但保持功能 */ - &::-webkit-scrollbar { - display: none; - } -} - -.profile-tab { - background: transparent; - border: none; - color: var(--text-secondary); - padding: var(--spacing-4) var(--spacing-5); - cursor: pointer; - border-bottom: 3px solid transparent; - transition: all var(--duration-fast) var(--ease-default); - font-weight: var(--font-weight-medium); - font-size: var(--font-size-sm); - white-space: nowrap; - position: relative; - - &:hover:not(.active) { - color: var(--text-primary); - background: var(--bg-tertiary); - border-radius: var(--radius-md) var(--radius-md) 0 0; - } - - &.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); - background: linear-gradient(to top, rgba(var(--primary-color-rgb), 0.08), transparent); - } -} - -.tab-content { - display: none; - animation: fadeInUp 0.3s ease-out; -} - -.tab-content.active { - display: block; -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* ==================== 响应式设计 (Responsive Design) ==================== */ -@media (max-width: 1024px) { - .profile-container { - margin: var(--margin-6); - padding: var(--spacing-6); - } - - .profile-content { - gap: var(--spacing-6); - } - - .profile-sidebar { - flex: 0 0 240px; - } -} - -@media (max-width: 768px) { - .profile-content { - flex-direction: column; - } - - .profile-sidebar { - flex: none; - margin-right: 0; - margin-bottom: var(--margin-8); - order: 2; /* 在移动端显示在下方 */ - } - - .profile-main { - order: 1; /* 在移动端显示在上方 */ - } - - .profile-avatar { - width: 120px; - height: 120px; - margin-bottom: var(--margin-4); - } - - .profile-actions { - justify-content: center; - } - - .form-row { - grid-template-columns: 1fr; - } - - .profile-name { - font-size: var(--font-size-xl); - } - - .profile-tabs { - flex-wrap: nowrap; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - - .profile-tab { - padding: var(--spacing-3) var(--spacing-4); - font-size: var(--font-size-xs); - } - - .form-section, - .password-change-section { - padding: var(--spacing-5); - } -} - -@media (max-width: 480px) { - .profile-container { - margin: var(--margin-4); - padding: var(--spacing-5); - border-radius: var(--radius-lg); - } - - .profile-avatar { - width: 100px; - height: 100px; - } - - .profile-name { - font-size: var(--font-size-lg); - } - - .profile-actions { - flex-direction: column; - width: 100%; - - .btn { - width: 100%; - justify-content: center; - } - } -} diff --git a/static/css/provider.css b/static/css/provider.css deleted file mode 100644 index 3db152c..0000000 --- a/static/css/provider.css +++ /dev/null @@ -1,4442 +0,0 @@ -/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ -@layer properties; -@layer theme, base, components, utilities; -@layer theme { - :root, :host { - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', - monospace; - --color-red-200: oklch(88.5% 0.062 18.334); - --color-red-300: oklch(80.8% 0.114 19.571); - --color-red-400: oklch(70.4% 0.191 22.216); - --color-red-500: oklch(63.7% 0.237 25.331); - --color-red-600: oklch(57.7% 0.245 27.325); - --color-red-900: oklch(39.6% 0.141 25.723); - --color-amber-100: oklch(96.2% 0.059 95.617); - --color-amber-200: oklch(92.4% 0.12 95.746); - --color-amber-300: oklch(87.9% 0.169 91.605); - --color-amber-400: oklch(82.8% 0.189 84.429); - --color-amber-500: oklch(76.9% 0.188 70.08); - --color-amber-600: oklch(66.6% 0.179 58.318); - --color-amber-800: oklch(47.3% 0.137 46.201); - --color-green-400: oklch(79.2% 0.209 151.711); - --color-green-500: oklch(72.3% 0.219 149.579); - --color-emerald-400: oklch(76.5% 0.177 163.223); - --color-emerald-500: oklch(69.6% 0.17 162.48); - --color-emerald-600: oklch(59.6% 0.145 163.225); - --color-cyan-200: oklch(91.7% 0.08 205.041); - --color-cyan-300: oklch(86.5% 0.127 207.078); - --color-cyan-400: oklch(78.9% 0.154 211.53); - --color-cyan-500: oklch(71.5% 0.143 215.221); - --color-cyan-600: oklch(60.9% 0.126 221.723); - --color-blue-50: oklch(97% 0.014 254.604); - --color-blue-400: oklch(70.7% 0.165 254.624); - --color-blue-500: oklch(62.3% 0.214 259.815); - --color-blue-600: oklch(54.6% 0.245 262.881); - --color-purple-400: oklch(71.4% 0.203 305.504); - --color-purple-500: oklch(62.7% 0.265 303.9); - --color-slate-200: oklch(92.9% 0.013 255.508); - --color-slate-300: oklch(86.9% 0.022 252.894); - --color-slate-400: oklch(70.4% 0.04 256.788); - --color-slate-500: oklch(55.4% 0.046 257.417); - --color-slate-600: oklch(44.6% 0.043 257.281); - --color-slate-700: oklch(37.2% 0.044 257.287); - --color-slate-800: oklch(27.9% 0.041 260.031); - --color-slate-900: oklch(20.8% 0.042 265.755); - --color-slate-950: oklch(12.9% 0.042 264.695); - --color-gray-100: oklch(96.7% 0.003 264.542); - --color-gray-500: oklch(55.1% 0.027 264.364); - --color-gray-800: oklch(27.8% 0.033 256.848); - --color-black: #000; - --color-white: #fff; - --spacing: 0.25rem; - --container-xs: 20rem; - --container-md: 28rem; - --container-lg: 32rem; - --container-2xl: 42rem; - --container-3xl: 48rem; - --container-4xl: 56rem; - --container-5xl: 64rem; - --container-6xl: 72rem; - --container-7xl: 80rem; - --text-xs: 0.75rem; - --text-xs--line-height: calc(1 / 0.75); - --text-sm: 0.875rem; - --text-sm--line-height: calc(1.25 / 0.875); - --text-base: 1rem; - --text-base--line-height: calc(1.5 / 1); - --text-lg: 1.125rem; - --text-lg--line-height: calc(1.75 / 1.125); - --text-xl: 1.25rem; - --text-xl--line-height: calc(1.75 / 1.25); - --text-2xl: 1.5rem; - --text-2xl--line-height: calc(2 / 1.5); - --text-3xl: 1.875rem; - --text-3xl--line-height: calc(2.25 / 1.875); - --text-4xl: 2.25rem; - --text-4xl--line-height: calc(2.5 / 2.25); - --text-6xl: 3.75rem; - --text-6xl--line-height: 1; - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - --tracking-wider: 0.05em; - --leading-tight: 1.25; - --leading-normal: 1.5; - --leading-relaxed: 1.625; - --radius-sm: 0.25rem; - --radius-md: 12px; - --radius-lg: 0.5rem; - --radius-xl: 0.75rem; - --radius-3xl: 1.5rem; - --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); - --ease-in: cubic-bezier(0.4, 0, 1, 1); - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --animate-spin: spin 1s linear infinite; - --blur-sm: 8px; - --blur-md: 12px; - --blur-xl: 24px; - --blur-2xl: 40px; - --blur-3xl: 64px; - --default-transition-duration: 150ms; - --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - --default-font-family: var(--font-sans); - --default-mono-font-family: var(--font-mono); - --color-md-primary: #D0BCFF; - --color-md-on-primary: #381E72; - --color-md-primary-container: #4F378B; - --color-md-on-primary-container: #EADDFF; - --color-md-secondary: #CCC2DC; - --color-md-on-secondary: #332D41; - --color-md-secondary-container: #4A4458; - --color-md-on-secondary-container: #EADDFF; - --color-md-tertiary-container: #633B48; - --color-md-on-tertiary-container: #FFD8E4; - --color-md-surface: #1C1B1F; - --color-md-on-surface: #E6E1E5; - --color-md-on-surface-variant: #CAC4D0; - --color-md-surface-variant: #49454F; - --color-md-surface-container: #211F26; - --color-md-surface-container-low: #1D1B20; - --color-md-surface-container-high: #2B2930; - --color-md-outline: #938F99; - --color-md-outline-variant: #49454F; - --color-md-error: #F2B8B5; - --color-md-on-error: #601410; - --color-md-error-container: #8C1D18; - --color-md-on-error-container: #F9DEDC; - --radius-md-lg: 16px; - --radius-md-xl: 28px; - } -} -@layer base { - *, ::after, ::before, ::backdrop, ::file-selector-button { - box-sizing: border-box; - margin: 0; - padding: 0; - border: 0 solid; - } - html, :host { - line-height: 1.5; - -webkit-text-size-adjust: 100%; - tab-size: 4; - font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); - font-feature-settings: var(--default-font-feature-settings, normal); - font-variation-settings: var(--default-font-variation-settings, normal); - -webkit-tap-highlight-color: transparent; - } - hr { - height: 0; - color: inherit; - border-top-width: 1px; - } - abbr:where([title]) { - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; - } - h1, h2, h3, h4, h5, h6 { - font-size: inherit; - font-weight: inherit; - } - a { - color: inherit; - -webkit-text-decoration: inherit; - text-decoration: inherit; - } - b, strong { - font-weight: bolder; - } - code, kbd, samp, pre { - font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace); - font-feature-settings: var(--default-mono-font-feature-settings, normal); - font-variation-settings: var(--default-mono-font-variation-settings, normal); - font-size: 1em; - } - small { - font-size: 80%; - } - sub, sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; - } - sub { - bottom: -0.25em; - } - sup { - top: -0.5em; - } - table { - text-indent: 0; - border-color: inherit; - border-collapse: collapse; - } - :-moz-focusring { - outline: auto; - } - progress { - vertical-align: baseline; - } - summary { - display: list-item; - } - ol, ul, menu { - list-style: none; - } - img, svg, video, canvas, audio, iframe, embed, object { - display: block; - vertical-align: middle; - } - img, video { - max-width: 100%; - height: auto; - } - button, input, select, optgroup, textarea, ::file-selector-button { - font: inherit; - font-feature-settings: inherit; - font-variation-settings: inherit; - letter-spacing: inherit; - color: inherit; - border-radius: 0; - background-color: transparent; - opacity: 1; - } - :where(select:is([multiple], [size])) optgroup { - font-weight: bolder; - } - :where(select:is([multiple], [size])) optgroup option { - padding-inline-start: 20px; - } - ::file-selector-button { - margin-inline-end: 4px; - } - ::placeholder { - opacity: 1; - } - @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { - ::placeholder { - color: currentcolor; - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, currentcolor 50%, transparent); - } - } - } - textarea { - resize: vertical; - } - ::-webkit-search-decoration { - -webkit-appearance: none; - } - ::-webkit-date-and-time-value { - min-height: 1lh; - text-align: inherit; - } - ::-webkit-datetime-edit { - display: inline-flex; - } - ::-webkit-datetime-edit-fields-wrapper { - padding: 0; - } - ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { - padding-block: 0; - } - ::-webkit-calendar-picker-indicator { - line-height: 1; - } - :-moz-ui-invalid { - box-shadow: none; - } - button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { - appearance: button; - } - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { - height: auto; - } - [hidden]:where(:not([hidden='until-found'])) { - display: none !important; - } -} -@layer utilities { - .pointer-events-none { - pointer-events: none; - } - .collapse { - visibility: collapse; - } - .invisible { - visibility: hidden; - } - .visible { - visibility: visible; - } - .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; - border-width: 0; - } - .absolute { - position: absolute; - } - .fixed { - position: fixed; - } - .relative { - position: relative; - } - .static { - position: static; - } - .sticky { - position: sticky; - } - .inset-0 { - inset: calc(var(--spacing) * 0); - } - .inset-y-0 { - inset-block: calc(var(--spacing) * 0); - } - .start { - inset-inline-start: var(--spacing); - } - .end { - inset-inline-end: var(--spacing); - } - .top-0 { - top: calc(var(--spacing) * 0); - } - .top-1 { - top: calc(var(--spacing) * 1); - } - .top-1\/2 { - top: calc(1 / 2 * 100%); - } - .top-2 { - top: calc(var(--spacing) * 2); - } - .top-5 { - top: calc(var(--spacing) * 5); - } - .top-20 { - top: calc(var(--spacing) * 20); - } - .top-full { - top: 100%; - } - .right-0 { - right: calc(var(--spacing) * 0); - } - .right-2 { - right: calc(var(--spacing) * 2); - } - .right-3 { - right: calc(var(--spacing) * 3); - } - .right-5 { - right: calc(var(--spacing) * 5); - } - .bottom-0 { - bottom: calc(var(--spacing) * 0); - } - .left-0 { - left: calc(var(--spacing) * 0); - } - .left-1 { - left: calc(var(--spacing) * 1); - } - .left-3 { - left: calc(var(--spacing) * 3); - } - .left-4 { - left: calc(var(--spacing) * 4); - } - .left-5 { - left: calc(var(--spacing) * 5); - } - .z-0 { - z-index: 0; - } - .z-10 { - z-index: 10; - } - .z-20 { - z-index: 20; - } - .z-30 { - z-index: 30; - } - .z-40 { - z-index: 40; - } - .z-50 { - z-index: 50; - } - .z-\[3\] { - z-index: 3; - } - .z-\[4\] { - z-index: 4; - } - .z-\[1000\] { - z-index: 1000; - } - .z-\[9999\] { - z-index: 9999; - } - .col-12 { - grid-column: 12; - } - .container { - width: 100%; - @media (width >= 40rem) { - max-width: 40rem; - } - @media (width >= 48rem) { - max-width: 48rem; - } - @media (width >= 64rem) { - max-width: 64rem; - } - @media (width >= 80rem) { - max-width: 80rem; - } - @media (width >= 96rem) { - max-width: 96rem; - } - } - .m-0 { - margin: calc(var(--spacing) * 0); - } - .-mx-3 { - margin-inline: calc(var(--spacing) * -3); - } - .-mx-6 { - margin-inline: calc(var(--spacing) * -6); - } - .mx-1 { - margin-inline: calc(var(--spacing) * 1); - } - .mx-4 { - margin-inline: calc(var(--spacing) * 4); - } - .mx-auto { - margin-inline: auto; - } - .my-1 { - margin-block: calc(var(--spacing) * 1); - } - .my-2 { - margin-block: calc(var(--spacing) * 2); - } - .my-2\.5 { - margin-block: calc(var(--spacing) * 2.5); - } - .my-4 { - margin-block: calc(var(--spacing) * 4); - } - .my-5 { - margin-block: calc(var(--spacing) * 5); - } - .me-2 { - margin-inline-end: calc(var(--spacing) * 2); - } - .mt-0 { - margin-top: calc(var(--spacing) * 0); - } - .mt-0\.5 { - margin-top: calc(var(--spacing) * 0.5); - } - .mt-1 { - margin-top: calc(var(--spacing) * 1); - } - .mt-1\.5 { - margin-top: calc(var(--spacing) * 1.5); - } - .mt-2 { - margin-top: calc(var(--spacing) * 2); - } - .mt-3 { - margin-top: calc(var(--spacing) * 3); - } - .mt-4 { - margin-top: calc(var(--spacing) * 4); - } - .mt-6 { - margin-top: calc(var(--spacing) * 6); - } - .mt-8 { - margin-top: calc(var(--spacing) * 8); - } - .mr-1 { - margin-right: calc(var(--spacing) * 1); - } - .mr-2 { - margin-right: calc(var(--spacing) * 2); - } - .mr-3 { - margin-right: calc(var(--spacing) * 3); - } - .mb-0 { - margin-bottom: calc(var(--spacing) * 0); - } - .mb-1 { - margin-bottom: calc(var(--spacing) * 1); - } - .mb-1\.5 { - margin-bottom: calc(var(--spacing) * 1.5); - } - .mb-2 { - margin-bottom: calc(var(--spacing) * 2); - } - .mb-3 { - margin-bottom: calc(var(--spacing) * 3); - } - .mb-4 { - margin-bottom: calc(var(--spacing) * 4); - } - .mb-5 { - margin-bottom: calc(var(--spacing) * 5); - } - .mb-6 { - margin-bottom: calc(var(--spacing) * 6); - } - .mb-8 { - margin-bottom: calc(var(--spacing) * 8); - } - .ml-1 { - margin-left: calc(var(--spacing) * 1); - } - .ml-2 { - margin-left: calc(var(--spacing) * 2); - } - .ml-3 { - margin-left: calc(var(--spacing) * 3); - } - .ml-4 { - margin-left: calc(var(--spacing) * 4); - } - .ml-auto { - margin-left: auto; - } - .box-border { - box-sizing: border-box; - } - .block { - display: block; - } - .contents { - display: contents; - } - .flex { - display: flex; - } - .grid { - display: grid; - } - .hidden { - display: none; - } - .inline { - display: inline; - } - .inline-block { - display: inline-block; - } - .inline-flex { - display: inline-flex; - } - .table { - display: table; - } - .h-1 { - height: calc(var(--spacing) * 1); - } - .h-1\.5 { - height: calc(var(--spacing) * 1.5); - } - .h-2 { - height: calc(var(--spacing) * 2); - } - .h-2\.5 { - height: calc(var(--spacing) * 2.5); - } - .h-4 { - height: calc(var(--spacing) * 4); - } - .h-5 { - height: calc(var(--spacing) * 5); - } - .h-6 { - height: calc(var(--spacing) * 6); - } - .h-7 { - height: calc(var(--spacing) * 7); - } - .h-8 { - height: calc(var(--spacing) * 8); - } - .h-9 { - height: calc(var(--spacing) * 9); - } - .h-10 { - height: calc(var(--spacing) * 10); - } - .h-11 { - height: calc(var(--spacing) * 11); - } - .h-12 { - height: calc(var(--spacing) * 12); - } - .h-14 { - height: calc(var(--spacing) * 14); - } - .h-16 { - height: calc(var(--spacing) * 16); - } - .h-20 { - height: calc(var(--spacing) * 20); - } - .h-\[18px\] { - height: 18px; - } - .h-full { - height: 100%; - } - .h-px { - height: 1px; - } - .h-screen { - height: 100vh; - } - .max-h-64 { - max-height: calc(var(--spacing) * 64); - } - .max-h-\[calc\(100vh-6rem\)\] { - max-height: calc(100vh - 6rem); - } - .min-h-\[40px\] { - min-height: 40px; - } - .min-h-\[44px\] { - min-height: 44px; - } - .min-h-\[56px\] { - min-height: 56px; - } - .min-h-\[60vh\] { - min-height: 60vh; - } - .min-h-\[80px\] { - min-height: 80px; - } - .min-h-\[120px\] { - min-height: 120px; - } - .min-h-\[calc\(100vh-64px\)\] { - min-height: calc(100vh - 64px); - } - .min-h-screen { - min-height: 100vh; - } - .w-0\.5 { - width: calc(var(--spacing) * 0.5); - } - .w-1\.5 { - width: calc(var(--spacing) * 1.5); - } - .w-2\.5 { - width: calc(var(--spacing) * 2.5); - } - .w-4 { - width: calc(var(--spacing) * 4); - } - .w-5 { - width: calc(var(--spacing) * 5); - } - .w-7 { - width: calc(var(--spacing) * 7); - } - .w-8 { - width: calc(var(--spacing) * 8); - } - .w-9 { - width: calc(var(--spacing) * 9); - } - .w-10 { - width: calc(var(--spacing) * 10); - } - .w-11 { - width: calc(var(--spacing) * 11); - } - .w-12 { - width: calc(var(--spacing) * 12); - } - .w-14 { - width: calc(var(--spacing) * 14); - } - .w-16 { - width: calc(var(--spacing) * 16); - } - .w-20 { - width: calc(var(--spacing) * 20); - } - .w-24 { - width: calc(var(--spacing) * 24); - } - .w-32 { - width: calc(var(--spacing) * 32); - } - .w-56 { - width: calc(var(--spacing) * 56); - } - .w-64 { - width: calc(var(--spacing) * 64); - } - .w-\[18px\] { - width: 18px; - } - .w-\[52px\] { - width: 52px; - } - .w-\[60px\] { - width: 60px; - } - .w-\[120px\] { - width: 120px; - } - .w-\[200px\] { - width: 200px; - } - .w-\[280px\] { - width: 280px; - } - .w-\[320px\] { - width: 320px; - } - .w-auto { - width: auto; - } - .w-full { - width: 100%; - } - .w-px { - width: 1px; - } - .max-w-2xl { - max-width: var(--container-2xl); - } - .max-w-3xl { - max-width: var(--container-3xl); - } - .max-w-4xl { - max-width: var(--container-4xl); - } - .max-w-5xl { - max-width: var(--container-5xl); - } - .max-w-6xl { - max-width: var(--container-6xl); - } - .max-w-7xl { - max-width: var(--container-7xl); - } - .max-w-\[90vw\] { - max-width: 90vw; - } - .max-w-\[160px\] { - max-width: 160px; - } - .max-w-\[200px\] { - max-width: 200px; - } - .max-w-\[420px\] { - max-width: 420px; - } - .max-w-\[450px\] { - max-width: 450px; - } - .max-w-\[600px\] { - max-width: 600px; - } - .max-w-\[800px\] { - max-width: 800px; - } - .max-w-\[1000px\] { - max-width: 1000px; - } - .max-w-\[1200px\] { - max-width: 1200px; - } - .max-w-\[1400px\] { - max-width: 1400px; - } - .max-w-full { - max-width: 100%; - } - .max-w-lg { - max-width: var(--container-lg); - } - .max-w-md { - max-width: var(--container-md); - } - .max-w-xs { - max-width: var(--container-xs); - } - .min-w-0 { - min-width: calc(var(--spacing) * 0); - } - .min-w-\[20px\] { - min-width: 20px; - } - .min-w-\[40px\] { - min-width: 40px; - } - .min-w-\[80px\] { - min-width: 80px; - } - .min-w-\[120px\] { - min-width: 120px; - } - .min-w-\[180px\] { - min-width: 180px; - } - .min-w-\[200px\] { - min-width: 200px; - } - .min-w-\[250px\] { - min-width: 250px; - } - .min-w-\[600px\] { - min-width: 600px; - } - .flex-1 { - flex: 1; - } - .flex-\[2\] { - flex: 2; - } - .flex-shrink-0 { - flex-shrink: 0; - } - .shrink-0 { - flex-shrink: 0; - } - .border-collapse { - border-collapse: collapse; - } - .-translate-x-8 { - --tw-translate-x: calc(var(--spacing) * -8); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .-translate-x-full { - --tw-translate-x: -100%; - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .translate-x-0 { - --tw-translate-x: calc(var(--spacing) * 0); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .translate-x-8 { - --tw-translate-x: calc(var(--spacing) * 8); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .-translate-y-1\/2 { - --tw-translate-y: calc(calc(1 / 2 * 100%) * -1); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .translate-y-0 { - --tw-translate-y: calc(var(--spacing) * 0); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .translate-y-2 { - --tw-translate-y: calc(var(--spacing) * 2); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - .scale-95 { - --tw-scale-x: 95%; - --tw-scale-y: 95%; - --tw-scale-z: 95%; - scale: var(--tw-scale-x) var(--tw-scale-y); - } - .scale-100 { - --tw-scale-x: 100%; - --tw-scale-y: 100%; - --tw-scale-z: 100%; - scale: var(--tw-scale-x) var(--tw-scale-y); - } - .rotate-180 { - rotate: 180deg; - } - .transform { - transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); - } - .animate-\[bounce_0\.6s_ease-out\] { - animation: bounce 0.6s ease-out; - } - .animate-\[fadeIn_0\.5s_ease-out\] { - animation: fadeIn 0.5s ease-out; - } - .animate-\[pulse_2s_ease-in-out_infinite\] { - animation: pulse 2s ease-in-out infinite; - } - .animate-spin { - animation: var(--animate-spin); - } - .cursor-not-allowed { - cursor: not-allowed; - } - .cursor-pointer { - cursor: pointer; - } - .resize { - resize: both; - } - .resize-none { - resize: none; - } - .resize-y { - resize: vertical; - } - .list-none { - list-style-type: none; - } - .appearance-none { - appearance: none; - } - .grid-cols-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - .grid-cols-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .grid-cols-\[repeat\(auto-fit\,minmax\(280px\,1fr\)\)\] { - grid-template-columns: repeat(auto-fit,minmax(280px,1fr)); - } - .flex-col { - flex-direction: column; - } - .flex-wrap { - flex-wrap: wrap; - } - .content-start { - align-content: flex-start; - } - .items-center { - align-items: center; - } - .items-end { - align-items: flex-end; - } - .items-start { - align-items: flex-start; - } - .items-stretch { - align-items: stretch; - } - .justify-between { - justify-content: space-between; - } - .justify-center { - justify-content: center; - } - .justify-end { - justify-content: flex-end; - } - .gap-1 { - gap: calc(var(--spacing) * 1); - } - .gap-1\.5 { - gap: calc(var(--spacing) * 1.5); - } - .gap-2 { - gap: calc(var(--spacing) * 2); - } - .gap-3 { - gap: calc(var(--spacing) * 3); - } - .gap-4 { - gap: calc(var(--spacing) * 4); - } - .gap-5 { - gap: calc(var(--spacing) * 5); - } - .gap-6 { - gap: calc(var(--spacing) * 6); - } - .gap-8 { - gap: calc(var(--spacing) * 8); - } - .space-y-0 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-0\.5 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 0.5) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 0.5) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-1 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-2 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-3 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-4 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-5 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-6 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse))); - } - } - .space-y-8 { - :where(& > :not(:last-child)) { - --tw-space-y-reverse: 0; - margin-block-start: calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse)); - margin-block-end: calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse))); - } - } - .gap-x-4 { - column-gap: calc(var(--spacing) * 4); - } - .gap-x-6 { - column-gap: calc(var(--spacing) * 6); - } - .gap-x-8 { - column-gap: calc(var(--spacing) * 8); - } - .gap-y-1 { - row-gap: calc(var(--spacing) * 1); - } - .gap-y-2 { - row-gap: calc(var(--spacing) * 2); - } - .gap-y-4 { - row-gap: calc(var(--spacing) * 4); - } - .divide-y { - :where(& > :not(:last-child)) { - --tw-divide-y-reverse: 0; - border-bottom-style: var(--tw-border-style); - border-top-style: var(--tw-border-style); - border-top-width: calc(1px * var(--tw-divide-y-reverse)); - border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); - } - } - .divide-md-outline-variant\/30 { - :where(& > :not(:last-child)) { - border-color: color-mix(in srgb, #49454F 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-md-outline-variant) 30%, transparent); - } - } - } - .divide-white\/5 { - :where(& > :not(:last-child)) { - border-color: color-mix(in srgb, #fff 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-white) 5%, transparent); - } - } - } - .truncate { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .overflow-hidden { - overflow: hidden; - } - .overflow-x-auto { - overflow-x: auto; - } - .overflow-y-auto { - overflow-y: auto; - } - .rounded { - border-radius: 0.25rem; - } - .rounded-3xl { - border-radius: var(--radius-3xl); - } - .rounded-full { - border-radius: calc(infinity * 1px); - } - .rounded-lg { - border-radius: var(--radius-lg); - } - .rounded-md { - border-radius: var(--radius-md); - } - .rounded-md-lg { - border-radius: var(--radius-md-lg); - } - .rounded-md-xl { - border-radius: var(--radius-md-xl); - } - .border { - border-style: var(--tw-border-style); - border-width: 1px; - } - .border-0 { - border-style: var(--tw-border-style); - border-width: 0px; - } - .border-2 { - border-style: var(--tw-border-style); - border-width: 2px; - } - .border-t { - border-top-style: var(--tw-border-style); - border-top-width: 1px; - } - .border-r { - border-right-style: var(--tw-border-style); - border-right-width: 1px; - } - .border-b { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 1px; - } - .border-b-2 { - border-bottom-style: var(--tw-border-style); - border-bottom-width: 2px; - } - .border-l-4 { - border-left-style: var(--tw-border-style); - border-left-width: 4px; - } - .border-l-\[3px\] { - border-left-style: var(--tw-border-style); - border-left-width: 3px; - } - .border-none { - --tw-border-style: none; - border-style: none; - } - .\!border-cyan-500\/30 { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 30%, transparent) !important; - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent) !important; - } - } - .border-amber-500\/20 { - border-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-amber-500) 20%, transparent); - } - } - .border-amber-500\/30 { - border-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-amber-500) 30%, transparent); - } - } - .border-blue-500\/30 { - border-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-blue-500) 30%, transparent); - } - } - .border-blue-600 { - border-color: var(--color-blue-600); - } - .border-cyan-400 { - border-color: var(--color-cyan-400); - } - .border-cyan-500\/20 { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent); - } - } - .border-cyan-500\/30 { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent); - } - } - .border-cyan-500\/50 { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 50%, transparent); - } - } - .border-cyan-600 { - border-color: var(--color-cyan-600); - } - .border-emerald-500\/30 { - border-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-emerald-500) 30%, transparent); - } - } - .border-emerald-600 { - border-color: var(--color-emerald-600); - } - .border-green-500\/20 { - border-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-green-500) 20%, transparent); - } - } - .border-green-500\/30 { - border-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-green-500) 30%, transparent); - } - } - .border-md-error { - border-color: var(--color-md-error); - } - .border-md-outline { - border-color: var(--color-md-outline); - } - .border-md-outline-variant { - border-color: var(--color-md-outline-variant); - } - .border-md-outline-variant\/30 { - border-color: color-mix(in srgb, #49454F 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-md-outline-variant) 30%, transparent); - } - } - .border-md-outline-variant\/50 { - border-color: color-mix(in srgb, #49454F 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-md-outline-variant) 50%, transparent); - } - } - .border-md-outline\/50 { - border-color: color-mix(in srgb, #938F99 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-md-outline) 50%, transparent); - } - } - .border-md-primary { - border-color: var(--color-md-primary); - } - .border-red-500 { - border-color: var(--color-red-500); - } - .border-red-500\/20 { - border-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-red-500) 20%, transparent); - } - } - .border-red-500\/30 { - border-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-red-500) 30%, transparent); - } - } - .border-red-500\/50 { - border-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-red-500) 50%, transparent); - } - } - .border-red-600 { - border-color: var(--color-red-600); - } - .border-slate-500\/30 { - border-color: color-mix(in srgb, oklch(55.4% 0.046 257.417) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-slate-500) 30%, transparent); - } - } - .border-slate-600 { - border-color: var(--color-slate-600); - } - .border-slate-600\/20 { - border-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-slate-600) 20%, transparent); - } - } - .border-slate-600\/30 { - border-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-slate-600) 30%, transparent); - } - } - .border-slate-700\/20 { - border-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-slate-700) 20%, transparent); - } - } - .border-slate-700\/30 { - border-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-slate-700) 30%, transparent); - } - } - .border-slate-700\/50 { - border-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-slate-700) 50%, transparent); - } - } - .border-slate-800 { - border-color: var(--color-slate-800); - } - .border-white\/5 { - border-color: color-mix(in srgb, #fff 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-white) 5%, transparent); - } - } - .border-white\/10 { - border-color: color-mix(in srgb, #fff 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-white) 10%, transparent); - } - } - .border-white\/20 { - border-color: color-mix(in srgb, #fff 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-white) 20%, transparent); - } - } - .border-l-md-error { - border-left-color: var(--color-md-error); - } - .border-l-md-primary { - border-left-color: var(--color-md-primary); - } - .border-l-transparent { - border-left-color: transparent; - } - .\!bg-md-surface-variant { - background-color: var(--color-md-surface-variant) !important; - } - .\!bg-red-500 { - background-color: var(--color-red-500) !important; - } - .bg-amber-100 { - background-color: var(--color-amber-100); - } - .bg-amber-400 { - background-color: var(--color-amber-400); - } - .bg-amber-500\/10 { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 10%, transparent); - } - } - .bg-amber-500\/15 { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent); - } - } - .bg-amber-500\/20 { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 20%, transparent); - } - } - .bg-amber-600\/20 { - background-color: color-mix(in srgb, oklch(66.6% 0.179 58.318) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-600) 20%, transparent); - } - } - .bg-black\/50 { - background-color: color-mix(in srgb, #000 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-black) 50%, transparent); - } - } - .bg-black\/60 { - background-color: color-mix(in srgb, #000 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-black) 60%, transparent); - } - } - .bg-black\/90 { - background-color: color-mix(in srgb, #000 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-black) 90%, transparent); - } - } - .bg-blue-50 { - background-color: var(--color-blue-50); - } - .bg-blue-500\/15 { - background-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-blue-500) 15%, transparent); - } - } - .bg-blue-500\/20 { - background-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-blue-500) 20%, transparent); - } - } - .bg-blue-600 { - background-color: var(--color-blue-600); - } - .bg-cyan-500\/5 { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 5%, transparent); - } - } - .bg-cyan-500\/10 { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 10%, transparent); - } - } - .bg-cyan-500\/20 { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent); - } - } - .bg-cyan-500\/30 { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent); - } - } - .bg-cyan-600 { - background-color: var(--color-cyan-600); - } - .bg-emerald-500\/10 { - background-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-emerald-500) 10%, transparent); - } - } - .bg-emerald-500\/20 { - background-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-emerald-500) 20%, transparent); - } - } - .bg-emerald-600 { - background-color: var(--color-emerald-600); - } - .bg-emerald-600\/20 { - background-color: color-mix(in srgb, oklch(59.6% 0.145 163.225) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-emerald-600) 20%, transparent); - } - } - .bg-gray-100 { - background-color: var(--color-gray-100); - } - .bg-green-400 { - background-color: var(--color-green-400); - } - .bg-green-500\/10 { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 10%, transparent); - } - } - .bg-green-500\/15 { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 15%, transparent); - } - } - .bg-green-500\/20 { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 20%, transparent); - } - } - .bg-md-error { - background-color: var(--color-md-error); - } - .bg-md-error-container { - background-color: var(--color-md-error-container); - } - .bg-md-error-container\/30 { - background-color: color-mix(in srgb, #8C1D18 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error-container) 30%, transparent); - } - } - .bg-md-error-container\/50 { - background-color: color-mix(in srgb, #8C1D18 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error-container) 50%, transparent); - } - } - .bg-md-error-container\/90 { - background-color: color-mix(in srgb, #8C1D18 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error-container) 90%, transparent); - } - } - .bg-md-error\/10 { - background-color: color-mix(in srgb, #F2B8B5 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error) 10%, transparent); - } - } - .bg-md-outline-variant { - background-color: var(--color-md-outline-variant); - } - .bg-md-primary { - background-color: var(--color-md-primary); - } - .bg-md-primary-container { - background-color: var(--color-md-primary-container); - } - .bg-md-primary-container\/90 { - background-color: color-mix(in srgb, #4F378B 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary-container) 90%, transparent); - } - } - .bg-md-primary\/30 { - background-color: color-mix(in srgb, #D0BCFF 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary) 30%, transparent); - } - } - .bg-md-secondary { - background-color: var(--color-md-secondary); - } - .bg-md-secondary-container { - background-color: var(--color-md-secondary-container); - } - .bg-md-secondary-container\/80 { - background-color: color-mix(in srgb, #4A4458 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-secondary-container) 80%, transparent); - } - } - .bg-md-surface { - background-color: var(--color-md-surface); - } - .bg-md-surface-container-high { - background-color: var(--color-md-surface-container-high); - } - .bg-md-surface-container-high\/50 { - background-color: color-mix(in srgb, #2B2930 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-surface-container-high) 50%, transparent); - } - } - .bg-md-surface-container-low { - background-color: var(--color-md-surface-container-low); - } - .bg-md-surface-container\/70 { - background-color: color-mix(in srgb, #211F26 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-surface-container) 70%, transparent); - } - } - .bg-md-surface-container\/80 { - background-color: color-mix(in srgb, #211F26 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-surface-container) 80%, transparent); - } - } - .bg-md-surface-variant { - background-color: var(--color-md-surface-variant); - } - .bg-md-surface\/50 { - background-color: color-mix(in srgb, #1C1B1F 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-surface) 50%, transparent); - } - } - .bg-md-surface\/80 { - background-color: color-mix(in srgb, #1C1B1F 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-surface) 80%, transparent); - } - } - .bg-md-tertiary-container { - background-color: var(--color-md-tertiary-container); - } - .bg-md-tertiary-container\/90 { - background-color: color-mix(in srgb, #633B48 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-tertiary-container) 90%, transparent); - } - } - .bg-purple-500\/15 { - background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-purple-500) 15%, transparent); - } - } - .bg-purple-500\/20 { - background-color: color-mix(in srgb, oklch(62.7% 0.265 303.9) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-purple-500) 20%, transparent); - } - } - .bg-red-500 { - background-color: var(--color-red-500); - } - .bg-red-500\/10 { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); - } - } - .bg-red-500\/15 { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 15%, transparent); - } - } - .bg-red-500\/20 { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent); - } - } - .bg-red-500\/\[0\.03\] { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 3%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 3%, transparent); - } - } - .bg-red-600 { - background-color: var(--color-red-600); - } - .bg-red-600\/15 { - background-color: color-mix(in srgb, oklch(57.7% 0.245 27.325) 15%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-600) 15%, transparent); - } - } - .bg-red-900\/20 { - background-color: color-mix(in srgb, oklch(39.6% 0.141 25.723) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-900) 20%, transparent); - } - } - .bg-slate-500\/20 { - background-color: color-mix(in srgb, oklch(55.4% 0.046 257.417) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-500) 20%, transparent); - } - } - .bg-slate-600 { - background-color: var(--color-slate-600); - } - .bg-slate-600\/20 { - background-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-600) 20%, transparent); - } - } - .bg-slate-600\/30 { - background-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-600) 30%, transparent); - } - } - .bg-slate-600\/50 { - background-color: color-mix(in srgb, oklch(44.6% 0.043 257.281) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-600) 50%, transparent); - } - } - .bg-slate-700 { - background-color: var(--color-slate-700); - } - .bg-slate-700\/30 { - background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-700) 30%, transparent); - } - } - .bg-slate-800 { - background-color: var(--color-slate-800); - } - .bg-slate-800\/50 { - background-color: color-mix(in srgb, oklch(27.9% 0.041 260.031) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-800) 50%, transparent); - } - } - .bg-slate-900\/30 { - background-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-900) 30%, transparent); - } - } - .bg-slate-900\/40 { - background-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 40%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-900) 40%, transparent); - } - } - .bg-slate-900\/50 { - background-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-900) 50%, transparent); - } - } - .bg-slate-900\/80 { - background-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-900) 80%, transparent); - } - } - .bg-slate-950 { - background-color: var(--color-slate-950); - } - .bg-slate-950\/30 { - background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-950) 30%, transparent); - } - } - .bg-slate-950\/50 { - background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-950) 50%, transparent); - } - } - .bg-slate-950\/70 { - background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-950) 70%, transparent); - } - } - .bg-slate-950\/80 { - background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-950) 80%, transparent); - } - } - .bg-slate-950\/95 { - background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 95%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-950) 95%, transparent); - } - } - .bg-transparent { - background-color: transparent; - } - .bg-white { - background-color: var(--color-white); - } - .bg-white\/5 { - background-color: color-mix(in srgb, #fff 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 5%, transparent); - } - } - .bg-white\/10 { - background-color: color-mix(in srgb, #fff 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 10%, transparent); - } - } - .bg-white\/20 { - background-color: color-mix(in srgb, #fff 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 20%, transparent); - } - } - .bg-white\/\[0\.02\] { - background-color: color-mix(in srgb, #fff 2%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 2%, transparent); - } - } - .bg-gradient-to-b { - --tw-gradient-position: to bottom in oklab; - background-image: linear-gradient(var(--tw-gradient-stops)); - } - .bg-gradient-to-br { - --tw-gradient-position: to bottom right in oklab; - background-image: linear-gradient(var(--tw-gradient-stops)); - } - .bg-gradient-to-r { - --tw-gradient-position: to right in oklab; - background-image: linear-gradient(var(--tw-gradient-stops)); - } - .bg-\[url\(\'\/static\/img\/bgimg\.jpg\'\)\] { - background-image: url('/static/img/bgimg.jpg'); - } - .from-\[\#1e3a5f\] { - --tw-gradient-from: #1e3a5f; - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .from-cyan-600 { - --tw-gradient-from: var(--color-cyan-600); - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .from-md-surface { - --tw-gradient-from: var(--color-md-surface); - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .from-md-surface\/60 { - --tw-gradient-from: color-mix(in srgb, #1C1B1F 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-from: color-mix(in oklab, var(--color-md-surface) 60%, transparent); - } - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .from-slate-950\/70 { - --tw-gradient-from: color-mix(in srgb, oklch(12.9% 0.042 264.695) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-from: color-mix(in oklab, var(--color-slate-950) 70%, transparent); - } - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .from-slate-950\/80 { - --tw-gradient-from: color-mix(in srgb, oklch(12.9% 0.042 264.695) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-from: color-mix(in oklab, var(--color-slate-950) 80%, transparent); - } - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .via-md-surface\/40 { - --tw-gradient-via: color-mix(in srgb, #1C1B1F 40%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-via: color-mix(in oklab, var(--color-md-surface) 40%, transparent); - } - --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-via-stops); - } - .via-slate-900\/50 { - --tw-gradient-via: color-mix(in srgb, oklch(20.8% 0.042 265.755) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-via: color-mix(in oklab, var(--color-slate-900) 50%, transparent); - } - --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-via-stops); - } - .via-slate-900\/70 { - --tw-gradient-via: color-mix(in srgb, oklch(20.8% 0.042 265.755) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-via: color-mix(in oklab, var(--color-slate-900) 70%, transparent); - } - --tw-gradient-via-stops: var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-via-stops); - } - .to-\[\#10b981\] { - --tw-gradient-to: #10b981; - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .to-cyan-400 { - --tw-gradient-to: var(--color-cyan-400); - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .to-md-surface-container { - --tw-gradient-to: var(--color-md-surface-container); - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .to-md-surface\/70 { - --tw-gradient-to: color-mix(in srgb, #1C1B1F 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-to: color-mix(in oklab, var(--color-md-surface) 70%, transparent); - } - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .to-slate-950\/80 { - --tw-gradient-to: color-mix(in srgb, oklch(12.9% 0.042 264.695) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-gradient-to: color-mix(in oklab, var(--color-slate-950) 80%, transparent); - } - --tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position)); - } - .bg-cover { - background-size: cover; - } - .bg-fixed { - background-attachment: fixed; - } - .bg-center { - background-position: center; - } - .object-cover { - object-fit: cover; - } - .p-0 { - padding: calc(var(--spacing) * 0); - } - .p-1 { - padding: calc(var(--spacing) * 1); - } - .p-1\.5 { - padding: calc(var(--spacing) * 1.5); - } - .p-2 { - padding: calc(var(--spacing) * 2); - } - .p-3 { - padding: calc(var(--spacing) * 3); - } - .p-4 { - padding: calc(var(--spacing) * 4); - } - .p-5 { - padding: calc(var(--spacing) * 5); - } - .p-6 { - padding: calc(var(--spacing) * 6); - } - .p-8 { - padding: calc(var(--spacing) * 8); - } - .p-10 { - padding: calc(var(--spacing) * 10); - } - .px-1\.5 { - padding-inline: calc(var(--spacing) * 1.5); - } - .px-2 { - padding-inline: calc(var(--spacing) * 2); - } - .px-2\.5 { - padding-inline: calc(var(--spacing) * 2.5); - } - .px-3 { - padding-inline: calc(var(--spacing) * 3); - } - .px-4 { - padding-inline: calc(var(--spacing) * 4); - } - .px-5 { - padding-inline: calc(var(--spacing) * 5); - } - .px-6 { - padding-inline: calc(var(--spacing) * 6); - } - .px-8 { - padding-inline: calc(var(--spacing) * 8); - } - .py-0\.5 { - padding-block: calc(var(--spacing) * 0.5); - } - .py-1 { - padding-block: calc(var(--spacing) * 1); - } - .py-1\.5 { - padding-block: calc(var(--spacing) * 1.5); - } - .py-2 { - padding-block: calc(var(--spacing) * 2); - } - .py-2\.5 { - padding-block: calc(var(--spacing) * 2.5); - } - .py-3 { - padding-block: calc(var(--spacing) * 3); - } - .py-4 { - padding-block: calc(var(--spacing) * 4); - } - .py-5 { - padding-block: calc(var(--spacing) * 5); - } - .py-6 { - padding-block: calc(var(--spacing) * 6); - } - .py-8 { - padding-block: calc(var(--spacing) * 8); - } - .py-12 { - padding-block: calc(var(--spacing) * 12); - } - .py-16 { - padding-block: calc(var(--spacing) * 16); - } - .pt-1 { - padding-top: calc(var(--spacing) * 1); - } - .pt-3 { - padding-top: calc(var(--spacing) * 3); - } - .pt-4 { - padding-top: calc(var(--spacing) * 4); - } - .pt-5 { - padding-top: calc(var(--spacing) * 5); - } - .pt-6 { - padding-top: calc(var(--spacing) * 6); - } - .pt-7 { - padding-top: calc(var(--spacing) * 7); - } - .pr-2 { - padding-right: calc(var(--spacing) * 2); - } - .pr-4 { - padding-right: calc(var(--spacing) * 4); - } - .pr-10 { - padding-right: calc(var(--spacing) * 10); - } - .pr-12 { - padding-right: calc(var(--spacing) * 12); - } - .pr-28 { - padding-right: calc(var(--spacing) * 28); - } - .pb-2 { - padding-bottom: calc(var(--spacing) * 2); - } - .pb-3 { - padding-bottom: calc(var(--spacing) * 3); - } - .pb-4 { - padding-bottom: calc(var(--spacing) * 4); - } - .pb-6 { - padding-bottom: calc(var(--spacing) * 6); - } - .pl-2 { - padding-left: calc(var(--spacing) * 2); - } - .pl-4 { - padding-left: calc(var(--spacing) * 4); - } - .pl-9 { - padding-left: calc(var(--spacing) * 9); - } - .pl-10 { - padding-left: calc(var(--spacing) * 10); - } - .text-center { - text-align: center; - } - .text-left { - text-align: left; - } - .text-right { - text-align: right; - } - .align-middle { - vertical-align: middle; - } - .font-\[inherit\] { - font-family: inherit; - } - .font-mono { - font-family: var(--font-mono); - } - .font-sans { - font-family: var(--font-sans); - } - .text-2xl { - font-size: var(--text-2xl); - line-height: var(--tw-leading, var(--text-2xl--line-height)); - } - .text-3xl { - font-size: var(--text-3xl); - line-height: var(--tw-leading, var(--text-3xl--line-height)); - } - .text-4xl { - font-size: var(--text-4xl); - line-height: var(--tw-leading, var(--text-4xl--line-height)); - } - .text-6xl { - font-size: var(--text-6xl); - line-height: var(--tw-leading, var(--text-6xl--line-height)); - } - .text-base { - font-size: var(--text-base); - line-height: var(--tw-leading, var(--text-base--line-height)); - } - .text-lg { - font-size: var(--text-lg); - line-height: var(--tw-leading, var(--text-lg--line-height)); - } - .text-sm { - font-size: var(--text-sm); - line-height: var(--tw-leading, var(--text-sm--line-height)); - } - .text-xl { - font-size: var(--text-xl); - line-height: var(--tw-leading, var(--text-xl--line-height)); - } - .text-xs { - font-size: var(--text-xs); - line-height: var(--tw-leading, var(--text-xs--line-height)); - } - .text-\[6rem\] { - font-size: 6rem; - } - .text-\[16px\] { - font-size: 16px; - } - .text-\[18px\] { - font-size: 18px; - } - .text-\[20px\] { - font-size: 20px; - } - .text-\[40px\] { - font-size: 40px; - } - .text-\[48px\] { - font-size: 48px; - } - .text-\[64px\] { - font-size: 64px; - } - .leading-none { - --tw-leading: 1; - line-height: 1; - } - .leading-normal { - --tw-leading: var(--leading-normal); - line-height: var(--leading-normal); - } - .leading-relaxed { - --tw-leading: var(--leading-relaxed); - line-height: var(--leading-relaxed); - } - .leading-tight { - --tw-leading: var(--leading-tight); - line-height: var(--leading-tight); - } - .font-bold { - --tw-font-weight: var(--font-weight-bold); - font-weight: var(--font-weight-bold); - } - .font-medium { - --tw-font-weight: var(--font-weight-medium); - font-weight: var(--font-weight-medium); - } - .font-semibold { - --tw-font-weight: var(--font-weight-semibold); - font-weight: var(--font-weight-semibold); - } - .tracking-\[0\.1em\] { - --tw-tracking: 0.1em; - letter-spacing: 0.1em; - } - .tracking-\[0\.3em\] { - --tw-tracking: 0.3em; - letter-spacing: 0.3em; - } - .tracking-wider { - --tw-tracking: var(--tracking-wider); - letter-spacing: var(--tracking-wider); - } - .break-words { - overflow-wrap: break-word; - } - .break-all { - word-break: break-all; - } - .whitespace-nowrap { - white-space: nowrap; - } - .whitespace-pre-wrap { - white-space: pre-wrap; - } - .\!text-md-error { - color: var(--color-md-error) !important; - } - .\!text-md-on-surface-variant { - color: var(--color-md-on-surface-variant) !important; - } - .\!text-md-outline { - color: var(--color-md-outline) !important; - } - .text-amber-200 { - color: var(--color-amber-200); - } - .text-amber-200\/70 { - color: color-mix(in srgb, oklch(92.4% 0.12 95.746) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-amber-200) 70%, transparent); - } - } - .text-amber-200\/80 { - color: color-mix(in srgb, oklch(92.4% 0.12 95.746) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-amber-200) 80%, transparent); - } - } - .text-amber-300\/70 { - color: color-mix(in srgb, oklch(87.9% 0.169 91.605) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-amber-300) 70%, transparent); - } - } - .text-amber-400 { - color: var(--color-amber-400); - } - .text-amber-400\/60 { - color: color-mix(in srgb, oklch(82.8% 0.189 84.429) 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-amber-400) 60%, transparent); - } - } - .text-amber-400\/70 { - color: color-mix(in srgb, oklch(82.8% 0.189 84.429) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-amber-400) 70%, transparent); - } - } - .text-amber-400\/80 { - color: color-mix(in srgb, oklch(82.8% 0.189 84.429) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-amber-400) 80%, transparent); - } - } - .text-amber-800 { - color: var(--color-amber-800); - } - .text-blue-400 { - color: var(--color-blue-400); - } - .text-blue-400\/60 { - color: color-mix(in srgb, oklch(70.7% 0.165 254.624) 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-blue-400) 60%, transparent); - } - } - .text-blue-400\/80 { - color: color-mix(in srgb, oklch(70.7% 0.165 254.624) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-blue-400) 80%, transparent); - } - } - .text-blue-600 { - color: var(--color-blue-600); - } - .text-cyan-200 { - color: var(--color-cyan-200); - } - .text-cyan-400 { - color: var(--color-cyan-400); - } - .text-cyan-400\/60 { - color: color-mix(in srgb, oklch(78.9% 0.154 211.53) 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-cyan-400) 60%, transparent); - } - } - .text-cyan-400\/80 { - color: color-mix(in srgb, oklch(78.9% 0.154 211.53) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-cyan-400) 80%, transparent); - } - } - .text-cyan-500 { - color: var(--color-cyan-500); - } - .text-emerald-400 { - color: var(--color-emerald-400); - } - .text-gray-500 { - color: var(--color-gray-500); - } - .text-gray-800 { - color: var(--color-gray-800); - } - .text-green-400 { - color: var(--color-green-400); - } - .text-green-400\/60 { - color: color-mix(in srgb, oklch(79.2% 0.209 151.711) 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-green-400) 60%, transparent); - } - } - .text-green-400\/80 { - color: color-mix(in srgb, oklch(79.2% 0.209 151.711) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-green-400) 80%, transparent); - } - } - .text-md-error { - color: var(--color-md-error); - } - .text-md-on-error { - color: var(--color-md-on-error); - } - .text-md-on-error-container { - color: var(--color-md-on-error-container); - } - .text-md-on-primary { - color: var(--color-md-on-primary); - } - .text-md-on-primary-container { - color: var(--color-md-on-primary-container); - } - .text-md-on-secondary { - color: var(--color-md-on-secondary); - } - .text-md-on-secondary-container { - color: var(--color-md-on-secondary-container); - } - .text-md-on-surface { - color: var(--color-md-on-surface); - } - .text-md-on-surface-variant { - color: var(--color-md-on-surface-variant); - } - .text-md-on-tertiary-container { - color: var(--color-md-on-tertiary-container); - } - .text-md-outline { - color: var(--color-md-outline); - } - .text-md-outline\/60 { - color: color-mix(in srgb, #938F99 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-md-outline) 60%, transparent); - } - } - .text-md-primary { - color: var(--color-md-primary); - } - .text-purple-400 { - color: var(--color-purple-400); - } - .text-red-200 { - color: var(--color-red-200); - } - .text-red-400 { - color: var(--color-red-400); - } - .text-red-400\/60 { - color: color-mix(in srgb, oklch(70.4% 0.191 22.216) 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-red-400) 60%, transparent); - } - } - .text-red-400\/70 { - color: color-mix(in srgb, oklch(70.4% 0.191 22.216) 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-red-400) 70%, transparent); - } - } - .text-red-400\/80 { - color: color-mix(in srgb, oklch(70.4% 0.191 22.216) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-red-400) 80%, transparent); - } - } - .text-slate-200 { - color: var(--color-slate-200); - } - .text-slate-300 { - color: var(--color-slate-300); - } - .text-slate-400 { - color: var(--color-slate-400); - } - .text-slate-500 { - color: var(--color-slate-500); - } - .text-white { - color: var(--color-white); - } - .text-white\/20 { - color: color-mix(in srgb, #fff 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 20%, transparent); - } - } - .text-white\/30 { - color: color-mix(in srgb, #fff 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 30%, transparent); - } - } - .text-white\/40 { - color: color-mix(in srgb, #fff 40%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 40%, transparent); - } - } - .text-white\/50 { - color: color-mix(in srgb, #fff 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 50%, transparent); - } - } - .text-white\/60 { - color: color-mix(in srgb, #fff 60%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 60%, transparent); - } - } - .text-white\/70 { - color: color-mix(in srgb, #fff 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 70%, transparent); - } - } - .text-white\/80 { - color: color-mix(in srgb, #fff 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 80%, transparent); - } - } - .text-white\/90 { - color: color-mix(in srgb, #fff 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 90%, transparent); - } - } - .lowercase { - text-transform: lowercase; - } - .uppercase { - text-transform: uppercase; - } - .italic { - font-style: italic; - } - .no-underline { - text-decoration-line: none; - } - .underline { - text-decoration-line: underline; - } - .placeholder-md-on-surface-variant\/50 { - &::placeholder { - color: color-mix(in srgb, #CAC4D0 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-md-on-surface-variant) 50%, transparent); - } - } - } - .placeholder-md-outline { - &::placeholder { - color: var(--color-md-outline); - } - } - .placeholder-slate-500 { - &::placeholder { - color: var(--color-slate-500); - } - } - .accent-\[\#1e3a5f\] { - accent-color: #1e3a5f; - } - .accent-md-primary { - accent-color: var(--color-md-primary); - } - .opacity-0 { - opacity: 0%; - } - .opacity-30 { - opacity: 30%; - } - .opacity-60 { - opacity: 60%; - } - .opacity-80 { - opacity: 80%; - } - .opacity-100 { - opacity: 100%; - } - .shadow-2xl { - --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-\[0_0_15px_-3px_rgba\(34\,211\,238\,0\.3\)\] { - --tw-shadow: 0 0 15px -3px var(--tw-shadow-color, rgba(34,211,238,0.3)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-\[0_0_20px_-3px_rgba\(34\,211\,238\,0\.2\)\] { - --tw-shadow: 0 0 20px -3px var(--tw-shadow-color, rgba(34,211,238,0.2)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-lg { - --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-md { - --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-none { - --tw-shadow: 0 0 #0000; - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-sm { - --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .shadow-xl { - --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .ring-2 { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - .ring-md-error { - --tw-ring-color: var(--color-md-error); - } - .blur { - --tw-blur: blur(8px); - filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); - } - .filter { - filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); - } - .backdrop-blur-2xl { - --tw-backdrop-blur: blur(var(--blur-2xl)); - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } - .backdrop-blur-3xl { - --tw-backdrop-blur: blur(var(--blur-3xl)); - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } - .backdrop-blur-md { - --tw-backdrop-blur: blur(var(--blur-md)); - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } - .backdrop-blur-sm { - --tw-backdrop-blur: blur(var(--blur-sm)); - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } - .backdrop-blur-xl { - --tw-backdrop-blur: blur(var(--blur-xl)); - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } - .backdrop-filter { - -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); - } - .transition { - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - .transition-\[background\] { - transition-property: background; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - .transition-all { - transition-property: all; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - .transition-opacity { - transition-property: opacity; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - .transition-transform { - transition-property: transform, translate, scale, rotate; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - .duration-150 { - --tw-duration: 150ms; - transition-duration: 150ms; - } - .duration-200 { - --tw-duration: 200ms; - transition-duration: 200ms; - } - .duration-300 { - --tw-duration: 300ms; - transition-duration: 300ms; - } - .duration-500 { - --tw-duration: 500ms; - transition-duration: 500ms; - } - .ease-in { - --tw-ease: var(--ease-in); - transition-timing-function: var(--ease-in); - } - .ease-out { - --tw-ease: var(--ease-out); - transition-timing-function: var(--ease-out); - } - .outline-none { - --tw-outline-style: none; - outline-style: none; - } - .select-all { - -webkit-user-select: all; - user-select: all; - } - .\[group\:2c2a\] { - group: 2c2a; - } - .\[program\:celery-beat\] { - program: celery-beat; - } - .\[program\:celery-worker\] { - program: celery-worker; - } - .\[program\:gunicorn\] { - program: gunicorn; - } - .group-hover\:bg-cyan-500\/30 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent); - } - } - } - } - .group-hover\:text-cyan-400 { - &:is(:where(.group):hover *) { - @media (hover: hover) { - color: var(--color-cyan-400); - } - } - } - .peer-checked\:translate-x-5 { - &:is(:where(.peer):checked ~ *) { - --tw-translate-x: calc(var(--spacing) * 5); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - } - .peer-checked\:bg-cyan-500 { - &:is(:where(.peer):checked ~ *) { - background-color: var(--color-cyan-500); - } - } - .peer-checked\:bg-cyan-600 { - &:is(:where(.peer):checked ~ *) { - background-color: var(--color-cyan-600); - } - } - .peer-checked\:bg-md-primary { - &:is(:where(.peer):checked ~ *) { - background-color: var(--color-md-primary); - } - } - .peer-focus\:ring-1 { - &:is(:where(.peer):focus ~ *) { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - .peer-focus\:ring-cyan-500\/50 { - &:is(:where(.peer):focus ~ *) { - --tw-ring-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-ring-color: color-mix(in oklab, var(--color-cyan-500) 50%, transparent); - } - } - } - .peer-focus\:outline-none { - &:is(:where(.peer):focus ~ *) { - --tw-outline-style: none; - outline-style: none; - } - } - .file\:mr-4 { - &::file-selector-button { - margin-right: calc(var(--spacing) * 4); - } - } - .file\:rounded-md { - &::file-selector-button { - border-radius: var(--radius-md); - } - } - .file\:border-0 { - &::file-selector-button { - border-style: var(--tw-border-style); - border-width: 0px; - } - } - .file\:bg-md-primary\/20 { - &::file-selector-button { - background-color: color-mix(in srgb, #D0BCFF 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary) 20%, transparent); - } - } - } - .file\:px-4 { - &::file-selector-button { - padding-inline: calc(var(--spacing) * 4); - } - } - .file\:py-1 { - &::file-selector-button { - padding-block: calc(var(--spacing) * 1); - } - } - .file\:text-sm { - &::file-selector-button { - font-size: var(--text-sm); - line-height: var(--tw-leading, var(--text-sm--line-height)); - } - } - .file\:font-medium { - &::file-selector-button { - --tw-font-weight: var(--font-weight-medium); - font-weight: var(--font-weight-medium); - } - } - .file\:text-md-primary { - &::file-selector-button { - color: var(--color-md-primary); - } - } - .before\:absolute { - &::before { - content: var(--tw-content); - position: absolute; - } - } - .before\:bottom-1 { - &::before { - content: var(--tw-content); - bottom: calc(var(--spacing) * 1); - } - } - .before\:left-1 { - &::before { - content: var(--tw-content); - left: calc(var(--spacing) * 1); - } - } - .before\:h-6 { - &::before { - content: var(--tw-content); - height: calc(var(--spacing) * 6); - } - } - .before\:w-6 { - &::before { - content: var(--tw-content); - width: calc(var(--spacing) * 6); - } - } - .before\:rounded-full { - &::before { - content: var(--tw-content); - border-radius: calc(infinity * 1px); - } - } - .before\:bg-md-outline { - &::before { - content: var(--tw-content); - background-color: var(--color-md-outline); - } - } - .before\:transition { - &::before { - content: var(--tw-content); - transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - } - .before\:content-\[\'\'\] { - &::before { - --tw-content: ''; - content: var(--tw-content); - } - } - .peer-checked\:before\:translate-x-5 { - &:is(:where(.peer):checked ~ *) { - &::before { - content: var(--tw-content); - --tw-translate-x: calc(var(--spacing) * 5); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - } - } - .peer-checked\:before\:bg-md-on-primary { - &:is(:where(.peer):checked ~ *) { - &::before { - content: var(--tw-content); - background-color: var(--color-md-on-primary); - } - } - } - .after\:absolute { - &::after { - content: var(--tw-content); - position: absolute; - } - } - .after\:start-\[2px\] { - &::after { - content: var(--tw-content); - inset-inline-start: 2px; - } - } - .after\:top-\[2px\] { - &::after { - content: var(--tw-content); - top: 2px; - } - } - .after\:h-4 { - &::after { - content: var(--tw-content); - height: calc(var(--spacing) * 4); - } - } - .after\:w-4 { - &::after { - content: var(--tw-content); - width: calc(var(--spacing) * 4); - } - } - .after\:rounded-full { - &::after { - content: var(--tw-content); - border-radius: calc(infinity * 1px); - } - } - .after\:bg-white { - &::after { - content: var(--tw-content); - background-color: var(--color-white); - } - } - .after\:transition-all { - &::after { - content: var(--tw-content); - transition-property: all; - transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); - transition-duration: var(--tw-duration, var(--default-transition-duration)); - } - } - .after\:content-\[\'\'\] { - &::after { - --tw-content: ''; - content: var(--tw-content); - } - } - .peer-checked\:after\:translate-x-full { - &:is(:where(.peer):checked ~ *) { - &::after { - content: var(--tw-content); - --tw-translate-x: 100%; - translate: var(--tw-translate-x) var(--tw-translate-y); - } - } - } - .peer-checked\:after\:border-white { - &:is(:where(.peer):checked ~ *) { - &::after { - content: var(--tw-content); - border-color: var(--color-white); - } - } - } - .last\:mb-0 { - &:last-child { - margin-bottom: calc(var(--spacing) * 0); - } - } - .last\:border-0 { - &:last-child { - border-style: var(--tw-border-style); - border-width: 0px; - } - } - .last\:pb-0 { - &:last-child { - padding-bottom: calc(var(--spacing) * 0); - } - } - .hover\:-translate-y-0\.5 { - &:hover { - @media (hover: hover) { - --tw-translate-y: calc(var(--spacing) * -0.5); - translate: var(--tw-translate-x) var(--tw-translate-y); - } - } - } - .hover\:scale-105 { - &:hover { - @media (hover: hover) { - --tw-scale-x: 105%; - --tw-scale-y: 105%; - --tw-scale-z: 105%; - scale: var(--tw-scale-x) var(--tw-scale-y); - } - } - } - .hover\:scale-110 { - &:hover { - @media (hover: hover) { - --tw-scale-x: 110%; - --tw-scale-y: 110%; - --tw-scale-z: 110%; - scale: var(--tw-scale-x) var(--tw-scale-y); - } - } - } - .hover\:border-cyan-500\/30 { - &:hover { - @media (hover: hover) { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent); - } - } - } - } - .hover\:border-cyan-500\/40 { - &:hover { - @media (hover: hover) { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 40%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 40%, transparent); - } - } - } - } - .hover\:border-cyan-500\/50 { - &:hover { - @media (hover: hover) { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 50%, transparent); - } - } - } - } - .hover\:border-md-primary { - &:hover { - @media (hover: hover) { - border-color: var(--color-md-primary); - } - } - } - .hover\:\!bg-red-600 { - &:hover { - @media (hover: hover) { - background-color: var(--color-red-600) !important; - } - } - } - .hover\:bg-amber-500\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 10%, transparent); - } - } - } - } - .hover\:bg-amber-500\/20 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 20%, transparent); - } - } - } - } - .hover\:bg-amber-500\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-500) 30%, transparent); - } - } - } - } - .hover\:bg-amber-600\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(66.6% 0.179 58.318) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-amber-600) 30%, transparent); - } - } - } - } - .hover\:bg-cyan-500 { - &:hover { - @media (hover: hover) { - background-color: var(--color-cyan-500); - } - } - } - .hover\:bg-cyan-500\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 10%, transparent); - } - } - } - } - .hover\:bg-cyan-500\/20 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent); - } - } - } - } - .hover\:bg-cyan-500\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-500) 30%, transparent); - } - } - } - } - .hover\:bg-cyan-600\/5 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(60.9% 0.126 221.723) 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-600) 5%, transparent); - } - } - } - } - .hover\:bg-cyan-600\/25 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(60.9% 0.126 221.723) 25%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-600) 25%, transparent); - } - } - } - } - .hover\:bg-cyan-600\/90 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(60.9% 0.126 221.723) 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-cyan-600) 90%, transparent); - } - } - } - } - .hover\:bg-emerald-500\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-emerald-500) 30%, transparent); - } - } - } - } - .hover\:bg-emerald-600\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(59.6% 0.145 163.225) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-emerald-600) 30%, transparent); - } - } - } - } - .hover\:bg-green-500\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 10%, transparent); - } - } - } - } - .hover\:bg-green-500\/20 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 20%, transparent); - } - } - } - } - .hover\:bg-green-500\/25 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(72.3% 0.219 149.579) 25%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-green-500) 25%, transparent); - } - } - } - } - .hover\:bg-md-error\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #F2B8B5 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error) 10%, transparent); - } - } - } - } - .hover\:bg-md-error\/20 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #F2B8B5 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error) 20%, transparent); - } - } - } - } - .hover\:bg-md-error\/90 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #F2B8B5 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-error) 90%, transparent); - } - } - } - } - .hover\:bg-md-primary\/8 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #D0BCFF 8%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary) 8%, transparent); - } - } - } - } - .hover\:bg-md-primary\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #D0BCFF 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary) 10%, transparent); - } - } - } - } - .hover\:bg-md-primary\/90 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #D0BCFF 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary) 90%, transparent); - } - } - } - } - .hover\:bg-md-secondary-container { - &:hover { - @media (hover: hover) { - background-color: var(--color-md-secondary-container); - } - } - } - .hover\:bg-md-secondary\/90 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #CCC2DC 90%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-secondary) 90%, transparent); - } - } - } - } - .hover\:bg-md-surface-container { - &:hover { - @media (hover: hover) { - background-color: var(--color-md-surface-container); - } - } - } - .hover\:bg-md-surface-container-high { - &:hover { - @media (hover: hover) { - background-color: var(--color-md-surface-container-high); - } - } - } - .hover\:bg-md-surface-container\/95 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #211F26 95%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-surface-container) 95%, transparent); - } - } - } - } - .hover\:bg-md-surface-variant { - &:hover { - @media (hover: hover) { - background-color: var(--color-md-surface-variant); - } - } - } - .hover\:bg-red-500\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 10%, transparent); - } - } - } - } - .hover\:bg-red-500\/20 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 20%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent); - } - } - } - } - .hover\:bg-red-500\/25 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 25%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 25%, transparent); - } - } - } - } - .hover\:bg-red-500\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-red-500) 30%, transparent); - } - } - } - } - .hover\:bg-red-600 { - &:hover { - @media (hover: hover) { - background-color: var(--color-red-600); - } - } - } - .hover\:bg-slate-700\/50 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(37.2% 0.044 257.287) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-700) 50%, transparent); - } - } - } - } - .hover\:bg-slate-800 { - &:hover { - @media (hover: hover) { - background-color: var(--color-slate-800); - } - } - } - .hover\:bg-slate-800\/50 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(27.9% 0.041 260.031) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-800) 50%, transparent); - } - } - } - } - .hover\:bg-slate-900\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(20.8% 0.042 265.755) 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-900) 30%, transparent); - } - } - } - } - .hover\:bg-slate-950\/80 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, oklch(12.9% 0.042 264.695) 80%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-slate-950) 80%, transparent); - } - } - } - } - .hover\:bg-white\/5 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #fff 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 5%, transparent); - } - } - } - } - .hover\:bg-white\/10 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #fff 10%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 10%, transparent); - } - } - } - } - .hover\:bg-white\/30 { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #fff 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 30%, transparent); - } - } - } - } - .hover\:bg-white\/\[0\.02\] { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #fff 2%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 2%, transparent); - } - } - } - } - .hover\:bg-white\/\[0\.05\] { - &:hover { - @media (hover: hover) { - background-color: color-mix(in srgb, #fff 5%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-white) 5%, transparent); - } - } - } - } - .hover\:text-amber-400 { - &:hover { - @media (hover: hover) { - color: var(--color-amber-400); - } - } - } - .hover\:text-blue-400 { - &:hover { - @media (hover: hover) { - color: var(--color-blue-400); - } - } - } - .hover\:text-cyan-300 { - &:hover { - @media (hover: hover) { - color: var(--color-cyan-300); - } - } - } - .hover\:text-cyan-400 { - &:hover { - @media (hover: hover) { - color: var(--color-cyan-400); - } - } - } - .hover\:text-green-400 { - &:hover { - @media (hover: hover) { - color: var(--color-green-400); - } - } - } - .hover\:text-md-on-surface { - &:hover { - @media (hover: hover) { - color: var(--color-md-on-surface); - } - } - } - .hover\:text-md-primary { - &:hover { - @media (hover: hover) { - color: var(--color-md-primary); - } - } - } - .hover\:text-red-300 { - &:hover { - @media (hover: hover) { - color: var(--color-red-300); - } - } - } - .hover\:text-red-400 { - &:hover { - @media (hover: hover) { - color: var(--color-red-400); - } - } - } - .hover\:text-slate-200 { - &:hover { - @media (hover: hover) { - color: var(--color-slate-200); - } - } - } - .hover\:text-white { - &:hover { - @media (hover: hover) { - color: var(--color-white); - } - } - } - .hover\:text-white\/70 { - &:hover { - @media (hover: hover) { - color: color-mix(in srgb, #fff 70%, transparent); - @supports (color: color-mix(in lab, red, red)) { - color: color-mix(in oklab, var(--color-white) 70%, transparent); - } - } - } - } - .hover\:underline { - &:hover { - @media (hover: hover) { - text-decoration-line: underline; - } - } - } - .hover\:opacity-80 { - &:hover { - @media (hover: hover) { - opacity: 80%; - } - } - } - .hover\:opacity-90 { - &:hover { - @media (hover: hover) { - opacity: 90%; - } - } - } - .hover\:shadow-2xl { - &:hover { - @media (hover: hover) { - --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - } - .hover\:shadow-lg { - &:hover { - @media (hover: hover) { - --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - } - .hover\:shadow-md { - &:hover { - @media (hover: hover) { - --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - } - .hover\:shadow-xl { - &:hover { - @media (hover: hover) { - --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - } - .hover\:file\:bg-md-primary\/30 { - &:hover { - @media (hover: hover) { - &::file-selector-button { - background-color: color-mix(in srgb, #D0BCFF 30%, transparent); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--color-md-primary) 30%, transparent); - } - } - } - } - } - .focus\:border-cyan-500 { - &:focus { - border-color: var(--color-cyan-500); - } - } - .focus\:border-cyan-500\/50 { - &:focus { - border-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - border-color: color-mix(in oklab, var(--color-cyan-500) 50%, transparent); - } - } - } - .focus\:border-transparent { - &:focus { - border-color: transparent; - } - } - .focus\:ring-1 { - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - .focus\:ring-2 { - &:focus { - --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - .focus\:ring-cyan-500 { - &:focus { - --tw-ring-color: var(--color-cyan-500); - } - } - .focus\:ring-cyan-500\/50 { - &:focus { - --tw-ring-color: color-mix(in srgb, oklch(71.5% 0.143 215.221) 50%, transparent); - @supports (color: color-mix(in lab, red, red)) { - --tw-ring-color: color-mix(in oklab, var(--color-cyan-500) 50%, transparent); - } - } - } - .focus\:ring-md-error { - &:focus { - --tw-ring-color: var(--color-md-error); - } - } - .focus\:ring-md-primary { - &:focus { - --tw-ring-color: var(--color-md-primary); - } - } - .focus\:ring-md-secondary { - &:focus { - --tw-ring-color: var(--color-md-secondary); - } - } - .focus\:outline-none { - &:focus { - --tw-outline-style: none; - outline-style: none; - } - } - .disabled\:cursor-not-allowed { - &:disabled { - cursor: not-allowed; - } - } - .disabled\:bg-md-surface-variant { - &:disabled { - background-color: var(--color-md-surface-variant); - } - } - .disabled\:text-md-on-surface-variant { - &:disabled { - color: var(--color-md-on-surface-variant); - } - } - .disabled\:opacity-50 { - &:disabled { - opacity: 50%; - } - } - .disabled\:opacity-60 { - &:disabled { - opacity: 60%; - } - } - .max-md\:absolute { - @media (width < 48rem) { - position: absolute; - } - } - .max-md\:top-16 { - @media (width < 48rem) { - top: calc(var(--spacing) * 16); - } - } - .max-md\:right-0 { - @media (width < 48rem) { - right: calc(var(--spacing) * 0); - } - } - .max-md\:left-0 { - @media (width < 48rem) { - left: calc(var(--spacing) * 0); - } - } - .max-md\:z-40 { - @media (width < 48rem) { - z-index: 40; - } - } - .max-md\:block { - @media (width < 48rem) { - display: block; - } - } - .max-md\:flex { - @media (width < 48rem) { - display: flex; - } - } - .max-md\:hidden { - @media (width < 48rem) { - display: none; - } - } - .max-md\:w-full { - @media (width < 48rem) { - width: 100%; - } - } - .max-md\:flex-col { - @media (width < 48rem) { - flex-direction: column; - } - } - .max-md\:justify-start { - @media (width < 48rem) { - justify-content: flex-start; - } - } - .max-md\:gap-6 { - @media (width < 48rem) { - gap: calc(var(--spacing) * 6); - } - } - .max-md\:rounded-md { - @media (width < 48rem) { - border-radius: var(--radius-md); - } - } - .max-md\:bg-md-surface { - @media (width < 48rem) { - background-color: var(--color-md-surface); - } - } - .max-md\:p-4 { - @media (width < 48rem) { - padding: calc(var(--spacing) * 4); - } - } - .max-md\:px-4 { - @media (width < 48rem) { - padding-inline: calc(var(--spacing) * 4); - } - } - .max-md\:py-3 { - @media (width < 48rem) { - padding-block: calc(var(--spacing) * 3); - } - } - .max-md\:shadow-2xl { - @media (width < 48rem) { - --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25)); - box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); - } - } - .max-sm\:flex-col { - @media (width < 40rem) { - flex-direction: column; - } - } - .max-sm\:items-start { - @media (width < 40rem) { - align-items: flex-start; - } - } - .max-sm\:gap-2 { - @media (width < 40rem) { - gap: calc(var(--spacing) * 2); - } - } - .max-sm\:gap-3 { - @media (width < 40rem) { - gap: calc(var(--spacing) * 3); - } - } - .sm\:col-span-2 { - @media (width >= 40rem) { - grid-column: span 2 / span 2; - } - } - .sm\:mb-0 { - @media (width >= 40rem) { - margin-bottom: calc(var(--spacing) * 0); - } - } - .sm\:block { - @media (width >= 40rem) { - display: block; - } - } - .sm\:flex { - @media (width >= 40rem) { - display: flex; - } - } - .sm\:w-1\/2 { - @media (width >= 40rem) { - width: calc(1 / 2 * 100%); - } - } - .sm\:w-1\/4 { - @media (width >= 40rem) { - width: calc(1 / 4 * 100%); - } - } - .sm\:w-3\/4 { - @media (width >= 40rem) { - width: calc(3 / 4 * 100%); - } - } - .sm\:w-36 { - @media (width >= 40rem) { - width: calc(var(--spacing) * 36); - } - } - .sm\:w-40 { - @media (width >= 40rem) { - width: calc(var(--spacing) * 40); - } - } - .sm\:w-44 { - @media (width >= 40rem) { - width: calc(var(--spacing) * 44); - } - } - .sm\:w-48 { - @media (width >= 40rem) { - width: calc(var(--spacing) * 48); - } - } - .sm\:min-w-\[150px\] { - @media (width >= 40rem) { - min-width: 150px; - } - } - .sm\:min-w-\[200px\] { - @media (width >= 40rem) { - min-width: 200px; - } - } - .sm\:grid-cols-2 { - @media (width >= 40rem) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - .sm\:grid-cols-3 { - @media (width >= 40rem) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - } - .sm\:grid-cols-\[repeat\(auto-fill\,minmax\(350px\,1fr\)\)\] { - @media (width >= 40rem) { - grid-template-columns: repeat(auto-fill,minmax(350px,1fr)); - } - } - .sm\:grid-cols-\[repeat\(auto-fit\,minmax\(250px\,1fr\)\)\] { - @media (width >= 40rem) { - grid-template-columns: repeat(auto-fit,minmax(250px,1fr)); - } - } - .sm\:grid-cols-\[repeat\(auto-fit\,minmax\(280px\,1fr\)\)\] { - @media (width >= 40rem) { - grid-template-columns: repeat(auto-fit,minmax(280px,1fr)); - } - } - .sm\:grid-cols-\[repeat\(auto-fit\,minmax\(300px\,1fr\)\)\] { - @media (width >= 40rem) { - grid-template-columns: repeat(auto-fit,minmax(300px,1fr)); - } - } - .sm\:flex-row { - @media (width >= 40rem) { - flex-direction: row; - } - } - .sm\:flex-wrap { - @media (width >= 40rem) { - flex-wrap: wrap; - } - } - .sm\:items-center { - @media (width >= 40rem) { - align-items: center; - } - } - .sm\:items-end { - @media (width >= 40rem) { - align-items: flex-end; - } - } - .sm\:items-start { - @media (width >= 40rem) { - align-items: flex-start; - } - } - .sm\:justify-between { - @media (width >= 40rem) { - justify-content: space-between; - } - } - .sm\:gap-4 { - @media (width >= 40rem) { - gap: calc(var(--spacing) * 4); - } - } - .sm\:gap-6 { - @media (width >= 40rem) { - gap: calc(var(--spacing) * 6); - } - } - .sm\:p-4 { - @media (width >= 40rem) { - padding: calc(var(--spacing) * 4); - } - } - .sm\:p-6 { - @media (width >= 40rem) { - padding: calc(var(--spacing) * 6); - } - } - .sm\:p-8 { - @media (width >= 40rem) { - padding: calc(var(--spacing) * 8); - } - } - .sm\:px-4 { - @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 4); - } - } - .sm\:px-8 { - @media (width >= 40rem) { - padding-inline: calc(var(--spacing) * 8); - } - } - .sm\:py-3 { - @media (width >= 40rem) { - padding-block: calc(var(--spacing) * 3); - } - } - .sm\:py-4 { - @media (width >= 40rem) { - padding-block: calc(var(--spacing) * 4); - } - } - .md\:hidden { - @media (width >= 48rem) { - display: none; - } - } - .md\:grid-cols-2 { - @media (width >= 48rem) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - .lg\:col-span-2 { - @media (width >= 64rem) { - grid-column: span 2 / span 2; - } - } - .lg\:block { - @media (width >= 64rem) { - display: block; - } - } - .lg\:flex { - @media (width >= 64rem) { - display: flex; - } - } - .lg\:hidden { - @media (width >= 64rem) { - display: none; - } - } - .lg\:w-1\/2 { - @media (width >= 64rem) { - width: calc(1 / 2 * 100%); - } - } - .lg\:w-1\/3 { - @media (width >= 64rem) { - width: calc(1 / 3 * 100%); - } - } - .lg\:w-2\/3 { - @media (width >= 64rem) { - width: calc(2 / 3 * 100%); - } - } - .lg\:grid-cols-2 { - @media (width >= 64rem) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - } - .lg\:grid-cols-3 { - @media (width >= 64rem) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - } - .lg\:grid-cols-4 { - @media (width >= 64rem) { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - } - .lg\:grid-cols-5 { - @media (width >= 64rem) { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - } - .lg\:grid-cols-6 { - @media (width >= 64rem) { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - } - .lg\:p-6 { - @media (width >= 64rem) { - padding: calc(var(--spacing) * 6); - } - } - .lg\:px-6 { - @media (width >= 64rem) { - padding-inline: calc(var(--spacing) * 6); - } - } - .xl\:block { - @media (width >= 80rem) { - display: block; - } - } - .rtl\:peer-checked\:after\:-translate-x-full { - &:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) { - &:is(:where(.peer):checked ~ *) { - &::after { - content: var(--tw-content); - --tw-translate-x: -100%; - translate: var(--tw-translate-x) var(--tw-translate-y); - } - } - } - } - .dark\:border { - @media (prefers-color-scheme: dark) { - border-style: var(--tw-border-style); - border-width: 1px; - } - } - .dark\:border-\[\#10b981\]\/30 { - @media (prefers-color-scheme: dark) { - border-color: color-mix(in oklab, #10b981 30%, transparent); - } - } - .dark\:bg-\[\#10b981\]\/20 { - @media (prefers-color-scheme: dark) { - background-color: color-mix(in oklab, #10b981 20%, transparent); - } - } - .dark\:accent-\[\#10b981\] { - @media (prefers-color-scheme: dark) { - accent-color: #10b981; - } - } - .dark\:hover\:bg-\[\#10b981\]\/30 { - @media (prefers-color-scheme: dark) { - &:hover { - @media (hover: hover) { - background-color: color-mix(in oklab, #10b981 30%, transparent); - } - } - } - } -} -:root { - color-scheme: dark; -} -select option { - background-color: #1e293b; - color: #ffffff; -} -select option:hover, select option:checked { - background-color: #334155; - color: #ffffff; -} -@property --tw-translate-x { - syntax: "*"; - inherits: false; - initial-value: 0; -} -@property --tw-translate-y { - syntax: "*"; - inherits: false; - initial-value: 0; -} -@property --tw-translate-z { - syntax: "*"; - inherits: false; - initial-value: 0; -} -@property --tw-scale-x { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-scale-y { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-scale-z { - syntax: "*"; - inherits: false; - initial-value: 1; -} -@property --tw-rotate-x { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-y { - syntax: "*"; - inherits: false; -} -@property --tw-rotate-z { - syntax: "*"; - inherits: false; -} -@property --tw-skew-x { - syntax: "*"; - inherits: false; -} -@property --tw-skew-y { - syntax: "*"; - inherits: false; -} -@property --tw-space-y-reverse { - syntax: "*"; - inherits: false; - initial-value: 0; -} -@property --tw-divide-y-reverse { - syntax: "*"; - inherits: false; - initial-value: 0; -} -@property --tw-border-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} -@property --tw-gradient-position { - syntax: "*"; - inherits: false; -} -@property --tw-gradient-from { - syntax: ""; - inherits: false; - initial-value: #0000; -} -@property --tw-gradient-via { - syntax: ""; - inherits: false; - initial-value: #0000; -} -@property --tw-gradient-to { - syntax: ""; - inherits: false; - initial-value: #0000; -} -@property --tw-gradient-stops { - syntax: "*"; - inherits: false; -} -@property --tw-gradient-via-stops { - syntax: "*"; - inherits: false; -} -@property --tw-gradient-from-position { - syntax: ""; - inherits: false; - initial-value: 0%; -} -@property --tw-gradient-via-position { - syntax: ""; - inherits: false; - initial-value: 50%; -} -@property --tw-gradient-to-position { - syntax: ""; - inherits: false; - initial-value: 100%; -} -@property --tw-leading { - syntax: "*"; - inherits: false; -} -@property --tw-font-weight { - syntax: "*"; - inherits: false; -} -@property --tw-tracking { - syntax: "*"; - inherits: false; -} -@property --tw-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-shadow-color { - syntax: "*"; - inherits: false; -} -@property --tw-shadow-alpha { - syntax: ""; - inherits: false; - initial-value: 100%; -} -@property --tw-inset-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-inset-shadow-color { - syntax: "*"; - inherits: false; -} -@property --tw-inset-shadow-alpha { - syntax: ""; - inherits: false; - initial-value: 100%; -} -@property --tw-ring-color { - syntax: "*"; - inherits: false; -} -@property --tw-ring-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-inset-ring-color { - syntax: "*"; - inherits: false; -} -@property --tw-inset-ring-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-ring-inset { - syntax: "*"; - inherits: false; -} -@property --tw-ring-offset-width { - syntax: ""; - inherits: false; - initial-value: 0px; -} -@property --tw-ring-offset-color { - syntax: "*"; - inherits: false; - initial-value: #fff; -} -@property --tw-ring-offset-shadow { - syntax: "*"; - inherits: false; - initial-value: 0 0 #0000; -} -@property --tw-blur { - syntax: "*"; - inherits: false; -} -@property --tw-brightness { - syntax: "*"; - inherits: false; -} -@property --tw-contrast { - syntax: "*"; - inherits: false; -} -@property --tw-grayscale { - syntax: "*"; - inherits: false; -} -@property --tw-hue-rotate { - syntax: "*"; - inherits: false; -} -@property --tw-invert { - syntax: "*"; - inherits: false; -} -@property --tw-opacity { - syntax: "*"; - inherits: false; -} -@property --tw-saturate { - syntax: "*"; - inherits: false; -} -@property --tw-sepia { - syntax: "*"; - inherits: false; -} -@property --tw-drop-shadow { - syntax: "*"; - inherits: false; -} -@property --tw-drop-shadow-color { - syntax: "*"; - inherits: false; -} -@property --tw-drop-shadow-alpha { - syntax: ""; - inherits: false; - initial-value: 100%; -} -@property --tw-drop-shadow-size { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-blur { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-brightness { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-contrast { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-grayscale { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-hue-rotate { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-invert { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-opacity { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-saturate { - syntax: "*"; - inherits: false; -} -@property --tw-backdrop-sepia { - syntax: "*"; - inherits: false; -} -@property --tw-duration { - syntax: "*"; - inherits: false; -} -@property --tw-ease { - syntax: "*"; - inherits: false; -} -@property --tw-content { - syntax: "*"; - initial-value: ""; - inherits: false; -} -@keyframes spin { - to { - transform: rotate(360deg); - } -} -@keyframes pulse { - 50% { - opacity: 0.5; - } -} -@keyframes bounce { - 0%, 100% { - transform: translateY(-25%); - animation-timing-function: cubic-bezier(0.8, 0, 1, 1); - } - 50% { - transform: none; - animation-timing-function: cubic-bezier(0, 0, 0.2, 1); - } -} -@layer properties { - @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { - *, ::before, ::after, ::backdrop { - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-translate-z: 0; - --tw-scale-x: 1; - --tw-scale-y: 1; - --tw-scale-z: 1; - --tw-rotate-x: initial; - --tw-rotate-y: initial; - --tw-rotate-z: initial; - --tw-skew-x: initial; - --tw-skew-y: initial; - --tw-space-y-reverse: 0; - --tw-divide-y-reverse: 0; - --tw-border-style: solid; - --tw-gradient-position: initial; - --tw-gradient-from: #0000; - --tw-gradient-via: #0000; - --tw-gradient-to: #0000; - --tw-gradient-stops: initial; - --tw-gradient-via-stops: initial; - --tw-gradient-from-position: 0%; - --tw-gradient-via-position: 50%; - --tw-gradient-to-position: 100%; - --tw-leading: initial; - --tw-font-weight: initial; - --tw-tracking: initial; - --tw-shadow: 0 0 #0000; - --tw-shadow-color: initial; - --tw-shadow-alpha: 100%; - --tw-inset-shadow: 0 0 #0000; - --tw-inset-shadow-color: initial; - --tw-inset-shadow-alpha: 100%; - --tw-ring-color: initial; - --tw-ring-shadow: 0 0 #0000; - --tw-inset-ring-color: initial; - --tw-inset-ring-shadow: 0 0 #0000; - --tw-ring-inset: initial; - --tw-ring-offset-width: 0px; - --tw-ring-offset-color: #fff; - --tw-ring-offset-shadow: 0 0 #0000; - --tw-blur: initial; - --tw-brightness: initial; - --tw-contrast: initial; - --tw-grayscale: initial; - --tw-hue-rotate: initial; - --tw-invert: initial; - --tw-opacity: initial; - --tw-saturate: initial; - --tw-sepia: initial; - --tw-drop-shadow: initial; - --tw-drop-shadow-color: initial; - --tw-drop-shadow-alpha: 100%; - --tw-drop-shadow-size: initial; - --tw-backdrop-blur: initial; - --tw-backdrop-brightness: initial; - --tw-backdrop-contrast: initial; - --tw-backdrop-grayscale: initial; - --tw-backdrop-hue-rotate: initial; - --tw-backdrop-invert: initial; - --tw-backdrop-opacity: initial; - --tw-backdrop-saturate: initial; - --tw-backdrop-sepia: initial; - --tw-duration: initial; - --tw-ease: initial; - --tw-content: ""; - } - } -} diff --git a/static/css/theme.css b/static/css/theme.css deleted file mode 100644 index e2933a2..0000000 --- a/static/css/theme.css +++ /dev/null @@ -1,467 +0,0 @@ -/* - * 2c2a 设计系统 - 主题变量 - * 配色方案:浅色模式(深蓝强调色) / 深色模式(绿色主色调) - * 特性:毛玻璃效果(Glassmorphism)、现代化设计 - */ - -:root { - /* ==================== 颜色系统 - 浅色模式 ==================== */ - - /* 主要颜色 (Primary) - 深蓝色系 */ - --primary-color: #1e3a5f; - --primary-color-light: #2d5a8f; - --primary-color-dark: #0f1f33; - --primary-color-rgb: 30, 58, 95; - - /* 次要颜色 (Secondary) - 绿色系 */ - --secondary-color: #10b981; - --secondary-color-light: #34d399; - --secondary-color-dark: #059669; - --secondary-color-rgb: 16, 185, 129; - - /* 强调色 (Accent) */ - --accent-color: #06b6d4; - --accent-color-light: #22d3ee; - --accent-color-dark: #0891b2; - - /* 功能颜色 */ - --success-color: #10b981; - --success-color-rgb: 16, 185, 129; - --warning-color: #f59e0b; - --warning-color-rgb: 245, 158, 11; - --danger-color: #ef4444; - --danger-color-rgb: 239, 68, 68; - --info-color: #3b82f6; - --info-color-rgb: 59, 130, 246; - - /* 背景色 (Background) - 浅色模式 */ - --bg-primary: #ffffff; - --bg-secondary: #f8fafc; - --bg-tertiary: #f1f5f9; - --bg-glass: rgba(255, 255, 255, 0.7); - --bg-glass-heavy: rgba(255, 255, 255, 0.85); - - /* 文字颜色 (Text) - 浅色模式 */ - --text-primary: #0f172a; - --text-secondary: #475569; - --text-tertiary: #94a3b8; - --text-inverse: #ffffff; - - /* 边框颜色 (Border) */ - --border-color: rgba(0, 0, 0, 0.08); - --border-color-light: rgba(0, 0, 0, 0.05); - --border-glass: rgba(255, 255, 255, 0.18); - - /* 表面色 (Surface) */ - --surface-color: #ffffff; - --surface-color-elevated: #ffffff; - --surface-color-overlay: rgba(255, 255, 255, 0.9); - - /* ==================== 形状系统 ==================== */ - - /* 圆角 (Shape) */ - --radius-none: 0px; - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 16px; - --radius-xl: 24px; - --radius-full: 9999px; - - /* ==================== 排版系统 ==================== */ - - /* 字体家族 */ - --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace; - - /* 字体大小 (Typography - Size) */ - --font-size-xs: 0.75rem; /* 12px */ - --font-size-sm: 0.875rem; /* 14px */ - --font-size-base: 1rem; /* 16px */ - --font-size-lg: 1.125rem; /* 18px */ - --font-size-xl: 1.25rem; /* 20px */ - --font-size-2xl: 1.5rem; /* 24px */ - --font-size-3xl: 1.875rem; /* 30px */ - --font-size-4xl: 2.25rem; /* 36px */ - - /* 字体粗细 (Typography - Weight) */ - --font-weight-normal: 400; - --font-weight-medium: 500; - --font-weight-semibold: 600; - --font-weight-bold: 700; - - /* 行高 (Line Height) */ - --line-height-tight: 1.25; - --line-height-normal: 1.5; - --line-height-relaxed: 1.75; - - /* ==================== 间距系统 ==================== */ - - /* 内边距 (Padding) */ - --spacing-0: 0px; - --spacing-1: 4px; - --spacing-2: 8px; - --spacing-3: 12px; - --spacing-4: 16px; - --spacing-5: 20px; - --spacing-6: 24px; - --spacing-8: 32px; - --spacing-10: 40px; - --spacing-12: 48px; - - /* 外边距 (Margin) - 与内边距相同 */ - --margin-0: 0px; - --margin-1: 4px; - --margin-2: 8px; - --margin-3: 12px; - --margin-4: 16px; - --margin-5: 20px; - --margin-6: 24px; - --margin-8: 32px; - - /* ==================== 阴影系统 ==================== */ - - /* 基础阴影 */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - - /* 毛玻璃阴影 */ - --glass-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15); - --glass-shadow-lg: 0 8px 32px 0 rgba(31, 38, 135, 0.25); - - /* 彩色阴影 */ - --shadow-primary: 0 4px 14px 0 rgba(var(--primary-color-rgb), 0.25); - --shadow-secondary: 0 4px 14px 0 rgba(var(--secondary-color-rgb), 0.25); - - /* ==================== 过渡动画 ==================== */ - - /* 动画持续时间 */ - --duration-fast: 150ms; - --duration-normal: 250ms; - --duration-slow: 350ms; - --duration-slower: 500ms; - - /* 缓动函数 */ - --ease-default: cubic-bezier(0.4, 0, 0.2, 1); - --ease-in: cubic-bezier(0.4, 0, 1, 1); - --ease-out: cubic-bezier(0, 0, 0.2, 1); - --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); - - /* ==================== 毛玻璃效果 (Glassmorphism) ==================== */ - - /* 标准毛玻璃 */ - --glass-bg: rgba(255, 255, 255, 0.7); - --glass-blur: blur(12px); - --glass-border: 1px solid rgba(255, 255, 255, 0.18); - --glass-border-radius: var(--radius-lg); - - /* 强毛玻璃效果 */ - --glass-heavy-bg: rgba(255, 255, 255, 0.85); - --glass-heavy-blur: blur(16px); - - /* 轻量毛玻璃效果 */ - --glass-light-bg: rgba(255, 255, 255, 0.5); - --glass-light-blur: blur(8px); - - /* ==================== 断点系统 ==================== */ - - /* 响应式断点 */ - --breakpoint-sm: 640px; - --breakpoint-md: 768px; - --breakpoint-lg: 1024px; - --breakpoint-xl: 1280px; - --breakpoint-2xl: 1536px; - - /* ==================== 组件特定变量 ==================== */ - - /* 导航栏高度 */ - --navbar-height: 64px; - --navbar-height-mobile: 56px; - - /* 卡片样式 */ - --card-padding: var(--spacing-6); - --card-radius: var(--radius-lg); - --card-shadow: var(--shadow-md); - - /* 按钮尺寸 */ - --button-height: 40px; - --button-padding-x: var(--spacing-6); - --button-padding-y: var(--spacing-2); - --button-radius: var(--radius-md); - - /* 输入框尺寸 */ - --input-height: 48px; - --input-padding-x: var(--spacing-4); - --input-padding-y: var(--spacing-3); - --input-radius: var(--radius-md); - - /* 容器最大宽度 */ - --container-max-width: 1200px; -} - -/* ==================== 深色模式变量 ==================== */ -[data-theme="dark"] { - /* 主要颜色 - 保持深蓝色但在深色模式下调整亮度 */ - --primary-color: #60a5fa; - --primary-color-light: #93c5fd; - --primary-color-dark: #3b82f6; - --primary-color-rgb: 96, 165, 250; - - /* 次要颜色 - 绿色在深色模式下更亮 */ - --secondary-color: #10b981; - --secondary-color-light: #34d399; - --secondary-color-dark: #059669; - --secondary-color-rgb: 16, 185, 129; - - /* 强调色 */ - --accent-color: #22d3ee; - --accent-color-light: #67e8f9; - --accent-color-dark: #06b6d4; - - /* 背景色 - 深色模式 */ - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-tertiary: #334155; - --bg-glass: rgba(15, 23, 42, 0.7); - --bg-glass-heavy: rgba(15, 23, 42, 0.85); - - /* 文字颜色 - 深色模式 */ - --text-primary: #f1f5f9; - --text-secondary: #cbd5e1; - --text-tertiary: #94a3b8; - --text-inverse: #ffffff; - - /* 边框颜色 - 深色模式 */ - --border-color: rgba(255, 255, 255, 0.1); - --border-color-light: rgba(255, 255, 255, 0.05); - --border-glass: rgba(255, 255, 255, 0.18); - - /* 表面色 - 深色模式 */ - --surface-color: #1e293b; - --surface-color-elevated: #334155; - --surface-color-overlay: rgba(30, 41, 59, 0.9); - - /* 阴影 - 深色模式使用更柔和的阴影 */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4); - - /* 毛玻璃效果 - 深色模式 */ - --glass-bg: rgba(30, 41, 59, 0.7); - --glass-heavy-bg: rgba(30, 41, 59, 0.85); - --glass-light-bg: rgba(30, 41, 59, 0.5); - - /* 彩色阴影 - 深色模式增强发光效果 */ - --shadow-primary: 0 4px 14px 0 rgba(var(--primary-color-rgb), 0.35); - --shadow-secondary: 0 4px 14px 0 rgba(var(--secondary-color-rgb), 0.35); - --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); - --glass-shadow-lg: 0 8px 32px 0 rgba(0, 0, 0, 0.45); -} - -/* ==================== 高对比度模式 ==================== */ -@media (prefers-contrast: high) { - :root { - --border-color: rgba(0, 0, 0, 0.3); - --text-secondary: #334155; - } - - [data-theme="dark"] { - --border-color: rgba(255, 255, 255, 0.3); - --text-secondary: #e2e8f0; - } -} - -/* ==================== 减少动画偏好 ==================== */ -@media (prefers-reduced-motion: reduce) { - :root, - [data-theme="dark"] { - --duration-fast: 0ms; - --duration-normal: 0ms; - --duration-slow: 0ms; - --duration-slower: 0ms; - } -} - -/* ==================== Material Design 3 (MD3) 变量映射 ==================== */ -/* 这些变量用于兼容 base.html 中使用的 MD3 风格变量名 */ - -:root { - /* MD3 颜色系统 */ - --md-sys-color-primary: var(--primary-color); - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: rgba(var(--primary-color-rgb), 0.12); - --md-sys-color-on-primary-container: var(--primary-color); - - --md-sys-color-secondary: var(--secondary-color); - --md-sys-color-on-secondary: #ffffff; - --md-sys-color-secondary-container: rgba(var(--secondary-color-rgb), 0.12); - --md-sys-color-on-secondary-container: var(--secondary-color-dark); - - --md-sys-color-tertiary: var(--accent-color); - --md-sys-color-on-tertiary: #ffffff; - - --md-sys-color-error: var(--danger-color); - --md-sys-color-on-error: #ffffff; - --md-sys-color-error-container: rgba(var(--danger-color-rgb), 0.12); - --md-sys-color-on-error-container: var(--danger-color); - - /* MD3 表面色 */ - --md-sys-color-surface: var(--surface-color); - --md-sys-color-on-surface: var(--text-primary); - --md-sys-color-surface-variant: var(--bg-tertiary); - --md-sys-color-on-surface-variant: var(--text-secondary); - --md-sys-color-surface-container: var(--bg-secondary); - --md-sys-color-surface-container-high: var(--bg-tertiary); - - --md-sys-color-outline: var(--border-color); - --md-sys-color-outline-variant: rgba(0, 0, 0, 0.12); - - /* MD3 警告色 */ - --md-sys-color-warning-container: rgba(var(--warning-color-rgb), 0.15); - --md-sys-color-on-warning-container: #92400e; - - /* MD3 信息色 */ - --md-sys-color-info-container: rgba(var(--info-color-rgb), 0.15); - --md-sys-color-on-info-container: #1e40af; - - /* MD3 成功色 */ - --md-sys-color-success: var(--success-color); - --md-sys-color-success-container: rgba(var(--success-color-rgb), 0.15); - --md-sys-color-on-success-container: #065f46; - - /* MD3 阴影 */ - --md-sys-elevation-level1: var(--shadow-sm); - --md-sys-elevation-level2: var(--shadow-md); - --md-sys-elevation-level3: var(--shadow-lg); - --md-sys-elevation-level4: var(--shadow-xl); - - /* MD3 形状 */ - --md-sys-shape-corner-none: var(--radius-none); - --md-sys-shape-corner-extra-small: var(--radius-sm); - --md-sys-shape-corner-small: var(--radius-sm); - --md-sys-shape-corner-medium: var(--radius-md); - --md-sys-shape-corner-large: var(--radius-lg); - --md-sys-shape-corner-extra-large: var(--radius-xl); - --md-sys-shape-corner-full: var(--radius-full); - - /* MD3 间距 */ - --md-sys-spacing-padding-xs: var(--spacing-1); - --md-sys-spacing-padding-sm: var(--spacing-2); - --md-sys-spacing-padding-md: var(--spacing-3); - --md-sys-spacing-padding-lg: var(--spacing-4); - --md-sys-spacing-padding-xl: var(--spacing-6); - --md-sys-spacing-padding-xxl: var(--spacing-10); - - --md-sys-spacing-margin-xs: var(--margin-1); - --md-sys-spacing-margin-sm: var(--margin-2); - --md-sys-spacing-margin-md: var(--margin-3); - --md-sys-spacing-margin-lg: var(--margin-4); - --md-sys-spacing-margin-xl: var(--margin-6); - --md-sys-spacing-margin-xxl: var(--margin-8); - - /* MD3 排版 */ - --md-sys-typescale-display-large-size: 3.5rem; - --md-sys-typescale-display-medium-size: 2.75rem; - --md-sys-typescale-display-small-size: 2.25rem; - - --md-sys-typescale-headline-large-size: var(--font-size-4xl); - --md-sys-typescale-headline-medium-size: var(--font-size-3xl); - --md-sys-typescale-headline-small-size: var(--font-size-2xl); - - --md-sys-typescale-title-large-size: var(--font-size-xl); - --md-sys-typescale-title-medium-size: var(--font-size-lg); - --md-sys-typescale-title-small-size: var(--font-size-base); - - --md-sys-typescale-body-large-size: var(--font-size-base); - --md-sys-typescale-body-medium-size: var(--font-size-sm); - --md-sys-typescale-body-small-size: var(--font-size-xs); - - --md-sys-typescale-label-large-size: var(--font-size-sm); - --md-sys-typescale-label-medium-size: var(--font-size-xs); - --md-sys-typescale-label-small-size: 0.6875rem; - - --md-sys-typescale-weight-normal: var(--font-weight-normal); - --md-sys-typescale-weight-medium: var(--font-weight-medium); - --md-sys-typescale-weight-bold: var(--font-weight-bold); - - --md-sys-typescale-line-height-condensed: 1.25; - --md-sys-typescale-line-height-default: 1.5; - --md-sys-typescale-line-height-medium: var(--line-height-normal); - - /* MD3 动画 */ - --md-sys-motion-duration-short1: 100ms; - --md-sys-motion-duration-short2: var(--duration-fast); - --md-sys-motion-duration-short3: 200ms; - --md-sys-motion-duration-short4: 250ms; - --md-sys-motion-duration-medium1: var(--duration-normal); - --md-sys-motion-duration-medium2: 300ms; - --md-sys-motion-duration-medium3: 350ms; - --md-sys-motion-duration-medium4: 400ms; - --md-sys-motion-duration-long1: 450ms; - --md-sys-motion-duration-long2: 500ms; - --md-sys-motion-easing-standard: var(--ease-default); - --md-sys-motion-easing-emphasized: var(--ease-bounce); -} - -/* MD3 深色模式变量 */ -[data-theme="dark"] { - --md-sys-color-primary: var(--primary-color); - --md-sys-color-on-primary: #ffffff; - --md-sys-color-primary-container: rgba(var(--primary-color-rgb), 0.15); - --md-sys-color-on-primary-container: var(--primary-color-light); - - --md-sys-color-secondary: var(--secondary-color); - --md-sys-color-on-secondary: #0f172a; - --md-sys-color-secondary-container: rgba(var(--secondary-color-rgb), 0.15); - --md-sys-color-on-secondary-container: var(--secondary-color-light); - - --md-sys-color-surface: var(--surface-color); - --md-sys-color-on-surface: var(--text-primary); - --md-sys-color-surface-variant: var(--bg-tertiary); - --md-sys-color-on-surface-variant: var(--text-secondary); - - --md-sys-color-outline: var(--border-color); - --md-sys-color-outline-variant: rgba(255, 255, 255, 0.12); - - --md-sys-elevation-level1: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --md-sys-elevation-level2: 0 4px 6px -1px rgba(0, 0, 0, 0.4); - --md-sys-elevation-level3: 0 10px 15px -3px rgba(0, 0, 0, 0.5); - --md-sys-elevation-level4: 0 20px 25px -5px rgba(0, 0, 0, 0.6); - - --md-sys-color-warning-container: rgba(var(--warning-color-rgb), 0.2); - --md-sys-color-on-warning-container: #fcd34d; - - /* MD3 信息色 - 深色模式 */ - --md-sys-color-info-container: rgba(var(--info-color-rgb), 0.2); - --md-sys-color-on-info-container: #93c5fd; - - /* MD3 成功色 - 深色模式 */ - --md-sys-color-success: var(--success-color); - --md-sys-color-success-container: rgba(var(--success-color-rgb), 0.2); - --md-sys-color-on-success-container: #34d399; - - /* MD3 错误色 - 深色模式 */ - --md-sys-color-error-container: rgba(var(--danger-color-rgb), 0.2); - --md-sys-color-on-error-container: #f87171; -} - -/* ==================== 组件深色模式适配 ==================== */ - -/* list-group-item 深色模式 */ -[data-theme="dark"] .list-group-item { - background-color: var(--bg-secondary); - color: var(--text-primary); - border-color: var(--border-color); -} - -[data-theme="dark"] .list-group-item.list-group-item-warning { - background-color: var(--md-sys-color-warning-container); - color: var(--md-sys-color-on-warning-container); - border-color: rgba(var(--warning-color-rgb), 0.3); -} - -[data-theme="dark"] .list-group-item-warning .text-muted { - color: var(--text-tertiary) !important; -} diff --git a/static/img/bgimg.jpg b/static/img/bgimg.jpg deleted file mode 100644 index a2da1c6..0000000 Binary files a/static/img/bgimg.jpg and /dev/null differ diff --git a/static/img/default.png b/static/img/default.png deleted file mode 100755 index a7313b7..0000000 Binary files a/static/img/default.png and /dev/null differ diff --git a/static/img/favicon.svg b/static/img/favicon.svg deleted file mode 100644 index 8764a1a..0000000 --- a/static/img/favicon.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/static/img/head-logo.png b/static/img/head-logo.png deleted file mode 100644 index 02cda23..0000000 Binary files a/static/img/head-logo.png and /dev/null differ diff --git a/static/js/accounts.js b/static/js/accounts.js deleted file mode 100755 index 46ab6e2..0000000 --- a/static/js/accounts.js +++ /dev/null @@ -1,541 +0,0 @@ -/** - * 用户账户JavaScript功能 - */ - -// 认证管理器 -const Auth = { - /** - * 初始化认证功能 - */ - init() { - this.bindEvents(); - this.initForms(); - }, - - /** - * 绑定事件 - */ - bindEvents() { - // 登录表单 - const loginForm = document.getElementById('login-form'); - if (loginForm) { - loginForm.addEventListener('submit', (e) => { - this.handleLogin(e); - }); - } - - // 注册表单 - const registerForm = document.getElementById('register-form'); - if (registerForm) { - registerForm.addEventListener('submit', (e) => { - this.handleRegister(e); - }); - } - - // 忘记密码表单 - const forgotPasswordForm = document.getElementById('forgot-password-form'); - if (forgotPasswordForm) { - forgotPasswordForm.addEventListener('submit', (e) => { - this.handleForgotPassword(e); - }); - } - - // 重置密码表单 - const resetPasswordForm = document.getElementById('reset-password-form'); - if (resetPasswordForm) { - resetPasswordForm.addEventListener('submit', (e) => { - this.handleResetPassword(e); - }); - } - - // 退出登录按钮 - const logoutBtn = document.getElementById('logout-btn'); - if (logoutBtn) { - logoutBtn.addEventListener('click', (e) => { - e.preventDefault(); - this.handleLogout(); - }); - } - }, - - /** - * 初始化表单 - */ - initForms() { - // 初始化表单验证 - this.initFormValidation(); - }, - - /** - * 初始化表单验证 - */ - initFormValidation() { - const forms = document.querySelectorAll('.needs-validation'); - forms.forEach(form => { - form.addEventListener('submit', event => { - if (!form.checkValidity()) { - event.preventDefault(); - event.stopPropagation(); - } - form.classList.add('was-validated'); - }, false); - }); - }, - - /** - * 处理登录 - */ - async handleLogin(event) { - event.preventDefault(); - - const form = event.target; - if (!form.checkValidity()) { - return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - try { - Utils.showLoading(); - - const response = await fetch(form.action, { - method: 'POST', - headers: { - 'X-CSRFToken': Config.csrfToken, - }, - body: formData, - }); - - if (response.ok) { - // 登录成功,重定向到指定页面或首页 - const redirectUrl = this.getSafeRedirectUrl(); - window.location.href = redirectUrl; - } else { - const errorData = await response.json(); - Utils.showAlert(errorData.message || '登录失败,请检查用户名和密码', 'danger'); - } - } catch (error) { - console.error('Login error:', error); - Utils.showAlert('登录失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 获取安全的重定向地址(仅允许站内相对路径) - */ - getSafeRedirectUrl() { - const next = new URLSearchParams(window.location.search).get('next'); - if (!next) { - return '/'; - } - - // 拒绝协议相对URL(如 //evil.com) - if (next.startsWith('//')) { - return '/'; - } - - try { - const url = new URL(next, window.location.origin); - // 仅允许同源地址 - if (url.origin !== window.location.origin) { - return '/'; - } - // 仅允许以 / 开头的站内路径 - if (!url.pathname.startsWith('/')) { - return '/'; - } - return `${url.pathname}${url.search}${url.hash}`; - } catch (e) { - return '/'; - } - }, - - /** - * 处理注册 - */ - async handleRegister(event) { - event.preventDefault(); - - const form = event.target; - if (!form.checkValidity()) { - return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - // 验证密码 - if (data.password1 !== data.password2) { - Utils.showAlert('两次输入的密码不一致', 'danger'); - return; - } - - try { - Utils.showLoading(); - - const response = await API.post('/accounts/api/register/', data); - - if (response.status === 'success') { - Utils.showAlert('注册成功,请登录', 'success'); - setTimeout(() => { - window.location.href = '/accounts/login/'; - }, 1500); - } else { - Utils.showAlert(response.message || '注册失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Register error:', error); - Utils.showAlert('注册失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 处理忘记密码 - */ - async handleForgotPassword(event) { - event.preventDefault(); - - const form = event.target; - if (!form.checkValidity()) { - return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - try { - Utils.showLoading(); - - const response = await API.post('/accounts/api/forgot-password/', data); - - if (response.status === 'success') { - Utils.showAlert('重置密码链接已发送到您的邮箱', 'success'); - form.reset(); - } else { - Utils.showAlert(response.message || '发送失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Forgot password error:', error); - Utils.showAlert('发送失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 处理重置密码 - */ - async handleResetPassword(event) { - event.preventDefault(); - - const form = event.target; - if (!form.checkValidity()) { - return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - // 验证密码 - if (data.password !== data.confirm_password) { - Utils.showAlert('两次输入的密码不一致', 'danger'); - return; - } - - try { - Utils.showLoading(); - - const response = await API.post('/accounts/api/reset-password/', data); - - if (response.status === 'success') { - Utils.showAlert('密码重置成功,请使用新密码登录', 'success'); - setTimeout(() => { - window.location.href = '/accounts/login/'; - }, 1500); - } else { - Utils.showAlert(response.message || '重置失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Reset password error:', error); - Utils.showAlert('重置失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 处理退出登录 - */ - async handleLogout() { - if (!Utils.confirm('确定要退出登录吗?')) { - return; - } - - try { - Utils.showLoading(); - - await fetch('/accounts/logout/', { - method: 'POST', - headers: { - 'X-CSRFToken': Config.csrfToken, - }, - }); - - window.location.href = '/accounts/login/'; - } catch (error) { - console.error('Logout error:', error); - Utils.showAlert('退出失败,请稍后重试', 'danger'); - Utils.hideLoading(); - } - } -}; - -// 用户资料管理器 -const Profile = { - /** - * 初始化用户资料 - */ - init() { - this.bindEvents(); - this.initAvatarUpload(); - }, - - /** - * 绑定事件 - */ - bindEvents() { - // 资料表单 - const profileForm = document.getElementById('profile-form'); - if (profileForm) { - profileForm.addEventListener('submit', (e) => { - this.handleProfileUpdate(e); - }); - } - - // 密码修改表单 - const passwordForm = document.getElementById('password-form'); - if (passwordForm) { - passwordForm.addEventListener('submit', (e) => { - this.handlePasswordChange(e); - }); - } - - // 头像上传按钮 - const avatarUploadBtn = document.getElementById('avatar-upload-btn'); - if (avatarUploadBtn) { - avatarUploadBtn.addEventListener('click', () => { - document.getElementById('avatar-input').click(); - }); - } - }, - - /** - * 初始化头像上传 - */ - initAvatarUpload() { - const avatarInput = document.getElementById('avatar-input'); - if (avatarInput) { - avatarInput.addEventListener('change', (e) => { - this.handleAvatarUpload(e); - }); - } - }, - - /** - * 处理资料更新 - */ - async handleProfileUpdate(event) { - event.preventDefault(); - - const form = event.target; - if (!form.checkValidity()) { - return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - try { - Utils.showLoading(); - - const response = await API.post('/accounts/api/profile/update/', data); - - if (response.status === 'success') { - Utils.showAlert('资料更新成功', 'success'); - } else { - Utils.showAlert(response.message || '更新失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Profile update error:', error); - Utils.showAlert('更新失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 处理密码修改 - */ - async handlePasswordChange(event) { - event.preventDefault(); - - const form = event.target; - if (!form.checkValidity()) { - return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - // 验证新密码 - if (data.new_password !== data.confirm_password) { - Utils.showAlert('两次输入的新密码不一致', 'danger'); - return; - } - - try { - Utils.showLoading(); - - const response = await API.post('/accounts/api/password/change/', data); - - if (response.status === 'success') { - Utils.showAlert('密码修改成功,请重新登录', 'success'); - form.reset(); - setTimeout(() => { - window.location.href = '/accounts/login/'; - }, 1500); - } else { - Utils.showAlert(response.message || '修改失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Password change error:', error); - Utils.showAlert('修改失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 处理通知设置更新 - */ - async handleNotificationUpdate(event) { - event.preventDefault(); - - const form = event.target; - const formData = new FormData(form); - - try { - Utils.showLoading(); - - const response = await fetch(window.location.href, { - method: 'POST', - headers: { - 'X-CSRFToken': getCsrfToken(), - }, - body: formData - }); - - if (response.ok) { - Utils.showAlert('通知设置更新成功', 'success'); - } else { - Utils.showAlert('设置更新失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Notification update error:', error); - Utils.showAlert('设置更新失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 处理头像上传 - */ - async handleAvatarUpload(event) { - const file = event.target.files[0]; - if (!file) return; - - // 验证文件类型 - const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']; - if (!allowedTypes.includes(file.type.toLowerCase())) { - Utils.showAlert('请选择JPG、PNG或GIF格式的图片文件', 'danger'); - return; - } - - // 验证文件大小(限制为5MB,与后端保持一致) - if (file.size > 5 * 1024 * 1024) { - Utils.showAlert('图片大小不能超过5MB', 'danger'); - return; - } - - // 验证文件扩展名 - const fileName = file.name.toLowerCase(); - const allowedExtensions = ['.jpg', '.jpeg', '.png', '.gif']; - const hasValidExtension = allowedExtensions.some(ext => fileName.endsWith(ext)); - - if (!hasValidExtension) { - Utils.showAlert('请选择JPG、PNG或GIF格式的图片文件', 'danger'); - return; - } - - const formData = new FormData(); - formData.append('avatar', file); - - try { - Utils.showLoading(); - - const response = await fetch('/accounts/api/avatar/upload/', { - method: 'POST', - headers: { - 'X-CSRFToken': Config.csrfToken, - }, - body: formData, - }); - - if (response.ok) { - const data = await response.json(); - if (data.status === 'success') { - // 更新头像显示 - const avatarImg = document.getElementById('profile-avatar'); - if (avatarImg) { - // 添加时间戳防止缓存 - avatarImg.src = data.avatar_url + '?t=' + new Date().getTime(); - } - Utils.showAlert('头像上传成功', 'success'); - } else { - Utils.showAlert(data.message || '上传失败', 'danger'); - } - } else { - Utils.showAlert('上传失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Avatar upload error:', error); - Utils.showAlert('上传失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - } -}; - -// 页面加载完成后初始化 -document.addEventListener('DOMContentLoaded', () => { - const isAuthPage = document.querySelector('.auth-page'); - const isProfilePage = document.querySelector('.profile-page'); - - if (isAuthPage) { - Auth.init(); - } - - if (isProfilePage) { - Profile.init(); - } -}); - -// 导出到全局 -window.Auth = Auth; -window.Profile = Profile; \ No newline at end of file diff --git a/static/js/auth-animation.js b/static/js/auth-animation.js deleted file mode 100755 index 70716a9..0000000 --- a/static/js/auth-animation.js +++ /dev/null @@ -1,157 +0,0 @@ -/** - * 认证页面动画效果 - * 为登录和注册页面添加动态背景效果 - */ - -document.addEventListener('DOMContentLoaded', function() { - // 检查是否在登录或注册页面 - if (document.body.classList.contains('login-page') || document.body.classList.contains('register-page')) { - initAuthAnimation(); - } -}); - -function initAuthAnimation() { - // 创建粒子背景效果 - createParticleEffect(); - - // 监听表单交互,添加动态效果 - addFormInteractionEffects(); -} - -function createParticleEffect() { - // 创建Canvas元素 - const canvas = document.createElement('canvas'); - canvas.style.position = 'fixed'; - canvas.style.top = '0'; - canvas.style.left = '0'; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - canvas.style.zIndex = '1'; - canvas.style.pointerEvents = 'none'; - canvas.id = 'auth-particles'; - - document.body.appendChild(canvas); - - const ctx = canvas.getContext('2d'); - let particles = []; - const particleCount = 50; - - // 设置canvas尺寸 - function resizeCanvas() { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; - } - - resizeCanvas(); - window.addEventListener('resize', resizeCanvas); - - // 粒子类 - class Particle { - constructor() { - this.x = Math.random() * canvas.width; - this.y = Math.random() * canvas.height; - this.size = Math.random() * 2 + 0.5; - this.speedX = Math.random() * 1 - 0.5; - this.speedY = Math.random() * 1 - 0.5; - this.color = `rgba(203, 213, 225, ${Math.random() * 0.5 + 0.1})`; - } - - update() { - this.x += this.speedX; - this.y += this.speedY; - - if (this.x > canvas.width || this.x < 0) { - this.speedX = -this.speedX; - } - if (this.y > canvas.height || this.y < 0) { - this.speedY = -this.speedY; - } - } - - draw() { - ctx.fillStyle = this.color; - ctx.beginPath(); - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); - ctx.closePath(); - ctx.fill(); - } - } - - // 创建粒子 - for (let i = 0; i < particleCount; i++) { - particles.push(new Particle()); - } - - // 连接粒子 - function connectParticles() { - for (let a = 0; a < particles.length; a++) { - for (let b = a; b < particles.length; b++) { - const dx = particles[a].x - particles[b].x; - const dy = particles[a].y - particles[b].y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance < 100) { - const opacity = 1 - distance / 100; - ctx.strokeStyle = `rgba(203, 213, 225, ${opacity * 0.2})`; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(particles[a].x, particles[a].y); - ctx.lineTo(particles[b].x, particles[b].y); - ctx.stroke(); - } - } - } - } - - // 动画循环 - function animate() { - ctx.clearRect(0, 0, canvas.width, canvas.height); - - for (let i = 0; i < particles.length; i++) { - particles[i].update(); - particles[i].draw(); - } - - connectParticles(); - - requestAnimationFrame(animate); - } - - animate(); -} - -function addFormInteractionEffects() { - // 为表单控件添加聚焦效果 - const inputs = document.querySelectorAll('.form-control'); - inputs.forEach(input => { - input.addEventListener('focus', function() { - this.parentElement.classList.add('focused'); - }); - - input.addEventListener('blur', function() { - this.parentElement.classList.remove('focused'); - }); - }); - - // 为登录卡片添加鼠标移动效果 - const authCard = document.querySelector('.auth-card'); - if (authCard) { - authCard.addEventListener('mousemove', function(e) { - const rect = authCard.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const centerX = rect.width / 2; - const centerY = rect.height / 2; - - const angleY = (x - centerX) / 25; - const angleX = (centerY - y) / 25; - - authCard.style.transform = `perspective(1000px) rotateX(${angleX}deg) rotateY(${angleY}deg) translateY(-5px)`; - }); - - authCard.addEventListener('mouseleave', function() { - authCard.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) translateY(-5px)'; - }); - } -} \ No newline at end of file diff --git a/static/js/base.js b/static/js/base.js deleted file mode 100755 index 926edfc..0000000 --- a/static/js/base.js +++ /dev/null @@ -1,275 +0,0 @@ -/** - * 基础JavaScript功能 - */ - -// 全局配置 -const Config = { - apiBase: '/api/', -}; - -function getCsrfToken() { - return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || - document.querySelector('[name=csrfmiddlewaretoken]')?.value || - document.cookie.split('; ').find(row => row.startsWith('csrftoken='))?.split('=')[1] || - ''; -} - -Config.csrfToken = getCsrfToken(); - -// 工具函数 -const Utils = { - /** - * 显示加载动画 - */ - showLoading() { - const loading = document.createElement('div'); - loading.className = 'loading-overlay'; - loading.id = 'loading-overlay'; - loading.innerHTML = ` -
    - 加载中... -
    - `; - document.body.appendChild(loading); - }, - - /** - * 隐藏加载动画 - */ - hideLoading() { - const loading = document.getElementById('loading-overlay'); - if (loading) { - loading.remove(); - } - }, - - /** - * 显示提示信息 - */ - showAlert(message, type = 'info') { - const alert = document.createElement('div'); - alert.className = `alert alert-${type} alert-dismissible fade show`; - alert.innerHTML = ` - ${message} - - `; - - const container = document.querySelector('.container') || document.body; - container.insertBefore(alert, container.firstChild); - - // 5秒后自动关闭 - setTimeout(() => { - alert.classList.remove('show'); - setTimeout(() => alert.remove(), 150); - }, 5000); - }, - - /** - * 确认对话框 - */ - confirm(message) { - return window.confirm(message); - }, - - /** - * 格式化日期时间 - */ - formatDateTime(dateStr) { - const date = new Date(dateStr); - return date.toLocaleString('zh-CN'); - }, - - /** - * 格式化文件大小 - */ - formatFileSize(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; - }, - - /** - * 防抖函数 - */ - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - }, - - /** - * 节流函数 - */ - throttle(func, limit) { - let inThrottle; - return function(...args) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => inThrottle = false, limit); - } - }; - } -}; - -// API请求封装 -const API = { - /** - * 发送GET请求 - */ - async get(url, params = {}) { - const queryString = new URLSearchParams(params).toString(); - const fullUrl = queryString ? `${url}?${queryString}` : url; - - const response = await fetch(fullUrl, { - method: 'GET', - headers: { - 'X-CSRFToken': Config.csrfToken, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - /** - * 发送POST请求 - */ - async post(url, data = {}) { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': Config.csrfToken, - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - /** - * 发送PUT请求 - */ - async put(url, data = {}) { - const response = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': Config.csrfToken, - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - }, - - /** - * 发送DELETE请求 - */ - async delete(url) { - const response = await fetch(url, { - method: 'DELETE', - headers: { - 'X-CSRFToken': Config.csrfToken, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); - } -}; - -// 表单处理 -const FormHandler = { - /** - * 初始化表单 - */ - init(formSelector, options = {}) { - const form = document.querySelector(formSelector); - if (!form) return; - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - - if (options.beforeSubmit) { - const result = await options.beforeSubmit(form); - if (result === false) return; - } - - const formData = new FormData(form); - const data = Object.fromEntries(formData.entries()); - - try { - Utils.showLoading(); - - const response = await API.post(form.action, data); - - if (options.onSuccess) { - options.onSuccess(response, form); - } - } catch (error) { - console.error('Form submission error:', error); - - if (options.onError) { - options.onError(error, form); - } else { - Utils.showAlert('提交失败,请稍后重试', 'danger'); - } - } finally { - Utils.hideLoading(); - } - }); - } -}; - -// 初始化 -document.addEventListener('DOMContentLoaded', () => { - // 初始化所有提示框的关闭按钮 - document.querySelectorAll('.alert[data-dismiss="alert"]').forEach(alert => { - alert.querySelector('.close').addEventListener('click', () => { - alert.classList.remove('show'); - setTimeout(() => alert.remove(), 150); - }); - }); - - // 初始化所有确认按钮 - document.querySelectorAll('[data-confirm]').forEach(button => { - button.addEventListener('click', (e) => { - if (!Utils.confirm(button.dataset.confirm)) { - e.preventDefault(); - } - }); - }); -}); - -// 导出到全局 -window.Config = Config; -window.Utils = Utils; -window.API = API; -window.FormHandler = FormHandler; -window.getCsrfToken = getCsrfToken; diff --git a/static/js/dashboard.js b/static/js/dashboard.js deleted file mode 100755 index be01d27..0000000 --- a/static/js/dashboard.js +++ /dev/null @@ -1,250 +0,0 @@ -/** - * 仪表盘JavaScript功能 - */ - -// 仪表盘管理器 -const Dashboard = { - charts: {}, - refreshInterval: null, - - /** - * 初始化仪表盘 - */ - init() { - this.initCharts(); - this.startAutoRefresh(); - this.bindEvents(); - }, - - /** - * 初始化图表 - */ - initCharts() { - // 主机状态分布图 - const hostStatusCtx = document.getElementById('hostStatusChart'); - if (hostStatusCtx) { - this.charts.hostStatus = new Chart(hostStatusCtx, { - type: 'doughnut', - data: { - labels: ['在线', '离线', '未知'], - datasets: [{ - data: [0, 0, 0], - backgroundColor: [ - '#28a745', - '#dc3545', - '#6c757d' - ] - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom' - } - } - } - }); - } - - // 操作趋势图 - const operationTrendCtx = document.getElementById('operationTrendChart'); - if (operationTrendCtx) { - this.charts.operationTrend = new Chart(operationTrendCtx, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: '操作次数', - data: [], - borderColor: '#007bff', - fill: false, - tension: 0.1 - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - scales: { - y: { - beginAtZero: true, - ticks: { - stepSize: 1 - } - } - }, - plugins: { - legend: { - position: 'bottom' - } - } - } - }); - } - }, - - /** - * 更新图表数据 - */ - async updateCharts() { - try { - const data = await API.get('/dashboard/api/stats/'); - - if (this.charts.hostStatus) { - this.charts.hostStatus.data.datasets[0].data = [ - data.hosts.online, - data.hosts.offline, - data.hosts.error - ]; - this.charts.hostStatus.update(); - } - - if (this.charts.operationTrend) { - const trendData = await this.getOperationTrend(); - this.charts.operationTrend.data.labels = trendData.labels; - this.charts.operationTrend.data.datasets[0].data = trendData.data; - this.charts.operationTrend.update(); - } - } catch (error) { - console.error('Failed to update charts:', error); - } - }, - - /** - * 获取操作趋势数据 - */ - async getOperationTrend() { - try { - const response = await API.get('/dashboard/api/stats/', { type: 'operations' }); - // 这里可以根据实际API返回的数据格式进行调整 - return { - labels: [], - data: [] - }; - } catch (error) { - console.error('Failed to get operation trend:', error); - return { labels: [], data: [] }; - } - }, - - /** - * 开始自动刷新 - */ - startAutoRefresh() { - // 每5分钟刷新一次数据 - this.refreshInterval = setInterval(() => { - this.updateCharts(); - }, 300000); - }, - - /** - * 停止自动刷新 - */ - stopAutoRefresh() { - if (this.refreshInterval) { - clearInterval(this.refreshInterval); - this.refreshInterval = null; - } - }, - - /** - * 绑定事件 - */ - bindEvents() { - // 刷新按钮 - const refreshBtn = document.getElementById('refresh-dashboard'); - if (refreshBtn) { - refreshBtn.addEventListener('click', () => { - this.updateCharts(); - }); - } - - // 组件配置按钮 - const configBtn = document.getElementById('widget-config-btn'); - if (configBtn) { - configBtn.addEventListener('click', () => { - window.location.href = '/dashboard/widget-config/'; - }); - } - } -}; - -// 组件配置管理器 -const WidgetConfig = { - /** - * 初始化组件配置 - */ - init() { - this.bindEvents(); - }, - - /** - * 保存配置 - */ - async saveConfig() { - const widgets = []; - - document.querySelectorAll('.widget-item').forEach(item => { - const widgetId = item.dataset.widgetId; - const isEnabled = item.querySelector('.widget-enabled').checked; - const displayOrder = item.querySelector('.widget-order').value; - - widgets.push({ - widget_id: parseInt(widgetId), - is_enabled: isEnabled, - display_order: parseInt(displayOrder) - }); - }); - - try { - Utils.showLoading(); - - const response = await API.post('/dashboard/api/widget-config/', { widgets }); - - if (response.status === 'success') { - Utils.showAlert('配置保存成功', 'success'); - setTimeout(() => { - window.location.href = '/dashboard/'; - }, 1000); - } else { - Utils.showAlert(response.message || '保存失败', 'danger'); - } - } catch (error) { - console.error('Failed to save widget config:', error); - Utils.showAlert('保存失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 绑定事件 - */ - bindEvents() { - const saveBtn = document.getElementById('save-widget-config'); - if (saveBtn) { - saveBtn.addEventListener('click', () => { - this.saveConfig(); - }); - } - } -}; - -// 页面加载完成后初始化 -document.addEventListener('DOMContentLoaded', () => { - const isDashboardPage = document.querySelector('.dashboard-page'); - const isConfigPage = document.querySelector('.widget-config-page'); - - if (isDashboardPage) { - Dashboard.init(); - } - - if (isConfigPage) { - WidgetConfig.init(); - } -}); - -// 导出到全局 -window.Dashboard = Dashboard; -window.WidgetConfig = WidgetConfig; diff --git a/static/js/email_code.js b/static/js/email_code.js deleted file mode 100755 index e4e7461..0000000 --- a/static/js/email_code.js +++ /dev/null @@ -1,79 +0,0 @@ -(function () { - function qs(sel) { return document.querySelector(sel); } - var btn = qs('#get-email-code'); - if (!btn) return; - - var countdownTimers = {}; - - function startCountdown(button, initialText) { - var buttonId = button.id || 'unknown-button'; - if (countdownTimers[buttonId]) { - clearInterval(countdownTimers[buttonId]); - } - var count = 60; - var originalText = initialText || button.textContent || '获取验证码'; - button.disabled = true; - button.textContent = originalText + ' (' + count + 's)'; - countdownTimers[buttonId] = setInterval(function () { - count--; - button.textContent = originalText + ' (' + count + 's)'; - if (count <= 0) { - clearInterval(countdownTimers[buttonId]); - delete countdownTimers[buttonId]; - button.disabled = false; - button.textContent = originalText; - } - }, 1000); - } - - btn.addEventListener('click', function (e) { - e.preventDefault(); - var emailInput = document.querySelector('input[name="email"]') || document.querySelector('input[type="email"]'); - var email = emailInput && emailInput.value && emailInput.value.trim(); - if (!email) { alert('请先输入邮箱'); return; } - - var isForgotPassword = window.location.pathname.includes('forgot-password'); - var endpoint = isForgotPassword ? '/accounts/email/send-forgot-password-code/' : '/accounts/email/send-code/'; - var provider = window.CAPTCHA_PROVIDER || 'none'; - - if (provider === 'tianai') { - return; - } - - function postCode(payload, buttonRef) { - fetch(endpoint, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : '' - }, - body: payload - }).then(function (resp) { - if (resp.ok) { - alert('验证码已发送,请注意查收'); - if (buttonRef) startCountdown(buttonRef, '获取验证码'); - } else { - if (buttonRef) { - buttonRef.disabled = false; - buttonRef.textContent = '获取验证码'; - } - resp.json().then(function (j) { alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function () { alert('发送失败'); }); - } - }).catch(function (err) { - console.error(err); - if (buttonRef) { - buttonRef.disabled = false; - buttonRef.textContent = '获取验证码'; - } - alert('网络错误'); - }); - } - - var fd = new FormData(); - fd.append('email', email); - if (window.REGLINK_TOKEN) { - fd.append('reglink_token', window.REGLINK_TOKEN); - } - postCode(fd, btn); - }); -})(); diff --git a/static/js/operations.js b/static/js/operations.js deleted file mode 100755 index d108dd6..0000000 --- a/static/js/operations.js +++ /dev/null @@ -1,538 +0,0 @@ -/** - * 操作记录JavaScript功能 - */ - -// 操作日志管理器 -const OperationLog = { - /** - * 初始化操作日志 - */ - init() { - this.bindEvents(); - this.initFilters(); - }, - - /** - * 绑定事件 - */ - bindEvents() { - // 导出按钮 - const exportBtn = document.getElementById('export-logs-btn'); - if (exportBtn) { - exportBtn.addEventListener('click', () => { - this.exportLogs(); - }); - } - - // 筛选表单 - const filterForm = document.getElementById('filter-form'); - if (filterForm) { - filterForm.addEventListener('submit', (e) => { - this.handleFilterSubmit(e); - }); - } - - // 重置筛选按钮 - const resetBtn = document.getElementById('reset-filter-btn'); - if (resetBtn) { - resetBtn.addEventListener('click', () => { - this.resetFilters(); - }); - } - - // 查看详情按钮 - document.querySelectorAll('.view-log-detail-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const logId = e.target.dataset.logId; - this.showLogDetail(logId); - }); - }); - }, - - /** - * 初始化筛选器 - */ - initFilters() { - // 初始化日期选择器 - const dateInputs = document.querySelectorAll('.date-picker'); - dateInputs.forEach(input => { - // 这里可以集成日期选择器库 - // 例如:flatpickr、bootstrap-datepicker等 - }); - }, - - /** - * 处理筛选表单提交 - */ - async handleFilterSubmit(event) { - event.preventDefault(); - - const form = event.target; - const formData = new FormData(form); - const params = Object.fromEntries(formData.entries()); - - try { - Utils.showLoading(); - - // 构建查询字符串 - const queryString = new URLSearchParams(params).toString(); - window.location.href = `${window.location.pathname}?${queryString}`; - } catch (error) { - console.error('Failed to filter logs:', error); - Utils.showAlert('筛选失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 重置筛选器 - */ - resetFilters() { - const filterForm = document.getElementById('filter-form'); - if (filterForm) { - filterForm.reset(); - window.location.href = window.location.pathname; - } - }, - - /** - * 显示操作日志详情 - */ - async showLogDetail(logId) { - try { - Utils.showLoading(); - - const response = await API.get(`/operations/api/logs/${logId}/`); - - if (response.status === 'success') { - this.showLogDetailModal(response.data); - } else { - Utils.showAlert(response.message || '获取详情失败', 'danger'); - } - } catch (error) { - console.error('Failed to get log detail:', error); - Utils.showAlert('获取详情失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 显示操作日志详情模态框 - */ - showLogDetailModal(logData) { - const modalHtml = ` - - `; - - const modalContainer = document.createElement('div'); - modalContainer.innerHTML = modalHtml; - document.body.appendChild(modalContainer); - - const modal = new bootstrap.Modal(document.getElementById('logDetailModal')); - modal.show(); - - document.getElementById('logDetailModal').addEventListener('hidden.bs.modal', () => { - modalContainer.remove(); - }); - }, - - /** - * 获取状态徽章HTML - */ - getStatusBadge(status) { - const statusMap = { - success: '成功', - failed: '失败', - pending: '进行中' - }; - return statusMap[status] || status; - }, - - /** - * 导出操作日志 - */ - async exportLogs() { - try { - Utils.showLoading(); - - // 获取当前筛选条件 - const filterForm = document.getElementById('filter-form'); - const formData = filterForm ? new FormData(filterForm) : new FormData(); - const params = Object.fromEntries(formData.entries()); - - // 调用导出API - const response = await fetch('/operations/api/export/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': Config.csrfToken, - }, - body: JSON.stringify(params), - }); - - if (response.ok) { - // 下载文件 - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `operation_logs_${new Date().getTime()}.xlsx`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - Utils.showAlert('导出成功', 'success'); - } else { - Utils.showAlert('导出失败,请稍后重试', 'danger'); - } - } catch (error) { - console.error('Failed to export logs:', error); - Utils.showAlert('导出失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - } -}; - -// 任务管理器 -const TaskManager = { - /** - * 初始化任务管理 - */ - init() { - this.bindEvents(); - this.startAutoRefresh(); - }, - - /** - * 绑定事件 - */ - bindEvents() { - // 取消任务按钮 - document.querySelectorAll('.cancel-task-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const taskId = e.target.dataset.taskId; - this.cancelTask(taskId); - }); - }); - - // 重试任务按钮 - document.querySelectorAll('.retry-task-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const taskId = e.target.dataset.taskId; - this.retryTask(taskId); - }); - }); - - // 查看任务详情按钮 - document.querySelectorAll('.view-task-detail-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const taskId = e.target.dataset.taskId; - this.showTaskDetail(taskId); - }); - }); - }, - - /** - * 开始自动刷新 - */ - startAutoRefresh() { - // 每30秒刷新一次任务状态 - this.refreshInterval = setInterval(() => { - this.updateTaskStatus(); - }, 30000); - }, - - /** - * 停止自动刷新 - */ - stopAutoRefresh() { - if (this.refreshInterval) { - clearInterval(this.refreshInterval); - this.refreshInterval = null; - } - }, - - /** - * 更新任务状态 - */ - async updateTaskStatus() { - const taskItems = document.querySelectorAll('.task-item[data-status="running"]'); - - for (const item of taskItems) { - const taskId = item.dataset.taskId; - try { - const response = await API.get(`/operations/api/tasks/${taskId}/`); - - if (response.status === 'success') { - this.updateTaskItem(item, response.data); - } - } catch (error) { - console.error(`Failed to update task ${taskId}:`, error); - } - } - }, - - /** - * 更新任务项 - */ - updateTaskItem(item, data) { - // 更新状态 - const statusBadge = item.querySelector('.task-status'); - if (statusBadge) { - statusBadge.className = `task-status ${data.status}`; - statusBadge.textContent = this.getStatusText(data.status); - } - - // 更新进度 - const progressBar = item.querySelector('.progress-bar .progress'); - if (progressBar) { - progressBar.style.width = `${data.progress}%`; - } - - // 更新数据属性 - item.dataset.status = data.status; - }, - - /** - * 获取状态文本 - */ - getStatusText(status) { - const statusMap = { - pending: '等待中', - running: '执行中', - success: '成功', - failed: '失败', - cancelled: '已取消' - }; - return statusMap[status] || status; - }, - - /** - * 取消任务 - */ - async cancelTask(taskId) { - if (!Utils.confirm('确定要取消此任务吗?')) { - return; - } - - try { - Utils.showLoading(); - - const response = await API.post(`/operations/api/tasks/${taskId}/cancel/`); - - if (response.status === 'success') { - Utils.showAlert('任务已取消', 'success'); - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - Utils.showAlert(response.message || '取消失败', 'danger'); - } - } catch (error) { - console.error('Failed to cancel task:', error); - Utils.showAlert('取消失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 重试任务 - */ - async retryTask(taskId) { - if (!Utils.confirm('确定要重试此任务吗?')) { - return; - } - - try { - Utils.showLoading(); - - const response = await API.post(`/operations/api/tasks/${taskId}/retry/`); - - if (response.status === 'success') { - Utils.showAlert('任务已重新开始', 'success'); - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - Utils.showAlert(response.message || '重试失败', 'danger'); - } - } catch (error) { - console.error('Failed to retry task:', error); - Utils.showAlert('重试失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 显示任务详情 - */ - async showTaskDetail(taskId) { - try { - Utils.showLoading(); - - const response = await API.get(`/operations/api/tasks/${taskId}/`); - - if (response.status === 'success') { - this.showTaskDetailModal(response.data); - } else { - Utils.showAlert(response.message || '获取详情失败', 'danger'); - } - } catch (error) { - console.error('Failed to get task detail:', error); - Utils.showAlert('获取详情失败,请稍后重试', 'danger'); - } finally { - Utils.hideLoading(); - } - }, - - /** - * 显示任务详情模态框 - */ - showTaskDetailModal(taskData) { - const modalHtml = ` - - `; - - const modalContainer = document.createElement('div'); - modalContainer.innerHTML = modalHtml; - document.body.appendChild(modalContainer); - - const modal = new bootstrap.Modal(document.getElementById('taskDetailModal')); - modal.show(); - - document.getElementById('taskDetailModal').addEventListener('hidden.bs.modal', () => { - modalContainer.remove(); - }); - } -}; - -// 页面加载完成后初始化 -document.addEventListener('DOMContentLoaded', () => { - if (document.querySelector('.operation-logs-page')) { - OperationLog.init(); - } - - if (document.querySelector('.tasks-page')) { - TaskManager.init(); - } -}); - -// 导出到全局 -window.OperationLog = OperationLog; -window.TaskManager = TaskManager; diff --git a/static/js/tianai_adapter.js b/static/js/tianai_adapter.js deleted file mode 100644 index 27c4dcf..0000000 --- a/static/js/tianai_adapter.js +++ /dev/null @@ -1,214 +0,0 @@ -(function () { - function qs(sel) { return document.querySelector(sel); } - function $all(sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); } - - var _countdownTimer = null; - var _activeOverlay = null; - var _activeCaptchaBox = null; - - function startCountdown(button, initialText) { - if (_countdownTimer) clearInterval(_countdownTimer); - var count = 60; - var originalText = initialText || '获取验证码'; - button.disabled = true; - button.textContent = originalText + ' (' + count + 's)'; - _countdownTimer = setInterval(function () { - count--; - button.textContent = originalText + ' (' + count + 's)'; - if (count <= 0) { - clearInterval(_countdownTimer); - _countdownTimer = null; - button.disabled = false; - button.textContent = originalText; - } - }, 1000); - } - - function getGenerateUrl(captchaType) { - var baseUrl = '/captcha/generate'; - if (captchaType && captchaType !== 'SLIDER') { - return baseUrl + '?type=' + encodeURIComponent(captchaType); - } - return baseUrl; - } - - function createModal() { - if (_activeOverlay) return _activeOverlay.querySelector('#tianai-captcha-box'); - - var overlay = document.createElement('div'); - overlay.id = 'tianai-captcha-overlay'; - overlay.style.cssText = 'position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;'; - - var captchaBox = document.createElement('div'); - captchaBox.id = 'tianai-captcha-box'; - captchaBox.style.cssText = 'position:relative;'; - - overlay.appendChild(captchaBox); - document.body.appendChild(overlay); - - _activeOverlay = overlay; - _activeCaptchaBox = captchaBox; - - overlay.addEventListener('click', function (e) { - if (e.target === overlay) { - destroyModal(); - } - }); - - return captchaBox; - } - - function destroyModal() { - if (_activeOverlay) { - _activeOverlay.remove(); - _activeOverlay = null; - _activeCaptchaBox = null; - } - } - - function showTianaiCaptcha(onSuccess, captchaType) { - var captchaBox = createModal(); - - var config = { - requestCaptchaDataUrl: getGenerateUrl(captchaType), - validCaptchaUrl: "/captcha/check", - bindEl: "#tianai-captcha-box", - validSuccess: function (res, c, tac) { - var token = null; - if (res && res.data && res.data.token) { - token = res.data.token; - } else if (res && res.token) { - token = res.token; - } - tac.destroyWindow(); - destroyModal(); - if (onSuccess) onSuccess(token); - }, - validFail: function (res, c, tac) { - tac.reloadCaptcha(); - }, - btnCloseFun: function (el, tac) { - tac.destroyWindow(); - destroyModal(); - } - }; - var style = {}; - var tac = new window.TAC(config, style); - tac.init(); - } - - function setTokenField(form, token) { - var input = form.querySelector('input[name="captcha_token"]'); - if (!input) { - input = document.createElement('input'); - input.type = 'hidden'; - input.name = 'captcha_token'; - form.appendChild(input); - } - input.value = token || ''; - } - - function postEmailCode(email, token, button) { - var isForgotPassword = window.location.pathname.includes('forgot-password'); - var endpoint = isForgotPassword ? '/accounts/email/send-forgot-password-code/' : '/accounts/email/send-code/'; - - var formData = new FormData(); - formData.append('email', email); - if (token) { - formData.append('captcha_token', token); - } - if (window.REGLINK_TOKEN) { - formData.append('reglink_token', window.REGLINK_TOKEN); - } - - fetch(endpoint, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]') ? document.querySelector('[name=csrfmiddlewaretoken]').value : '' - }, - body: formData - }).then(function (resp) { - if (resp.ok) { - alert('验证码已发送,请注意查收'); - if (button) startCountdown(button, '获取验证码'); - } else { - if (button) { - button.disabled = false; - button.textContent = '获取验证码'; - } - resp.json().then(function (j) { alert('发送失败:' + (j.message || JSON.stringify(j))); }).catch(function () { alert('发送失败'); }); - } - }).catch(function (err) { - console.error(err); - if (button) { - button.disabled = false; - button.textContent = '获取验证码'; - } - alert('网络错误'); - }); - } - - document.addEventListener('DOMContentLoaded', function () { - if (window.CAPTCHA_PROVIDER !== 'tianai') return; - - var captchaType = window.CAPTCHA_TYPE || 'SLIDER'; - - $all('[data-tianai-captcha-trigger]').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.preventDefault(); - e.stopImmediatePropagation(); - - var form = btn.closest('form'); - showTianaiCaptcha(function (token) { - if (form) setTokenField(form, token); - var action = btn.dataset.action; - if (action === 'submit') { - form.submit(); - } - }, captchaType); - }); - }); - - $all('#get-email-code[data-tianai-email-trigger]').forEach(function (button) { - var newBtn = button.cloneNode(true); - button.parentNode.replaceChild(newBtn, button); - - newBtn.addEventListener('click', function (e) { - e.preventDefault(); - e.stopImmediatePropagation(); - if (this.disabled) return; - - var emailField = document.querySelector('input[type="email"]'); - if (!emailField || !emailField.value) { - alert('请先输入邮箱'); - emailField && emailField.focus(); - return; - } - - var emailCaptchaType = window.CAPTCHA_TYPE_EMAIL || captchaType; - showTianaiCaptcha(function (token) { - postEmailCode(emailField.value, token, newBtn); - }, emailCaptchaType); - }); - }); - - $all('form').forEach(function (form) { - var hasCaptchaTrigger = form.querySelector('[data-tianai-captcha-trigger]'); - if (!hasCaptchaTrigger) return; - - form.addEventListener('submit', function (e) { - var tokenField = form.querySelector('input[name="captcha_token"]'); - if (!tokenField || !tokenField.value) { - if (window.CAPTCHA_PROVIDER === 'tianai') { - e.preventDefault(); - showTianaiCaptcha(function (token) { - setTokenField(form, token); - form.submit(); - }, captchaType); - } - } - }); - }); - }); -})(); diff --git a/static/scripts/init.ps1 b/static/scripts/init.ps1 deleted file mode 100644 index 89319e9..0000000 --- a/static/scripts/init.ps1 +++ /dev/null @@ -1,222 +0,0 @@ -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$EncodedToken = $args[0] -if (-not $EncodedToken) { - Write-Host "Usage: & ([ScriptBlock]::Create(`$script)) [debug]" -ForegroundColor Red - return -} -$Debug = $args[1] -eq 'debug' -or $args[1] -eq '1' - -$padLen = 4 - ($EncodedToken.Length % 4) -if ($padLen -ne 4) { $EncodedToken += '=' * $padLen } -try { - $jsonBytes = [System.Convert]::FromBase64String($EncodedToken) - $jsonStr = [System.Text.Encoding]::UTF8.GetString($jsonBytes) - $tokenObj = $jsonStr | ConvertFrom-Json - $Token = $tokenObj.t - $Scheme = $tokenObj.s - $ServerHost = $tokenObj.h -} catch { - Write-Host "Token解码失败" -ForegroundColor Red; return -} -if (-not $Token -or -not $Scheme -or -not $ServerHost) { - Write-Host "Token格式无效" -ForegroundColor Red; return -} -$ServerUrl = "${Scheme}://${ServerHost}" -if ($Debug) { Write-Host " ServerUrl: $ServerUrl" -ForegroundColor DarkGray } - -Write-Host "=== 2c2a WinRM 证书自动配置 ===" -ForegroundColor Cyan - -Write-Host "[1/17] 验证Token..." -ForegroundColor Yellow -$validateResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/validate/?token=$Token" -Method Get -if (-not $validateResp.valid) { - Write-Host "Token无效或已过期" -ForegroundColor Red; return -} -if ($Debug) { Write-Host " ServerHost: $ServerHost" -ForegroundColor DarkGray } -Write-Host " Token验证通过" -ForegroundColor Green - -Write-Host "[2/17] 上传主机名..." -ForegroundColor Yellow -$hostname = $env:COMPUTERNAME -$body = @{ token = $Token; hostname = $hostname } | ConvertTo-Json -Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/upload-hostname/" -Method Post -Body $body -ContentType "application/json" -if ($Debug) { Write-Host " 主机名: $hostname" -ForegroundColor DarkGray } -Write-Host " 主机名已上传: $hostname" -ForegroundColor Green - -Write-Host "[3/17] 等待证书签发..." -ForegroundColor Yellow -$certData = $null -$maxWait = 120 -$waited = 0 -while ($waited -lt $maxWait) { - Start-Sleep -Seconds 5 - $waited += 5 - try { - $statusResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/validate/?token=$Token" -Method Get - if ($Debug) { Write-Host " Token状态: $($statusResp.status)" -ForegroundColor DarkGray } - } catch { - continue - } - try { - $certResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/download-certs/?token=$Token" -Method Get - if ($certResp.ca_cert) { - $certData = $certResp - break - } - } catch { - } - Write-Host " 等待中... ($waited/${maxWait}s)" -ForegroundColor DarkGray -} -if (-not $certData) { - Write-Host "证书签发超时" -ForegroundColor Red; return -} -if ($Debug) { Write-Host " 证书数据已获取" -ForegroundColor DarkGray } -Write-Host " 证书已就绪" -ForegroundColor Green - -Write-Host "[4/17] 下载证书文件..." -ForegroundColor Yellow -$TempDir = "$env:TEMP\2c2a_Certs" -New-Item -ItemType Directory -Force -Path $TempDir | Out-Null -[System.IO.File]::WriteAllBytes("$TempDir\ca.crt", [System.Convert]::FromBase64String($certData.ca_cert)) -[System.IO.File]::WriteAllBytes("$TempDir\client.crt", [System.Convert]::FromBase64String($certData.client_cert)) -[System.IO.File]::WriteAllBytes("$TempDir\server.pfx", [System.Convert]::FromBase64String($certData.server_pfx)) -$PfxPassword = $certData.pfx_password -$NtlmUser = $certData.ntlm_user -$NtlmPassword = $certData.ntlm_password -$UpnValue = $certData.upn_value -if ($Debug) { Write-Host " 保存路径: $TempDir" -ForegroundColor DarkGray } -Write-Host " 证书文件已保存到 $TempDir" -ForegroundColor Green - -Write-Host "[5/17] 导入证书..." -ForegroundColor Yellow -$tempCa = Import-Certificate -FilePath "$TempDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root -$caIssuerPattern = $tempCa.Subject -replace '.*CN=([^,]+).*','$1' -Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -match $caIssuerPattern | Remove-Item -Force -$importedCa = Import-Certificate -FilePath "$TempDir\ca.crt" -CertStoreLocation Cert:\LocalMachine\Root -Get-ChildItem Cert:\LocalMachine\My | Where-Object { $_.Issuer -match $caIssuerPattern } | Remove-Item -Force -Get-ChildItem Cert:\LocalMachine\TrustedPeople | Where-Object Subject -match "winrm-client" | Remove-Item -Force -$secPwd = ConvertTo-SecureString $PfxPassword -AsPlainText -Force -$importedServer = Import-PfxCertificate -FilePath "$TempDir\server.pfx" -CertStoreLocation Cert:\LocalMachine\My -Password $secPwd -Import-Certificate -FilePath "$TempDir\client.crt" -CertStoreLocation Cert:\LocalMachine\TrustedPeople -if ($Debug) { Write-Host " CA Thumbprint: $($importedCa.Thumbprint)" -ForegroundColor DarkGray } -if ($Debug) { Write-Host " Server Thumbprint: $($importedServer.Thumbprint)" -ForegroundColor DarkGray } -Write-Host " 证书导入完成" -ForegroundColor Green - -Write-Host "[6/17] 创建本地用户..." -ForegroundColor Yellow -$SecurePassword = ConvertTo-SecureString $NtlmPassword -AsPlainText -Force -if (-not (Get-LocalUser -Name $NtlmUser -ErrorAction SilentlyContinue)) { - New-LocalUser -Name $NtlmUser -Password $SecurePassword -Description "2c2a WinRM Certificate Auth User" -} else { - Set-LocalUser -Name $NtlmUser -Password $SecurePassword -} -Add-LocalGroupMember -Group "Administrators" -Member $NtlmUser -ErrorAction SilentlyContinue -if ($Debug) { Write-Host " 用户: $NtlmUser" -ForegroundColor DarkGray } -Write-Host " 用户 $NtlmUser 已创建" -ForegroundColor Green - -Write-Host "[7/17] 配置HTTPS监听器..." -ForegroundColor Yellow -Get-ChildItem WSMan:\localhost\Listener | Where-Object { $_.Keys -match "Transport=HTTPS" } | Remove-Item -Recurse -Force -New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint $importedServer.Thumbprint -Force -if ($Debug) { Write-Host " Thumbprint: $($importedServer.Thumbprint)" -ForegroundColor DarkGray } -Write-Host " 监听器已绑定: $($importedServer.Thumbprint)" -ForegroundColor Green - -Write-Host "[8/17] 配置客户端证书映射..." -ForegroundColor Yellow -Get-ChildItem WSMan:\localhost\ClientCertificate | Remove-Item -Recurse -Force -$cred = New-Object System.Management.Automation.PSCredential($NtlmUser, $SecurePassword) -New-Item -Path WSMan:\localhost\ClientCertificate -Subject $UpnValue -Issuer $importedCa.Thumbprint -Credential $cred -Force -if ($Debug) { Write-Host " Subject=$UpnValue Issuer=$($importedCa.Thumbprint)" -ForegroundColor DarkGray } -Write-Host " 映射已建立: Subject=$UpnValue" -ForegroundColor Green - -Write-Host "[9/17] 配置Schannel注册表..." -ForegroundColor Yellow -reg add "HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL" /v ClientAuthTrustMode /t REG_DWORD /d 2 /f -reg add "HKLM\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL" /v SendTrustedIssuerList /t REG_DWORD /d 0 /f -if ($Debug) { Write-Host " ClientAuthTrustMode=2, SendTrustedIssuerList=0" -ForegroundColor DarkGray } -Write-Host " ClientAuthTrustMode=2, SendTrustedIssuerList=0" -ForegroundColor Green - -Write-Host "[10/17] 启用证书认证..." -ForegroundColor Yellow -Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true -Write-Host " 证书认证已启用" -ForegroundColor Green - -Write-Host "[11/17] 配置防火墙..." -ForegroundColor Yellow -$FirewallRuleName = "WinRM HTTPS (5986)" -$existingRule = Get-NetFirewallRule -DisplayName $FirewallRuleName -ErrorAction SilentlyContinue -if (-not $existingRule) { - New-NetFirewallRule -DisplayName $FirewallRuleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5986 -} else { - Enable-NetFirewallRule -DisplayName $FirewallRuleName -} -if ($Debug) { Write-Host " 规则: $FirewallRuleName" -ForegroundColor DarkGray } -Write-Host " 防火墙已配置" -ForegroundColor Green - -Write-Host "[12/17] 重启WinRM服务..." -ForegroundColor Yellow -Restart-Service WinRM -Force -Write-Host " WinRM服务已重启" -ForegroundColor Green - -Write-Host "[13/17] 通知服务器配置完成..." -ForegroundColor Yellow -$notifyBody = @{ token = $Token } | ConvertTo-Json -$notifyResp = $null -$notifyOk = $false -for ($i = 1; $i -le 3; $i++) { - try { - $notifyResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/notify-complete/" -Method Post -Body $notifyBody -ContentType "application/json" - $notifyOk = $true - break - } catch { - if ($i -lt 3) { Start-Sleep -Seconds 5 } - } -} -$testDeferred = $false -if ($notifyOk) { - if ($notifyResp.test -eq "deferred") { - Write-Host " 已通知服务器(连接测试将在主机注册后执行)" -ForegroundColor Green - $testDeferred = $true - } else { - Write-Host " 已通知服务器" -ForegroundColor Green - } -} else { - Write-Host " 通知服务器失败,但本地配置已完成" -ForegroundColor Yellow - $testDeferred = $true -} - -if ($testDeferred) { - Write-Host "[14/17] 连接测试已延后,请在后台完成主机注册" -ForegroundColor Yellow -} else { - Write-Host "[14/17] 等待连接测试..." -ForegroundColor Yellow - $testResult = $null - $testWaited = 0 - $maxTestWait = 60 - while ($testWaited -lt $maxTestWait) { - Start-Sleep -Seconds 5 - $testWaited += 5 - try { - $testResp = Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/test-result/?token=$Token" -Method Get - if ($testResp.status -ne "testing") { - $testResult = $testResp - break - } - } catch { - continue - } - Write-Host " 测试中... ($testWaited/${maxTestWait}s)" -ForegroundColor DarkGray - } - if ($testResult -and $testResult.status -eq "success") { - Write-Host " 连接测试成功!" -ForegroundColor Green - } else { - Write-Host " 连接测试失败或超时" -ForegroundColor Yellow - } -} - -Write-Host "[15/17] 安全性提升选项" -ForegroundColor Yellow -$choice = Read-Host "是否禁用密码认证以提升安全性?(Y/N)" -if ($choice -eq "Y" -or $choice -eq "y") { - Write-Host "[16/17] 禁用密码认证..." -ForegroundColor Yellow - Set-Item -Path WSMan:\localhost\Service\Auth\Basic -Value $false - Set-Item -Path WSMan:\localhost\Service\Auth\Digest -Value $false - Set-Item -Path WSMan:\localhost\Service\Auth\Kerberos -Value $false - Set-Item -Path WSMan:\localhost\Service\Auth\CredSSP -Value $false - Set-Item -Path WSMan:\localhost\Service\AllowUnencrypted -Value $false - Restart-Service WinRM -Force - $disableBody = @{ token = $Token } | ConvertTo-Json - Invoke-RestMethod -Uri "$ServerUrl/bootstrap/api/cert-provision/disable-password-auth/" -Method Post -Body $disableBody -ContentType "application/json" - Write-Host " 已禁用密码认证,仅允许证书认证" -ForegroundColor Green -} else { - Write-Host " 保留密码认证" -ForegroundColor Yellow -} - -Write-Host "[17/17] 清理临时文件..." -ForegroundColor Yellow -Remove-Item -Path $TempDir -Recurse -Force -ErrorAction SilentlyContinue -Write-Host "`n=== 配置完成 ===" -ForegroundColor Cyan diff --git a/static/src/tailwind.css b/static/src/tailwind.css deleted file mode 100644 index da983d3..0000000 --- a/static/src/tailwind.css +++ /dev/null @@ -1,81 +0,0 @@ -@import "tailwindcss"; - -/* ============================================================================ - * 2c2a - Material Design 3 (MD3) Theme Configuration - * Tailwind CSS v4 CSS-first configuration - * - * Dark mode baseline: - * All MD3 colors use the `md-` namespace - * Purple-based Material You dark palette - * ============================================================================ */ - -@theme { - /* ---- MD3 Primary ---- */ - --color-md-primary: #D0BCFF; - --color-md-on-primary: #381E72; - --color-md-primary-container: #4F378B; - --color-md-on-primary-container: #EADDFF; - - /* ---- MD3 Secondary ---- */ - --color-md-secondary: #CCC2DC; - --color-md-on-secondary: #332D41; - --color-md-secondary-container: #4A4458; - --color-md-on-secondary-container: #EADDFF; - - /* ---- MD3 Tertiary ---- */ - --color-md-tertiary: #EFB8C8; - --color-md-on-tertiary: #492532; - --color-md-tertiary-container: #633B48; - --color-md-on-tertiary-container: #FFD8E4; - - /* ---- MD3 Surface ---- */ - --color-md-surface: #1C1B1F; - --color-md-on-surface: #E6E1E5; - --color-md-on-surface-variant: #CAC4D0; - --color-md-surface-variant: #49454F; - --color-md-surface-container: #211F26; - --color-md-surface-container-low: #1D1B20; - --color-md-surface-container-high: #2B2930; - - /* ---- MD3 Outline ---- */ - --color-md-outline: #938F99; - --color-md-outline-variant: #49454F; - - /* ---- MD3 Error ---- */ - --color-md-error: #F2B8B5; - --color-md-on-error: #601410; - --color-md-error-container: #8C1D18; - --color-md-on-error-container: #F9DEDC; - - /* ---- MD3 Border Radius ---- */ - --radius-md: 12px; - --radius-md-lg: 16px; - --radius-md-xl: 28px; -} - -/* ============================================================================ - * Source directives - scan all template directories for Tailwind classes - * ============================================================================ */ -@source "../../templates"; -@source "../../apps/*/templates"; -@source "../../static/js"; - -/* ============================================================================ - * Select Dropdown Dark Theme - * Forces all native -
    - {% if form.email.errors %} -

    {{ form.email.errors.0 }}

    - {% endif %} - - -
    -
    - mail - - {% if CAPTCHA_PROVIDER == 'tianai' %} - - {% else %} - - {% endif %} -
    -
    - -
    -
    - lock - - -
    -

    至少8个字符

    - {% if form.new_password1.errors %} -

    {{ form.new_password1.errors.0 }}

    - {% endif %} -
    - -
    -
    - lock - - -
    - {% if form.new_password2.errors %} -

    {{ form.new_password2.errors.0 }}

    - {% endif %} -
    - - - -
    - {% if CAPTCHA_PROVIDER == 'tianai' %} - - {% else %} - - {% endif %} -
    - - -
    -

    记得密码了? 立即登录

    -
    - - - - - - - - - - {% if CAPTCHA_PROVIDER == 'tianai' %} - {% load static %} - - - - {% endif %} - - - - - diff --git a/templates/accounts/login.html b/templates/accounts/login.html deleted file mode 100644 index ab55492..0000000 --- a/templates/accounts/login.html +++ /dev/null @@ -1,234 +0,0 @@ -{% load static cotton %} - - - - - - 登录 - {{ site_name }} - - - - - - -
    -
    - -
    -
    -
    -
    - {{ site_name }} -
    -

    欢迎回来

    -

    登录您的账户以继续

    -
    - {% if messages %} - {% for message in messages %} - {{ message }} - {% endfor %} - {% endif %} - -
    -

    - 没有账户? 立即注册 -

    -

    - description查看文档 -

    -
    -
    -
    - - - - - {% if CAPTCHA_PROVIDER == 'tianai' %} - {% load static %} - - - - {% endif %} - - - - diff --git a/templates/accounts/migrate.html b/templates/accounts/migrate.html deleted file mode 100644 index 9022589..0000000 --- a/templates/accounts/migrate.html +++ /dev/null @@ -1,196 +0,0 @@ -{% load static cotton %} - - - - - - 站点迁移 - {{ site_name }} - - - - - - -
    -
    -
    -
    -
    -
    -
    - sync_alt -
    -
    -

    站点迁移

    -

    您正在访问站点组「{{ site_group_name }}」

    -
    - - {% if messages %} - {% for message in messages %} - {{ message }} - {% endfor %} - {% endif %} - -
    -
    -
    - info -
    -

    - 您的账户 {{ username }} 尚未加入该站点组。 -

    -

    - 迁移后,该站点组的管理员可以查看并管理您的账户数据,但不影响您在其他站点的使用。 -

    -
    -
    -
    - - {% if email_not_compliant %} -
    -
    - warning -
    -

    邮箱后缀不符合要求

    -

    该站点组要求邮箱后缀在白名单中,您当前的邮箱不满足条件。请先绑定符合条件的邮箱。

    -
    -
    -
    - -
    -

    绑定合规邮箱

    -
    - {% csrf_token %} -
    - - -
    -
    - -
    - -
    -
    - {% endif %} - -
    - {% csrf_token %} -
    - {% if not email_not_compliant %} - - {% endif %} - - logout - 暂不迁移,退出登录 - -
    -
    -
    -
    -
    - - - - - - diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html deleted file mode 100755 index 51f1ec7..0000000 --- a/templates/accounts/profile.html +++ /dev/null @@ -1,513 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}账户设置 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -

    账户设置

    -

    管理您的个人资料、安全设置和通知偏好

    -
    - -
    - -
    - - - - -
    - - -
    - -
    -
    -

    基本资料

    -

    更新您的个人信息和联系方式

    -
    - -
    - {% csrf_token %} - -
    -
    - - - 用户名不可更改 -
    - -
    - - - {% if form.email.errors %} -

    {{ form.email.errors }}

    - {% endif %} -
    -
    - -
    -
    - - -
    - -
    - - -
    -
    - -
    -
    - - -
    - -
    - - -
    -
    - -
    - - -
    - -
    - -
    -
    -
    - - - - - - - - - -
    -
    -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/accounts/register.html b/templates/accounts/register.html deleted file mode 100644 index d9f4c5a..0000000 --- a/templates/accounts/register.html +++ /dev/null @@ -1,253 +0,0 @@ -{% load static cotton %} - - - - - - 注册 - {{ site_name }} - - - - - - -
    - - - -
    -
    -
    -
    - {{ site_name }} -
    -

    {% if reglink %}邀请注册{% else %}创建账户{% endif %}

    -

    {% if reglink %}您已被邀请加入 2c2a{% else %}加入我们,开启云电脑之旅{% endif %}

    -
    - - {% if target_group %} -
    -
    - group -
    -

    注册后将加入用户组

    -

    {{ target_group.name }}

    -
    -
    -
    - {% endif %} - - {% if messages %} - {% for message in messages %} - {{ message }} - {% endfor %} - {% endif %} - -
    - {% csrf_token %} - - -
    -
    - person - -
    - {% if form.username.errors %} -

    {{ form.username.errors.0 }}

    - {% endif %} -
    - -
    -
    - email - -
    - {% if form.email.errors %} -

    {{ form.email.errors.0 }}

    - {% endif %} -
    - -
    -
    - mail - - {% if CAPTCHA_PROVIDER == 'tianai' %} - - {% else %} - - {% endif %} -
    -
    - -
    -
    - lock - - -
    - {% if form.password1.errors %} -

    {{ form.password1.errors.0 }}

    - {% endif %} -
    - -
    -
    - lock - - -
    - {% if form.password2.errors %} -

    {{ form.password2.errors.0 }}

    - {% endif %} -
    - -
    - - -
    - -
    - -
    -
    - -
    -

    已有账户? 立即登录

    -
    -
    -
    - - - - - - - - {% if CAPTCHA_PROVIDER == 'tianai' %} - {% load static %} - - - - {% endif %} - - - - - diff --git a/templates/admin/base.html b/templates/admin/base.html deleted file mode 100755 index b2a37b9..0000000 --- a/templates/admin/base.html +++ /dev/null @@ -1,127 +0,0 @@ -{% load i18n static %} -{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} - - - -{% block title %}{% endblock %} - -{% block dark-mode-vars %} - - -{% endblock %} -{% if not is_popup and is_nav_sidebar_enabled %} - - -{% endif %} -{% block extrastyle %}{% endblock %} -{% if LANGUAGE_BIDI %}{% endif %} -{% block extrahead %}{% endblock %} -{% block responsive %} - - - {% if LANGUAGE_BIDI %}{% endif %} -{% endblock %} -{% block blockbots %}{% endblock %} -{% block page-icon %}{% endblock %} - - - -{% translate 'Skip to main content' %} - -
    - - {% if not is_popup %} - - {% block header %} - - {% endblock %} - - {% block nav-breadcrumbs %} - - {% endblock %} - {% endif %} - -
    - {% if not is_popup and is_nav_sidebar_enabled %} - {% block nav-sidebar %} - {% include "admin/nav_sidebar.html" %} - {% endblock %} - {% endif %} -
    - {% block messages %} - {% if messages %} -
      {% for message in messages %} - {{ message|capfirst }} - {% endfor %}
    - {% endif %} - {% endblock messages %} - -
    - {% block pretitle %}{% endblock %} - {% block content_title %}{% if title %}

    {{ title }}

    {% endif %}{% endblock %} - {% block content_subtitle %}{% if subtitle %}

    {{ subtitle }}

    {% endif %}{% endblock %} - {% block content %} - {% block object-tools %}{% endblock %} - {{ content }} - {% endblock %} - {% block sidebar %}{% endblock %} -
    -
    - - {% block footer %}{% endblock %} -
    -
    -
    - - - - - - - - - - - diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html deleted file mode 100755 index 38086f0..0000000 --- a/templates/admin/base_site.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "admin/base.html" %} -{% load static %} - -{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} - -{% block branding %} -

    - - - -

    -{% endblock %} - -{% block nav-global %}{% endblock %} diff --git a/templates/admin/hosts/host/manage_permissions.html b/templates/admin/hosts/host/manage_permissions.html deleted file mode 100644 index fcf0c03..0000000 --- a/templates/admin/hosts/host/manage_permissions.html +++ /dev/null @@ -1,106 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n admin_urls static %} - -{% block title %}{{ title }}{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - -{% block content %} -
    -

    {{ host.name }} - 权限管理

    - -
    - 说明:在此页面您可以管理主机上用户的管理员权限。只有关联到此主机的产品用户才会显示在这里。 -
    - - {% if products %} - {% for product in products %} -
    -
    -

    {{ product.display_name }}

    -

    {{ product.display_description|default:"无描述" }}

    -
    -
    - {% with product_users=users|dictsort:"username" %} - {% if product_users %} -
    - - - - - - - - - - - - - {% for user in product_users %} - {% if user.product.id == product.id %} - - - - - - - - - {% endif %} - {% endfor %} - -
    用户名用户姓名邮箱状态管理员权限操作
    {{ user.username }}{{ user.fullname|default:"-" }}{{ user.email|default:"-" }} - - {{ user.get_status_display }} - - - {% if user.is_admin %} - 管理员 - {% else %} - 普通用户 - {% endif %} - - {% if user.is_admin %} - - 撤销管理员 - - {% else %} - - 授予管理员 - - {% endif %} -
    -
    - {% else %} -

    此产品暂无用户

    - {% endif %} - {% endwith %} -
    -
    - {% endfor %} - {% else %} -
    -

    暂无关联产品

    -

    此主机尚未关联任何产品,因此没有可管理的用户权限。

    -
    - {% endif %} - - -
    -{% endblock %} \ No newline at end of file diff --git a/templates/admin_base/audit/auditlog_detail.html b/templates/admin_base/audit/auditlog_detail.html deleted file mode 100644 index 595544c..0000000 --- a/templates/admin_base/audit/auditlog_detail.html +++ /dev/null @@ -1,88 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 审计日志详情{% endblock %} - -{% block breadcrumb %} -审计日志 -chevron_right -日志详情 #{{ log.pk }} -{% endblock %} - -{% block content %} -
    -
    -

    审计日志详情

    -

    此页面为只读

    -
    - - arrow_back - 返回列表 - -
    - -
    - -
    -
    - 操作类型 - -
    -
    - 操作用户 - - {% if log.user %}{{ log.user.username }}{% else %}-{% endif %} - -
    -
    - IP 地址 - - {% if log.ip_address %}{{ log.ip_address }}{% else %}-{% endif %} - -
    -
    - 操作时间 - {{ log.timestamp|date:"Y-m-d H:i:s" }} -
    -
    - 是否成功 - {% if log.success %} - - {% else %} - - {% endif %} -
    -
    -
    - - -
    -
    - 关联主机 - - {% if log.host %}{{ log.host.name }} ({{ log.host.hostname }}){% else %}-{% endif %} - -
    - {% if log.content_type %} -
    - 关联对象 - - {{ log.content_type }} #{{ log.object_id }} - -
    - {% endif %} - {% if log.result %} -
    - 操作结果 - {{ log.result }} -
    - {% endif %} -
    -
    -
    - -{% if log.details %} - -
    {{ log.details|pprint }}
    -
    -{% endif %} -{% endblock %} diff --git a/templates/admin_base/audit/auditlog_list.html b/templates/admin_base/audit/auditlog_list.html deleted file mode 100644 index e4e3255..0000000 --- a/templates/admin_base/audit/auditlog_list.html +++ /dev/null @@ -1,158 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 审计日志{% endblock %} - -{% block breadcrumb %} -审计日志 -chevron_right -日志列表 -{% endblock %} - -{% block content %} -
    -
    -

    审计日志

    -

    查看系统操作记录,此页面为只读

    -
    -
    - - -
    -
    -
    - search - -
    -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    - - 筛选 - -
    -
    - -{% if page_obj %} -
    - - -
    - {% for log in page_obj %} - -
    -
    -
    - {% if log.action == 'create' %} - add_circle - {% elif log.action == 'update' %} - edit - {% elif log.action == 'delete' %} - delete - {% elif log.action == 'login' %} - login - {% elif log.action == 'logout' %} - logout - {% else %} - history - {% endif %} -
    -
    - -
    -
    - - {% if log.success %} - - {% else %} - - {% endif %} - {{ log.timestamp|date:"Y-m-d H:i:s" }} -
    -
    - {% if log.user %} - - person - {{ log.user.username }} - - {% else %} - 系统 - {% endif %} - {% if log.host %} - - · - - dns - {{ log.host.name }} - - - {% endif %} - {% if log.ip_address %} - - · - {{ log.ip_address }} - - {% endif %} -
    - {% if log.description %} -

    {{ log.description|truncatechars:120 }}

    - {% endif %} -
    - - -
    -
    - {% endfor %} -
    -
    - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/base.html b/templates/admin_base/base.html deleted file mode 100644 index 6fb6857..0000000 --- a/templates/admin_base/base.html +++ /dev/null @@ -1,447 +0,0 @@ -{% load static %} -{% load plugin_extensions %} - - - - - - - - {% block title %} - {% block page_title %}{{ site_name }}{% endblock %} - {% endblock %} - - - - - - {% block extra_css %}{% endblock %} - - -
    - Background -
    -
    -
    - -
    -
    -
    -
    -
    - - - -
    -
    -
    - {% if messages %} -
    - {% for message in messages %} -
    -
    - {% if message.tags == 'success' %} - check_circle - {% elif message.tags == 'error' %} - error - {% elif message.tags == 'warning' %} - warning - {% else %} - info - {% endif %} - {{ message }} -
    - -
    - {% endfor %} -
    - {% endif %} -
    - {% block content %}{% endblock %} -
    -
    -
    - - {% block extra_js %}{% endblock %} - - diff --git a/templates/admin_base/dashboard.html b/templates/admin_base/dashboard.html deleted file mode 100644 index 72868eb..0000000 --- a/templates/admin_base/dashboard.html +++ /dev/null @@ -1,177 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - 指挥中心{% endblock %} - -{% block breadcrumb %} -超管后台 -chevron_right -指挥中心 -{% endblock %} - -{% block content %} - -
    -

    指挥中心

    -

    欢迎回来,{{ user.username }},以下是您需要关注的事项和系统状态

    -
    - - - - - -
    - -
    - - {% if attention_items %} -
    - {% for item in attention_items %} -
    -
    -
    - {{ item.icon }} -
    - {{ item.description }} -
    - - {{ item.action_label }} - arrow_forward - -
    - {% endfor %} -
    - {% else %} -
    -
    - check_circle -
    -

    一切正常

    -

    当前没有需要关注的事项

    -
    - {% endif %} -
    -
    - - -
    - -
    - -
    -
    - dns - 主机 -
    -
    - - - {{ system_health.online_hosts }} 在线 - - / - - - {{ system_health.offline_hosts }} 离线 - -
    -
    - - -
    -
    - link - 隧道 -
    -
    - - - {{ system_health.active_tunnels }} 活跃 - - {% if system_health.inactive_tunnels > 0 %} - / - - - {{ system_health.inactive_tunnels }} 异常 - - {% endif %} -
    -
    - -
    - - -
    -
    - person - 活跃用户 -
    - {{ system_health.active_users }} -
    - - -
    -
    - cloud - 产品数 -
    - {{ system_health.active_products }} -
    -
    -
    -
    -
    - - - - {% if recent_activities %} -
    - {% for activity in recent_activities %} -
    - -
    - {{ activity.icon }} -
    - -
    -
    -

    {{ activity.description }}

    - {{ activity.actor }} -
    -

    {{ activity.timestamp|date:"m-d H:i" }}

    -
    -
    - {% endfor %} -
    - {% else %} -
    - event_note -

    暂无最近动态

    -
    - {% endif %} - - {% if recent_activities %} - - {% endif %} -
    -{% endblock %} diff --git a/templates/admin_base/dashboard/systemconfig_edit.html b/templates/admin_base/dashboard/systemconfig_edit.html deleted file mode 100644 index 7b3aedc..0000000 --- a/templates/admin_base/dashboard/systemconfig_edit.html +++ /dev/null @@ -1,315 +0,0 @@ -{% extends "admin_base/base.html" %} -{% load plugin_extensions %} -{% block title %}{{ site_name }} 超级管理员 - 系统配置{% endblock %} -{% block breadcrumb %}系统配置{% endblock %} -{% block content %} - -
    -
    -

    系统配置

    -

    管理系统全局配置(单例模式)

    -
    -
    - - -
    - {% csrf_token %} - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %}

    {{ error }}

    {% endfor %} -
    - {% endif %} - -
    -

    - info - 基本信息 -

    -
    - {% with field=form.site_name %}{% endwith %} -
    - -
    - - -
    - {% if form.enable_registration.help_text %} -

    {{ form.enable_registration.help_text }}

    - {% endif %} -
    -
    -
    - -
    -

    - gavel - 备案信息 -

    -
    - {% with field=form.icp_number %} - - {% endwith %} - {% with field=form.police_number %} - - {% endwith %} -
    -
    - -
    -

    - mail - SMTP 配置 -

    -
    - {% with field=form.smtp_host %} - - {% endwith %} - {% with field=form.smtp_port %} - - {% endwith %} - {% with field=form.smtp_encryption %} - - {% for value, label in form.fields.smtp_encryption.choices %} - - {% endfor %} - - {% endwith %} - {% with field=form.smtp_username %}{% endwith %} - {% with field=form.smtp_password %} - - {% endwith %} - {% with field=form.smtp_from_email %} - - {% endwith %} - {% with field=form.smtp_from_name %}{% endwith %} -
    -
    - -
    -

    - verified_user - 验证码设置 -

    -
    - {% with field=form.captcha_provider %} - - {% for value, label in form.fields.captcha_provider.choices %} - - {% endfor %} - - {% endwith %} - {% with field=form.captcha_type %} - - {% for value, label in form.fields.captcha_type.choices %} - - {% endfor %} - - {% endwith %} -
    -

    场景级别类型留空则使用默认类型

    -
    - {% with field=form.login_captcha_type %} - - - {% for value, label in form.fields.login_captcha_type.choices %} - - {% endfor %} - - {% endwith %} - {% with field=form.register_captcha_type %} - - - {% for value, label in form.fields.register_captcha_type.choices %} - - {% endfor %} - - {% endwith %} - {% with field=form.email_captcha_type %} - - - {% for value, label in form.fields.email_captcha_type.choices %} - - {% endfor %} - - {% endwith %} -
    -
    - -
    -

    - alternate_email - 邮箱后缀配置 -

    -

    白名单和黑名单同时生效时,白名单优先;两者都留空则不限制邮箱后缀。

    -
    -
    - - - {% if form.email_suffix_whitelist.help_text %} -

    {{ form.email_suffix_whitelist.help_text }}

    - {% endif %} - {% if form.email_suffix_whitelist.errors %} -
    - {% for error in form.email_suffix_whitelist.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - - {% if form.email_suffix_blacklist.help_text %} -

    {{ form.email_suffix_blacklist.help_text }}

    - {% endif %} - {% if form.email_suffix_blacklist.errors %} -
    - {% for error in form.email_suffix_blacklist.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - -
    -

    - lock - 安全设置 -

    -
    - -
    - - -
    - {% if form.local_access_locked.help_text %} -

    {{ form.local_access_locked.help_text }}

    - {% endif %} -
    -
    - -
    -

    - language - 主机名品牌绑定 -

    -

    为不同的访问主机名绑定专用的站点名称和图标。未配置的主机名将使用上方"站点名称"的全局默认值和默认图标。

    -
    - - {{ form.hostname_branding }} - {% if form.hostname_branding.help_text %} -

    {{ form.hostname_branding.help_text }}

    - {% endif %} - {% if form.hostname_branding.errors %} -
    - {% for error in form.hostname_branding.errors %}

    {{ error }}

    {% endfor %} -
    - {% endif %} -
    -
    - {% plugin_extensions "system_config_after_sections" %} - -
    - -
    - - - -
    - - 保存配置 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/dashboard/test_email_progress.html b/templates/admin_base/dashboard/test_email_progress.html deleted file mode 100644 index ce7e694..0000000 --- a/templates/admin_base/dashboard/test_email_progress.html +++ /dev/null @@ -1,171 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} - 测试邮件发送中{% endblock %} - -{% block breadcrumb %} -系统配置 -chevron_right -测试邮件 -{% endblock %} - -{% block content %} -
    - - -
    - - - - - - - - - - - -
    - - -
    -
    - terminal - 调试日志 -
    -
    - - -
    -
    -
    -
    - - -{% endblock %} diff --git a/templates/admin_base/dashboard/widget_confirm_delete.html b/templates/admin_base/dashboard/widget_confirm_delete.html deleted file mode 100644 index 037bf15..0000000 --- a/templates/admin_base/dashboard/widget_confirm_delete.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除仪表盘组件{% endblock %} - -{% block breadcrumb %} -仪表盘组件 -chevron_right -删除组件 -{% endblock %} - -{% block content %} - -
    -

    确认删除

    -

    此操作不可撤销

    -
    - - - -
    -

    确定要删除以下仪表盘组件吗?

    -
    -
    - 标题 - {{ widget.title }} -
    -
    - 组件类型 - -
    -
    - 显示顺序 - {{ widget.display_order }} -
    -
    -
    - -
    - - 取消 - -
    - {% csrf_token %} - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/dashboard/widget_form.html b/templates/admin_base/dashboard/widget_form.html deleted file mode 100644 index 589b9d1..0000000 --- a/templates/admin_base/dashboard/widget_form.html +++ /dev/null @@ -1,160 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}仪表盘组件{% endblock %} - -{% block breadcrumb %} -仪表盘组件 -chevron_right -{% if is_create %}创建组件{% else %}编辑组件{% endif %} -{% endblock %} - -{% block content %} -
    -
    -

    {% if is_create %}创建仪表盘组件{% else %}编辑仪表盘组件{% endif %}

    -

    - {% if is_create %}填写以下信息创建新组件{% else %}修改组件「{{ widget.title }}」的信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - -
    - {% csrf_token %} - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - -
    -
    - - - {% if form.widget_type.errors %} -
    - {% for error in form.widget_type.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.title.errors %} -
    - {% for error in form.title.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.display_order.errors %} -
    - {% for error in form.display_order.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - -
    - {% if form.is_enabled.help_text %} -

    {{ form.is_enabled.help_text }}

    - {% endif %} -
    -
    - -
    - - - {% if form.widget_config.help_text %} -

    {{ form.widget_config.help_text }}

    - {% endif %} - {% if form.widget_config.errors %} -
    - {% for error in form.widget_config.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/dashboard/widget_list.html b/templates/admin_base/dashboard/widget_list.html deleted file mode 100644 index 3c63584..0000000 --- a/templates/admin_base/dashboard/widget_list.html +++ /dev/null @@ -1,90 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 仪表盘组件{% endblock %} - -{% block breadcrumb %} -仪表盘组件 -chevron_right -组件列表 -{% endblock %} - -{% block content %} -
    -
    -

    仪表盘组件

    -

    管理系统仪表盘上的组件配置

    -
    - - 添加组件 - -
    - - -
    -
    -
    - search - -
    -
    - - 筛选 - -
    -
    - -{% if page_obj %} - - {% for widget in page_obj %} - - - {{ widget.title }} - - - - - {{ widget.display_order }} - - {% if widget.is_enabled %} - - {% else %} - - {% endif %} - - {{ widget.created_at|date:"Y-m-d H:i" }} - - - - - {% endfor %} - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 添加组件 - - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/groups/group_confirm_delete.html b/templates/admin_base/groups/group_confirm_delete.html deleted file mode 100644 index f2baae8..0000000 --- a/templates/admin_base/groups/group_confirm_delete.html +++ /dev/null @@ -1,54 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除用户组{% endblock %} - -{% block breadcrumb %} -用户组管理 -chevron_right -删除用户组 -{% endblock %} - -{% block content %} -
    - -
    -
    - warning -
    -

    确认删除用户组

    -

    - 您确定要删除用户组「{{ group_profile.group.name }}」吗?此操作不可撤销。 -

    - - {% if group_profile.group.user_set.exists %} -
    -
    - group - 该用户组下仍有成员 -
    -

    - 删除后,以下 {{ group_profile.group.user_set.count }} 名用户将从该组移除: -

    -
    - {% for user in group_profile.group.user_set.all %} - - {{ user.username }} - - {% endfor %} -
    -
    - {% endif %} - -
    - {% csrf_token %} - - 取消 - - - 确认删除 - -
    -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/groups/group_form.html b/templates/admin_base/groups/group_form.html deleted file mode 100644 index 282011c..0000000 --- a/templates/admin_base/groups/group_form.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}用户组{% endblock %} - -{% block breadcrumb %} -用户组管理 -chevron_right -{% if is_create %}创建用户组{% else %}编辑用户组{% endif %} -{% endblock %} - -{% block content %} -
    -
    -

    {% if is_create %}创建用户组{% else %}编辑用户组{% endif %}

    -

    - {% if is_create %}填写以下信息创建新的用户组{% else %}修改用户组「{{ group_profile.group.name }}」的信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - -
    - {% csrf_token %} - -
    -

    - group - 基本信息 -

    -
    -
    - - -
    -
    - - -

    数值越小排序越靠前,默认为0

    -
    -
    -
    - -
    -

    - description - 描述 -

    -
    - - -
    -
    - -
    -

    - badge - 员工身份 -

    - -
    - - {% if not is_create and group_profile.is_default %} -
    -
    - info - 此用户组为系统默认组,不可删除 -
    -
    - {% endif %} - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/groups/group_list.html b/templates/admin_base/groups/group_list.html deleted file mode 100644 index 1bb5922..0000000 --- a/templates/admin_base/groups/group_list.html +++ /dev/null @@ -1,125 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 用户组管理{% endblock %} - -{% block breadcrumb %} -用户组管理 -chevron_right -用户组列表 -{% endblock %} - -{% block content %} -
    -
    -

    用户组管理

    -

    管理系统中的用户组与权限角色

    -
    - - 创建用户组 - -
    - -{% if group_profiles %} -
    - {% for gp in group_profiles %} - -
    -
    -
    - {% if gp.group.name == '超管' %} - shield - {% elif gp.group.name == '主机提供商' %} - dns - {% elif gp.group.name == '云电脑审批' %} - how_to_reg - {% elif gp.group.name == '工单技术客服' %} - support_agent - {% elif gp.group.name == '普通用户' %} - person - {% else %} - group - {% endif %} -
    -
    -
    - {{ gp.group.name }} - {% if gp.is_default %} - - {% endif %} - {% if gp.auto_staff %} - - {% endif %} - - {{ gp.group.user_set.count }} 名成员 - -
    - {% if gp.description %} -

    {{ gp.description }}

    - {% endif %} -
    -
    - -
    - - edit - - {% if not gp.is_default %} - - delete - - {% else %} - - lock - - {% endif %} -
    -
    -
    - {% endfor %} -
    -{% else %} - - - 创建用户组 - - -{% endif %} - -{% if unprofiled_groups %} -
    -

    未配置的用户组

    -
    - {% for group in unprofiled_groups %} - -
    -
    -
    - group -
    -
    - {{ group.name }} - {{ group.user_set.count }} 名成员 -
    -
    - 未配置描述 -
    -
    - {% endfor %} -
    -
    -{% endif %} -{% endblock %} diff --git a/templates/admin_base/hosts/host_confirm_delete.html b/templates/admin_base/hosts/host_confirm_delete.html deleted file mode 100644 index af08c9c..0000000 --- a/templates/admin_base/hosts/host_confirm_delete.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除主机{% endblock %} - -{% block breadcrumb %} -主机管理 -chevron_right -删除主机 -{% endblock %} - -{% block content %} - -
    -

    确认删除

    -

    此操作不可撤销

    -
    - - - -
    -

    确定要删除以下主机吗?

    -
    -
    - 主机名称 - {{ host.name }} -
    -
    - 主机地址 - {{ host.hostname }}:{{ host.port }} -
    -
    - 连接类型 - -
    -
    - 状态 - {% if host.status == 'online' %} - - {% elif host.status == 'error' %} - - {% else %} - - {% endif %} -
    - {% if product_count > 0 %} -
    - 关联产品 - -
    - {% endif %} - {% if host.providers.exists %} -
    - 提供商 -
    - {% for provider in host.providers.all %} - - {% endfor %} -
    -
    - {% endif %} -
    - {% if product_count > 0 %} -
    -
    - error -

    该主机关联了 {{ product_count }} 个产品,删除后关联产品也将被一并删除!

    -
    -
    - {% endif %} -
    - -
    - - 取消 - -
    - {% csrf_token %} - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/hosts/host_detail.html b/templates/admin_base/hosts/host_detail.html deleted file mode 100644 index de2d817..0000000 --- a/templates/admin_base/hosts/host_detail.html +++ /dev/null @@ -1,497 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {{ host.name }}{% endblock %} - -{% block breadcrumb %} -主机管理 -chevron_right -{{ host.name }} -{% endblock %} - -{% block content %} -
    - - - - -
    -
    -

    {{ host.name }}

    -

    {{ host.hostname }}:{{ host.port }}

    -
    - -
    - - -{% if generated_password %} - -
    - key -
    -

    自动生成的密码

    -

    {{ generated_password }}

    -

    请妥善保存,此密码仅显示一次

    -
    -
    -
    -{% endif %} - - - -
    -
    -

    主机名称

    -

    {{ host.name }}

    -
    -
    -

    主机地址

    -

    {{ host.hostname }}

    -
    -
    -

    连接类型

    - -
    -
    -

    连接端口

    -

    {{ host.port }}

    -
    -
    -

    RDP端口

    -

    {{ host.rdp_port }}

    -
    -
    -

    使用SSL

    - {% if host.use_ssl %} - - {% else %} - - {% endif %} -
    -
    -

    用户名

    -

    {{ host.username }}

    -
    -
    -

    操作系统

    -

    {{ host.os_version|default:"-" }}

    -
    -
    -

    状态

    - - - -
    -
    -

    创建者

    -

    {{ host.created_by|default:"-" }}

    -
    -
    -

    创建时间

    -

    {{ host.created_at|date:"Y-m-d H:i" }}

    -
    -
    -

    更新时间

    -

    {{ host.updated_at|date:"Y-m-d H:i" }}

    -
    -
    - - {% if host.description %} -
    -

    描述

    -

    {{ host.description }}

    -
    - {% endif %} -
    - - -{% if host.connection_type == 'tunnel' %} - -
    -
    -

    隧道状态

    - {% if host.tunnel_status == 'online' %} - - {% elif host.tunnel_status == 'error' %} - - {% elif host.tunnel_status == 'offline' %} - - {% else %} - - {% endif %} -
    -
    -

    客户端版本

    -

    {{ host.tunnel_client_version|default:"-" }}

    -
    -
    -

    客户端IP

    -

    {{ host.tunnel_client_ip|default:"-" }}

    -
    -
    -

    连接时间

    -

    {{ host.tunnel_connected_at|date:"Y-m-d H:i"|default:"-" }}

    -
    -
    -

    最后心跳

    -

    {{ host.tunnel_last_seen_at|date:"Y-m-d H:i"|default:"-" }}

    -
    -
    -
    -{% endif %} - - - - {% if host.providers.all %} -
    - {% for provider in host.providers.all %} -
    -
    - person -
    -
    -

    {{ provider.username }}

    -

    {{ provider.email|default:"-" }}

    -
    -
    - {% endfor %} -
    - {% else %} -

    暂未分配提供商

    - {% endif %} -
    - - - - {% if host.administrators.all %} -
    - {% for admin in host.administrators.all %} -
    -
    - person -
    -
    -

    {{ admin.username }}

    -

    {{ admin.email|default:"-" }}

    -
    -
    - {% endfor %} -
    - {% else %} -

    暂未分配管理员

    - {% endif %} -
    - - - - {% if products %} -
    - - - - - - - - - {% for product in products %} - - - - - {% endfor %} - -
    产品名称用户数
    {{ product.name }}{{ product.user_count|default:0 }}
    -
    - {% else %} -

    暂无关联产品

    - {% endif %} -
    - - -{% if host.auth_method == 'certificate' %} - -
    -

    - 在目标主机上以管理员权限运行以下命令,自动完成 H 端初始化和证书配置。 -

    - - - - - - -
    -
    -{% endif %} -
    - - -{% endblock %} diff --git a/templates/admin_base/hosts/host_form.html b/templates/admin_base/hosts/host_form.html deleted file mode 100644 index f32b1f6..0000000 --- a/templates/admin_base/hosts/host_form.html +++ /dev/null @@ -1,738 +0,0 @@ -{% extends "admin_base/base.html" %} -{% load plugin_extensions %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}主机{% endblock %} - -{% block breadcrumb %} -主机管理 -chevron_right -{% if is_create %}创建主机{% else %}编辑主机{% endif %} -{% endblock %} - -{% block content %} -
    -
    -
    -

    {% if is_create %}创建主机{% else %}编辑主机{% endif %}

    -

    - {% if is_create %}填写以下信息创建新主机{% else %}修改主机「{{ host.name }}」的信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - -
    - {% csrf_token %} - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - -
    -

    - dns - 基本信息 -

    -
    -
    - - - {% if form.name.errors %} -
    - {% for error in form.name.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - expand_more -
    - {% if form.os_type.errors %} -
    - {% for error in form.os_type.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.hostname.errors %} -
    - {% for error in form.hostname.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - -
    - - {% if form.connection_type.errors %} -
    - {% for error in form.connection_type.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - -
    -

    - settings_ethernet - 连接配置 -

    -
    -
    -
    - - -

    - {% if form.port.errors %} -
    - {% for error in form.port.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.rdp_port.errors %} -
    - {% for error in form.rdp_port.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - -
    - - - (端口5986通常需要SSL) -
    - -
    - -
    - - -
    - -
    - -
    - -
    - -
    -
    - - - {% if form.username.errors %} -
    - {% for error in form.username.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - -
    - {% if host %}

    留空则不修改密码

    {% endif %} - {% if form.password.errors %} -
    - {% for error in form.password.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - -
    -
    - -
    - - -
    -
    - -
    - -
    -

    1. 复制下方PowerShell脚本

    -

    2. 在目标主机上以管理员权限运行

    -

    3. 脚本将自动配置WinRM证书认证并导出证书文件

    -

    4. 将导出的证书文件上传到下方

    -
    -
    -
    -
    - - -
    -
    -
    - terminal - PowerShell -
    -
    - -
    -
    -
    -
    - -
    - -
    -

    请上传PEM格式的客户端证书和私钥文件

    -

    证书文件需包含公钥,私钥文件需包含完整的私钥数据

    -
    -
    -
    - -
    -
    - - -

    - {% if host and host.cert_pem_path %} -

    当前已存储证书文件

    - {% endif %} - {% if form.cert_pem.errors %} -
    - {% for error in form.cert_pem.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - -

    - {% if host and host.cert_key_path %} -

    当前已存储私钥文件

    - {% endif %} - {% if form.cert_key.errors %} -
    - {% for error in form.cert_key.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    -
    -
    - - {% plugin_extensions "host_form_after_auth" %} - -
    -

    - group - 提供商分配 -

    - -
    -
    - - -
    -
    - - - -
    - -
    - -
    - - - - - - - - - - - - - - -
    类型名称操作
    - person_off -

    暂未分配提供商,请使用上方表单添加

    -
    -
    - - -
    - - {% plugin_extensions "host_form_after_providers" %} - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/admin_base/hosts/host_list.html b/templates/admin_base/hosts/host_list.html deleted file mode 100644 index 6648008..0000000 --- a/templates/admin_base/hosts/host_list.html +++ /dev/null @@ -1,176 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 主机管理{% endblock %} - -{% block breadcrumb %} -主机管理 -chevron_right -主机列表 -{% endblock %} - -{% block content %} - -
    -
    -

    主机管理

    -

    管理所有主机,无数据隔离

    -
    - -
    - - - -
    -
    -
    - search - -
    -
    -
    - -
    -
    - -
    - - 筛选 - -
    -
    - - -{% if connection_type_filter or status_filter %} - -{% endif %} - - -{% if page_obj %} -
    - {% for host in page_obj %} - -
    - -
    - -
    - {% if host.status == 'online' %} - dns - {% elif host.status == 'error' %} - dns - {% else %} - dns - {% endif %} -
    - -
    -
    - - {{ host.name }} - - {% if host.status == 'online' %} - - {% elif host.status == 'error' %} - - {% else %} - - {% endif %} -
    -

    - {{ host.hostname }}·{{ host.get_connection_type_display }}·:{{ host.port }} - {% if host.use_ssl %}·SSL{% endif %} -

    - -
    - {% for provider in host.providers.all %} - - person - {{ provider.username }} - - {% empty %} - {% if not host.providers.exists %} - 未分配提供商 - [分配] - {% endif %} - {% endfor %} -
    -
    -
    - - - -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 添加主机 - - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/hosts/host_wizard.html b/templates/admin_base/hosts/host_wizard.html deleted file mode 100644 index 7beb8f8..0000000 --- a/templates/admin_base/hosts/host_wizard.html +++ /dev/null @@ -1,1100 +0,0 @@ -{% extends "admin_base/base.html" %} -{% load plugin_extensions %} - -{% block title %}{{ site_name }} 超级管理员 - 添加主机向导{% endblock %} - -{% block breadcrumb %} -主机管理 -chevron_right -添加主机 -{% endblock %} - -{% block content %} -
    - -
    -
    -

    添加主机

    -

    按照步骤引导创建新主机

    -
    - - arrow_back - 返回列表 - -
    - -
    - -
    - -
    -
    - info
    基本信息 -
    -
    - settings_ethernet
    连接配置 -
    -
    - admin_panel_settings
    分配提供商 -
    -
    - -
    - {% csrf_token %} - - {% if form.non_field_errors %} -
    - - {% for error in form.non_field_errors %}{{ error }}{% endfor %} - -
    - {% endif %} - - -
    - -
    -
    - - - {% if form.name.errors %} -
    - {% for error in form.name.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - expand_more -
    - {% if form.os_type.errors %} -
    - {% for error in form.os_type.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.hostname.errors %} -
    - {% for error in form.hostname.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - -
    - - {% if form.connection_type.errors %} -
    - {% for error in form.connection_type.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    -
    - - -
    - -
    -
    -
    - - -

    - {% if form.port.errors %} -
    - {% for error in form.port.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.rdp_port.errors %} -
    - {% for error in form.rdp_port.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - -
    - - - (端口5986通常需要SSL) -
    - -
    - -
    - - -
    -
    - -
    -
    - - - - -
    -
    - - - {% if form.username.errors %} -
    - {% for error in form.username.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - - -
    - {% if form.password.errors %} -
    - {% for error in form.password.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    -
    - -
    - - -
    - -
    - - -
    - -
    -

    点击下方按钮生成初始化命令,在目标主机上以管理员权限运行即可。

    -
    -
    - - - - - - -
    - - -
    - -
    -

    请上传PEM格式的客户端证书和私钥文件

    -
    -
    -
    - - -

    - {% if form.cert_pem.errors %} -
    - {% for error in form.cert_pem.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -

    - {% if form.cert_key.errors %} -
    - {% for error in form.cert_key.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - - -
    - -
    - - - -
    -
    - - - -
    -
    - - -
    -
    - progress_activity - 等待目标主机连接... -
    -
    - progress_activity - H 端已连接,正在上传证书并测试连接... -
    -
    - -
    - check_circle - 证书上传成功!保存主机后将完成配置 -
    -
    -
    -
    - -
    - check_circle - H 端初始化成功!目标主机已完成配置 -
    -
    -
    -
    - - 等待超时,目标主机未在有效时间内完成初始化。请确认命令已在目标主机上运行。 - -
    -
    - - - -
    -
    -
    -
    -
    - - -
    - -
    -
    - -

    选择可以管理此主机的提供商用户

    - - {% if providers_with_count %} -
    - {% for provider in providers_with_count %} - - {% endfor %} -
    - {% else %} -
    - person_off -

    暂无可用的提供商用户

    -

    请先在用户管理中创建提供商用户

    -
    - {% endif %} - - {% if form.providers.errors %} -
    - {% for error in form.providers.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - -
    - -
    -

    - summarize - 创建预览 -

    -
    -
    - 主机名称 - -
    -
    - 主机系统 - -
    -
    - 主机地址 - -
    -
    - 连接类型 - -
    -
    - 端口 - -
    -
    - SSL - -
    -
    - 连接方式 - -
    - - -
    - 提供商 - -
    -
    -
    -
    -
    -
    - - -
    - -
    - - - - -
    -
    -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} \ No newline at end of file diff --git a/templates/admin_base/hosts/hostgroup_confirm_delete.html b/templates/admin_base/hosts/hostgroup_confirm_delete.html deleted file mode 100644 index 203bcb8..0000000 --- a/templates/admin_base/hosts/hostgroup_confirm_delete.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除主机组{% endblock %} - -{% block breadcrumb %} -主机组管理 -chevron_right -删除主机组 -{% endblock %} - -{% block content %} - -
    -

    确认删除

    -

    此操作不可撤销

    -
    - - - -
    -

    确定要删除以下主机组吗?

    -
    -
    - 组名称 - {{ hostgroup.name }} -
    -
    - 描述 - {{ hostgroup.description|default:"-" }} -
    -
    - 主机数量 - -
    - {% if hostgroup.providers.exists %} -
    - 提供商 -
    - {% for provider in hostgroup.providers.all %} - - {% endfor %} -
    -
    - {% endif %} -
    - {% if host_count > 0 %} -
    -
    - error -

    该主机组包含 {{ host_count }} 台主机,删除主机组不会删除其中的主机。

    -
    -
    - {% endif %} -
    - -
    - - 取消 - -
    - {% csrf_token %} - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/hosts/hostgroup_form.html b/templates/admin_base/hosts/hostgroup_form.html deleted file mode 100644 index ff8b85c..0000000 --- a/templates/admin_base/hosts/hostgroup_form.html +++ /dev/null @@ -1,137 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}主机组{% endblock %} - -{% block breadcrumb %} -主机组管理 -chevron_right -{% if is_create %}创建主机组{% else %}编辑主机组{% endif %} -{% endblock %} - -{% block content %} - -
    -
    -

    {% if is_create %}创建主机组{% else %}编辑主机组{% endif %}

    -

    - {% if is_create %}填写以下信息创建新主机组{% else %}修改主机组「{{ hostgroup.name }}」的信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - - -
    -

    - folder - 基本信息 -

    -
    - {% with field=form.name %} - - {% endwith %} - -
    - {% with field=form.description %} - - {% endwith %} -
    -
    -
    - - -
    -

    - group - 成员分配 -

    -
    - -
    - - - {% if form.hosts.help_text %} -

    {{ form.hosts.help_text }}

    - {% endif %} - {% if form.hosts.errors %} -
    - {% for error in form.hosts.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - - {% if form.providers.help_text %} -

    {{ form.providers.help_text }}

    - {% endif %} - {% if form.providers.errors %} -
    - {% for error in form.providers.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/hosts/hostgroup_list.html b/templates/admin_base/hosts/hostgroup_list.html deleted file mode 100644 index 46f8b92..0000000 --- a/templates/admin_base/hosts/hostgroup_list.html +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 主机组管理{% endblock %} - -{% block breadcrumb %} -主机组管理 -chevron_right -主机组列表 -{% endblock %} - -{% block content %} - -
    -
    -

    主机组管理

    -

    管理所有主机组,无数据隔离

    -
    - -
    - - - -
    -
    -
    - search - -
    -
    - - 筛选 - -
    -
    - - -{% if page_obj %} - - {% for hostgroup in page_obj %} - - - {{ hostgroup.name }} - - - {{ hostgroup.description|default:"-" }} - - - - - -
    - {% for provider in hostgroup.providers.all %} - - {% empty %} - - - {% endfor %} -
    - - - - - - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 创建主机组 - - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/grant_list.html b/templates/admin_base/operations/grant_list.html deleted file mode 100644 index a0f06df..0000000 --- a/templates/admin_base/operations/grant_list.html +++ /dev/null @@ -1,131 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 访问授权{% endblock %} - -{% block breadcrumb %} -{% if is_provider %} -访问授权 -{% else %} -访问授权 -{% endif %} -chevron_right -授权列表 -{% endblock %} - -{% block content %} - -
    -
    -

    访问授权

    -

    查看所有产品访问授权记录

    -
    -
    - - - -
    -
    -
    - search - -
    -
    - - 搜索 - -
    -
    - - -{% if page_obj %} -
    - {% for grant in page_obj %} - -
    - -
    - -
    - {% if grant.is_revoked %} - shield_off - {% elif grant.is_expired %} - shield - {% else %} - shield - {% endif %} -
    - -
    -
    - {{ grant.user.username }} - - → - {% if grant.product %} - {{ grant.product.display_name }} - {% elif grant.product_group %} - {{ grant.product_group.name }} - {% else %} - -- - {% endif %} - - {% if grant.is_revoked %} - - {% elif grant.is_expired %} - - {% else %} - - {% endif %} -
    -

    - schedule - 授权时间: {{ grant.granted_at|date:"Y-m-d H:i" }} - · - 过期时间: - {% if grant.expires_at %} - {% if grant.is_expired %} - {{ grant.expires_at|date:"Y-m-d H:i" }} - {% else %} - {{ grant.expires_at|date:"Y-m-d H:i" }} - {% endif %} - {% else %} - 永久 - {% endif %} -

    - {% if grant.granted_by_token %} -

    - key - 来源令牌: {{ grant.granted_by_token.token|truncatechars:12 }} -

    - {% endif %} - {% if grant.product_group %} -
    - - folder - {{ grant.product_group.name }} - -
    - {% endif %} -
    -
    -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/product_confirm_delete.html b/templates/admin_base/operations/product_confirm_delete.html deleted file mode 100644 index f5732b4..0000000 --- a/templates/admin_base/operations/product_confirm_delete.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除产品 {{ product.display_name }}{% endblock %} - -{% block breadcrumb %} -产品管理 -chevron_right -删除产品 -{% endblock %} - -{% block content %} - -
    -

    删除产品

    -

    此操作不可撤销,请谨慎确认

    -
    - - - - 您即将删除产品 {{ product.display_name }}。 - {% if user_count > 0 %} -
    该产品下还有 {{ user_count }} 个关联用户,删除产品将同时删除这些用户及关联数据。 - {% endif %} -
    此操作不可撤销。 -
    - - - -
    -
    -

    产品名称

    -

    {{ product.display_name }}

    -
    -
    -

    关联主机

    -

    {{ product.host.name }}

    -
    -
    -

    产品组

    -

    {{ product.product_group.name|default:"未分组" }}

    -
    -
    -

    创建者

    -

    - {% if product.created_by %}{{ product.created_by.username }}{% else %}-{% endif %} -

    -
    -
    -

    关联用户数

    -

    {{ user_count }}

    -
    -
    -

    可见性

    -

    {{ product.get_visibility_display }}

    -
    -
    -

    创建时间

    -

    {{ product.created_at|date:"Y-m-d H:i:s" }}

    -
    -
    -
    - - -
    - {% csrf_token %} -
    - - 返回列表 - - 确认删除 -
    -
    -{% endblock %} diff --git a/templates/admin_base/operations/product_form.html b/templates/admin_base/operations/product_form.html deleted file mode 100644 index 9f0bd74..0000000 --- a/templates/admin_base/operations/product_form.html +++ /dev/null @@ -1,493 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建产品{% else %}编辑产品{% endif %}{% endblock %} - -{% block breadcrumb %} -产品管理 -chevron_right -{% if is_create %}创建产品{% else %}编辑产品{% endif %} -{% endblock %} - -{% block content %} -
    -
    -

    {% if is_create %}创建产品{% else %}编辑产品{% endif %}

    -

    - {% if is_create %}填写以下信息创建新的云电脑产品{% else %}修改产品 {{ product.display_name }} 的信息{% endif %} -

    -
    - - -
    - {% csrf_token %} - - -
    -

    - info - 基本信息 -

    -
    -
    - - {{ form.display_name }} - {% if form.display_name.errors %} -
    - {% for error in form.display_name.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - {{ form.product_group }} - expand_more -
    - {% if form.product_group.errors %} -
    - {% for error in form.product_group.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - -
    - - {{ form.display_description }} - {% if form.display_description.errors %} -
    - {% for error in form.display_description.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    -

    - dns - 主机关联与状态 -

    -
    -
    - -
    - - expand_more -
    - {% if form.host.help_text %} -

    {{ form.host.help_text }}

    - {% endif %} - {% if form.host.errors %} -
    - {% for error in form.host.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - -
    - {{ form.visibility }} - expand_more -
    - {% if form.visibility.help_text %} -

    {{ form.visibility.help_text }}

    - {% endif %} - {% if form.visibility.errors %} -
    - {% for error in form.visibility.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.is_available.errors %} -
    - {% for error in form.is_available.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.auto_approval.errors %} -
    - {% for error in form.auto_approval.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - - -
    -

    - shield - 主机保护 -

    -
    - - {% if form.enable_host_protection.errors %} -
    - {% for error in form.enable_host_protection.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    -

    - monitor - 显示配置 -

    -
    -
    - - {{ form.display_hostname }} - {% if form.display_hostname.errors %} -
    - {% for error in form.display_hostname.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - {{ form.rdp_port }} - {% if form.rdp_port.errors %} -
    - {% for error in form.rdp_port.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - - -
    -

    - hard_disk - 磁盘配额管理 -

    - -
    - - {% if form.enable_disk_quota.errors %} -
    - {% for error in form.enable_disk_quota.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    -
    - - 请先选择关联主机 -
    - - -
    -

    -
    - - -
    - - - - - - - - - - - - - -
    磁盘总容量配额 (MB)允许额外申请操作
    -

    配额为 0 表示不限制该磁盘容量

    -
    - - -
    - hard_disk -

    点击"扫描主机磁盘"获取磁盘列表

    -
    - - - - -
    -
    - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - - -
    - - 取消 - - - {% if is_create %}创建产品{% else %}保存修改{% endif %} - -
    -
    -
    -
    - - -{% endblock %} diff --git a/templates/admin_base/operations/product_list.html b/templates/admin_base/operations/product_list.html deleted file mode 100644 index ff3330a..0000000 --- a/templates/admin_base/operations/product_list.html +++ /dev/null @@ -1,370 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 产品管理{% endblock %} - -{% block breadcrumb %} -产品管理 -chevron_right -产品列表 -{% endblock %} - -{% block content %} - -
    - - - -
    -
    -

    产品管理

    -

    管理所有云电脑产品

    -
    - - 创建产品 - -
    - - - -
    -
    -
    - search - -
    -
    -
    - -
    -
    - -
    - - 筛选 - -
    -
    - - -{% if available_filter or visibility_filter %} - -{% endif %} - - -{% if page_obj %} -
    - {% for product in page_obj %} - -
    - -
    - -
    - {% if product.is_available %} - inventory_2 - {% else %} - inventory_2 - {% endif %} -
    - -
    -
    - {{ product.display_name }} - {% if product.is_available %} - - {% else %} - - {% endif %} - {% if product.visibility == 'public' %} - - {% else %} - - {% endif %} -
    -

    - dns - {{ product.host.name }} - · - person - {% if product.created_by %}{{ product.created_by.username }}{% else %}-{% endif %} -

    - -
    - {% if product.product_group %} - - folder - {{ product.product_group.name }} - - {% endif %} - {% if product.enable_disk_quota %} - - hard_disk - 磁盘配额 - - {% endif %} - {% if product.enable_host_protection %} - - shield - 主机保护 - - {% endif %} - {% if product.auto_approval %} - - bolt - 自动审核 - - {% endif %} -
    -
    -
    - - -
    - {% if product.visibility == 'invite_only' %} - - {% endif %} - - edit - - - delete - -
    -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 创建产品 - - -{% endif %} -
    - - -{% endblock %} diff --git a/templates/admin_base/operations/product_wizard.html b/templates/admin_base/operations/product_wizard.html deleted file mode 100644 index 72b0fbd..0000000 --- a/templates/admin_base/operations/product_wizard.html +++ /dev/null @@ -1,741 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 创建产品向导{% endblock %} - -{% block breadcrumb %} -产品管理 -chevron_right -创建产品 -{% endblock %} - -{% block content %} -
    - - -
    -
    -

    创建产品

    -

    按照步骤引导创建新的云电脑产品

    -
    - - arrow_back - 返回列表 - -
    - - -
    - -
    - - -
    -
    - info
    基本信息 -
    -
    - dns
    主机关联 -
    -
    - tune
    高级设置 -
    -
    - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - - {% for error in form.non_field_errors %}{{ error }}{% endfor %} - -
    - {% endif %} - - -
    - -
    - -
    - - - {% if form.display_name.errors %} -
    - {% for error in form.display_name.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - - {% if form.display_description.errors %} -
    - {% for error in form.display_description.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - -
    - - expand_more -
    - {% if form.product_group.errors %} -
    - {% for error in form.product_group.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    -
    - - -
    - -
    - -
    - -
    - - expand_more -
    -

    此产品运行所在的主机

    - {% if form.host.errors %} -
    - {% for error in form.host.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - - -
    -
    - dns - - -
    -
    -
    地址:
    -
    连接类型:
    -
    -
    -
    - - -
    -
    - - -

    用户连接时看到的地址

    - {% if form.display_hostname.errors %} -
    - {% for error in form.display_hostname.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% if form.rdp_port.errors %} -
    - {% for error in form.rdp_port.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    - -
    - - expand_more -
    -

    公开对所有用户可见,邀请访问仅对已授权用户可见

    - {% if form.visibility.errors %} -
    - {% for error in form.visibility.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    -
    - -
    -
    - -
    -
    -
    -
    -
    - - -
    - -
    - -
    - - {% if form.enable_host_protection.errors %} -
    - {% for error in form.enable_host_protection.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - {% if form.enable_disk_quota.errors %} -
    - {% for error in form.enable_disk_quota.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - - -
    -
    - - 请先选择关联主机 -
    - - -
    -

    -
    - - -
    - - - - - - - - - - - - - -
    磁盘总容量配额 (MB)允许额外申请操作
    -

    配额为 0 表示不限制该磁盘容量

    -
    - - -
    - hard_disk -

    点击"扫描主机磁盘"获取磁盘列表

    -
    - - - - -
    -
    - - -
    -

    - summarize - 创建预览 -

    -
    -
    - 显示名称 - -
    -
    - 产品组 - -
    -
    - 关联主机 - -
    -
    - 显示地址 - -
    -
    - RDP端口 - -
    -
    - 可见性 - -
    -
    - 是否可用 - -
    -
    - 自动审核 - -
    -
    - 主机保护 - -
    -
    - 磁盘配额 - -
    - -
    -
    -
    -
    -
    - - -
    - -
    - - - - -
    -
    -
    - - -{% endblock %} diff --git a/templates/admin_base/operations/productgroup_confirm_delete.html b/templates/admin_base/operations/productgroup_confirm_delete.html deleted file mode 100644 index 069b60b..0000000 --- a/templates/admin_base/operations/productgroup_confirm_delete.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除产品组 {{ productgroup.name }}{% endblock %} - -{% block breadcrumb %} -产品组管理 -chevron_right -删除产品组 -{% endblock %} - -{% block content %} - -
    -

    删除产品组

    -

    此操作不可撤销,请谨慎确认

    -
    - - - - 您即将删除产品组 {{ productgroup.name }}。 - {% if product_count > 0 %} -
    该产品组下还有 {{ product_count }} 个关联产品。 - {% endif %} -
    此操作不可撤销。 -
    - - - -
    -
    -

    产品组名称

    -

    {{ productgroup.name }}

    -
    -
    -

    显示顺序

    -

    {{ productgroup.display_order }}

    -
    -
    -

    可见性

    -

    {{ productgroup.get_visibility_display }}

    -
    -
    -

    创建者

    -

    - {% if productgroup.created_by %}{{ productgroup.created_by.username }}{% else %}-{% endif %} -

    -
    -
    -

    关联产品数

    -

    {{ product_count }}

    -
    -
    -

    创建时间

    -

    {{ productgroup.created_at|date:"Y-m-d H:i:s" }}

    -
    -
    -
    - - -
    - {% csrf_token %} -
    - - 返回列表 - - 确认删除 -
    -
    -{% endblock %} diff --git a/templates/admin_base/operations/productgroup_form.html b/templates/admin_base/operations/productgroup_form.html deleted file mode 100644 index 63e062e..0000000 --- a/templates/admin_base/operations/productgroup_form.html +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建产品组{% else %}编辑产品组{% endif %}{% endblock %} - -{% block breadcrumb %} -产品组管理 -chevron_right -{% if is_create %}创建产品组{% else %}编辑产品组{% endif %} -{% endblock %} - -{% block content %} - -
    -

    {% if is_create %}创建产品组{% else %}编辑产品组{% endif %}

    -

    - {% if is_create %}填写以下信息创建新的产品组{% else %}修改产品组 {{ productgroup.name }} 的信息{% endif %} -

    -
    - - - -
    - {% csrf_token %} - -
    - -
    - - {{ form.name }} - {% if form.name.errors %} -
    - {% for error in form.name.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - {{ form.display_order }} - {% if form.display_order.help_text %} -

    {{ form.display_order.help_text }}

    - {% endif %} - {% if form.display_order.errors %} -
    - {% for error in form.display_order.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - -
    - {{ form.visibility }} - expand_more -
    - {% if form.visibility.help_text %} -

    {{ form.visibility.help_text }}

    - {% endif %} - {% if form.visibility.errors %} -
    - {% for error in form.visibility.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - - {% if form.is_active.errors %} -
    - {% for error in form.is_active.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    - - {{ form.description }} - {% if form.description.errors %} -
    - {% for error in form.description.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - - -
    - - 取消 - - - {% if is_create %}创建产品组{% else %}保存修改{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/operations/productgroup_list.html b/templates/admin_base/operations/productgroup_list.html deleted file mode 100644 index ff64fb5..0000000 --- a/templates/admin_base/operations/productgroup_list.html +++ /dev/null @@ -1,104 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 产品组管理{% endblock %} - -{% block breadcrumb %} -产品组管理 -chevron_right -产品组列表 -{% endblock %} - -{% block content %} - -
    -
    -

    产品组管理

    -

    管理所有产品分组

    -
    - - 添加产品组 - -
    - - - -
    -
    -
    - search - -
    -
    - - 筛选 - -
    -
    - - -{% if productgroups %} - - {% for pg in productgroups %} - - - {{ pg.name }} - - {{ pg.description|truncatechars:40|default:"-" }} - {{ pg.display_order }} - - {% if pg.visibility == 'public' %} - - {% else %} - - {% endif %} - - - {% if pg.is_active %} - - {% else %} - - {% endif %} - - - {% if pg.created_by %}{{ pg.created_by.username }}{% else %}-{% endif %} - - {{ pg.created_at|date:"Y-m-d H:i" }} - - - - - {% endfor %} - - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -
    - folder_open -

    暂无产品组

    -

    点击下方按钮添加第一个产品组

    - - 添加产品组 - -
    -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/request_detail.html b/templates/admin_base/operations/request_detail.html deleted file mode 100644 index 31846cf..0000000 --- a/templates/admin_base/operations/request_detail.html +++ /dev/null @@ -1,334 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 申请详情{% endblock %} - -{% block breadcrumb %} -开户申请 -chevron_right -{{ request_obj.username }} -{% endblock %} - -{% block content %} - -
    -
    - - arrow_back - -
    -

    申请详情

    -

    {{ request_obj.username }} - {{ request_obj.target_product.display_name }}

    -
    -
    -
    - {% if request_obj.status == 'pending' %} -
    - {% csrf_token %} - 批准 -
    - - - -
    - {% csrf_token %} -
    - - -
    - -
    -
    - {% elif request_obj.status == 'failed' %} - - -
    - {% csrf_token %} -
    -

    - 确定要重试此失败的开户申请吗?系统将重新执行用户创建流程。 -

    - {% if request_obj.retry_count > 0 %} -

    - 此申请已重试过 {{ request_obj.retry_count }} 次。 -

    - {% endif %} -
    - -
    -
    - {% endif %} -
    -
    - -
    - -
    - - - -
    - {% if request_obj.status == 'pending' %} -
    - schedule -
    - {% elif request_obj.status == 'approved' %} -
    - check_circle -
    - {% elif request_obj.status == 'rejected' %} -
    - cancel -
    - {% elif request_obj.status == 'processing' %} -
    - sync -
    - {% elif request_obj.status == 'completed' %} -
    - task_alt -
    - {% elif request_obj.status == 'failed' %} -
    - error -
    - {% endif %} -
    -

    {{ request_obj.get_status_display }}

    -

    提交于 {{ request_obj.created_at|date:"Y-m-d H:i" }}

    - {% if request_obj.retry_count > 0 %} -

    已重试 {{ request_obj.retry_count }} 次

    - {% endif %} -
    -
    -
    - - - -
    -
    -

    申请人

    -

    {{ request_obj.applicant.username }}

    -
    -
    -

    联系邮箱

    -

    {{ request_obj.contact_email }}

    -
    - {% if request_obj.contact_phone %} -
    -

    联系电话

    -

    {{ request_obj.contact_phone }}

    -
    - {% endif %} -
    -
    - - - -
    -
    -

    用户名

    -

    {{ request_obj.username }}

    -
    -
    -

    用户姓名

    -

    {{ request_obj.user_fullname }}

    -
    -
    -

    用户邮箱

    -

    {{ request_obj.user_email }}

    -
    -
    -

    目标产品

    -

    {{ request_obj.target_product.display_name }}

    -
    - {% if request_obj.user_description %} -
    -

    用户描述

    -

    {{ request_obj.user_description }}

    -
    - {% endif %} - {% if request_obj.requested_disk_capacity %} -
    -

    需求磁盘容量

    -
    - {% for disk, capacity in request_obj.requested_disk_capacity.items %} - - {{ disk }}: {{ capacity }} MB - - {% endfor %} -
    -
    - {% endif %} -
    -
    - - - {% if request_obj.approved_by %} - -
    -
    -

    审核人

    -

    {{ request_obj.approved_by.username }}

    -
    -
    -

    审核时间

    -

    {{ request_obj.approval_date|date:"Y-m-d H:i" }}

    -
    - {% if request_obj.approval_notes %} -
    -

    审核备注

    -

    {{ request_obj.approval_notes }}

    -
    - {% endif %} -
    -
    - {% endif %} - - - {% if request_obj.result_message %} - -
    - {% if request_obj.cloud_user_id %} -
    -

    云电脑用户ID

    -

    {{ request_obj.cloud_user_id }}

    -
    - {% endif %} -
    -

    结果信息

    -

    {{ request_obj.result_message }}

    -
    -
    -
    - {% endif %} -
    - - -
    - -
    - {% for step in timeline %} -
    -
    -
    - - {% if step.done %}check_circle{% else %}radio_button_unchecked{% endif %} - -
    - {% if not forloop.last %} -
    - {% endif %} -
    -
    -

    {{ step.label }}

    - {% if step.time %} -

    {{ step.time|date:"Y-m-d H:i" }}

    - {% endif %} - {% if step.detail %} -

    {{ step.detail }}

    - {% endif %} -
    -
    - {% endfor %} -
    -
    - - - -
    - {% if request_obj.status == 'pending' %} -
    - {% csrf_token %} - -
    - - - -
    - {% csrf_token %} -
    - - -
    - -
    -
    - {% else %} - {% if request_obj.status == 'failed' %} - - -
    - {% csrf_token %} -
    -

    - 确定要重试此失败的开户申请吗?系统将重新执行用户创建流程。 -

    - {% if request_obj.retry_count > 0 %} -

    - 此申请已重试过 {{ request_obj.retry_count }} 次。 -

    - {% endif %} -
    - -
    -
    - {% else %} -

    当前状态无可用操作

    - {% endif %} - {% endif %} -
    -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/operations/request_list.html b/templates/admin_base/operations/request_list.html deleted file mode 100644 index 073017c..0000000 --- a/templates/admin_base/operations/request_list.html +++ /dev/null @@ -1,200 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 开户申请{% endblock %} - -{% block breadcrumb %} -开户申请 -chevron_right -申请列表 -{% endblock %} - -{% block content %} - -
    -
    -

    开户申请

    -

    审核和管理所有开户申请

    -
    -
    - - -
    - - 全部 - - {{ total_count }} - - - {% for value, label, count in status_choices_with_counts %} - - {{ label }} - - {{ count }} - - - {% endfor %} -
    - - - -
    - {% if current_status %} - - {% endif %} -
    -
    - search - -
    -
    - - 搜索 - -
    -
    - - -{% if page_obj %} -
    - {% for req in page_obj %} - -
    - -
    - -
    - {% if req.status == 'pending' %} - mark_email_unread - {% elif req.status == 'approved' %} - mark_email_read - {% elif req.status == 'rejected' %} - mail - {% elif req.status == 'processing' %} - pending - {% elif req.status == 'completed' %} - task_alt - {% elif req.status == 'failed' %} - error - {% else %} - mail - {% endif %} -
    - -
    -
    - - {{ req.username }} - - - → {{ req.target_product.display_name }} - - {% if req.status == 'pending' %} - - {% elif req.status == 'approved' %} - - {% elif req.status == 'rejected' %} - - {% elif req.status == 'processing' %} - - {% elif req.status == 'completed' %} - - {% elif req.status == 'failed' %} - - {% endif %} -
    -

    - person - {{ req.user_fullname }} - · - schedule - {{ req.created_at|date:"Y-m-d H:i" }} - · - 申请人: {{ req.applicant.username }} -

    -
    -
    - - -
    - - visibility - - {% if req.status == 'pending' %} -
    - {% csrf_token %} - -
    -
    - {% csrf_token %} - - -
    - {% elif req.status == 'approved' %} - - check_circle - 已批准 - - {% elif req.status == 'rejected' %} - - cancel - 已驳回 - - {% elif req.status == 'failed' %} -
    - {% csrf_token %} - -
    - {% endif %} -
    -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/route_list.html b/templates/admin_base/operations/route_list.html deleted file mode 100644 index bfafd4f..0000000 --- a/templates/admin_base/operations/route_list.html +++ /dev/null @@ -1,103 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 域名路由{% endblock %} - -{% block breadcrumb %} -域名路由 -chevron_right -路由列表 -{% endblock %} - -{% block content %} - -
    -
    -

    域名路由

    -

    查看所有 RDP 域名路由(只读)

    -
    -
    - - - -
    -
    -
    - search - -
    -
    - - 搜索 - -
    -
    - - -{% if page_obj %} -
    - {% for route in page_obj %} - -
    - -
    - -
    - {% if route.is_active %} - language - {% else %} - language - {% endif %} -
    - -
    -
    - {{ route.domain }} - {% if route.is_active %} - - {% else %} - - {% endif %} -
    -

    - inventory_2 - {{ route.product.display_name }} - · - person - {{ route.assigned_to.username }} -

    -
    - {% if route.is_protected %} - - shield - 主机保护 - - {% endif %} - - schedule - 过期: {{ route.expires_at|date:"m-d H:i" }} - -
    -
    -
    -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/task_list.html b/templates/admin_base/operations/task_list.html deleted file mode 100644 index 88f6bfd..0000000 --- a/templates/admin_base/operations/task_list.html +++ /dev/null @@ -1,157 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 系统任务{% endblock %} - -{% block breadcrumb %} -系统任务 -chevron_right -任务列表 -{% endblock %} - -{% block content %} -
    -
    -

    系统任务

    -

    查看所有 Celery 异步任务执行状态(只读)

    -
    -
    - - -
    -
    -
    - search - -
    -
    - - 搜索 - -
    -
    - -{% if page_obj %} -
    - {% for task in page_obj %} - -
    -
    -
    - {% if task.status == 'pending' %} - hourglass_empty - {% elif task.status == 'running' %} - progress_activity - {% elif task.status == 'success' %} - task_alt - {% elif task.status == 'failed' %} - error - {% elif task.status == 'cancelled' %} - block - {% else %} - task - {% endif %} -
    -
    -
    - {{ task.name }} - {% if task.status == 'pending' %} - - {% elif task.status == 'running' %} - - {% elif task.status == 'success' %} - - {% elif task.status == 'failed' %} - - {% elif task.status == 'cancelled' %} - - {% endif %} -
    -

    - {% if task.target_content_type %} - {{ task.target_content_type }} - · - {% endif %} - schedule - {{ task.created_at|date:"Y-m-d H:i" }} - {% if task.created_by %} - · - person - {{ task.created_by.username }} - {% endif %} - {% if task.duration %} - · - timer - {{ task.duration }} - {% endif %} -

    - {% if task.status == 'running' or task.status == 'pending' %} -
    -
    -
    -
    - {{ task.progress }}% -
    - {% endif %} - {% if task.result %} -

    - description - {% if task.result is string %} - {{ task.result|truncatechars:80 }} - {% else %} - {{ task.result|default_if_none:"" }} - {% endif %} -

    - {% endif %} - {% if task.status == 'failed' and task.error_message %} -

    - warning - {{ task.error_message|truncatechars:80 }} -

    - {% endif %} - {% if task.progress_updates.exists %} -
    - - expand_more - 进度详情 - -
    - {% for p in task.progress_updates.all %} -

    - {{ p.progress }}% - · - {{ p.message|default:"" }} - {{ p.timestamp|date:"H:i:s" }} -

    - {% endfor %} -
    -
    - {% endif %} -
    -
    -
    - {{ task.task_id|truncatechars:16 }} -
    -
    -
    - {% endfor %} -
    - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/token_detail.html b/templates/admin_base/operations/token_detail.html deleted file mode 100644 index 1ff331a..0000000 --- a/templates/admin_base/operations/token_detail.html +++ /dev/null @@ -1,229 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} - 邀请令牌详情{% endblock %} - -{% block breadcrumb %} -{% if is_provider %} -邀请令牌 -{% else %} -邀请令牌 -{% endif %} -chevron_right -令牌详情 -{% endblock %} - -{% block content %} - -
    -
    -

    邀请令牌详情

    -

    查看令牌信息和使用该令牌的用户列表

    -
    -
    - {% if is_provider %} - - arrow_back - 返回列表 - - {% else %} - - arrow_back - 返回列表 - - {% endif %} -
    -
    - - - -
    -
    -
    - {% if token_obj.is_valid %} - key - {% elif token_obj.is_expired %} - key_off - {% else %} - key - {% endif %} -
    -
    -
    - {{ token_obj.token }} -
    - -
    - {% if not token_obj.is_active %} - - {% elif token_obj.is_expired %} - - {% elif token_obj.is_exhausted %} - - {% else %} - - {% endif %} -
    -
    -
    - inventory_2 - 关联产品: - {% if token_obj.product %} - {{ token_obj.product.display_name }} - {% elif token_obj.product_group %} - {{ token_obj.product_group.name }} - {% else %} - -- - {% endif %} -
    -
    - counter_1 - 使用次数: - {{ token_obj.used_count }}{% if token_obj.max_uses > 0 %}/{{ token_obj.max_uses }}{% else %}/∞{% endif %} -
    -
    - schedule - 过期时间: - {% if token_obj.expires_at %} - {% if token_obj.is_expired %} - {{ token_obj.expires_at|date:"Y-m-d H:i" }} - {% else %} - {{ token_obj.expires_at|date:"Y-m-d H:i" }} - {% endif %} - {% else %} - 永不过期 - {% endif %} -
    -
    - person - 创建者: - {% if token_obj.created_by %}{{ token_obj.created_by.username }}{% else %}-{% endif %} -
    -
    - event - 创建时间: - {{ token_obj.created_at|date:"Y-m-d H:i" }} -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    - group -
    -
    -

    {{ grant_count }}

    -

    总使用人数

    -
    -
    -
    -
    -
    -
    - verified_user -
    -
    -

    {{ effective_grant_count }}

    -

    有效授权

    -
    -
    -
    -
    -
    -
    - counter_1 -
    -
    -

    {{ token_obj.used_count }}{% if token_obj.max_uses > 0 %}/{{ token_obj.max_uses }}{% else %}/∞{% endif %}

    -

    使用次数

    -
    -
    -
    -
    - - - -
    - person_add -

    使用记录

    - ({{ grant_count }}人) -
    - - {% if grants %} -
    - {% for grant in grants %} -
    -
    - {% if grant.is_revoked %} - person_off - {% elif grant.is_expired %} - person - {% else %} - person - {% endif %} -
    -
    -
    - {{ grant.user.username }} - {% if grant.user.email %} - {{ grant.user.email }} - {% endif %} - {% if grant.is_revoked %} - - {% elif grant.is_expired %} - - {% else %} - - {% endif %} -
    -

    - schedule - 授权时间: {{ grant.granted_at|date:"Y-m-d H:i" }} - · - 过期时间: - {% if grant.expires_at %} - {% if grant.is_expired %} - {{ grant.expires_at|date:"Y-m-d H:i" }} - {% else %} - {{ grant.expires_at|date:"Y-m-d H:i" }} - {% endif %} - {% else %} - 永久 - {% endif %} -

    - {% if grant.product_group %} -

    - folder - 产品组: {{ grant.product_group.name }} -

    - {% endif %} -
    -
    - {% endfor %} -
    - - {% if grants.has_other_pages %} -
    - -
    - {% endif %} - - {% else %} - - {% endif %} -
    -{% endblock %} diff --git a/templates/admin_base/operations/token_list.html b/templates/admin_base/operations/token_list.html deleted file mode 100644 index f924635..0000000 --- a/templates/admin_base/operations/token_list.html +++ /dev/null @@ -1,149 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} - 邀请令牌{% endblock %} - -{% block breadcrumb %} -{% if is_provider %} -邀请令牌 -{% else %} -邀请令牌 -{% endif %} -chevron_right -令牌列表 -{% endblock %} - -{% block content %} - -
    -
    -

    邀请令牌

    -

    查看所有产品邀请令牌

    -
    -
    - - - -
    -
    -
    - search - -
    -
    - - 搜索 - -
    -
    - - -{% if page_obj %} -
    - {% for token in page_obj %} - -
    - -
    - -
    - {% if token.is_valid %} - key - {% elif token.is_expired %} - key_off - {% else %} - key - {% endif %} -
    - -
    -
    - - {{ token.token|truncatechars:16 }} -
    - -
    - {% if not token.is_active %} - - {% elif token.is_expired %} - - {% elif token.is_exhausted %} - - {% else %} - - {% endif %} -
    -

    - inventory_2 - {% if token.product %} - {{ token.product.display_name }} - {% elif token.product_group %} - {{ token.product_group.name }} - {% else %} - -- - {% endif %} - · - counter_1 - {{ token.used_count }}{% if token.max_uses > 0 %}/{{ token.max_uses }}{% else %}/∞{% endif %} -

    -

    - schedule - {% if token.expires_at %} - {% if token.is_expired %} - {{ token.expires_at|date:"Y-m-d H:i" }} - {% else %} - {{ token.expires_at|date:"Y-m-d H:i" }} - {% endif %} - {% else %} - 永不过期 - {% endif %} - · - person - {% if token.created_by %}{{ token.created_by.username }}{% else %}-{% endif %} -

    -
    -
    - -
    - {% if is_provider %} - - visibility - 查看详情 - - {% else %} - - visibility - 查看详情 - - {% endif %} -
    -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/operations/user_detail.html b/templates/admin_base/operations/user_detail.html deleted file mode 100644 index 3c40d21..0000000 --- a/templates/admin_base/operations/user_detail.html +++ /dev/null @@ -1,500 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {{ cloud_user.username }}{% endblock %} - -{% block breadcrumb %} -云电脑用户 -chevron_right -{{ cloud_user.username }} -{% endblock %} - -{% block content %} -
    - - - - - -
    -
    - - arrow_back - -
    -

    -

    {{ cloud_user.product.display_name }}

    -
    -
    -
    - - - - -
    -
    - - - - - -
    - - - -
    -
    - 用户名 - -
    -
    - 姓名 - {{ cloud_user.fullname|default:"-" }} -
    -
    - 邮箱 - {{ cloud_user.email|default:"-" }} -
    -
    - 描述 - {{ cloud_user.description|default:"-" }} -
    -
    - 用户组 - {{ cloud_user.groups|default:"-" }} -
    -
    -
    - - - -
    -
    - 所属产品 - {{ cloud_user.product.display_name }} -
    -
    - 关联主机 - {{ cloud_user.product.host.name }} -
    -
    - 管理员权限 - - -
    -
    - 所有者 - - {% if cloud_user.owner %}{{ cloud_user.owner.username }}{% else %}-{% endif %} - -
    -
    - 创建时间 - {{ cloud_user.created_at|date:"Y-m-d H:i:s" }} -
    -
    - 更新时间 - {{ cloud_user.updated_at|date:"Y-m-d H:i:s" }} -
    -
    -
    -
    - - - -
    - - - - - - -
    -
    - - -{% if cloud_user.created_from_request %} - -
    - -
    - 申请人 - - {% if cloud_user.created_from_request.applicant %}{{ cloud_user.created_from_request.applicant.username }}{% else %}-{% endif %} - -
    -
    -
    -{% endif %} - - - - - - - -
    - - -{% endblock %} diff --git a/templates/admin_base/operations/user_list.html b/templates/admin_base/operations/user_list.html deleted file mode 100644 index 033cb75..0000000 --- a/templates/admin_base/operations/user_list.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 云电脑用户{% endblock %} - -{% block breadcrumb %} -云电脑用户 -chevron_right -用户列表 -{% endblock %} - -{% block content %} - -
    -
    -

    云电脑用户

    -

    管理所有云电脑用户

    -
    -
    - - - -
    -
    -
    - search - -
    -
    -
    - -
    -
    - -
    - - 筛选 - -
    -
    - - -{% if page_obj %} -
    - {% for user in page_obj %} - -
    - -
    - -
    - {% if user.status == 'active' %} - person - {% elif user.status == 'disabled' %} - person_off - {% elif user.status == 'deleted' %} - person_remove - {% else %} - person - {% endif %} -
    - -
    -
    - - {{ user.username }} - - {% if user.status == 'active' %} - - {% elif user.status == 'inactive' %} - - {% elif user.status == 'disabled' %} - - {% elif user.status == 'deleted' %} - - {% endif %} - {% if user.is_admin %} - - {% endif %} -
    -

    - inventory_2 - {{ user.product.display_name }} - · - dns - {{ user.product.host.name }} -

    - - {% if user.disk_quota %} -
    - {% for disk, quota in user.disk_quota.items %} - - hard_disk - {{ disk }} {{ quota }}MB - - {% endfor %} -
    - {% endif %} -
    -
    - - - -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/provider/coming_soon.html b/templates/admin_base/provider/coming_soon.html deleted file mode 100644 index 72a3cff..0000000 --- a/templates/admin_base/provider/coming_soon.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 提供商后台 - {{ page_title }}{% endblock %} - -{% block breadcrumb %} -首页 -chevron_right -{{ page_title }} -{% endblock %} - -{% block content %} - -
    -
    -
    - {{ feature_icon }} -
    -

    {{ feature_name }}

    -

    该功能正在开发中,敬请期待

    - - arrow_back - 返回仪表盘 - -
    -
    -{% endblock %} diff --git a/templates/admin_base/provider/dashboard.html b/templates/admin_base/provider/dashboard.html deleted file mode 100644 index 87aa1f2..0000000 --- a/templates/admin_base/provider/dashboard.html +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 提供商后台 - 仪表盘{% endblock %} - -{% block breadcrumb %} -仪表盘 -{% endblock %} - -{% block content %} - -
    -

    仪表盘

    -

    欢迎回来,{{ user.username }}

    -
    - - -
    - - -
    -

    {{ stats.host_count }}

    - 查看全部 -
    -
    - - - -
    -

    {{ stats.product_count }}

    - 查看全部 -
    -
    - - - -
    -

    {{ stats.pending_request_count }}

    - 去处理 -
    -
    - - - -
    -

    {{ stats.active_user_count }}

    - 查看全部 -
    -
    -
    - - -
    - -
    -
    - workspaces -
    -

    {{ stats.hostgroup_count }}

    -

    主机组

    -
    -
    -
    - - -
    -
    - category -
    -

    {{ stats.productgroup_count }}

    -

    产品组

    -
    -
    -
    - - -
    -
    - link -
    -

    {{ stats.invitation_token_count }}

    -

    活跃邀请码

    -
    -
    -
    - - -
    -
    - key -
    -

    {{ stats.access_grant_count }}

    -

    有效授权

    -
    -
    -
    - - -
    -
    - language -
    -

    {{ stats.rdp_route_count }}

    -

    域名路由

    -
    -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/providers/host_list.html b/templates/admin_base/providers/host_list.html deleted file mode 100644 index 4d6f84a..0000000 --- a/templates/admin_base/providers/host_list.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 主机提供商分配{% endblock %} - -{% block breadcrumb %} -超管后台 -chevron_right -主机提供商分配 -{% endblock %} - -{% block content %} -
    -
    -

    主机提供商分配

    -

    管理所有主机的提供商分配,控制哪些提供商可以管理哪些主机

    -
    -
    - - -
    -
    -
    - search - -
    -
    -
    - -
    -
    - -
    - - 筛选 - -
    -
    - -{% if hosts %} - - {% for host in hosts %} - - - {{ host.name }} - - {{ host.hostname }}:{{ host.port }} - - {% if host.connection_type == 'winrm' %} - - {% elif host.connection_type == 'ssh' %} - - {% elif host.connection_type == 'tunnel' %} - - {% elif host.connection_type == 'localwinserver' %} - - {% else %} - - {% endif %} - - - {% if host.status == 'online' %} - - {% elif host.status == 'offline' %} - - {% elif host.status == 'error' %} - - {% else %} - - {% endif %} - - - {% if host.providers.all %} -
    - {% for provider in host.providers.all %} - - {% endfor %} -
    - {% else %} - 未分配 - {% endif %} - - - {% if host.created_by %} - {{ host.created_by.username }} - {% else %} - - - {% endif %} - - - - group_add - 分配提供商 - - - - {% endfor %} -
    - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/providers/host_provider_assign.html b/templates/admin_base/providers/host_provider_assign.html deleted file mode 100644 index 7c3adac..0000000 --- a/templates/admin_base/providers/host_provider_assign.html +++ /dev/null @@ -1,226 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 分配提供商给主机{% endblock %} - -{% block breadcrumb %} -超管后台 -chevron_right -主机提供商分配 -chevron_right -{{ host.name }} -{% endblock %} - -{% block content %} -
    -
    -

    分配提供商 - {{ host.name }}

    -

    选择可以管理此主机的提供商用户

    -
    - - - 返回列表 - - -
    - - -
    -
    -

    主机名称

    -

    {{ host.name }}

    -
    -
    -

    主机地址

    -

    {{ host.hostname }}:{{ host.port }}

    -
    -
    -

    连接类型

    -

    {{ host.get_connection_type_display }}

    -
    -
    -

    状态

    - {% if host.status == 'online' %} - - {% elif host.status == 'offline' %} - - {% elif host.status == 'error' %} - - {% else %} - - {% endif %} -
    -
    -
    - - -
    - {% csrf_token %} - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - -
    -
    -
    - - -
    -
    - - - -
    - -
    - -
    - - - - - - - - - - - - - - -
    类型名称操作
    - person_off -

    暂未分配提供商,请使用上方表单添加

    -
    -
    - - -
    - -
    - - 取消 - - - 保存分配 - -
    -
    -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/admin_base/providers/hostgroup_list.html b/templates/admin_base/providers/hostgroup_list.html deleted file mode 100644 index b138706..0000000 --- a/templates/admin_base/providers/hostgroup_list.html +++ /dev/null @@ -1,93 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 主机组提供商分配{% endblock %} - -{% block breadcrumb %} -超管后台 -chevron_right -主机组提供商分配 -{% endblock %} - -{% block content %} -
    -
    -

    主机组提供商分配

    -

    管理所有主机组的提供商分配,控制哪些提供商可以管理哪些主机组

    -
    -
    - - -
    -
    -
    - search - -
    -
    - - 筛选 - -
    -
    - -{% if hostgroups %} - - {% for group in hostgroups %} - - - {{ group.name }} - - - {% if group.description %} - {{ group.description|truncatechars:40 }} - {% else %} - - - {% endif %} - - - - - - {% if group.providers.all %} -
    - {% for provider in group.providers.all %} - - {% endfor %} -
    - {% else %} - 未分配 - {% endif %} - - - {% if group.created_by %} - {{ group.created_by.username }} - {% else %} - - - {% endif %} - - - - group_add - 分配提供商 - - - - {% endfor %} -
    - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/providers/hostgroup_provider_assign.html b/templates/admin_base/providers/hostgroup_provider_assign.html deleted file mode 100644 index 7e9c5e6..0000000 --- a/templates/admin_base/providers/hostgroup_provider_assign.html +++ /dev/null @@ -1,117 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 分配提供商给主机组{% endblock %} - -{% block breadcrumb %} -超管后台 -chevron_right -主机组提供商分配 -chevron_right -{{ hostgroup.name }} -{% endblock %} - -{% block content %} -
    -
    -

    分配提供商 - {{ hostgroup.name }}

    -

    选择可以管理此主机组的提供商用户

    -
    - - - 返回列表 - - -
    - - -
    -
    -

    组名称

    -

    {{ hostgroup.name }}

    -
    -
    -

    描述

    -

    - {% if hostgroup.description %} - {{ hostgroup.description }} - {% else %} - - - {% endif %} -

    -
    -
    -

    包含主机数

    -

    {{ hostgroup.hosts.count }} 台

    -
    -
    - - {% if hostgroup.hosts.all %} -
    -

    包含的主机:

    -
    - {% for host in hostgroup.hosts.all %} - - {% endfor %} -
    -
    - {% endif %} -
    - - - {% if current_providers %} -
    - {% for provider in current_providers %} -
    - person - {{ provider.username }} - {% if provider.email %} - ({{ provider.email }}) - {% endif %} -
    - {% endfor %} -
    - {% else %} -
    - info - 尚未分配任何提供商 -
    - {% endif %} -
    - - - - 选择可以管理此主机组的提供商用户。提供商可以查看和管理分配给他们的主机组及其包含的主机资源。按住 Ctrl/Cmd 可多选。 - - -
    - {% csrf_token %} -
    - - {{ form.providers }} - {% if form.providers.help_text %} -

    {{ form.providers.help_text }}

    - {% endif %} - {% if form.providers.errors %} -
    - {% for error in form.providers.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - -
    - - - 取消 - - - - 保存分配 - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/reglinks/reglink_confirm_delete.html b/templates/admin_base/reglinks/reglink_confirm_delete.html deleted file mode 100644 index 1a888a5..0000000 --- a/templates/admin_base/reglinks/reglink_confirm_delete.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除注册链接{% endblock %} - -{% block breadcrumb %} -注册链接管理 -chevron_right -删除注册链接 -{% endblock %} - -{% block content %} -
    -
    -

    删除注册链接

    -

    确认删除此注册链接

    -
    - - arrow_back - 返回列表 - -
    - - -
    -
    - warning -
    -

    确认删除

    -

    确定要删除此注册链接吗?此操作不可撤销。

    - -
    -
    - 用户组 - {{ reglink.group.name }} -
    -
    - 使用次数 - - {% if reglink.max_uses == 0 %} - 已用 {{ reglink.used_count }}次 / 不限 - {% else %} - 已用 {{ reglink.used_count }}/{{ reglink.max_uses }}次 - {% endif %} - -
    -
    - 创建者 - {{ reglink.created_by.username|default:"未知" }} -
    -
    - 创建时间 - {{ reglink.created_at|date:"Y-m-d H:i" }} -
    - {% if reglink.note %} -
    - 备注 - {{ reglink.note }} -
    - {% endif %} -
    - -
    - {% csrf_token %} - - 取消 - - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/reglinks/reglink_form.html b/templates/admin_base/reglinks/reglink_form.html deleted file mode 100644 index 9111a4c..0000000 --- a/templates/admin_base/reglinks/reglink_form.html +++ /dev/null @@ -1,121 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 创建注册链接{% endblock %} - -{% block breadcrumb %} -注册链接管理 -chevron_right -创建注册链接 -{% endblock %} - -{% block content %} -
    -
    -

    创建注册链接

    -

    创建注册链接,注册后将自动加入指定用户组

    -
    - - arrow_back - 返回列表 - -
    - - -
    - {% csrf_token %} - -
    -

    - group - 用户组设置 -

    -
    - - -

    通过此链接注册的用户将自动加入所选用户组

    -
    -
    - -
    -

    - counter_1 - 使用次数设置 -

    -
    - - -

    设置为0表示不限制使用次数,设置为1则为一次性链接

    -
    -
    - -
    -

    - schedule - 有效期设置 -

    -
    - - -

    留空表示永不过期

    -
    -
    - -
    -

    - description - 备注 -

    -
    - - -

    仅后台可见,方便管理

    -
    -
    - -
    -
    - info - 注册链接达到最大使用次数后将自动失效,不受系统注册开关影响 -
    -
    - -
    - - 取消 - - 创建链接 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/reglinks/reglink_list.html b/templates/admin_base/reglinks/reglink_list.html deleted file mode 100644 index 4496215..0000000 --- a/templates/admin_base/reglinks/reglink_list.html +++ /dev/null @@ -1,208 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 注册链接管理{% endblock %} - -{% block breadcrumb %} -注册链接管理 -chevron_right -注册链接列表 -{% endblock %} - -{% block content %} -
    -
    -

    注册链接管理

    -

    创建注册链接,指定注册后加入的用户组

    -
    - - 创建注册链接 - -
    - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    - 筛选 -
    -
    - -{% if page_obj %} -
    - {% for link in page_obj %} - -
    -
    -
    - - {% if link.is_exhausted %}link_off{% elif link.is_expired %}timer_off{% else %}link{% endif %} - -
    -
    -
    - {{ link.group.name }} - {% if link.is_exhausted %} - - {% elif link.is_expired %} - - {% else %} - - {% endif %} - {% if link.max_uses == 0 %} - - {% endif %} -
    - {% if link.note %} -

    {{ link.note }}

    - {% endif %} -
    - - counter_1 - {% if link.max_uses == 0 %} - 已用 {{ link.used_count }}次 / 不限 - {% else %} - 已用 {{ link.used_count }}/{{ link.max_uses }}次 - {% endif %} - - - person - 创建者: {{ link.created_by.username|default:"未知" }} - - - schedule - 创建: {{ link.created_at|date:"Y-m-d H:i" }} - - {% if link.expires_at %} - - event_busy - 过期: {{ link.expires_at|date:"Y-m-d H:i" }} - - {% else %} - - all_inclusive - 永不过期 - - {% endif %} - {% if link.used_count > 0 %} - - how_to_reg - 最后使用者: {{ link.used_by.username|default:"未知" }} - - - done_all - 最后使用: {{ link.used_at|date:"Y-m-d H:i" }} - - {% endif %} -
    - {% if not link.is_exhausted and not link.is_expired %} -
    - - {% url 'accounts:register_by_link' token=link.token as link_path %}{{ request.scheme }}://{{ request.get_host }}{{ link_path }} - - -
    - {% endif %} -
    -
    - -
    - {% if not link.is_exhausted %} - - delete - - {% else %} - - lock - - {% endif %} -
    -
    -
    - {% endfor %} -
    - -{% if page_obj.has_other_pages %} - -{% endif %} - -{% else %} - - - 创建注册链接 - - -{% endif %} -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/admin_base/themes/pagecontent_confirm_delete.html b/templates/admin_base/themes/pagecontent_confirm_delete.html deleted file mode 100644 index 58c34b0..0000000 --- a/templates/admin_base/themes/pagecontent_confirm_delete.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除页面内容{% endblock %} - -{% block breadcrumb %} -页面内容 -chevron_right -删除内容 -{% endblock %} - -{% block content %} - -
    -

    确认删除

    -

    此操作不可撤销

    -
    - - - -
    -

    确定要删除以下页面内容吗?

    -
    -
    - 位置 - -
    -
    - 标题 - - {% if page.title %}{{ page.title }}{% else %}-{% endif %} - -
    -
    -
    - -
    - - 取消 - -
    - {% csrf_token %} - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/themes/pagecontent_form.html b/templates/admin_base/themes/pagecontent_form.html deleted file mode 100644 index 715b4b8..0000000 --- a/templates/admin_base/themes/pagecontent_form.html +++ /dev/null @@ -1,131 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}页面内容{% endblock %} - -{% block breadcrumb %} -页面内容 -chevron_right -{% if is_create %}创建内容{% else %}编辑内容{% endif %} -{% endblock %} - -{% block content %} - -
    -
    -

    {% if is_create %}创建页面内容{% else %}编辑页面内容{% endif %}

    -

    - {% if is_create %}填写以下信息创建新内容{% else %}修改页面内容信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - -
    - {% with field=form.position %} - - {% for value, label in form.fields.position.choices %} - - {% endfor %} - - {% endwith %} - - {% with field=form.title %} - - {% endwith %} -
    - - -
    - - - {% if form.content.help_text %} -

    {{ form.content.help_text }}

    - {% endif %} - {% if form.content.errors %} -
    - {% for error in form.content.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - -
    - - -
    -
    - - -
    - - - {% if form.metadata.help_text %} -

    {{ form.metadata.help_text }}

    - {% endif %} - {% if form.metadata.errors %} -
    - {% for error in form.metadata.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/themes/pagecontent_list.html b/templates/admin_base/themes/pagecontent_list.html deleted file mode 100644 index 6421163..0000000 --- a/templates/admin_base/themes/pagecontent_list.html +++ /dev/null @@ -1,93 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 页面内容{% endblock %} - -{% block breadcrumb %} -页面内容 -chevron_right -内容列表 -{% endblock %} - -{% block content %} - -
    -
    -

    页面内容

    -

    管理系统中各页面的可编辑内容

    -
    - - 添加内容 - -
    - - - -
    -
    -
    - search - -
    -
    - - 筛选 - -
    -
    - - -{% if page_obj %} - - {% for page in page_obj %} - - - - - - {% if page.title %}{{ page.title }}{% else %}-{% endif %} - - - {% if page.is_enabled %} - - {% else %} - - {% endif %} - - {{ page.updated_at|date:"Y-m-d H:i" }} - - - - - {% endfor %} - - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 添加内容 - - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/themes/themeconfig_edit.html b/templates/admin_base/themes/themeconfig_edit.html deleted file mode 100644 index 6076681..0000000 --- a/templates/admin_base/themes/themeconfig_edit.html +++ /dev/null @@ -1,167 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 主题配置{% endblock %} - -{% block breadcrumb %} -主题配置 -{% endblock %} - -{% block content %} - -
    -
    -

    主题配置

    -

    管理系统主题风格和品牌设置(单例模式)

    -
    - -
    - {% csrf_token %} - 清除缓存 -
    -
    - - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - - -
    -

    - palette - 主题选择 -

    -
    - {% with field=form.active_theme %} - - {% for value, label in form.fields.active_theme.choices %} - - {% endfor %} - - {% endwith %} -
    -
    - - -
    -

    - branding_watermark - 品牌资源 -

    -
    - - - {% if form.branding.help_text %} -

    {{ form.branding.help_text }}

    - {% endif %} - {% if form.branding.errors %} -
    - {% for error in form.branding.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    -

    - colorize - 自定义颜色 -

    -
    - - - {% if form.custom_colors.help_text %} -

    {{ form.custom_colors.help_text }}

    - {% endif %} - {% if form.custom_colors.errors %} -
    - {% for error in form.custom_colors.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    -

    - tune - 高级设置 -

    -
    -
    - - - {% if form.css_overrides.help_text %} -

    {{ form.css_overrides.help_text }}

    - {% endif %} - {% if form.css_overrides.errors %} -
    - {% for error in form.css_overrides.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - -
    - - -
    -
    -
    -
    - - -
    - 保存配置 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/themes/widgetlayout_confirm_delete.html b/templates/admin_base/themes/widgetlayout_confirm_delete.html deleted file mode 100644 index 01893b9..0000000 --- a/templates/admin_base/themes/widgetlayout_confirm_delete.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除组件布局{% endblock %} - -{% block breadcrumb %} -组件布局 -chevron_right -删除布局 -{% endblock %} - -{% block content %} - -
    -

    确认删除

    -

    此操作不可撤销

    -
    - - - -
    -

    确定要删除以下组件布局吗?

    -
    -
    - 组件类型 - {{ layout.widget_type }} -
    -
    - 显示顺序 - {{ layout.display_order }} -
    -
    - 列跨度 - -
    -
    -
    - -
    - - 取消 - -
    - {% csrf_token %} - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/themes/widgetlayout_form.html b/templates/admin_base/themes/widgetlayout_form.html deleted file mode 100644 index badf78a..0000000 --- a/templates/admin_base/themes/widgetlayout_form.html +++ /dev/null @@ -1,115 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}组件布局{% endblock %} - -{% block breadcrumb %} -组件布局 -chevron_right -{% if is_create %}创建布局{% else %}编辑布局{% endif %} -{% endblock %} - -{% block content %} - -
    -
    -

    {% if is_create %}创建组件布局{% else %}编辑组件布局{% endif %}

    -

    - {% if is_create %}填写以下信息创建新布局{% else %}修改组件布局信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - -
    - {% with field=form.widget_type %} - - {% endwith %} - {% with field=form.display_order %} - - {% endwith %} - {% with field=form.column_span %} - - {% for value, label in form.fields.column_span.choices %} - - {% endfor %} - - {% endwith %} - {% with field=form.row_span %} - - {% for value, label in form.fields.row_span.choices %} - - {% endfor %} - - {% endwith %} -
    - - -
    - -
    - - -
    -
    - - -
    - - - {% if form.responsive.help_text %} -

    {{ form.responsive.help_text }}

    - {% endif %} - {% if form.responsive.errors %} -
    - {% for error in form.responsive.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/themes/widgetlayout_list.html b/templates/admin_base/themes/widgetlayout_list.html deleted file mode 100644 index 79012f8..0000000 --- a/templates/admin_base/themes/widgetlayout_list.html +++ /dev/null @@ -1,96 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 组件布局{% endblock %} - -{% block breadcrumb %} -组件布局 -chevron_right -布局列表 -{% endblock %} - -{% block content %} - -
    -
    -

    组件布局

    -

    管理仪表盘组件的布局配置

    -
    - - 添加布局 - -
    - - - -
    -
    -
    - search - -
    -
    - - 筛选 - -
    -
    - - -{% if page_obj %} - - {% for layout in page_obj %} - - - {{ layout.widget_type }} - - {{ layout.display_order }} - - - - - - - - {% if layout.is_visible %} - - {% else %} - - {% endif %} - - - - - - {% endfor %} - - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 添加布局 - - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/tickets/activity_list.html b/templates/admin_base/tickets/activity_list.html deleted file mode 100644 index 4ff1b3b..0000000 --- a/templates/admin_base/tickets/activity_list.html +++ /dev/null @@ -1,129 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - 活动日志{% endblock %} - -{% block breadcrumb %} -超级管理员 -chevron_right -活动日志 -{% endblock %} - -{% block content %} - -
    -
    -

    活动日志

    -

    查看系统中所有工单操作记录

    -
    -
    - - - -
    -
    -
    - search - -
    -
    -
    - -
    - - 筛选 - -
    -
    - - -{% if activities %} -
    - - - -
    - {% for activity in activities %} - -
    - -
    -
    - {% if activity.action == 'create' %} - add_circle - {% elif activity.action == 'status_change' %} - swap_horiz - {% elif activity.action == 'comment' %} - chat - {% elif activity.action == 'assign' %} - person_add - {% elif activity.action == 'close' %} - lock - {% elif activity.action == 'update' %} - edit - {% else %} - history - {% endif %} -
    -
    - - -
    -
    - {% if activity.action == 'create' %} - - {% elif activity.action == 'status_change' %} - - {% elif activity.action == 'comment' %} - - {% elif activity.action == 'assign' %} - - {% elif activity.action == 'close' %} - - {% elif activity.action == 'update' %} - - {% else %} - - {% endif %} - {{ activity.created_at|date:"Y-m-d H:i" }} -
    -
    - {{ activity.actor.username|default:"系统" }} - {{ activity.description|truncatechars:100 }} -
    - {% if activity.ticket %} - - {% endif %} -
    -
    -
    - {% endfor %} -
    -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/tickets/category_confirm_delete.html b/templates/admin_base/tickets/category_confirm_delete.html deleted file mode 100644 index 395265b..0000000 --- a/templates/admin_base/tickets/category_confirm_delete.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - 删除分类 {{ category.name }}{% endblock %} - -{% block breadcrumb %} -超级管理员 -chevron_right -工单分类 -chevron_right -删除 -{% endblock %} - -{% block content %} - -
    -

    删除工单分类

    -

    此操作不可撤销,请谨慎确认

    -
    - - - - 您即将删除工单分类 {{ category.name }}。 - {% if ticket_count > 0 %} -
    该分类下还有 {{ ticket_count }} 个关联工单,删除分类后这些工单的分类将变为空。 - {% endif %} -
    此操作不可撤销。 -
    - - - -
    -
    -

    分类名称

    -

    {{ category.name }}

    -
    -
    -

    图标

    -

    - {{ category.icon|default:"help_outline" }} - {{ category.icon|default:"help_outline" }} -

    -
    -
    -

    默认优先级

    -

    {{ category.get_default_priority_display }}

    -
    -
    -

    SLA时限

    -

    {{ category.sla_hours }}小时

    -
    -
    -

    自动分配给

    -

    {{ category.auto_assign_to.username|default:"-" }}

    -
    -
    -

    关联工单数

    -

    {{ ticket_count }}

    -
    -
    -
    - - -
    - {% csrf_token %} -
    - - - 返回列表 - - - - 确认删除 - -
    -
    -{% endblock %} diff --git a/templates/admin_base/tickets/category_form.html b/templates/admin_base/tickets/category_form.html deleted file mode 100644 index b969bec..0000000 --- a/templates/admin_base/tickets/category_form.html +++ /dev/null @@ -1,247 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - {% if is_create %}创建工单分类{% else %}编辑工单分类{% endif %}{% endblock %} - -{% block breadcrumb %} -超级管理员 -chevron_right -工单分类 -chevron_right -{% if is_create %}创建分类{% else %}编辑分类{% endif %} -{% endblock %} - -{% block content %} - -
    -

    {% if is_create %}创建工单分类{% else %}编辑工单分类{% endif %}

    -

    - {% if is_create %}填写以下信息创建新的工单分类{% else %}修改分类 {{ category.name }} 的信息{% endif %} -

    -
    - - - -
    - {% csrf_token %} - - -
    -

    - info - 基本信息 -

    -
    - -
    - - {{ form.name }} - {% if form.name.errors %} -
    - {% for error in form.name.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - {{ form.icon }} - {% if form.icon.errors %} -
    - {% for error in form.icon.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    - - {{ form.description }} - {% if form.description.errors %} -
    - {% for error in form.description.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    -

    - settings - 配置 -

    -
    - -
    - -
    - {{ form.default_priority }} - expand_more -
    - {% if form.default_priority.errors %} -
    - {% for error in form.default_priority.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - {{ form.sla_hours }} - {% if form.sla_hours.errors %} -
    - {% for error in form.sla_hours.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - -
    - {{ form.auto_assign_to }} - expand_more -
    - {% if form.auto_assign_to.help_text %} -

    {{ form.auto_assign_to.help_text }}

    - {% endif %} - {% if form.auto_assign_to.errors %} -
    - {% for error in form.auto_assign_to.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - -
    - {{ form.auto_assign_to_group }} - expand_more -
    - {% if form.auto_assign_to_group.help_text %} -

    {{ form.auto_assign_to_group.help_text }}

    - {% endif %} - {% if form.auto_assign_to_group.errors %} -
    - {% for error in form.auto_assign_to_group.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - - -
    -

    - tune - 显示设置 -

    -
    - -
    - - - {% if form.is_active.errors %} -
    - {% for error in form.is_active.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - - {% if form.allow_banned_users.errors %} -
    - {% for error in form.allow_banned_users.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    - - -
    - - {{ form.display_order }} - {% if form.display_order.errors %} -
    - {% for error in form.display_order.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    -
    - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} - {{ error }} - {% endfor %} -
    - {% endif %} - - -
    - - - 取消 - - - - {% if is_create %}创建分类{% else %}保存修改{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/tickets/category_list.html b/templates/admin_base/tickets/category_list.html deleted file mode 100644 index 18865cd..0000000 --- a/templates/admin_base/tickets/category_list.html +++ /dev/null @@ -1,136 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - 工单分类{% endblock %} - -{% block breadcrumb %} -超级管理员 -chevron_right -工单分类 -{% endblock %} - -{% block content %} - -
    -
    -

    工单分类

    -

    管理系统中所有工单分类

    -
    - - - 创建分类 - - -
    - - - -
    -
    -
    - search - -
    -
    - - 搜索 - -
    -
    - - -{% if categories %} -
    - {% for category in categories %} - -
    - -
    -
    -
    - {{ category.icon|default:"help_outline" }} -
    -
    - - {{ category.name }} - - {% if category.description %} -

    {{ category.description }}

    - {% endif %} -
    -
    - {% if category.is_active %} - - {% else %} - - {% endif %} -
    - - -
    -
    - confirmation_number - 工单数: {{ category.tickets.count|default:"0" }} -
    -
    - flag - 默认优先级: - {% if category.default_priority == 'urgent' %} - - {% elif category.default_priority == 'high' %} - - {% elif category.default_priority == 'medium' %} - - {% else %} - - {% endif %} -
    -
    - schedule - SLA: {{ category.sla_hours }}小时 -
    - {% if category.auto_assign_to %} -
    - person - 自动分配: {{ category.auto_assign_to.username }} -
    - {% endif %} -
    - - - -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - - 创建分类 - - -{% endif %} -{% endblock %} diff --git a/templates/admin_base/tickets/ticket_detail.html b/templates/admin_base/tickets/ticket_detail.html deleted file mode 100644 index c095181..0000000 --- a/templates/admin_base/tickets/ticket_detail.html +++ /dev/null @@ -1,267 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - 工单 {{ ticket.ticket_no }}{% endblock %} - -{% block breadcrumb %} -超级管理员 -chevron_right -工单管理 -chevron_right -{{ ticket.ticket_no }} -{% endblock %} - -{% block content %} - -
    -
    -
    -

    {{ ticket.ticket_no }}

    - {% if ticket.status == 'pending' %} - - {% elif ticket.status == 'processing' %} - - {% elif ticket.status == 'waiting_feedback' %} - - {% elif ticket.status == 'resolved' %} - - {% elif ticket.status == 'closed' %} - - {% elif ticket.status == 'rejected' %} - - {% endif %} - {% if ticket.is_overdue %} - - {% endif %} -
    -

    {{ ticket.title }}

    -
    - - - 返回列表 - - -
    - -
    - -
    - - - -
    -
    -

    工单编号

    -

    {{ ticket.ticket_no }}

    -
    -
    -

    分类

    -

    {{ ticket.category.name|default:"未分类" }}

    -
    -
    -

    优先级

    -

    - {% if ticket.priority == 'urgent' %} - - {% elif ticket.priority == 'high' %} - - {% elif ticket.priority == 'medium' %} - - {% else %} - - {% endif %} -

    -
    -
    -

    来源

    -

    {{ ticket.get_source_display }}

    -
    -
    -

    创建者

    -

    {{ ticket.creator.username }}

    -
    -
    -

    处理人

    -

    - {% if ticket.assignee or ticket.assigned_group %} - {% if ticket.assignee %}{{ ticket.assignee.username }}{% endif %}{% if ticket.assignee and ticket.assigned_group %} / {% endif %}{% if ticket.assigned_group %}{{ ticket.assigned_group.name }}(组){% endif %} - {% else %} - 未分配 - {% endif %} -

    -
    - {% if ticket.related_cloud_computer %} -
    -

    关联云电脑

    -

    {{ ticket.related_cloud_computer.username }}@{{ ticket.related_cloud_computer.product.display_name }}

    -
    - {% endif %} - {% if ticket.related_request %} -
    -

    关联申请

    -

    {{ ticket.related_request }} ({{ ticket.related_request.get_status_display }})

    -
    - {% endif %} -
    -

    创建时间

    -

    {{ ticket.created_at|date:"Y-m-d H:i:s" }}

    -
    - {% if ticket.due_at %} -
    -

    截止时间

    -

    - {{ ticket.due_at|date:"Y-m-d H:i:s" }} -

    -
    - {% endif %} - {% if ticket.resolved_at %} -
    -

    解决时间

    -

    {{ ticket.resolved_at|date:"Y-m-d H:i:s" }}

    -
    - {% endif %} - {% if ticket.closed_at %} -
    -

    关闭时间

    -

    {{ ticket.closed_at|date:"Y-m-d H:i:s" }}

    -
    - {% endif %} - {% if ticket.satisfaction %} -
    -

    满意度评分

    -

    {{ ticket.satisfaction }}/5

    -
    - {% endif %} -
    - - -
    -

    详细描述

    -
    {{ ticket.description }}
    -
    -
    - - - - {% if comments %} -
    - {% for comment in comments %} -
    -
    -
    -
    - person -
    - {{ comment.author.username }} - {{ comment.created_at|date:"Y-m-d H:i" }} - {% if comment.is_internal %} - - {% endif %} -
    -
    -
    {{ comment.content }}
    -
    - {% endfor %} -
    - {% else %} -

    暂无评论

    - {% endif %} - - -
    -

    添加评论

    -
    - {% csrf_token %} -
    - {{ comment_form.content }} - {% if comment_form.content.errors %} -
    - {% for error in comment_form.content.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - - 提交评论 - -
    -
    -
    -
    -
    - - -
    - - - - {% if attachments %} -
    - {% for attachment in attachments %} -
    -
    - description -
    -

    {{ attachment.filename|truncatechars:30 }}

    -

    {{ attachment.uploaded_by.username }} - {{ attachment.created_at|date:"Y-m-d H:i" }}

    -
    -
    -
    - {% endfor %} -
    - {% else %} -

    暂无附件

    - {% endif %} -
    - - - - {% if activities %} -
    - {% for activity in activities %} -
    -
    - {% if activity.action == 'status_change' %} - swap_horiz - {% elif activity.action == 'assign' %} - person_add - {% elif activity.action == 'comment' %} - chat - {% elif activity.action == 'create' %} - add_circle - {% elif activity.action == 'close' %} - lock - {% else %} - info - {% endif %} -
    -
    -

    {{ activity.description }}

    -

    - {{ activity.actor.username|default:"系统" }} - {{ activity.created_at|date:"Y-m-d H:i" }} -

    -
    -
    - {% endfor %} -
    - {% else %} -

    暂无活动记录

    - {% endif %} - - {% if activities %} - - {% endif %} -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/tickets/ticket_list.html b/templates/admin_base/tickets/ticket_list.html deleted file mode 100644 index e340ebf..0000000 --- a/templates/admin_base/tickets/ticket_list.html +++ /dev/null @@ -1,213 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超管后台 - 工单管理{% endblock %} - -{% block breadcrumb %} -超级管理员 -chevron_right -工单管理 -{% endblock %} - -{% block content %} - -
    -
    -

    工单管理

    -

    查看和管理系统中所有工单

    -
    - - 管理分类 - -
    - - - - - - -
    - {% if status_filter %} - - {% endif %} -
    -
    - search - -
    -
    -
    - -
    - - 筛选 - -
    -
    - - -
    -
    - {% csrf_token %} -
    - - - - -
    - - - {% if tickets %} -
    - {% for ticket in tickets %} - -
    - -
    - - - -
    - {% if ticket.priority == 'urgent' %} - - {% elif ticket.priority == 'high' %} - - {% elif ticket.priority == 'medium' %} - - {% else %} - - {% endif %} -
    - -
    -
    - - {{ ticket.ticket_no }} - - · - - {{ ticket.title|truncatechars:50 }} - - {% if ticket.status == 'pending' %} - - {% elif ticket.status == 'processing' %} - - {% elif ticket.status == 'waiting_feedback' %} - - {% elif ticket.status == 'resolved' %} - - {% elif ticket.status == 'closed' %} - - {% elif ticket.status == 'rejected' %} - - {% endif %} - {% if ticket.priority == 'urgent' %} - - {% elif ticket.priority == 'high' %} - - {% elif ticket.priority == 'medium' %} - - {% else %} - - {% endif %} -
    -

    - {{ ticket.category.name|default:"未分类" }} - · - {{ ticket.creator.username }} - · - {{ ticket.created_at|date:"Y-m-d H:i" }} - {% if ticket.assignee or ticket.assigned_group %} - · - 处理人: {% if ticket.assignee %}{{ ticket.assignee.username }}{% endif %}{% if ticket.assignee and ticket.assigned_group %} / {% endif %}{% if ticket.assigned_group %}{{ ticket.assigned_group.name }}(组){% endif %} - {% endif %} -

    -
    -
    - - - -
    -
    - {% endfor %} -
    - - - {% if page_obj.has_other_pages %} -
    - -
    - {% endif %} - - {% else %} - - - {% endif %} -
    -
    -{% endblock %} diff --git a/templates/admin_base/users/user_confirm_delete.html b/templates/admin_base/users/user_confirm_delete.html deleted file mode 100644 index 741a0a7..0000000 --- a/templates/admin_base/users/user_confirm_delete.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 删除用户{% endblock %} - -{% block breadcrumb %} -用户管理 -chevron_right -删除用户 -{% endblock %} - -{% block content %} - -
    -

    确认删除

    -

    此操作不可撤销

    -
    - - - -
    -

    确定要删除以下用户吗?删除后该用户的所有数据将无法恢复。

    -
    -
    - 用户名 - {{ target_user.username }} -
    -
    - 邮箱 - {{ target_user.email|default:"-" }} -
    -
    - 姓名 - {{ target_user.get_full_name|default:"-" }} -
    -
    - 员工状态 - {% if target_user.is_staff %} - - {% else %} - - {% endif %} -
    -
    - 用户组 - {% if target_user.groups.exists %} -
    - {% for group in target_user.groups.all %} - - {% endfor %} -
    - {% else %} - - - {% endif %} -
    -
    - 创建时间 - {{ target_user.created_at|date:"Y-m-d H:i" }} -
    -
    -
    - -
    - - 取消 - -
    - {% csrf_token %} - 确认删除 -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/users/user_form.html b/templates/admin_base/users/user_form.html deleted file mode 100644 index 4ce00ca..0000000 --- a/templates/admin_base/users/user_form.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {% if is_create %}创建{% else %}编辑{% endif %}用户{% endblock %} - -{% block breadcrumb %} -用户管理 -chevron_right -{% if is_create %}创建用户{% else %}编辑用户{% endif %} -{% endblock %} - -{% block content %} - -
    -
    -

    {% if is_create %}创建用户{% else %}编辑用户{% endif %}

    -

    - {% if is_create %}填写以下信息创建新用户{% else %}修改用户「{{ target_user.username }}」的信息{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - - -
    -

    - person - 基本信息 -

    -
    - - - - -
    -
    - - - {% if is_create %} -
    -

    - key - 密码 -

    -
    - - -
    -
    - {% else %} -
    -

    - key - 密码 -

    - - 编辑用户时不支持修改密码,请使用 - 重置密码 - 功能 - -
    - {% endif %} - - -
    -

    - group - 用户组 -

    -
    - -
    - {{ form.groups }} - expand_more -
    - {% if form.groups.errors %} -
    - {% for error in form.groups.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} -
    -
    - - -
    - - 取消 - - - {% if is_create %}创建{% else %}保存{% endif %} - -
    -
    -
    -{% endblock %} diff --git a/templates/admin_base/users/user_list.html b/templates/admin_base/users/user_list.html deleted file mode 100644 index b206f2d..0000000 --- a/templates/admin_base/users/user_list.html +++ /dev/null @@ -1,216 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 用户管理{% endblock %} - -{% block breadcrumb %} -用户管理 -chevron_right -用户列表 -{% endblock %} - -{% block content %} - -
    -
    -

    用户管理

    -

    管理系统中的所有用户账号

    -
    - - 创建用户 - -
    - - - -
    -
    -
    - search - -
    -
    -
    -
    - - expand_more -
    -
    - - expand_more -
    - - 筛选 - -
    -
    -
    - - -{% if page_obj %} -
    - {% for user in page_obj %} - -
    - -
    - -
    - {{ user.username|first|upper }} -
    - -
    -
    - {{ user.username }} - {% if user.is_superuser %} - - {% endif %} - {% if user.is_staff %} - - {% endif %} - {% if user.is_active %} - - {% else %} - - {% endif %} - {% if user.active_ban %} - - {% endif %} -
    -

    - {{ user.email|default:"未设置邮箱" }} - {% if user.get_full_name %} - · - {{ user.get_full_name }} - {% endif %} -

    - - {% if user.groups.exists %} -
    - {% for group in user.groups.all %} - - {{ group.name }} - - {% endfor %} -
    - {% endif %} -
    -
    - - -
    - - edit - - - key - - -
    - {% csrf_token %} - {% if user.active_ban %} - - {% else %} - - {% endif %} -
    - - - delete - -
    -
    -
    - {% endfor %} -
    - - -{% if page_obj.has_other_pages %} -
    - -
    -{% endif %} - -{% else %} - - - 创建用户 - - -{% endif %} -{% endblock %} - -{% block extra_js %} - - - - -{% endblock %} diff --git a/templates/admin_base/users/user_reset_password.html b/templates/admin_base/users/user_reset_password.html deleted file mode 100644 index 3a5eb46..0000000 --- a/templates/admin_base/users/user_reset_password.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 重置密码{% endblock %} - -{% block breadcrumb %} -用户管理 -chevron_right -{{ target_user.username }} -chevron_right -重置密码 -{% endblock %} - -{% block content %} - -
    -
    -

    重置密码

    -

    为用户「{{ target_user.username }}」设置新密码

    -
    - - arrow_back - 返回列表 - -
    - - - -
    -
    -

    用户名

    -

    {{ target_user.username }}

    -
    -
    -

    邮箱

    -

    {{ target_user.email|default:"-" }}

    -
    -
    -

    姓名

    -

    {{ target_user.get_full_name|default:"-" }}

    -
    -
    -
    - - - -
    - {% csrf_token %} - - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - - - 重置密码后,用户需要使用新密码登录。此操作不可撤销。 - - -
    - {% with field=form.new_password1 %} - - {% endwith %} - {% with field=form.new_password2 %} - - {% endwith %} -
    - - -
    - - 取消 - - - 重置密码 - -
    -
    -
    -{% endblock %} diff --git a/templates/base.html b/templates/base.html deleted file mode 100755 index 9dc6db8..0000000 --- a/templates/base.html +++ /dev/null @@ -1,389 +0,0 @@ -{% load static %} - - - - - - - - {% block title %}{{ site_name }}{% endblock %} - - - - - - - - - - - - - - {% block extra_css %}{% endblock %} - - - -
    - - - - - - {% if messages %} -
    - {% for message in messages %} - - {% endfor %} -
    - {% endif %} - - -
    - {% block content %}{% endblock %} -
    - - - - - - - {% block extra_js %}{% endblock %} - - - - diff --git a/templates/components/alert.html b/templates/components/alert.html deleted file mode 100644 index b95e6a4..0000000 --- a/templates/components/alert.html +++ /dev/null @@ -1,67 +0,0 @@ -{# Material Design 3 警告框组件 #} - -使用示例: -{% include 'components/alert.html' with - type="info" # info, success, warning, error - title="提示标题" # 警告框标题(可选) - dismissible=true # 是否可关闭 - icon=true # 是否显示图标 - classes="custom-class" # 自定义CSS类(可选) -%} -警告内容 -{% endinclude %} - - -{# 设置默认值 #} -{% with type=type|default:"info" dismissible=dismissible|default:False icon=icon|default:True classes=classes|default:"" %} - - - -{% endwith %} - - diff --git a/templates/components/button.html b/templates/components/button.html deleted file mode 100644 index 733e9b0..0000000 --- a/templates/components/button.html +++ /dev/null @@ -1,113 +0,0 @@ -{# Material Design 3 按钮组件 #} - -使用示例: -{% include 'components/button.html' with - variant="filled" # filled, outlined, text, elevated, tonal - size="medium" # small, medium, large - color="primary" # primary, secondary, tertiary, error - icon="check" # 图标名称(可选) - icon_position="start" # start, end(图标位置) - disabled=false # 是否禁用 - href="#" # 链接地址(可选,用于链接按钮) - type="button" # button, submit, reset - text="点击我" # 按钮文本 - classes="custom-class" # 自定义CSS类(可选) -%} - - -{% with variant=variant|default:"filled" size=size|default:"medium" color=color|default:"primary" icon_position=icon_position|default:"start" disabled=disabled|default:False type=type|default:"button" classes=classes|default:"" attributes=attributes|default:"" %} - -{% if variant == "tonal" %} - {% if color == "secondary" %} - - {% elif color == "error" %} - - {% else %} - - {% endif %} - -{% elif variant == "outlined" %} - {% if color == "secondary" %} - - {% elif color == "error" %} - - {% else %} - - {% endif %} - -{% elif variant == "text" %} - {% if color == "secondary" %} - - {% elif color == "error" %} - - {% else %} - - {% endif %} - -{% elif variant == "elevated" %} - - -{% else %} - {# variant == "filled" (default) #} - {% if color == "secondary" %} - - {% elif color == "error" %} - - {% else %} - - {% endif %} -{% endif %} - -{% endwith %} diff --git a/templates/cotton/x_admin_alert.html b/templates/cotton/x_admin_alert.html deleted file mode 100644 index 63976db..0000000 --- a/templates/cotton/x_admin_alert.html +++ /dev/null @@ -1,85 +0,0 @@ -{% firstof attrs.type attrs.variant "info" as alert_type %} -{% if alert_type == "success" %} -
    - check_circle -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% elif alert_type == "error" %} -
    - error -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% elif alert_type == "warning" %} -
    - warning -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% else %} -
    - info -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    -{% endif %} diff --git a/templates/cotton/x_admin_badge.html b/templates/cotton/x_admin_badge.html deleted file mode 100644 index 8c7b03e..0000000 --- a/templates/cotton/x_admin_badge.html +++ /dev/null @@ -1,30 +0,0 @@ -{% if attrs.color == "success" %} - - {% if attrs.icon %}{{ attrs.icon }}{% endif %} - {{ attrs.text }} - - -{% elif attrs.color == "error" %} - - {% if attrs.icon %}{{ attrs.icon }}{% endif %} - {{ attrs.text }} - - -{% elif attrs.color == "warning" %} - - {% if attrs.icon %}{{ attrs.icon }}{% endif %} - {{ attrs.text }} - - -{% elif attrs.color == "info" %} - - {% if attrs.icon %}{{ attrs.icon }}{% endif %} - {{ attrs.text }} - - -{% else %} - - {% if attrs.icon %}{{ attrs.icon }}{% endif %} - {{ attrs.text }} - -{% endif %} diff --git a/templates/cotton/x_admin_button.html b/templates/cotton/x_admin_button.html deleted file mode 100644 index ded2d48..0000000 --- a/templates/cotton/x_admin_button.html +++ /dev/null @@ -1,36 +0,0 @@ -{% if attrs.variant == "outlined" %} - - -{% elif attrs.variant == "text" %} - - -{% elif attrs.color == "error" %} - - -{% else %} - -{% endif %} diff --git a/templates/cotton/x_admin_card.html b/templates/cotton/x_admin_card.html deleted file mode 100644 index 3c03d61..0000000 --- a/templates/cotton/x_admin_card.html +++ /dev/null @@ -1,22 +0,0 @@ -
    - {% if attrs.title or attrs.icon %} -
    - {% if attrs.icon %} - {{ attrs.icon }} - {% endif %} - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {% endif %} - -
    - {{ slot }} -
    - - {% if attrs.footer %} -
    - {{ attrs.footer }} -
    - {% endif %} -
    diff --git a/templates/cotton/x_admin_empty.html b/templates/cotton/x_admin_empty.html deleted file mode 100644 index a1e5472..0000000 --- a/templates/cotton/x_admin_empty.html +++ /dev/null @@ -1,12 +0,0 @@ -
    -
    - {{ attrs.icon|default:"info" }} -
    -

    {{ attrs.title }}

    -

    {{ attrs.description }}

    - {% if slot %} -
    - {{ slot }} -
    - {% endif %} -
    diff --git a/templates/cotton/x_admin_input.html b/templates/cotton/x_admin_input.html deleted file mode 100644 index 5cf5859..0000000 --- a/templates/cotton/x_admin_input.html +++ /dev/null @@ -1,46 +0,0 @@ -
    - {% with f=attrs.field|default:field %} - {% if attrs.label or f %} - - {% endif %} - - {% if attrs.type == 'select' %} - - {% else %} - - {% endif %} - - {% if attrs.help_text|default:f.help_text and not attrs.errors and not f.errors %} -

    {{ attrs.help_text|default:f.help_text }}

    - {% endif %} - - {% if attrs.errors or f.errors %} -
    - {% for error in attrs.errors %} -

    {{ error }}

    - {% endfor %} - {% for error in f.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - {% endwith %} -
    diff --git a/templates/cotton/x_admin_modal.html b/templates/cotton/x_admin_modal.html deleted file mode 100644 index cab52c8..0000000 --- a/templates/cotton/x_admin_modal.html +++ /dev/null @@ -1,46 +0,0 @@ -
    - {{ trigger }} - -
    -
    - -
    -
    -

    {{ attrs.title }}

    - -
    - -
    - {{ slot }} -
    - - {% if footer %} -
    - {{ footer }} -
    - {% endif %} -
    -
    -
    diff --git a/templates/cotton/x_admin_pagination.html b/templates/cotton/x_admin_pagination.html deleted file mode 100644 index ff0f4b7..0000000 --- a/templates/cotton/x_admin_pagination.html +++ /dev/null @@ -1,57 +0,0 @@ -
    -
    - 显示 {{ page_obj.start_index }} - {{ page_obj.end_index }} 条,共 {{ page_obj.paginator.count }} 条 -
    -
    - {% if page_obj.has_previous %} - - first_page - - - chevron_left - - {% else %} - - first_page - - - chevron_left - - {% endif %} - -
    - {% for num in page_obj.paginator.page_range %} - {% if num == page_obj.number %} - - {{ num }} - - {% elif num > page_obj.number|add:"-3" and num < page_obj.number|add:3 %} - - {{ num }} - - {% endif %} - {% endfor %} -
    - - {% if page_obj.has_next %} - - chevron_right - - - last_page - - {% else %} - - chevron_right - - - last_page - - {% endif %} -
    -
    diff --git a/templates/cotton/x_admin_table.html b/templates/cotton/x_admin_table.html deleted file mode 100644 index 9b0e6bf..0000000 --- a/templates/cotton/x_admin_table.html +++ /dev/null @@ -1,19 +0,0 @@ -{% load theme_tags %} -
    - - - - {% if attrs.headers %} - {% for header in attrs.headers|split:"," %} - - {% endfor %} - {% else %} - {{ header }} - {% endif %} - - - - {{ slot }} - -
    {{ header|trim }}
    -
    diff --git a/templates/cotton/x_md_alert.html b/templates/cotton/x_md_alert.html deleted file mode 100644 index c8ca588..0000000 --- a/templates/cotton/x_md_alert.html +++ /dev/null @@ -1,105 +0,0 @@ -{% if attrs.variant == "success" %} -
    - check_circle -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% elif attrs.variant == "warning" %} -
    - warning -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% elif attrs.variant == "error" %} -
    - error -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% elif attrs.variant == "info" %} -
    - info -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    - -{% else %} -
    - info -
    - {% if attrs.title %} -

    {{ attrs.title }}

    - {% endif %} -
    - {{ slot }} -
    -
    - {% if attrs.dismissible == "true" %} - - {% endif %} -
    -{% endif %} diff --git a/templates/dashboard/base.html b/templates/dashboard/base.html deleted file mode 100755 index 22f7545..0000000 --- a/templates/dashboard/base.html +++ /dev/null @@ -1,33 +0,0 @@ -{% load static %} - - - - - - - {% block title %}{{ site_name }} 仪表盘{% endblock %} - - {% block extra_css %}{% endblock %} - - - - -
    - {% block content %}{% endblock %} -
    - - - {% block extra_js %}{% endblock %} - - \ No newline at end of file diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html deleted file mode 100755 index 8223846..0000000 --- a/templates/dashboard/index.html +++ /dev/null @@ -1,164 +0,0 @@ -{% extends 'base.html' %} - -{% load markdown_extras %} - -{% block title %}仪表盘 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -

    云电脑产品

    -

    浏览可用的云电脑产品并申请开户

    -
    - -
    -
    -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - - 重置 - -
    -
    -
    -
    - - {% if grouped_products %} - {% for group_data in grouped_products %} - {% if group_data.group %} -
    - folder -
    -

    {{ group_data.group.name }}

    - {% if group_data.group.description %} -

    {{ group_data.group.description }}

    - {% endif %} -
    -
    - {% else %} -
    - widgets -

    其他产品

    -
    - {% endif %} - -
    - {% for product in group_data.products %} -
    -
    -
    -

    {{ product.display_name }}

    - - {% if product.status == 'online' %}在线 - {% elif product.status == 'offline' %}离线 - {% elif product.status == 'error' %}错误 - {% else %}未知{% endif %} - -
    -
    - -
    -
    - 自动审核 -

    - {% if product.auto_approval %} - 已启用 - {% else %} - 未启用 - {% endif %} -

    -
    - - {% if product.display_description %} -
    - {{ product.display_description|markdown_filter }} -
    - {% endif %} - -
    - {% if existing_cloud_users|get_item:product.id %} - - desktop_windows - 查看已有账户 - - {% elif pending_request_ids|get_item:product.id %} - - schedule - 查看申请详细 - - {% else %} - - cloud - 立即申请 - - {% endif %} -
    -
    -
    - {% endfor %} -
    - {% endfor %} - {% else %} -
    - desktop_windows -

    暂无可用的云电脑主机

    -

    当前没有可申请的云电脑产品

    -
    - {% endif %} -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/dashboard/sitegroup_config.html b/templates/dashboard/sitegroup_config.html deleted file mode 100644 index 7508362..0000000 --- a/templates/dashboard/sitegroup_config.html +++ /dev/null @@ -1,189 +0,0 @@ -{% extends "admin_base/base.html" %} -{% block title %}{{ site_name }} - 站点组配置 - {{ sitegroup.name }}{% endblock %} -{% block breadcrumb %} - {% if user.is_superuser %} - 站点组管理 - chevron_right - {{ sitegroup.name }} - chevron_right - {% endif %} - 配置覆盖 -{% endblock %} -{% block content %} -
    -
    -

    {{ sitegroup.name }} - 配置覆盖

    -

    留空的字段将使用全局默认配置

    -
    - {% if user.is_superuser %} - - arrow_back - 返回详情 - - {% endif %} -
    -
    - {% csrf_token %} - -
    -
    - palette -

    站点外观

    -
    -
    -
    -
    - - {{ form.site_name }} -

    全局默认: {{ global_config.site_name|default:"2c2a" }}

    -
    -
    - - {{ form.site_icon }} -

    全局默认: {{ global_config.site_icon_for_hostname|default:"/static/img/favicon.svg" }}

    -
    -
    -
    -
    - - {{ form.icp_number }} -

    全局默认: {{ global_config.icp_number|default:"未配置" }}

    -
    -
    - - {{ form.police_number }} -

    全局默认: {{ global_config.police_number|default:"未配置" }}

    -
    -
    -
    -
    - -
    -
    - how_to_reg -

    注册与邮箱

    -
    -
    -
    - - {{ form.enable_registration }} -

    - 全局默认: - {% if global_config.enable_registration %} - 启用 - {% else %} - 禁用 - {% endif %} -

    -
    -
    - - {{ form.email_suffix_whitelist }} -

    - 全局默认: {{ global_config.email_suffix_whitelist|default:"不限制"|truncatewords:5 }} -

    -
    -
    - - {{ form.email_suffix_blacklist }} -

    - 全局默认: {{ global_config.email_suffix_blacklist|default:"不限制"|truncatewords:5 }} -

    -
    -
    -
    - -
    -
    - mail -

    SMTP 邮件配置

    -
    -
    -
    -
    - - {{ form.smtp_host }} -

    全局: {{ global_config.smtp_host|default:"未配置" }}

    -
    -
    - - {{ form.smtp_port }} -

    全局: {{ global_config.smtp_port|default:"未配置" }}

    -
    -
    -
    -
    - - {{ form.smtp_encryption }} -

    全局: {{ global_config.get_smtp_encryption_display|default:"未配置" }}

    -
    -
    - - {{ form.smtp_username }} -

    全局: {{ global_config.smtp_username|default:"未配置" }}

    -
    -
    -
    - - {{ form.smtp_password }} -

    留空保持原值不变

    -
    -
    -
    - - {{ form.smtp_from_email }} -

    全局: {{ global_config.smtp_from_email|default:"未配置" }}

    -
    -
    - - {{ form.smtp_from_name }} -

    全局: {{ global_config.smtp_from_name|default:"未配置" }}

    -
    -
    -
    -
    - -
    -
    - verified_user -

    验证码配置

    -
    -
    -
    - - {{ form.captcha_provider }} -

    全局: {{ global_config.get_captcha_provider_display|default:"未配置" }}

    -
    -
    -
    - - {{ form.captcha_type }} -
    -
    - - {{ form.login_captcha_type }} -
    -
    -
    -
    - - {{ form.register_captcha_type }} -
    -
    - - {{ form.email_captcha_type }} -
    -
    -
    -
    -
    - -
    -
    -{% endblock %} diff --git a/templates/dashboard/sitegroup_detail.html b/templates/dashboard/sitegroup_detail.html deleted file mode 100644 index 479908b..0000000 --- a/templates/dashboard/sitegroup_detail.html +++ /dev/null @@ -1,189 +0,0 @@ -{% extends "admin_base/base.html" %} -{% block title %}{{ site_name }} 超级管理员 - 站点组详情 - {{ sitegroup.name }}{% endblock %} -{% block breadcrumb %} - 站点组管理 - chevron_right - {{ sitegroup.name }} -{% endblock %} -{% block content %} - -
    -
    -
    - info -

    基本信息

    -
    -
    -
    - 名称 - {{ sitegroup.name }} -
    -
    - 标识符 - {{ sitegroup.slug }} -
    -
    - 描述 - {{ sitegroup.description|default:"-" }} -
    -
    - 站点名称 - {{ sitegroup.site_name|default:"-" }} -
    -
    - 站点图标 - {{ sitegroup.site_icon|default:"-" }} -
    -
    - 状态 - {% if sitegroup.is_active %} - - - 启用 - - {% else %} - - - 禁用 - - {% endif %} -
    -
    -
    -
    -
    - language -

    绑定主机名

    -
    -
    -
    - {% csrf_token %} - - -
    - {% if hostnames %} -
    - {% for hostname in hostnames %} -
    - {{ hostname.hostname }} -
    - {% csrf_token %} - -
    -
    - {% endfor %} -
    - {% else %} -

    暂无绑定主机名

    - {% endif %} -
    -
    -
    -
    -
    - admin_panel_settings -

    站点组管理员

    -
    -
    -
    - {% csrf_token %} - - -
    - {% if admins %} -
    - {% for admin in admins %} -
    -
    -
    - {{ admin.username|first|upper }} -
    - {{ admin.username }} -
    -
    - {% csrf_token %} - -
    -
    - {% endfor %} -
    - {% else %} -

    暂无站点组管理员

    - {% endif %} -
    -
    -
    -
    -
    - dangerous -
    -

    危险操作

    -

    删除站点组将同时删除所有关联的主机名绑定,此操作不可逆

    -
    -
    -
    - {% csrf_token %} - -
    -
    -
    -{% endblock %} diff --git a/templates/dashboard/sitegroup_form.html b/templates/dashboard/sitegroup_form.html deleted file mode 100644 index 5bf3ff2..0000000 --- a/templates/dashboard/sitegroup_form.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - {{ title }}{% endblock %} - -{% block breadcrumb %} -站点组管理 -chevron_right -{{ title }} -{% endblock %} - -{% block content %} -
    -
    -

    {{ title }}

    -

    - {% if sitegroup %}修改站点组「{{ sitegroup.name }}」的信息{% else %}填写以下信息创建新的站点组{% endif %} -

    -
    - - arrow_back - 返回列表 - -
    - -
    -
    -
    - {% csrf_token %} - - {% if form.non_field_errors %} -
    - {% for error in form.non_field_errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - -
    -

    - domain - 基本信息 -

    -
    - {% for field in form %} - {% if field.name == 'is_active' %} -
    - {{ field }} - - {% if field.help_text %} - - {{ field.help_text }} - {% endif %} -
    - {% else %} -
    - - {{ field }} - {% if field.help_text %} -

    {{ field.help_text }}

    - {% endif %} - {% for error in field.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endif %} - {% endfor %} -
    -
    - -
    - 取消 - -
    -
    -
    -
    -{% endblock %} diff --git a/templates/dashboard/sitegroup_list.html b/templates/dashboard/sitegroup_list.html deleted file mode 100644 index bb19aaf..0000000 --- a/templates/dashboard/sitegroup_list.html +++ /dev/null @@ -1,89 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} 超级管理员 - 站点组管理{% endblock %} - -{% block breadcrumb %} -站点组管理 -chevron_right -站点组列表 -{% endblock %} - -{% block content %} -
    -
    -

    站点组管理

    -

    管理系统中的所有站点组及其绑定主机名和管理员

    -
    - - - -
    - -
    -
    - - - - - - - - - - - - - - {% for sitegroup in sitegroups %} - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    名称标识符站点名称状态主机名管理员操作
    - {{ sitegroup.name }} - - {{ sitegroup.slug }} - {{ sitegroup.site_name|default:"-" }} - {% if sitegroup.is_active %} - - - 启用 - - {% else %} - - - 禁用 - - {% endif %} - {{ sitegroup.hostnames.count }}{{ sitegroup.admins.count }} - -
    - domain - 暂无站点组,点击上方按钮创建 -
    -
    -
    -{% endblock %} diff --git a/templates/dashboard/sitegroup_user_list.html b/templates/dashboard/sitegroup_user_list.html deleted file mode 100644 index f77cb1c..0000000 --- a/templates/dashboard/sitegroup_user_list.html +++ /dev/null @@ -1,203 +0,0 @@ -{% extends "admin_base/base.html" %} -{% block title %}{{ site_name }} - {{ site_group.name }} - 用户管理{% endblock %} -{% block breadcrumb %} - {{ site_group.name }} - chevron_right - 用户管理 -{% endblock %} -{% block content %} -
    -
    -

    用户管理

    -

    管理站点组「{{ site_group.name }}」下的用户

    -
    -
    - -
    -
    -
    -
    - search - -
    -
    -
    -
    - - expand_more -
    - -
    -
    -
    - - {% if page_obj %} -
    - {% for user in page_obj %} -
    -
    - -
    -
    - {{ user.username|first|upper }} -
    -
    -
    - {{ user.username }} - {% if user.is_superuser %} - 超管 - {% elif user.pk in admin_ids %} - 站点管理员 - {% endif %} - {% if user.is_staff %} - 员工 - {% endif %} - {% if user.active_ban %} - 封禁 - {% elif user.is_active %} - 活跃 - {% else %} - 禁用 - {% endif %} -
    -

    - {{ user.email|default:"未设置邮箱" }} - {% if user.get_full_name %} - · - {{ user.get_full_name }} - {% endif %} -

    - {% if user.groups.exists %} -
    - {% for group in user.groups.all %} - - {{ group.name }} - - {% endfor %} -
    - {% endif %} -
    -
    - - {% if not user.is_superuser %} -
    - - key - - -
    - {% csrf_token %} - {% if user.active_ban %} - - {% else %} - - {% endif %} -
    - - - remove_circle_outline - -
    - {% endif %} -
    -
    - {% endfor %} -
    - - {% if page_obj.has_other_pages %} -
    - -
    - {% endif %} - {% else %} -
    - person_off -

    暂无用户

    -

    该站点组下还没有用户

    -
    - {% endif %} -{% endblock %} - -{% block extra_js %} - - - - -{% endblock %} diff --git a/templates/dashboard/sitegroup_user_remove.html b/templates/dashboard/sitegroup_user_remove.html deleted file mode 100644 index 95394a4..0000000 --- a/templates/dashboard/sitegroup_user_remove.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} - 移出用户 - {{ target_user.username }}{% endblock %} - -{% block breadcrumb %} -用户管理 -chevron_right -移出用户 -{% endblock %} - -{% block content %} -
    -
    -
    - remove_circle_outline -

    移出站点组

    -
    -
    -
    -
    - {{ target_user.username|first|upper }} -
    -
    -

    {{ target_user.username }}

    -

    {{ target_user.email|default:"未设置邮箱" }}

    -
    -
    -

    - 确定将用户「{{ target_user.username }}」从站点组「{{ site_group.name }}」移出吗?移出后该用户将无法通过该站点组登录,但其账户数据不会被删除。 -

    -
    - {% csrf_token %} - - - 取消 - -
    -
    -
    -
    -{% endblock %} diff --git a/templates/dashboard/sitegroup_user_reset_password.html b/templates/dashboard/sitegroup_user_reset_password.html deleted file mode 100644 index 9c53bd0..0000000 --- a/templates/dashboard/sitegroup_user_reset_password.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "admin_base/base.html" %} - -{% block title %}{{ site_name }} - 重置密码 - {{ target_user.username }}{% endblock %} - -{% block breadcrumb %} -用户管理 -chevron_right -重置密码 - {{ target_user.username }} -{% endblock %} - -{% block content %} -
    -
    -
    - key -

    重置用户密码

    -
    -
    -
    -
    - {{ target_user.username|first|upper }} -
    -
    -

    {{ target_user.username }}

    -

    {{ target_user.email|default:"未设置邮箱" }}

    -
    -
    - -
    - {% csrf_token %} - {% for field in form %} -
    - - - {% if field.help_text %} -

    {{ field.help_text }}

    - {% endif %} - {% for error in field.errors %} -

    {{ error }}

    - {% endfor %} -
    - {% endfor %} -
    - - - 取消 - -
    -
    -
    -
    -
    -{% endblock %} diff --git a/templates/dashboard/system_config.html b/templates/dashboard/system_config.html deleted file mode 100755 index 3e0682a..0000000 --- a/templates/dashboard/system_config.html +++ /dev/null @@ -1,199 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}系统配置 - {{ site_name }}{% endblock %} - -{% block content %} -
    -

    系统配置

    - -
    -
    -
    系统配置
    -
    -
    -
    - {% csrf_token %} - -
    -
    基本信息
    -
    -
    - - {{ form.site_name }} - {% if form.site_name.help_text %} - {{ form.site_name.help_text }} - {% endif %} - {% for error in form.site_name.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -
    - -
    -
    备案信息
    -
    -
    - - {{ form.icp_number }} - {% if form.icp_number.help_text %} - {{ form.icp_number.help_text }} - {% endif %} - {% for error in form.icp_number.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.police_number }} - {% if form.police_number.help_text %} - {{ form.police_number.help_text }} - {% endif %} - {% for error in form.police_number.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -
    - -
    -
    SMTP配置
    -
    -
    - - {{ form.smtp_host }} - {% if form.smtp_host.help_text %} - {{ form.smtp_host.help_text }} - {% endif %} - {% for error in form.smtp_host.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.smtp_port }} - {% if form.smtp_port.help_text %} - {{ form.smtp_port.help_text }} - {% endif %} - {% for error in form.smtp_port.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -
    -
    - - {{ form.smtp_username }} - {% if form.smtp_username.help_text %} - {{ form.smtp_username.help_text }} - {% endif %} - {% for error in form.smtp_username.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.smtp_password }} - {% if form.smtp_password.help_text %} - {{ form.smtp_password.help_text }} - {% endif %} - {% for error in form.smtp_password.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -
    -
    - - {{ form.smtp_from_email }} - {% if form.smtp_from_email.help_text %} - {{ form.smtp_from_email.help_text }} - {% endif %} - {% for error in form.smtp_from_email.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.smtp_encryption }} - {% if form.smtp_encryption.help_text %} - {{ form.smtp_encryption.help_text }} - {% endif %} - {% for error in form.smtp_encryption.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -
    - -
    -
    验证码设置
    -
    -
    - - {{ form.captcha_provider }} - {% if form.captcha_provider.help_text %} - {{ form.captcha_provider.help_text }} - {% endif %} - {% for error in form.captcha_provider.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.captcha_type }} - {% if form.captcha_type.help_text %} - {{ form.captcha_type.help_text }} - {% endif %} - {% for error in form.captcha_type.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -

    场景级别类型留空则使用默认类型

    -
    -
    - - {{ form.login_captcha_type }} - {% if form.login_captcha_type.help_text %} - {{ form.login_captcha_type.help_text }} - {% endif %} - {% for error in form.login_captcha_type.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.register_captcha_type }} - {% if form.register_captcha_type.help_text %} - {{ form.register_captcha_type.help_text }} - {% endif %} - {% for error in form.register_captcha_type.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    - - {{ form.email_captcha_type }} - {% if form.email_captcha_type.help_text %} - {{ form.email_captcha_type.help_text }} - {% endif %} - {% for error in form.email_captcha_type.errors %} -
    {{ error }}
    - {% endfor %} -
    -
    -
    - -
    - - - 返回 -
    -
    -
    -
    -
    -{% endblock %} diff --git a/templates/dashboard/widget_config.html b/templates/dashboard/widget_config.html deleted file mode 100755 index e30aab7..0000000 --- a/templates/dashboard/widget_config.html +++ /dev/null @@ -1,105 +0,0 @@ -{% extends 'dashboard/base.html' %} - -{% block title %}组件配置 - {{ site_name }} 仪表盘{% endblock %} - -{% block content %} -
    -
    -
    -
    仪表盘组件配置
    - 返回仪表盘 -
    -
    -
    - {% csrf_token %} -
    - - - - - - - - - - - {% for widget in widgets %} - - - - - - - {% empty %} - - - - {% endfor %} - -
    组件类型标题显示顺序是否启用
    {{ widget.get_widget_type_display }}{{ widget.title }} - - - -
    暂无组件
    -
    -
    - -
    -
    -
    -
    -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/docs/index.html b/templates/docs/index.html deleted file mode 100644 index 18d2ea4..0000000 --- a/templates/docs/index.html +++ /dev/null @@ -1,365 +0,0 @@ -{% load static markdown_extras %} - - - - - - {{ doc_title }} - {{ site_name }} - - - - - - -
    - - - -
    -
    - - -
    - - -
    - -
    -
    -
    - {{ md_text|markdown_filter }} -
    -
    -
    - - -
    -
    - -
    -
    -

    © {% now "Y" %} 2c2a. All rights reserved.

    -
    -
    - - - - diff --git a/templates/errors/400.html b/templates/errors/400.html deleted file mode 100755 index 10586fd..0000000 --- a/templates/errors/400.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block title %}{{ error_title }} - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    - cancel -
    - -
    400
    - -

    {{ error_title }}

    - -

    - {{ error_message }} -

    - - {% if request_id %} -
    - 请求ID:{{ request_id }} -
    - {% endif %} - -
    -
    - info - 常见原因 -
    -
      -
    • 表单提交的数据格式不正确
    • -
    • 请求参数缺失或无效
    • -
    • 会话已过期,请重新登录
    • -
    -
    - -
    - - home - 返回首页 - - -
    -
    -
    -{% endblock %} diff --git a/templates/errors/403.html b/templates/errors/403.html deleted file mode 100755 index c16ef08..0000000 --- a/templates/errors/403.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block title %}{{ error_title }} - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    - lock -
    - -
    403
    - -

    {{ error_title }}

    - -

    - {{ error_message }} -

    - - {% if request_id %} -
    - 请求ID:{{ request_id }} -
    - {% endif %} - -
    -
    - lightbulb - 建议操作 -
    -

    您没有权限访问此资源。如果您认为这是一个错误,请联系系统管理员获取相应权限,或登录后重试。

    -
    - - -
    -
    -{% endblock %} diff --git a/templates/errors/404.html b/templates/errors/404.html deleted file mode 100755 index 96334fc..0000000 --- a/templates/errors/404.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block title %}{{ error_title }} - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    - travel_explore -
    - -
    404
    - -

    {{ error_title }}

    - -

    - {{ error_message }} -

    - - {% if request_path %} -
    - 请求路径: - {{ request_path }} -
    - {% endif %} - - {% if request_id %} -
    - 请求ID:{{ request_id }} -
    - {% endif %} - -
    -
    - lightbulb - 解决建议 -
    -
      -
    • 检查网址拼写是否正确
    • -
    • 返回上一页,尝试其他链接
    • -
    • 清除浏览器缓存后重试
    • -
    -
    - - -
    -
    -{% endblock %} diff --git a/templates/errors/500.html b/templates/errors/500.html deleted file mode 100755 index dc093ba..0000000 --- a/templates/errors/500.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block title %}{{ error_title }} - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    - construction -
    - -
    500
    - -

    {{ error_title }}

    - -

    - {{ error_message }} -

    - - {% if support_message %} -
    -
    - support_agent - 技术支持 -
    -

    {{ support_message }}

    -
    - {% endif %} - -
    - {% if request_id %} -
    - 请求ID:{{ request_id }} -
    - {% endif %} - - {% if trace_id %} -
    - 追踪ID:{{ trace_id }} -
    - {% endif %} -
    - -
    - - home - 返回首页 - - -
    -
    -
    -{% endblock %} diff --git a/templates/maintenance.html b/templates/maintenance.html deleted file mode 100755 index 600eca8..0000000 --- a/templates/maintenance.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - {{ site_name }} - 系统维护中 - - - - - - - - -
    -
    - build -
    - -

    系统升级维护中

    -

    - 为了提供更稳定、高效的 {{ site_name }} 云管理体验,我们正在进行关键的服务器优化。 -
    在此期间,Web端及开户服务将暂时不可用。 -

    - - -
    - 加载中... -
    - - -
    -
    -
    - -
    - email 联系管理员 - code GitHub Issues -
    © {% now "Y" %} {{ site_name }}. All rights reserved.
    -
    -
    - - - - diff --git a/templates/operations/account_opening_confirm.html b/templates/operations/account_opening_confirm.html deleted file mode 100644 index 11cdad9..0000000 --- a/templates/operations/account_opening_confirm.html +++ /dev/null @@ -1,85 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}确认开户申请 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    - {% if messages %} - {% for message in messages %} - {% include 'components/alert.html' with message=message.tags content=message %} - {% endfor %} - {% endif %} - -
    -
    -

    确认开户申请

    -
    - -
    -

    请仔细核对以下信息,确认无误后提交申请。

    - -
    -
    - 联系邮箱 -
    {{ confirm_data.contact_email }}
    -
    - -
    - 主机连接用户名 -
    {{ confirm_data.username }}
    -
    - -
    - 主机显示用户名 -
    {{ confirm_data.user_fullname }}
    -
    - -
    - 目标产品 -
    {{ confirm_data.target_product_name }}
    -
    -
    - -
    - 申请理由 -
    {{ confirm_data.user_description }}
    -
    - - {% if confirm_data.requested_disk_capacity %} -
    - 磁盘容量需求 -
    - {% for disk, capacity in confirm_data.requested_disk_capacity.items %} -
    - {{ disk }}: - {{ capacity }} MB -
    - {% endfor %} -
    -
    - {% endif %} - -
    -
    - info - 提交后申请将进入审核流程,请耐心等待审核结果。审核通过后系统将自动为您创建账户。 -
    -
    - -
    - {% csrf_token %} -
    - {% include 'components/button.html' with type="submit" variant="filled" text="确认提交" size="large" icon="check" %} - {% url 'operations:account_opening_create' as account_opening_create_url %} - {% include 'components/button.html' with href=account_opening_create_url variant="outlined" text="返回修改" size="large" icon="edit" %} -
    -
    -
    -
    -
    -{% endblock %} diff --git a/templates/operations/account_opening_request_detail.html b/templates/operations/account_opening_request_detail.html deleted file mode 100755 index 66c4964..0000000 --- a/templates/operations/account_opening_request_detail.html +++ /dev/null @@ -1,216 +0,0 @@ -{% extends 'base.html' %} -{% load static %} -{% block title %}申请详情 - {{ site_name }}{% endblock %} -{% block extra_css %} - -{% endblock %} -{% block content %} -
    - {% if messages %} - {% for message in messages %} - {% include 'components/alert.html' with message=message.tags content=message %} - {% endfor %} - {% endif %} -
    -

    申请详情

    - {% url 'operations:account_opening_list' as account_opening_list_url %} - {% include 'components/button.html' with href=account_opening_list_url variant="outlined" text="返回列表" icon="arrow_back" %} -
    -
    -
    -

    申请信息

    -
    -
    -
    -
    - 申请人 -
    - {{ request.applicant.username }} -
    -
    -
    - 联系邮箱 -
    - {{ request.applicant.email }} -
    -
    -
    - 主机连接用户名 -
    - {{ request.username }} -
    -
    -
    - 主机显示用户名 -
    - {{ request.user_fullname }} -
    -
    -
    - 用户邮箱 -
    - {{ request.user_email }} -
    -
    -
    - 用户描述 -
    - {{ request.user_description|default:"暂无描述" }} -
    -
    -
    - 目标产品 -
    - {{ request.target_product.display_name }} -
    -
    -
    -
    -
    -
    -
    -

    状态时间线

    -
    -
    -
    - {% for step in timeline %} -
    -
    -
    - - {% if step.done %} - check_circle - {% else %} - radio_button_unchecked - {% endif %} - -
    - {% if not forloop.last %} -
    - {% endif %} -
    -
    -

    {{ step.label }}

    - {% if step.time %}

    {{ step.time|date:"Y-m-d H:i" }}

    {% endif %} - {% if step.detail %}

    {{ step.detail }}

    {% endif %} -
    -
    - {% endfor %} -
    -
    -
    -
    -
    -

    审核信息

    -
    -
    -
    -
    - 申请状态 -
    - - {{ request.get_status_display }} - -
    -
    - {% if request.approved_by %} -
    - 审核人 -
    - {{ request.approved_by.username }} -
    -
    - {% endif %} - {% if request.approval_date %} -
    - 审核时间 -
    - {{ request.approval_date|date:"Y-m-d H:i:s" }} -
    -
    - {% endif %} - {% if request.approval_notes %} -
    - 审核备注 -
    - {{ request.approval_notes }} -
    -
    - {% endif %} - {% if request.status == 'rejected' %} -
    - 拒绝原因 -
    - {% if request.approval_notes %} - {{ request.approval_notes }} - {% else %} - 未提供具体原因 - {% endif %} -
    -
    - {% endif %} - {% if request.result_message %} -
    - 处理结果 -
    - {% if request.status == 'failed' %} - 开户处理失败,请联系管理员了解详情 - {% else %} - {{ request.result_message }} - {% endif %} -
    -
    - {% endif %} -
    -
    -
    - {% if request.status == 'completed' %} -
    -
    -

    账户信息

    -
    -
    -
    -
    - 云电脑用户ID -
    - {{ request.cloud_user_id }} -
    -
    -
    - 初始密码 -
    - 出于安全考虑,密码不在此显示 -
    -
    -
    -
    -
    - {% endif %} -
    -
    -

    时间信息

    -
    -
    -
    -
    - 创建时间 -
    - {{ request.created_at|date:"Y-m-d H:i:s" }} -
    -
    -
    - 更新时间 -
    - {{ request.updated_at|date:"Y-m-d H:i:s" }} -
    -
    -
    -
    -
    -
    - {% url 'operations:account_opening_list' as account_opening_list_url %} - {% include 'components/button.html' with href=account_opening_list_url variant="outlined" text="返回列表" icon="arrow_back" %} -
    -
    -{% endblock %} diff --git a/templates/operations/account_opening_request_form.html b/templates/operations/account_opening_request_form.html deleted file mode 100644 index 355e71d..0000000 --- a/templates/operations/account_opening_request_form.html +++ /dev/null @@ -1,240 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}提交开户申请 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    - {% if messages %} - {% for message in messages %} - {% include 'components/alert.html' with message=message.tags content=message %} - {% endfor %} - {% endif %} - -
    -
    -

    提交开户申请

    -
    - -
    -
    - {% csrf_token %} - -
    -

    基本信息

    - -
    -
    -
    - - - 使用当前账户的注册邮箱,不可修改 -
    -
    -
    -
    - -
    -

    用户信息

    - -
    -
    -
    - - - {% if form.username.errors %} -
    - {% for error in form.username.errors %}{{ error }}{% endfor %} -
    - {% else %} -
    将在云电脑主机上创建的连接用户名
    - {% endif %} -
    -
    - -
    -
    - - - {% if form.user_fullname.errors %} -
    - {% for error in form.user_fullname.errors %}{{ error }}{% endfor %} -
    - {% else %} -
    用于在系统中显示的用户名
    - {% endif %} -
    -
    -
    -
    - -
    -

    申请详情

    - -
    -
    -
    - - - {% if form.user_description.errors %} -
    - {% for error in form.user_description.errors %}{{ error }}{% endfor %} -
    - {% else %} -
    请说明申请云电脑主机的用途和理由
    - {% endif %} -
    -
    -
    -
    - -
    - 目标产品: - {% if form.target_product.field.queryset.first %} - {{ form.target_product.field.queryset.first.display_name }} - {% else %} - 未指定 - {% endif %} -
    - - {{ form.target_product }} - - - -
    - {% include 'components/button.html' with type="submit" variant="filled" text="提交申请并确认" size="large" %} - {% url 'operations:account_opening_list' as account_opening_list_url %} - {% include 'components/button.html' with href=account_opening_list_url variant="outlined" text="取消" size="large" %} -
    -
    -
    -
    -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/operations/account_opening_request_list.html b/templates/operations/account_opening_request_list.html deleted file mode 100755 index 7e337cd..0000000 --- a/templates/operations/account_opening_request_list.html +++ /dev/null @@ -1,141 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}我提交的申请 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    -

    我提交的申请

    -
    - - -
    -
    -
    -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - {% include 'components/button.html' with type="submit" variant="outlined" text="搜索" %} - {% url 'operations:account_opening_list' as account_opening_list_url %} - {% include 'components/button.html' with href=account_opening_list_url variant="text" text="重置" %} -
    -
    -
    -
    - - -
    - - - - - - - - - - - - - {% if requests %} - {% for request in requests %} - - - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
    用户名用户姓名目标产品申请状态创建时间操作
    {{ request.username }}{{ request.user_fullname }}{{ request.target_product.display_name }} - - {{ request.get_status_display }} - - {{ request.created_at|date:"Y-m-d H:i:s" }} - {% url 'operations:account_opening_detail' request.id as detail_url %} - {% include 'components/button.html' with href=detail_url variant="text" text="查看详情" size="small" %} -
    暂无开户申请
    -
    - - - {% if is_paginated and paginator.num_pages > 1 %} -
    - -
    - {% endif %} -
    -{% endblock %} diff --git a/templates/operations/cloud_computer_user_list.html b/templates/operations/cloud_computer_user_list.html deleted file mode 100755 index 1c2e079..0000000 --- a/templates/operations/cloud_computer_user_list.html +++ /dev/null @@ -1,169 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}云电脑用户列表 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    -

    云电脑用户列表

    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - 重置 -
    -
    -
    -
    - - -
    -
    -
    - - -
    - 已选择 0 个用户 -
    -
    - - -
    -
    - - - - - - - - - - - - - - {% for user in users %} - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    - - 用户名姓名邮箱所属产品状态创建时间
    - - {{ user.username }}{{ user.fullname }}{{ user.email }}{{ user.product.display_name }} - - {{ user.get_status_display }} - - {{ user.created_at|date:"Y-m-d H:i" }}
    - people -

    暂无云电脑用户

    -
    -
    -
    - - - {% if is_paginated and paginator.num_pages > 1 %} -
    - {% if page_obj.has_previous %} - - 上一页 - - {% endif %} - - {{ page_obj.number }} / {{ paginator.num_pages }} - - {% if page_obj.has_next %} - - 下一页 - - {% endif %} -
    - {% endif %} -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/operations/invite_result.html b/templates/operations/invite_result.html deleted file mode 100644 index 443fdfe..0000000 --- a/templates/operations/invite_result.html +++ /dev/null @@ -1,87 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}邀请结果 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    - {% if needs_confirm %} - lock_open -

    确认解锁

    -

    {{ message }}

    -
    - {% csrf_token %} -
    - - - 取消 - -
    -
    - {% elif success %} - check_circle -

    邀请成功

    -

    {{ message }}

    - - {% if created_users %} -
    -

    已创建的用户:

    -
      - {% for user_info in created_users %} -
    • -
      {{ user_info.username }}
      -
      {{ user_info.fullname }} - {{ user_info.email }}
      -
    • - {% endfor %} -
    -
    - {% endif %} - - {% if products %} -
    -

    关联的产品:

    -
      - {% for product_info in products %} -
    • -
      {{ product_info.name }}
      -
      {{ product_info.hostname }}:{{ product_info.rdp_port }}
      -
    • - {% endfor %} -
    -
    - {% endif %} - - {% else %} - error -

    邀请失败

    -

    {{ error_message }}

    - - {% if errors %} -
    -

    错误详情:

    -
      - {% for error in errors %} -
    • {{ error }}
    • - {% endfor %} -
    -
    - {% endif %} - {% endif %} - - {% if not needs_confirm %} - - {% endif %} -
    -
    -{% endblock %} diff --git a/templates/operations/my_cloud_computer_detail.html b/templates/operations/my_cloud_computer_detail.html deleted file mode 100755 index ba64bcd..0000000 --- a/templates/operations/my_cloud_computer_detail.html +++ /dev/null @@ -1,324 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}{{ cloud_user.username }} - 云电脑详情 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    -

    云电脑用户详情

    -
    - -
    -
    -

    {{ cloud_user.username }}

    - - {{ cloud_user.get_status_display }} - -
    - -
    -
    -
    - 用户姓名 -

    {{ cloud_user.fullname }}

    -
    - -
    - 邮箱 -

    {{ cloud_user.email }}

    -
    - -
    - 所属产品 -

    {{ cloud_user.product.display_name }}

    -
    - -
    - 创建时间 -

    {{ cloud_user.created_at|date:"Y-m-d H:i:s" }}

    -
    -
    - -
    - 用户描述 -

    {{ cloud_user.description|default:"-" }}

    -
    - - {% if cloud_user.created_from_request %} - - {% endif %} - -
    -
    - settings_ethernet -
    连接信息
    -
    - -
    -
    - 地址: - {{ cloud_user.product.display_hostname }} -
    -
    - RDP端口: - {{ cloud_user.product.rdp_port }} -
    -
    - 用户名: - {{ cloud_user.username }} -
    -
    - - -
    - -
    - - -
    -
    - vpn_key -
    初始密码
    -
    - -
    - {% if cloud_user.password_viewed %} -
    - 密码已被查看并销毁 -
    - - -
    - -
    - {% else %} - - -
    - -
    - {% endif %} - - {% if cloud_user.password_viewed and cloud_user.password_viewed_at %} -
    - - 密码已于 {{ cloud_user.password_viewed_at|date:"Y-m-d H:i:s" }} 被查看并销毁 - -
    - {% endif %} -
    -
    - -

    - 注意:请妥善保管您的登录凭据,首次登录可能需要更改密码 -

    -
    -
    -
    - - -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/operations/my_cloud_computers.html b/templates/operations/my_cloud_computers.html deleted file mode 100755 index 2b27a6e..0000000 --- a/templates/operations/my_cloud_computers.html +++ /dev/null @@ -1,153 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}我拥有的云电脑 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    -

    我拥有的云电脑

    -

    显示您通过开户申请创建的所有云电脑用户

    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - - 重置 - -
    -
    -
    -
    - - - {% if cloud_users_by_product %} - {% for product_name, users in cloud_users_by_product.items %} -
    -
    - computer -

    {{ product_name }}

    -
    - -
    - {% for user in users %} -
    -
    -
    -

    {{ user.username }}

    - - {{ user.get_status_display }} - -
    -
    - -
    -
    - 用户姓名 -

    {{ user.fullname }}

    -
    - -
    - 邮箱 -

    {{ user.email }}

    -
    - -
    - 描述 -

    {{ user.description|default:'-' }}

    -
    - -
    - 创建时间 -

    {{ user.created_at|date:"Y-m-d H:i:s" }}

    -
    - - {% if user.created_from_request %} - - {% endif %} - -
    - 连接信息 -

    地址:{{ user.product.display_hostname }}:{{ user.product.rdp_port }}

    -
    -
    - - -
    - {% endfor %} -
    -
    - {% endfor %} - {% else %} -
    - desktop_windows -

    暂无云电脑用户

    -

    您还没有通过开户申请创建任何云电脑用户

    - - 立即申请云电脑 - -
    - {% endif %} - - - {% if is_paginated and paginator.num_pages > 1 %} -
    - {% if page_obj.has_previous %} - - chevron_left - - {% endif %} - - {{ page_obj.number }} / {{ paginator.num_pages }} - - {% if page_obj.has_next %} - - chevron_right - - {% endif %} -
    - {% endif %} -
    -{% endblock %} diff --git a/templates/operations/systemtask_detail.html b/templates/operations/systemtask_detail.html deleted file mode 100755 index fa940c8..0000000 --- a/templates/operations/systemtask_detail.html +++ /dev/null @@ -1,88 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}系统任务详情 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    -

    系统任务详情

    - - 返回列表 - -
    - -
    -
    -

    基本信息

    -
    -
    -
    -
    - 任务类型 -

    {{ task.task_type }}

    -
    -
    - 状态 -

    - - {{ task.get_status_display }} - -

    -
    -
    - 创建时间 -

    {{ task.created_at|date:"Y-m-d H:i:s" }}

    -
    -
    - 完成时间 -

    {% if task.completed_at %}{{ task.completed_at|date:"Y-m-d H:i:s" }}{% else %}-{% endif %}

    -
    -
    -
    -
    - - {% if task.error_message %} -
    -

    - error - 错误信息 -

    -
    {{ task.error_message }}
    -
    - {% endif %} - - {% if task.result_data %} -
    -
    -

    任务结果

    -
    -
    -
    - {% for key, value in task.result_data.items %} -
    - {{ key }} -

    {{ value }}

    -
    - {% endfor %} -
    -
    -
    - {% endif %} - - -
    -{% endblock %} diff --git a/templates/operations/systemtask_list.html b/templates/operations/systemtask_list.html deleted file mode 100755 index e118830..0000000 --- a/templates/operations/systemtask_list.html +++ /dev/null @@ -1,115 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block title %}系统任务列表 - {{ site_name }}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} -
    -
    -

    系统任务列表

    -
    - - -
    -
    -
    -
    - - -
    -
    - - 重置 -
    -
    -
    -
    - - -
    -
    - - - - - - - - - - - - - {% for task in tasks %} - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    任务类型状态创建时间完成时间结果操作
    {{ task.task_type }} - - {{ task.get_status_display }} - - {{ task.created_at|date:"Y-m-d H:i" }}{% if task.completed_at %}{{ task.completed_at|date:"Y-m-d H:i" }}{% else %}-{% endif %} - {% if task.error_message %} - {{ task.error_message|truncatechars:50 }} - {% else %} - - - {% endif %} - - - 查看 - -
    - task -

    暂无系统任务

    -
    -
    -
    - - - {% if is_paginated and paginator.num_pages > 1 %} -
    - {% if page_obj.has_previous %} - - 上一页 - - {% endif %} - - {{ page_obj.number }} / {{ paginator.num_pages }} - - {% if page_obj.has_next %} - - 下一页 - - {% endif %} -
    - {% endif %} -
    -{% endblock %} diff --git a/templates/tickets/dashboard.html b/templates/tickets/dashboard.html deleted file mode 100644 index cf1cd8b..0000000 --- a/templates/tickets/dashboard.html +++ /dev/null @@ -1,157 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}工单仪表盘 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    -

    - dashboard - 工单仪表盘 -

    -

    工单统计概览

    -
    - -
    - - -
    -
    -

    {{ total_tickets }}

    - 总工单数 -
    -
    -

    {{ pending_count }}

    - 待处理 -
    -
    -

    {{ processing_count }}

    - 处理中 -
    -
    -

    {{ resolved_count }}

    - 已解决 -
    -
    -

    {{ closed_count }}

    - 已关闭 -
    - {% if user.is_staff or user.is_superuser %} -
    -

    {{ overdue_count }}

    - 已超时 -
    - {% endif %} -
    - - -
    -
    -
    -
    -
    优先级分布
    -
    -
    - -
    -
    -
    -
    -
    -
    -
    最近工单
    -
    -
    - {% for ticket in tickets %} - -
    - {{ ticket.ticket_no }} - {{ ticket.title|truncatechars:25 }} -
    - {{ ticket.get_status_display }} -
    - {% empty %} -
    -

    暂无工单

    -
    - {% endfor %} -
    -
    -
    -
    - - -
    -
    -
    快速操作
    -
    - -
    -
    -{% endblock %} - -{% block extra_js %} - - -{% endblock %} diff --git a/templates/tickets/email/assigned.html b/templates/tickets/email/assigned.html deleted file mode 100644 index dc2cbe4..0000000 --- a/templates/tickets/email/assigned.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - 工单分配通知 - - -
    -
    -

    工单分配通知

    -
    -
    -

    您好,

    -

    您已被分配处理以下工单:

    - -
    -

    {{ ticket.title }}

    -

    工单编号:{{ ticket.ticket_no }}

    -

    优先级:{{ ticket.get_priority_display }}

    -

    分类:{% if ticket.category %}{{ ticket.category.name }}{% else %}未分类{% endif %}

    -

    创建者:{{ ticket.creator.username }}

    -

    创建时间:{{ ticket.created_at|date:"Y-m-d H:i:s" }}

    -
    - -

    问题描述:

    -

    {{ ticket.description|linebreaksbr }}

    - -

    - 查看工单详情 -

    -
    -
    -

    此邮件由 2c2a 工单系统自动发送

    -

    如不需要接收此类邮件,请联系管理员

    -
    -
    - - diff --git a/templates/tickets/email/closed.html b/templates/tickets/email/closed.html deleted file mode 100644 index 2fcae6e..0000000 --- a/templates/tickets/email/closed.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - 工单已关闭 - - -
    -
    -

    工单已关闭

    -
    -
    -

    您好 {{ ticket.creator.username }},

    -

    您提交的工单已处理完成并关闭:

    - -
    -

    {{ ticket.title }}

    -

    工单编号:{{ ticket.ticket_no }}

    -

    最终状态:{{ ticket.get_status_display }}

    -

    关闭时间:{{ ticket.closed_at|date:"Y-m-d H:i:s" }}

    -
    - -

    如果您对处理结果满意,欢迎给予好评。如有其他问题,请随时提交新的工单。

    - -

    - 查看工单详情 -

    -
    -
    -

    此邮件由 2c2a 工单系统自动发送

    -
    -
    - - diff --git a/templates/tickets/email/new_comment.html b/templates/tickets/email/new_comment.html deleted file mode 100644 index 94578f3..0000000 --- a/templates/tickets/email/new_comment.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - 工单新评论 - - -
    -
    -

    工单新评论

    -
    -
    -

    您好,

    -

    工单 {{ ticket.title }} 有新评论:

    - -
    -

    工单编号:{{ ticket.ticket_no }}

    -
    - -
    -

    {{ comment.author.username }} 评论:

    -

    {{ comment.content|linebreaksbr }}

    -

    {{ comment.created_at|date:"Y-m-d H:i:s" }}

    -
    - -

    - 查看工单详情 -

    -
    -
    -

    此邮件由 2c2a 工单系统自动发送

    -
    -
    - - diff --git a/templates/tickets/email/overdue.html b/templates/tickets/email/overdue.html deleted file mode 100644 index a5fc69d..0000000 --- a/templates/tickets/email/overdue.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - 工单超时提醒 - - -
    -
    -

    工单超时提醒

    -
    -
    -

    您好 {{ ticket.assignee.username }},

    - -
    - 注意:以下工单已超时或即将超时,请尽快处理。 -
    - -
    -

    {{ ticket.title }}

    -

    工单编号:{{ ticket.ticket_no }}

    -

    优先级:{{ ticket.get_priority_display }}

    -

    截止时间:{{ ticket.due_at|date:"Y-m-d H:i:s" }}

    -

    创建者:{{ ticket.creator.username }}

    -
    - -

    - 立即处理 -

    -
    -
    -

    此邮件由 2c2a 工单系统自动发送

    -
    -
    - - diff --git a/templates/tickets/email/status_update.html b/templates/tickets/email/status_update.html deleted file mode 100644 index 6512dfc..0000000 --- a/templates/tickets/email/status_update.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - 工单状态更新 - - -
    -
    -

    工单状态更新

    -
    -
    -

    您好 {{ ticket.creator.username }},

    -

    您提交的工单状态已更新:

    - -
    -

    {{ ticket.title }}

    -

    工单编号:{{ ticket.ticket_no }}

    -
    - -
    -

    状态变更:

    -

    {{ old_status }} → {{ new_status }}

    -
    - -

    - 查看工单详情 -

    -
    -
    -

    此邮件由 2c2a 工单系统自动发送

    -
    -
    - - diff --git a/templates/tickets/my_tickets.html b/templates/tickets/my_tickets.html deleted file mode 100644 index 3b9f118..0000000 --- a/templates/tickets/my_tickets.html +++ /dev/null @@ -1,119 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}我的工单 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    -

    - confirmation_number - 我的工单 -

    -

    查看您提交的所有工单

    -
    - -
    - - -
    -
    -
    - {{ filter_form.status.label_tag }} - {{ filter_form.status }} -
    -
    - - - 重置 - -
    -
    -
    - - -
    - {% for ticket in tickets %} -
    -
    -
    -
    - {{ ticket.ticket_no }} - {{ ticket.get_status_display }} -
    -
    - - {{ ticket.title|truncatechars:40 }} - -
    -

    {{ ticket.description|truncatechars:80 }}

    -
    -
    - {{ ticket.get_priority_display }} - {% if ticket.category %} - {{ ticket.category.name }} - {% endif %} -
    - {{ ticket.created_at|date:"m-d H:i" }} -
    - {% if ticket.is_overdue %} -
    - 已超时 -
    - {% endif %} -
    -
    - - {% if ticket.assignee %} - 处理人: {{ ticket.assignee.username }} - {% else %} - 待分配 - {% endif %} - - - 查看详情 - -
    -
    -
    - {% empty %} -
    -
    - inbox -

    暂无工单

    -

    您还没有提交过工单,点击下方按钮创建第一个工单

    - - add - 创建工单 - -
    -
    - {% endfor %} -
    - - - {% if is_paginated %} - - {% endif %} -
    -{% endblock %} diff --git a/templates/tickets/pending_list.html b/templates/tickets/pending_list.html deleted file mode 100644 index 90ccfa8..0000000 --- a/templates/tickets/pending_list.html +++ /dev/null @@ -1,99 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}待处理工单 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -

    - pending_actions - 待处理工单 -

    -

    查看需要处理的工单

    -
    - - -
    -
    - - - - - - - - - - - - - - {% for ticket in tickets %} - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    工单编号标题状态优先级创建者截止时间操作
    - {{ ticket.ticket_no }} - - - {{ ticket.title|truncatechars:30 }} - - - - {{ ticket.get_status_display }} - - - - {{ ticket.get_priority_display }} - - {{ ticket.creator.username }} - {% if ticket.due_at %} - {% if ticket.is_overdue %} - {{ ticket.due_at|date:"m-d H:i" }} (已超时) - {% else %} - {{ ticket.due_at|date:"m-d H:i" }} - {% endif %} - {% else %} - - - {% endif %} - - - 处理 - -
    - check_circle -

    暂无待处理工单

    -
    -
    -
    - - - {% if is_paginated %} - - {% endif %} -
    -{% endblock %} diff --git a/templates/tickets/ticket_detail.html b/templates/tickets/ticket_detail.html deleted file mode 100644 index f3ec706..0000000 --- a/templates/tickets/ticket_detail.html +++ /dev/null @@ -1,329 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}{{ ticket.title }} - 工单详情 - {{ site_name }}{% endblock %} - -{% block content %} -
    - {% if forbidden %} -
    - block - 您没有权限查看此工单。 -
    - 返回我的工单 - {% else %} - -
    -
    - -

    {{ ticket.title }}

    -
    - {{ ticket.get_status_display }} - {{ ticket.get_priority_display }} - {% if ticket.category %} - {{ ticket.category.name }} - {% endif %} - {% if ticket.is_overdue %} - 已超时 - {% endif %} -
    -
    -
    - {% if ticket.status != 'closed' and ticket.status != 'rejected' %} - {% if user.is_staff or user.is_superuser or user == ticket.creator %} - - {% endif %} - {% endif %} -
    -
    - -
    - -
    - -
    -
    -
    工单详情
    -
    -
    -
    -
    工单编号
    -
    {{ ticket.ticket_no }}
    -
    -
    -
    创建者
    -
    {{ ticket.creator.username }}
    -
    -
    -
    处理人
    -
    - {% if ticket.assignee or ticket.assigned_group %} - {% if ticket.assignee %}{{ ticket.assignee.username }}{% endif %}{% if ticket.assignee and ticket.assigned_group %} / {% endif %}{% if ticket.assigned_group %}{{ ticket.assigned_group.name }}(组){% endif %} - {% else %} - 未分配 - {% endif %} - {% if user.is_staff or user.is_superuser %} - - {% endif %} -
    -
    -
    -
    创建时间
    -
    {{ ticket.created_at|date:"Y-m-d H:i:s" }}
    -
    - {% if ticket.due_at %} -
    -
    截止时间
    -
    - {% if ticket.is_overdue %} - {{ ticket.due_at|date:"Y-m-d H:i:s" }} (已超时) - {% else %} - {{ ticket.due_at|date:"Y-m-d H:i:s" }} - {% endif %} -
    -
    - {% endif %} - {% if ticket.related_cloud_computer %} -
    -
    关联云电脑
    -
    {{ ticket.related_cloud_computer.username }}@{{ ticket.related_cloud_computer.product.display_name }}
    -
    - {% endif %} - {% if ticket.related_request %} -
    -
    关联申请
    -
    {{ ticket.related_request }} ({{ ticket.related_request.get_status_display }})
    -
    - {% endif %} -
    -
    -
    问题描述
    -

    {{ ticket.description|linebreaksbr }}

    -
    -
    -
    - - - {% if user.is_staff or user.is_superuser %} - {% if ticket.status != 'closed' and ticket.status != 'rejected' %} -
    -
    -
    状态变更
    -
    -
    -
    - {% csrf_token %} -
    -
    - {{ status_form.status.label_tag }} - {{ status_form.status }} -
    -
    - {{ status_form.notes.label_tag }} - {{ status_form.notes }} -
    -
    - -
    -
    -
    -
    -
    - {% endif %} - {% endif %} - - -
    -
    -
    - 评论/回复 - {{ comments.count }} -
    -
    -
    - {% if comments %} -
    - {% for comment in comments %} -
    -
    -
    - {{ comment.author.username }} - {% if comment.is_internal %} - 内部 - {% endif %} -
    - {{ comment.created_at|date:"Y-m-d H:i" }} -
    -

    {{ comment.content|linebreaksbr }}

    -
    - {% endfor %} -
    - {% else %} -

    暂无评论

    - {% endif %} -
    -
    - - - {% if ticket.status != 'closed' and ticket.status != 'rejected' %} -
    -
    -
    添加评论
    -
    -
    -
    - {% csrf_token %} -
    - {{ comment_form.content }} -
    - {% if user.is_staff or user.is_superuser %} -
    - {{ comment_form.is_internal }} - {{ comment_form.is_internal.label_tag }} -
    - {% endif %} - -
    -
    -
    - {% endif %} -
    - - -
    -
    -
    -
    活动记录
    -
    -
    - {% if activities %} -
    - {% for activity in activities %} -
    -
    - {{ activity.get_action_display }} - {{ activity.created_at|date:"m-d H:i" }} -
    -

    {{ activity.description }}

    - {% if activity.actor %} - 操作人: {{ activity.actor.username }} - {% endif %} -
    - {% endfor %} -
    - {% else %} -

    暂无活动记录

    - {% endif %} -
    -
    -
    -
    - - - {% if user.is_staff or user.is_superuser %} - - {% endif %} - - - - {% endif %} -
    -{% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/tickets/ticket_form.html b/templates/tickets/ticket_form.html deleted file mode 100644 index 2b948f9..0000000 --- a/templates/tickets/ticket_form.html +++ /dev/null @@ -1,114 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}创建工单 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    -
    -
    -

    - add_circle - 创建工单 -

    -
    -
    -
    - {% csrf_token %} - -
    - - {{ form.title }} - {% if form.title.errors %} -
    - {{ form.title.errors }} -
    - {% endif %} - {{ form.title.help_text }} -
    - -
    - - {{ form.category }} - {% if form.category.errors %} -
    - {{ form.category.errors }} -
    - {% endif %} - {{ form.category.help_text }} -
    - -
    - - {{ form.priority }} - {% if form.priority.errors %} -
    - {{ form.priority.errors }} -
    - {% endif %} - {{ form.priority.help_text }} -
    - -
    - - {{ form.description }} - {% if form.description.errors %} -
    - {{ form.description.errors }} -
    - {% endif %} - {{ form.description.help_text }} -
    - -
    -
    - - {{ form.related_cloud_computer }} - {% if form.related_cloud_computer.errors %} -
    - {{ form.related_cloud_computer.errors }} -
    - {% endif %} - {{ form.related_cloud_computer.help_text }} -
    -
    - - {{ form.related_request }} - {% if form.related_request.errors %} -
    - {{ form.related_request.errors }} -
    - {% endif %} - {{ form.related_request.help_text }} -
    -
    - -
    - - 取消 - - -
    -
    -
    -
    -
    -
    -
    -{% endblock %} diff --git a/templates/tickets/ticket_list.html b/templates/tickets/ticket_list.html deleted file mode 100644 index 16d7ae1..0000000 --- a/templates/tickets/ticket_list.html +++ /dev/null @@ -1,147 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}工单列表 - {{ site_name }}{% endblock %} - -{% block content %} -
    -
    -
    -

    - confirmation_number - 工单列表 -

    -

    查看和管理所有工单

    -
    - -
    - - -
    -
    -
    - {{ filter_form.status.label_tag }} - {{ filter_form.status }} -
    -
    - {{ filter_form.priority.label_tag }} - {{ filter_form.priority }} -
    -
    - {{ filter_form.category.label_tag }} - {{ filter_form.category }} -
    -
    - {{ filter_form.search.label_tag }} - {{ filter_form.search }} -
    -
    - - - 重置 - -
    -
    -
    - - -
    -
    - - - - - - - - - - - - - - - - {% for ticket in tickets %} - - - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    工单编号标题分类状态优先级创建者处理人创建时间操作
    - {{ ticket.ticket_no }} - - - {{ ticket.title|truncatechars:30 }} - - - {% if ticket.category %} - {{ ticket.category.name }} - {% else %} - - - {% endif %} - - - {{ ticket.get_status_display }} - - - - {{ ticket.get_priority_display }} - - {{ ticket.creator.username }} - {% if ticket.assignee or ticket.assigned_group %} - {% if ticket.assignee %}{{ ticket.assignee.username }}{% endif %}{% if ticket.assignee and ticket.assigned_group %} / {% endif %}{% if ticket.assigned_group %}{{ ticket.assigned_group.name }}(组){% endif %} - {% else %} - 未分配 - {% endif %} - {{ ticket.created_at|date:"Y-m-d H:i" }} - - 查看 - -
    - inbox -

    暂无工单

    - - 创建第一个工单 - -
    -
    -
    - - - {% if is_paginated %} - - {% endif %} -
    -{% endblock %} diff --git a/tickets/__init__.py b/tickets/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tickets/admin.py b/tickets/admin.py deleted file mode 100644 index 07fb639..0000000 --- a/tickets/admin.py +++ /dev/null @@ -1 +0,0 @@ -# 工单系统无需后台管理 diff --git a/tickets/apps.py b/tickets/apps.py deleted file mode 100644 index 45a7d76..0000000 --- a/tickets/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class TicketsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'tickets' diff --git a/tickets/migrations/__init__.py b/tickets/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tickets/models.py b/tickets/models.py deleted file mode 100644 index 71a8362..0000000 --- a/tickets/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/tickets/tests/__init__.py b/tickets/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tickets/views.py b/tickets/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/tickets/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100755 index 0bb439a..0000000 --- a/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -2c2a工具模块 -""" diff --git a/utils/ca_bundle.py b/utils/ca_bundle.py deleted file mode 100644 index 237d489..0000000 --- a/utils/ca_bundle.py +++ /dev/null @@ -1,454 +0,0 @@ -#!/usr/bin/env python3 -""" -WinRM PKI 证书生成器 - -生成 WinRM HTTPS 所需的 CA、服务器、客户端证书(含 UPN SAN)。 -依赖: pip install cryptography -""" - -import datetime -import ipaddress -import shutil -from pathlib import Path - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import ( - BestAvailableEncryption, - Encoding, - NoEncryption, - PrivateFormat, - pkcs12, -) -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID, ObjectIdentifier - -# ============================================================ -# 配置项(按需修改) -# ============================================================ -WINDOWS_IP = "192.168.122.234" -WINDOWS_HOSTNAME = "WIN-R8S5ITT8IC9" -OUTPUT_DIR = "winrm-pki" -VALIDITY_DAYS = 3650 -PFX_PASSWORD = b"changeit" -UPN_VALUE = "test@localhost" -UPN_OID = "1.3.6.1.4.1.311.20.2.3" - - -# ============================================================ -# 基础工具函数 -# ============================================================ - -def ensure_output_dir(output_dir: str) -> Path: - """确保输出目录存在并切换工作目录。""" - path = Path(output_dir) - path.mkdir(parents=True, exist_ok=True) - import os - os.chdir(path) - return path - - -def generate_ec_key() -> ec.EllipticCurvePrivateKey: - """生成 EC 私钥(prime256v1 / P-256 曲线)。""" - return ec.generate_private_key(ec.SECP256R1(), default_backend()) - - -def save_private_key(key: ec.EllipticCurvePrivateKey, filename: str) -> None: - """将私钥保存为 PEM 文件(SEC1 格式,与 openssl ecparam 输出一致)。""" - pem = key.private_bytes( - encoding=Encoding.PEM, - format=PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=NoEncryption(), - ) - Path(filename).write_bytes(pem) - print(f" 已保存私钥: {filename}") - - -def save_certificate(cert: x509.Certificate, filename: str) -> None: - """将证书保存为 PEM 文件。""" - pem = cert.public_bytes(Encoding.PEM) - Path(filename).write_bytes(pem) - print(f" 已保存证书: {filename}") - - -def export_pfx( - cert: x509.Certificate, - key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, - password: bytes, - filename: str, -) -> None: - """将证书 + 私钥 + CA 证书导出为 PKCS#12 (.pfx) 文件。""" - pfx_data = pkcs12.serialize_key_and_certificates( - name=None, - key=key, - cert=cert, - cas=[ca_cert], - encryption_algorithm=BestAvailableEncryption(password), - ) - Path(filename).write_bytes(pfx_data) - print(f" 已导出 PFX: {filename}") - - -def _validity_period(): - """返回证书的有效期起止时间。""" - now = datetime.datetime.now(datetime.timezone.utc) - return now, now + datetime.timedelta(days=VALIDITY_DAYS) - - -def _encode_upn_other_name(upn: str) -> x509.OtherName: - """ - 编码 UPN OtherName(OID 1.3.6.1.4.1.311.20.2.3)。 - 值为 DER 编码的 UTF8String。 - """ - oid = ObjectIdentifier(UPN_OID) - encoded = upn.encode("utf-8") - # DER: tag(0x0C=UTF8String) + length + value - der_value = b"\x0c" + bytes([len(encoded)]) + encoded - return x509.OtherName(oid, der_value) - - -# ============================================================ -# 步骤 1:创建 CA -# ============================================================ - -def build_ca_certificate(ca_key: ec.EllipticCurvePrivateKey) -> x509.Certificate: - """创建自签名 CA 证书(对应 bash 中 ca.cnf 的扩展配置)。""" - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "WinRM-CA"), - ]) - not_before, not_after = _validity_period() - - builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) - # basicConstraints = critical, CA:TRUE - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), critical=True - ) - # keyUsage = critical, digitalSignature, keyCertSign, cRLSign - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_cert_sign=True, - crl_sign=True, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - # subjectKeyIdentifier = hash - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), - critical=False, - ) - # authorityKeyIdentifier = keyid:always, issuer - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), - critical=False, - ) - ) - return builder.sign(ca_key, hashes.SHA256(), default_backend()) - - -def create_ca() -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: - """步骤 1:创建 CA 私钥和自签名证书。""" - print("\n" + "=" * 50) - print("1. 创建 CA") - print("=" * 50) - - ca_key = generate_ec_key() - save_private_key(ca_key, "ca.key") - - ca_cert = build_ca_certificate(ca_key) - save_certificate(ca_cert, "ca.crt") - - print_ca_info(ca_cert) - return ca_key, ca_cert - - -# ============================================================ -# 步骤 2:签发服务器证书 -# ============================================================ - -def build_server_csr( - server_key: ec.EllipticCurvePrivateKey, hostname: str -) -> x509.CertificateSigningRequest: - """生成服务器证书签名请求。""" - builder = x509.CertificateSigningRequestBuilder().subject_name( - x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) - ) - return builder.sign(server_key, hashes.SHA256(), default_backend()) - - -def sign_server_certificate( - csr: x509.CertificateSigningRequest, - ca_key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, - hostname: str, - ip_address: str, -) -> x509.Certificate: - """用 CA 签发服务器证书(对应 bash 中 server_ext.cnf 的扩展配置)。""" - not_before, not_after = _validity_period() - - san = x509.SubjectAlternativeName([ - x509.DNSName(hostname), - x509.IPAddress(ipaddress.ip_address(ip_address)), - ]) - - builder = ( - x509.CertificateBuilder() - .subject_name(csr.subject) - .issuer_name(ca_cert.subject) - .public_key(csr.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) - # basicConstraints = CA:FALSE - .add_extension( - x509.BasicConstraints(ca=False, path_length=None), critical=False - ) - # keyUsage = critical, digitalSignature, keyEncipherment - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - content_commitment=False, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - data_encipherment=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - # extendedKeyUsage = serverAuth - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), - critical=False, - ) - # subjectAltName - .add_extension(san, critical=False) - # subjectKeyIdentifier = hash - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), - critical=False, - ) - # authorityKeyIdentifier = keyid,issuer - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), - critical=False, - ) - ) - return builder.sign(ca_key, hashes.SHA256(), default_backend()) - - -def create_server_cert( - ca_key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, -) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: - """步骤 2:签发服务器证书。""" - print("\n" + "=" * 50) - print("2. 签发服务器证书") - print("=" * 50) - - server_key = generate_ec_key() - save_private_key(server_key, "server.key") - - csr = build_server_csr(server_key, WINDOWS_HOSTNAME) - server_cert = sign_server_certificate( - csr, ca_key, ca_cert, WINDOWS_HOSTNAME, WINDOWS_IP - ) - save_certificate(server_cert, "server.crt") - - export_pfx(server_cert, server_key, ca_cert, PFX_PASSWORD, "server.pfx") - - print_server_info(server_cert) - return server_key, server_cert - - -# ============================================================ -# 步骤 3:签发客户端证书(含 UPN SAN) -# ============================================================ - -def build_client_csr( - client_key: ec.EllipticCurvePrivateKey, -) -> x509.CertificateSigningRequest: - """生成客户端证书签名请求。""" - builder = x509.CertificateSigningRequestBuilder().subject_name( - x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "winrm-client")]) - ) - return builder.sign(client_key, hashes.SHA256(), default_backend()) - - -def sign_client_certificate( - csr: x509.CertificateSigningRequest, - ca_key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, -) -> x509.Certificate: - """用 CA 签发客户端证书(对应 bash 中 client_ext.cnf,含 UPN SAN)。""" - not_before, not_after = _validity_period() - - san = x509.SubjectAlternativeName([_encode_upn_other_name(UPN_VALUE)]) - - builder = ( - x509.CertificateBuilder() - .subject_name(csr.subject) - .issuer_name(ca_cert.subject) - .public_key(csr.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) - # basicConstraints = CA:FALSE - .add_extension( - x509.BasicConstraints(ca=False, path_length=None), critical=False - ) - # keyUsage = critical, digitalSignature - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - content_commitment=False, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - data_encipherment=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - # extendedKeyUsage = clientAuth - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), - critical=False, - ) - # subjectKeyIdentifier = hash - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(csr.public_key()), - critical=False, - ) - # authorityKeyIdentifier = keyid,issuer - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), - critical=False, - ) - # ★ subjectAltName = UPN otherName ★ - .add_extension(san, critical=False) - ) - return builder.sign(ca_key, hashes.SHA256(), default_backend()) - - -def create_client_cert( - ca_key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, -) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: - """步骤 3:签发客户端证书。""" - print("\n" + "=" * 50) - print("3. 签发客户端证书") - print("=" * 50) - - client_key = generate_ec_key() - save_private_key(client_key, "client.key") - - csr = build_client_csr(client_key) - client_cert = sign_client_certificate(csr, ca_key, ca_cert) - save_certificate(client_cert, "client.crt") - - # 复制为兼容命名的 PEM 文件 - shutil.copy2("client.crt", "client-cert.pem") - shutil.copy2("client.key", "client-key.pem") - print(" 已复制: client.crt -> client-cert.pem") - print(" 已复制: client.key -> client-key.pem") - - export_pfx(client_cert, client_key, ca_cert, PFX_PASSWORD, "client.pfx") - - print_client_info(client_cert) - return client_key, client_cert - - -# ============================================================ -# 证书信息打印 -# ============================================================ - -def print_ca_info(cert: x509.Certificate) -> None: - """打印 CA 证书关键信息。""" - print("\n=== CA 证书 ===") - print(f" Subject: {cert.subject.rfc4514_string()}") - for ext in cert.extensions: - if isinstance(ext.value, x509.BasicConstraints): - print(f" Basic Constraints: CA={ext.value.ca}") - elif isinstance(ext.value, x509.KeyUsage): - usages = [] - if ext.value.digital_signature: - usages.append("digitalSignature") - if ext.value.key_cert_sign: - usages.append("keyCertSign") - if ext.value.crl_sign: - usages.append("cRLSign") - print(f" Key Usage: {', '.join(usages)}") - fingerprint = cert.fingerprint(hashes.SHA256()).hex(":") - print(f" SHA256 Fingerprint: {fingerprint}") - - -def print_server_info(cert: x509.Certificate) -> None: - """打印服务器证书关键信息。""" - print("\n=== 服务器证书 ===") - print(f" Subject: {cert.subject.rfc4514_string()}") - print(f" Issuer: {cert.issuer.rfc4514_string()}") - - -def print_client_info(cert: x509.Certificate) -> None: - """打印客户端证书关键信息(含 SAN)。""" - print("\n=== 客户端证书 ===") - print(f" Subject: {cert.subject.rfc4514_string()}") - print(f" Issuer: {cert.issuer.rfc4514_string()}") - print(" --- SAN ---") - for ext in cert.extensions: - if isinstance(ext.value, x509.SubjectAlternativeName): - for name in ext.value: - if isinstance(name, x509.OtherName): - print( - f" OtherName (OID {name.type_id.dotted_string}): " - f"{name.value!r}" - ) - - -def print_summary() -> None: - """打印最终汇总信息。""" - print("\n" + "=" * 50) - print("生成完毕!需要导入 Windows 的文件:") - print(" ca.crt → LocalMachine\\Root") - print(" server.pfx → LocalMachine\\My") - print(" client.crt → LocalMachine\\TrustedPeople") - print("=" * 50) - - -# ============================================================ -# 主入口 -# ============================================================ - -def main() -> None: - """主入口:按顺序执行 CA → 服务器证书 → 客户端证书。""" - ensure_output_dir(OUTPUT_DIR) - - ca_key, ca_cert = create_ca() - create_server_cert(ca_key, ca_cert) - create_client_cert(ca_key, ca_cert) - - print_summary() - - -if __name__ == "__main__": - main() diff --git a/utils/cert_service.py b/utils/cert_service.py deleted file mode 100644 index b3c43f1..0000000 --- a/utils/cert_service.py +++ /dev/null @@ -1,272 +0,0 @@ -import datetime -import ipaddress -import secrets -import string - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import ( - BestAvailableEncryption, - pkcs12, -) -from cryptography.x509.oid import ( - ExtendedKeyUsageOID, - NameOID, - ObjectIdentifier, -) - - -def generate_ec_key() -> ec.EllipticCurvePrivateKey: - return ec.generate_private_key(ec.SECP256R1(), default_backend()) - - -def generate_ca( - ca_name: str = "WinRM-CA", - validity_days: int = 3650, -) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: - ca_key = generate_ec_key() - subject = issuer = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, ca_name), - ]) - now = datetime.datetime.now(datetime.timezone.utc) - not_before = now - not_after = now + datetime.timedelta(days=validity_days) - builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(ca_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) - .add_extension( - x509.BasicConstraints(ca=True, path_length=None), - critical=True, - ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_cert_sign=True, - crl_sign=True, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), - critical=False, - ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key( - ca_key.public_key() - ), - critical=False, - ) - ) - ca_cert = builder.sign(ca_key, hashes.SHA256(), default_backend()) - return ca_key, ca_cert - - -def issue_server_cert( - ca_key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, - hostname: str, - ip_address: str | None = None, - validity_days: int = 3650, - pfx_password: str | None = None, -) -> dict: - server_key = generate_ec_key() - subject = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, hostname), - ]) - now = datetime.datetime.now(datetime.timezone.utc) - not_before = now - not_after = now + datetime.timedelta(days=validity_days) - san_entries = [x509.DNSName(hostname)] - if ip_address: - try: - san_entries.append( - x509.IPAddress(ipaddress.ip_address(ip_address)) - ) - except ValueError: - # Invalid IP input is intentionally ignored; DNS SAN is still used. - pass - san = x509.SubjectAlternativeName(san_entries) - builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(ca_cert.subject) - .public_key(server_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) - .add_extension( - x509.BasicConstraints(ca=False, path_length=None), - critical=False, - ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - content_commitment=False, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - data_encipherment=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), - critical=False, - ) - .add_extension(san, critical=False) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(server_key.public_key()), - critical=False, - ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key( - ca_key.public_key() - ), - critical=False, - ) - ) - server_cert = builder.sign(ca_key, hashes.SHA256(), default_backend()) - if pfx_password is None: - pfx_password = generate_random_pfx_password() - pfx_data = export_pfx( - server_cert, server_key, ca_cert, - pfx_password.encode("utf-8"), - ) - return { - "server_key": server_key, - "server_cert": server_cert, - "pfx_data": pfx_data, - "pfx_password": pfx_password, - } - - -def issue_client_cert( - ca_key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, - upn_value: str, - validity_days: int = 3650, -) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]: - client_key = generate_ec_key() - subject = x509.Name([ - x509.NameAttribute(NameOID.COMMON_NAME, "winrm-client"), - ]) - now = datetime.datetime.now(datetime.timezone.utc) - not_before = now - not_after = now + datetime.timedelta(days=validity_days) - san = x509.SubjectAlternativeName([ - encode_upn_other_name(upn_value), - ]) - builder = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(ca_cert.subject) - .public_key(client_key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(not_before) - .not_valid_after(not_after) - .add_extension( - x509.BasicConstraints(ca=False, path_length=None), - critical=False, - ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=False, - content_commitment=False, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - data_encipherment=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.CLIENT_AUTH]), - critical=False, - ) - .add_extension( - x509.SubjectKeyIdentifier.from_public_key(client_key.public_key()), - critical=False, - ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key( - ca_key.public_key() - ), - critical=False, - ) - .add_extension(san, critical=False) - ) - client_cert = builder.sign(ca_key, hashes.SHA256(), default_backend()) - return client_key, client_cert - - -def encode_upn_other_name(upn: str) -> x509.OtherName: - oid = ObjectIdentifier("1.3.6.1.4.1.311.20.2.3") - encoded = upn.encode("utf-8") - der_value = b"\x0c" + bytes([len(encoded)]) + encoded - return x509.OtherName(oid, der_value) - - -def export_pfx( - cert: x509.Certificate, - key: ec.EllipticCurvePrivateKey, - ca_cert: x509.Certificate, - password_bytes: bytes, -) -> bytes: - return pkcs12.serialize_key_and_certificates( - name=None, - key=key, - cert=cert, - cas=[ca_cert], - encryption_algorithm=BestAvailableEncryption(password_bytes), - ) - - -def generate_random_pfx_password(length: int = 16) -> str: - alphabet = string.ascii_letters + string.digits - return "".join(secrets.choice(alphabet) for _ in range(length)) - - -def generate_random_username(prefix: str = "c2a_", length: int = 8) -> str: - alphabet = string.ascii_lowercase + string.digits - return prefix + "".join(secrets.choice(alphabet) for _ in range(length)) - - -def generate_random_password(length: int = 16) -> str: - if length < 4: - raise ValueError( - "Password length must be at least 4 " - "to meet complexity requirements" - ) - parts = [ - secrets.choice(string.ascii_uppercase), - secrets.choice(string.ascii_lowercase), - secrets.choice(string.digits), - secrets.choice("!@#$%^&*"), - ] - remaining = length - len(parts) - pool = string.ascii_letters + string.digits + "!@#$%^&*" - parts.extend(secrets.choice(pool) for _ in range(remaining)) - shuffled = list(parts) - for i in range(len(shuffled) - 1, 0, -1): - j = secrets.randbelow(i + 1) - shuffled[i], shuffled[j] = shuffled[j], shuffled[i] - return "".join(shuffled) diff --git a/utils/cert_storage.py b/utils/cert_storage.py deleted file mode 100644 index 0d051cd..0000000 --- a/utils/cert_storage.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import re -import secrets -import shutil -from pathlib import Path -from django.conf import settings - - -def _sanitizePathComponent(value: str) -> str: - """过滤非字母数字字符,防止路径穿越""" - return re.sub(r'[^a-zA-Z0-9]', '', value) - - -def get_cert_base_dir(): - return Path(settings.MEDIA_ROOT) / 'certificates' - - -def get_cert_dir(cert_root: str, cert_sub: str): - return ( - get_cert_base_dir() - / _sanitizePathComponent(cert_root) - / _sanitizePathComponent(cert_sub) - ) - - -def generate_cert_paths(): - cert_root = secrets.token_hex(1) - cert_sub = secrets.token_hex(1) - return cert_root, cert_sub - - -def save_cert_files( - cert_root: str, - cert_sub: str, - ca_cert_pem: bytes, - client_cert_pem: bytes, - server_pfx_bytes: bytes, - client_key_pem: bytes | None = None, -): - cert_dir = get_cert_dir(cert_root, cert_sub) - cert_dir.mkdir(parents=True, exist_ok=True) - - ca_cert_path = cert_dir / 'ca.crt' - ca_cert_path.write_bytes(ca_cert_pem) - os.chmod(ca_cert_path, 0o600) - - client_cert_path = cert_dir / 'client.crt' - client_cert_path.write_bytes(client_cert_pem) - os.chmod(client_cert_path, 0o600) - - if client_key_pem: - client_key_path = cert_dir / 'client.key' - client_key_path.write_bytes(client_key_pem) - os.chmod(client_key_path, 0o600) - - server_pfx_path = cert_dir / 'server.pfx' - server_pfx_path.write_bytes(server_pfx_bytes) - os.chmod(server_pfx_path, 0o600) - - return cert_dir - - -def delete_cert_files(cert_root: str, cert_sub: str): - cert_dir = get_cert_dir(cert_root, cert_sub) - if cert_dir.exists(): - shutil.rmtree(cert_dir) - parent_dir = cert_dir.parent - try: - parent_dir.rmdir() - except OSError: - # Best-effort cleanup: parent may be non-empty or changed concurrently. - pass - return True - return False - - -def get_cert_file_paths(cert_root: str, cert_sub: str): - cert_dir = get_cert_dir(cert_root, cert_sub) - return { - 'ca_cert': cert_dir / 'ca.crt', - 'client_cert': cert_dir / 'client.crt', - 'client_key': cert_dir / 'client.key', - 'server_pfx': cert_dir / 'server.pfx', - } - - -def get_ca_base_dir(): - return Path(settings.MEDIA_ROOT) / 'certificates' / 'ca' - - -def get_ca_dir(cert_root: str, cert_sub: str): - return ( - get_ca_base_dir() - / _sanitizePathComponent(cert_root) - / _sanitizePathComponent(cert_sub) - ) - - -def generate_ca_paths(): - cert_root = secrets.token_hex(1) - cert_sub = secrets.token_hex(1) - return cert_root, cert_sub - - -def save_ca_files(cert_root: str, cert_sub: str, ca_key_pem: bytes, ca_cert_pem: bytes): - ca_dir = get_ca_dir(cert_root, cert_sub) - ca_dir.mkdir(parents=True, exist_ok=True) - - key_path = ca_dir / 'ca.key' - key_path.write_bytes(ca_key_pem) - os.chmod(key_path, 0o600) - - cert_path = ca_dir / 'ca.crt' - cert_path.write_bytes(ca_cert_pem) - os.chmod(cert_path, 0o600) - - return ca_dir - - -def get_ca_file_paths(cert_root: str, cert_sub: str): - ca_dir = get_ca_dir(cert_root, cert_sub) - return { - 'key': ca_dir / 'ca.key', - 'cert': ca_dir / 'ca.crt', - } - - -def delete_ca_files(cert_root: str, cert_sub: str): - ca_dir = get_ca_dir(cert_root, cert_sub) - if ca_dir.exists(): - shutil.rmtree(ca_dir) - parent_dir = ca_dir.parent - try: - parent_dir.rmdir() - except OSError: - # Best-effort cleanup: parent may be non-empty or changed concurrently. - pass - return True - return False diff --git a/utils/crypto.py b/utils/crypto.py deleted file mode 100644 index c382567..0000000 --- a/utils/crypto.py +++ /dev/null @@ -1,28 +0,0 @@ -import base64 -import hashlib -from django.conf import settings -from cryptography.fernet import Fernet - -_fernet_instance = None - -def get_fernet(): - global _fernet_instance - if _fernet_instance is None: - key = hashlib.sha256(settings.SECRET_KEY.encode()).digest() - _fernet_instance = Fernet(base64.urlsafe_b64encode(key)) - return _fernet_instance - -def encrypt_value(plaintext): - if not plaintext: - return '' - f = get_fernet() - return f.encrypt(plaintext.encode()).decode() - -def decrypt_value(ciphertext): - if not ciphertext: - return '' - f = get_fernet() - try: - return f.decrypt(ciphertext.encode()).decode() - except Exception: - raise ValueError("解密失败") diff --git a/utils/disk_quota.py b/utils/disk_quota.py deleted file mode 100644 index ab7dfe1..0000000 --- a/utils/disk_quota.py +++ /dev/null @@ -1,336 +0,0 @@ -""" -磁盘配额管理工具 - -通过 WinRM 或本地命令管理 Windows 磁盘配额。 -使用 fsutil quota 和 PowerShell 管理NTFS磁盘配额。 -""" -import json -import logging -import os -import re -from typing import Dict, List, Optional, Any - -logger = logging.getLogger("2c2a") - -DISK_LETTER_PATTERN = re.compile(r'^[A-Za-z]:\\?$') -MB_TO_BYTES = 1024 * 1024 - - -def validate_disk_letter(disk_letter: str) -> str: - disk_letter = disk_letter.strip().upper() - if not DISK_LETTER_PATTERN.match(disk_letter): - raise ValueError(f"无效的磁盘盘符: {disk_letter}") - return disk_letter.rstrip('\\') - - -def validate_quota_value(value: Any, field_name: str = "配额值") -> int: - try: - v = int(value) - except (TypeError, ValueError): - raise ValueError(f"{field_name}必须为数字") - if v < 0: - raise ValueError(f"{field_name}不能为负数") - return v - - -def get_disk_info_via_client(client) -> List[Dict[str, Any]]: - """ - 通过客户端获取磁盘信息列表 - - Args: - client: WinrmClient 或 LocalWinServerClient 实例 - - Returns: - List[Dict]: 磁盘信息列表,每项包含 drive, total_mb, free_mb - """ - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info("DEMO模式: 返回模拟磁盘信息") - return [ - {"drive": "C:", "total_mb": 102400, "free_mb": 51200}, - {"drive": "D:", "total_mb": 204800, "free_mb": 102400}, - ] - - script = ''' -$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" -$result = @() -foreach ($disk in $disks) { - $result += [PSCustomObject]@{ - Drive = $disk.DeviceID - TotalMB = [math]::Round($disk.Size / 1MB) - FreeMB = [math]::Round($disk.FreeSpace / 1MB) - } -} -$result | ConvertTo-Json -Compress -''' - try: - result = client.execute_powershell(script) - if result.status_code == 0 and result.std_out.strip(): - output = result.std_out.strip() - try: - disks = json.loads(output) - except json.JSONDecodeError: - disks = [] - - if isinstance(disks, dict): - disks = [disks] - - disk_list = [] - for d in disks: - disk_list.append({ - "drive": d.get("Drive", ""), - "total_mb": d.get("TotalMB", 0), - "free_mb": d.get("FreeMB", 0), - }) - return disk_list - else: - logger.error(f"获取磁盘信息失败: {result.std_err}") - return [] - except Exception as e: - logger.error(f"获取磁盘信息异常: {str(e)}") - return [] - - -def set_disk_quota_via_client(client, username: str, disk_letter: str, quota_mb: int, warning_mb: Optional[int] = None) -> Dict[str, Any]: - """ - 通过客户端设置磁盘配额 - - Args: - client: WinrmClient 或 LocalWinServerClient 实例 - username: Windows 用户名 - disk_letter: 磁盘盘符,如 "C:" - quota_mb: 配额大小(MB) - warning_mb: 警告阈值(MB),默认为配额的80% - - Returns: - Dict: {"success": bool, "message": str} - """ - validate_disk_letter(disk_letter) - validate_quota_value(quota_mb, "配额大小") - - if warning_mb is None: - warning_mb = int(quota_mb * 0.8) - else: - validate_quota_value(warning_mb, "警告阈值") - - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f"DEMO模式: 模拟设置用户 {username} 在 {disk_letter} 的配额为 {quota_mb}MB") - return {"success": True, "message": f"DEMO模式: 已设置用户 {username} 在 {disk_letter} 的配额为 {quota_mb}MB"} - - disk_letter = disk_letter.upper().rstrip('\\') - quota_bytes = quota_mb * MB_TO_BYTES - warning_bytes = warning_mb * MB_TO_BYTES - - script = f''' -$ErrorActionPreference = 'Stop' -$drive = "{disk_letter}" -$username = "{username}" -$quotaBytes = [long]{quota_bytes} -$warningBytes = [long]{warning_bytes} - -try {{ - $vol = Get-CimInstance Win32_Volume -Filter "DriveLetter='$drive'" -ErrorAction Stop - if (-not $vol) {{ - Write-Error "找不到卷 $drive" - exit 1 - }} - - if (-not $vol.QuotasEnabled) {{ - $enforceOutput = & fsutil quota enforce $drive 2>&1 - if ($LASTEXITCODE -ne 0) {{ - Write-Error "启用配额失败: $enforceOutput" - exit 1 - }} - $vol = Get-CimInstance Win32_Volume -Filter "DriveLetter='$drive'" -ErrorAction Stop - if (-not $vol.QuotasEnabled) {{ - Write-Error "无法启用卷 $drive 的磁盘配额" - exit 1 - }} - }} - - $user = Get-LocalUser -Name $username -ErrorAction SilentlyContinue - if (-not $user) {{ - $user = Get-CimInstance Win32_UserAccount -Filter "Name='$username'" -ErrorAction SilentlyContinue - if (-not $user) {{ - Write-Error "用户 $username 不存在" - exit 1 - }} - }} - - $modifyOutput = & fsutil quota modify $drive $warningBytes $quotaBytes $username 2>&1 - if ($LASTEXITCODE -ne 0) {{ - Write-Error "设置用户配额失败: $modifyOutput" - exit 1 - }} - - Write-Output "SUCCESS" -}} catch {{ - Write-Error $_.Exception.Message - exit 1 -}} -''' - try: - result = client.execute_powershell(script) - if result.status_code == 0 and "SUCCESS" in result.std_out: - logger.info(f"设置磁盘配额成功: 用户={username}, 磁盘={disk_letter}, 配额={quota_mb}MB") - return {"success": True, "message": f"已设置用户 {username} 在 {disk_letter} 的配额为 {quota_mb}MB"} - else: - error_msg = result.std_err.strip() if result.std_err else "未知错误" - logger.error(f"设置磁盘配额失败: {error_msg}") - return {"success": False, "message": f"设置磁盘配额失败: {error_msg}"} - except Exception as e: - logger.error(f"设置磁盘配额异常: {str(e)}") - return {"success": False, "message": f"设置磁盘配额异常: {str(e)}"} - - -def get_disk_quota_via_client(client, username: str, disk_letter: str) -> Dict[str, Any]: - """ - 通过客户端获取用户磁盘配额 - - Args: - client: WinrmClient 或 LocalWinServerClient 实例 - username: Windows 用户名 - disk_letter: 磁盘盘符 - - Returns: - Dict: {"success": bool, "quota_mb": int, "warning_mb": int, "used_mb": int} - """ - validate_disk_letter(disk_letter) - - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f"DEMO模式: 模拟获取用户 {username} 在 {disk_letter} 的配额") - return {"success": True, "quota_mb": 10240, "warning_mb": 8192, "used_mb": 5120} - - disk_letter = disk_letter.upper().rstrip('\\') - - script = f''' -$ErrorActionPreference = 'Stop' -$drive = "{disk_letter}" -$username = "{username}" - -try {{ - $account = New-Object System.Security.Principal.NTAccount($username) - $sid = $account.Translate([System.Security.Principal.SecurityIdentifier]) - - $quota = Get-CimInstance Win32_DiskQuota -Filter "VolumePath='$drive\\' AND UserSID='$($sid.Value)'" -ErrorAction Stop - if ($quota) {{ - $result = [PSCustomObject]@{{ - QuotaMB = [math]::Round($quota.Limit / 1MB) - WarningMB = [math]::Round($quota.WarningLimit / 1MB) - UsedMB = [math]::Round($quota.DiskSpaceUsed / 1MB) - }} - $result | ConvertTo-Json -Compress - }} else {{ - Write-Output "NO_QUOTA" - }} -}} catch {{ - Write-Error $_.Exception.Message - exit 1 -}} -''' - try: - result = client.execute_powershell(script) - if result.status_code == 0: - output = result.std_out.strip() - if "NO_QUOTA" in output: - return {"success": True, "quota_mb": 0, "warning_mb": 0, "used_mb": 0} - try: - data = json.loads(output) - return { - "success": True, - "quota_mb": data.get("QuotaMB", 0), - "warning_mb": data.get("WarningMB", 0), - "used_mb": data.get("UsedMB", 0), - } - except json.JSONDecodeError: - return {"success": False, "quota_mb": 0, "warning_mb": 0, "used_mb": 0} - else: - return {"success": False, "quota_mb": 0, "warning_mb": 0, "used_mb": 0} - except Exception as e: - logger.error(f"获取磁盘配额异常: {str(e)}") - return {"success": False, "quota_mb": 0, "warning_mb": 0, "used_mb": 0} - - -def remove_disk_quota_via_client(client, username: str, disk_letter: str) -> Dict[str, Any]: - """ - 通过客户端删除用户磁盘配额 - - Args: - client: WinrmClient 或 LocalWinServerClient 实例 - username: Windows 用户名 - disk_letter: 磁盘盘符 - - Returns: - Dict: {"success": bool, "message": str} - """ - validate_disk_letter(disk_letter) - - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f"DEMO模式: 模拟删除用户 {username} 在 {disk_letter} 的配额") - return {"success": True, "message": f"DEMO模式: 已删除用户 {username} 在 {disk_letter} 的配额"} - - disk_letter = disk_letter.upper().rstrip('\\') - - script = f''' -$ErrorActionPreference = 'Stop' -$drive = "{disk_letter}" -$username = "{username}" - -try {{ - $account = New-Object System.Security.Principal.NTAccount($username) - $sid = $account.Translate([System.Security.Principal.SecurityIdentifier]) - - $quota = Get-CimInstance Win32_DiskQuota -Filter "VolumePath='$drive\\' AND UserSID='$($sid.Value)'" -ErrorAction Stop - if ($quota) {{ - Remove-CimInstance -InputObject $quota -ErrorAction Stop - Write-Output "SUCCESS" - }} else {{ - Write-Output "NO_QUOTA" - }} -}} catch {{ - Write-Error $_.Exception.Message - exit 1 -}} -''' - try: - result = client.execute_powershell(script) - if result.status_code == 0 and ("SUCCESS" in result.std_out or "NO_QUOTA" in result.std_out): - logger.info(f"删除磁盘配额成功: 用户={username}, 磁盘={disk_letter}") - return {"success": True, "message": f"已删除用户 {username} 在 {disk_letter} 的配额"} - else: - error_msg = result.std_err.strip() if result.std_err else "未知错误" - return {"success": False, "message": f"删除磁盘配额失败: {error_msg}"} - except Exception as e: - logger.error(f"删除磁盘配额异常: {str(e)}") - return {"success": False, "message": f"删除磁盘配额异常: {str(e)}"} - - -def set_user_disk_quotas(client, username: str, quota_config: Dict[str, int]) -> Dict[str, Any]: - """ - 批量设置用户磁盘配额 - - Args: - client: WinrmClient 或 LocalWinServerClient 实例 - username: Windows 用户名 - quota_config: 配额配置,如 {"C:": 10240, "D:": 20480} - - Returns: - Dict: {"success": bool, "results": list, "errors": list} - """ - results = [] - errors = [] - - for disk_letter, quota_mb in quota_config.items(): - try: - validate_quota_value(quota_mb, f"磁盘 {disk_letter} 配额") - result = set_disk_quota_via_client(client, username, disk_letter, quota_mb) - results.append({"disk": disk_letter, "result": result}) - if not result["success"]: - errors.append(f"{disk_letter}: {result['message']}") - except ValueError as e: - errors.append(f"{disk_letter}: {str(e)}") - - return { - "success": len(errors) == 0, - "results": results, - "errors": errors, - } diff --git a/utils/error_handlers.py b/utils/error_handlers.py deleted file mode 100755 index 4ca413d..0000000 --- a/utils/error_handlers.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -异常处理工具模块 -提供安全的异常处理和错误包装 -""" -import logging -from typing import Optional, Any, Dict -from django.db import DatabaseError, IntegrityError -from django.core.exceptions import ValidationError, PermissionDenied -from rest_framework.exceptions import APIException - -logger = logging.getLogger('2c2a') - - -class SecurityException(Exception): - """安全相关异常""" - pass - - -class WinRMConnectionException(Exception): - """WinRM 连接异常""" - pass - - -class InvalidUserInputException(Exception): - """用户输入无效异常""" - pass - - -def safe_exception_handler(func): - """安全的异常处理装饰器""" - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except SecurityException as e: - # 安全异常,记录但不暴露详情 - logger.warning(f"Security exception in {func.__name__}: {str(e)}") - raise Exception("操作被拒绝,请联系管理员") - except WinRMConnectionException as e: - # WinRM 连接异常,提供有用信息 - logger.error(f"WinRM connection error in {func.__name__}: {str(e)}") - raise Exception("无法连接到远程主机,请检查主机状态和网络连接") - except (DatabaseError, IntegrityError) as e: - # 数据库异常,不暴露具体错误 - logger.error(f"Database error in {func.__name__}: {type(e).__name__}") - raise Exception("数据处理失败,请稍后重试") - except ValidationError as e: - # 验证异常,可以返回原始信息 - logger.info(f"Validation error in {func.__name__}: {str(e)}") - raise - except PermissionDenied as e: - # 权限异常 - logger.warning(f"Permission denied in {func.__name__}: {str(e)}") - raise Exception("您没有执行此操作的权限") - except APIException as e: - # API 异常,保持原样 - raise - except ValueError as e: - # 值错误,提供有用信息 - logger.info(f"Value error in {func.__name__}: {str(e)}") - raise Exception(f"无效的输入: {str(e)}") - except Exception as e: - # 其他未预期的异常 - from django.conf import settings - logger.error(f"Unexpected error in {func.__name__}: {type(e).__name__}") - # 在生产环境中不暴露内部错误 - if hasattr(settings, 'DEBUG') and settings.DEBUG: - raise - else: - raise Exception("发生未知错误,请联系技术支持") - return wrapper - - -def sanitize_error_message(error_msg: str, user_friendly: bool = True) -> str: - """ - 清理错误消息,移除敏感信息 - - Args: - error_msg: 原始错误消息 - user_friendly: 是否返回用户友好的消息 - - Returns: - 清理后的错误消息 - """ - # 敏感信息模式 - sensitive_patterns = [ - r'password\s*[:=]\s*\S+', - r'pwd\s*[:=]\s*\S+', - r'secret\s*[:=]\s*\S+', - r'token\s*[:=]\s*\S+', - r'key\s*[:=]\s*\S+', - r'\\Users\\\w+', # Windows 用户名 - r'/home/\w+', # Linux 用户名 - r'\d+\.\d+\.\d+\.\d+', # IP 地址(部分脱敏) - ] - - import re - sanitized = error_msg - - for pattern in sensitive_patterns: - sanitized = re.sub(pattern, '***', sanitized, flags=re.IGNORECASE) - - if user_friendly: - # 替换技术术语为用户友好的消息 - replacements = { - 'NameResolutionError': '无法解析主机名', - 'ConnectionRefusedError': '连接被拒绝,请检查服务是否运行', - 'TimeoutError': '连接超时,请检查网络连接', - 'AuthenticationError': '认证失败,请检查用户名和密码', - 'AccessDenied': '访问被拒绝,权限不足', - 'NotFound': '请求的资源不存在', - } - - for tech_term, friendly_msg in replacements.items(): - if tech_term in sanitized: - sanitized = friendly_msg - break - - return sanitized - - -def create_error_response(error: Exception, request=None) -> Dict[str, Any]: - """ - 创建标准的错误响应 - - Args: - error: 异常对象 - request: HTTP 请求对象(可选) - - Returns: - 错误响应字典 - """ - from django.conf import settings - - error_type = type(error).__name__ - error_message = str(error) - - # 清理错误消息 - if not (hasattr(settings, 'DEBUG') and settings.DEBUG): - error_message = sanitize_error_message(error_message) - - response = { - 'success': False, - 'error': { - 'type': error_type, - 'message': error_message, - } - } - - # 添加追踪 ID(如果有) - import uuid - response['trace_id'] = str(uuid.uuid4()) - - # 添加请求信息(如果有) - if request: - response['request_id'] = getattr(request, 'request_id', None) - response['user'] = request.user.username if request.user.is_authenticated else 'anonymous' - - return response \ No newline at end of file diff --git a/utils/gateway_client.py b/utils/gateway_client.py deleted file mode 100644 index 9d1a14b..0000000 --- a/utils/gateway_client.py +++ /dev/null @@ -1,150 +0,0 @@ -import logging -from typing import Any, Dict, Optional - -logger = logging.getLogger('2c2a') - - -class GatewayError(Exception): - pass - - -def _get_gateway_service(): - from plugins.core.plugin_manager import get_plugin_manager - from plugins.gateway.interfaces import GatewayServiceInterface - - pm = get_plugin_manager() - service = pm.get_service('gateway') - if service is not None and isinstance(service, GatewayServiceInterface): - return service - return None - - -def is_gateway_enabled() -> bool: - service = _get_gateway_service() - if service is not None: - return service.is_enabled() - from django.conf import settings - return getattr(settings, 'GATEWAY_ENABLED', False) - - -class GatewayClient: - _instance = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __init__(self, socket_path: Optional[str] = None): - if hasattr(self, '_initialized'): - return - self._initialized = True - - def _get_service(self): - return _get_gateway_service() - - @property - def enabled(self) -> bool: - service = self._get_service() - if service: - return service.is_enabled() - return False - - def _is_available(self) -> bool: - service = self._get_service() - if service: - return service.is_available() - return False - - def tunnel_kick(self, token: str) -> bool: - service = self._get_service() - if service: - return service.tunnel_kick(token) - return False - - def tunnel_stats(self, token: Optional[str] = None) -> Optional[Any]: - service = self._get_service() - if service: - return service.tunnel_stats(token) - return None - - def rdp_session_stats(self) -> Optional[Any]: - service = self._get_service() - if service: - return service.rdp_session_stats() - return None - - def rdp_session_kick(self, session_id: str) -> bool: - service = self._get_service() - if service: - return service.rdp_session_kick(session_id) - return False - - def remote_exec( - self, - token: str, - script: bytes, - encrypted_key: Optional[bytes] = None, - signature: Optional[bytes] = None, - pub_key_id: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - service = self._get_service() - if service: - return service.remote_exec( - token, script, encrypted_key, signature, pub_key_id - ) - return None - - def issue_paa_token( - self, user_email: str, tunnel_token: str, - client_ip: Optional[str] = None, expires_in: int = 600 - ) -> str: - service = self._get_service() - if service: - return service.issue_paa_token( - user_email, tunnel_token, client_ip, expires_in - ) - return '' - - def generate_rdp_file( - self, gateway_address: str, gateway_port: int, - user_email: str, paa_token: str, - enable_clipboard: bool = True, enable_printers: bool = True, - enable_drive: bool = True, enable_port: bool = False, - enable_pnp: bool = False - ) -> str: - service = self._get_service() - if service: - return service.generate_rdp_file( - gateway_address, gateway_port, user_email, paa_token, - enable_clipboard, enable_printers, enable_drive, - enable_port, enable_pnp - ) - return '' - - -class GatewayEventListener: - def __init__(self, socket_path: Optional[str] = None): - self._socket_path = socket_path - self._running = False - self._handlers = {} - - def register_handler(self, event_type: str, handler): - self._handlers[event_type] = handler - - def start(self): - from plugins.core.plugin_manager import get_plugin_manager - pm = get_plugin_manager() - plugin = pm.get_plugin('gateway') - if plugin and hasattr(plugin, 'get_event_listener'): - listener = plugin.get_event_listener(self._socket_path) - for event_type, handler in self._handlers.items(): - listener.register_handler(event_type, handler) - listener.start() - else: - logger.warning( - 'Gateway plugin not available, event listener not starting' - ) - - def stop(self): - self._running = False diff --git a/utils/helpers.py b/utils/helpers.py deleted file mode 100755 index c9faf04..0000000 --- a/utils/helpers.py +++ /dev/null @@ -1,424 +0,0 @@ -""" -辅助函数模块 -提供项目中常用的辅助函数 -""" -import re -import json -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any, Union -from django.utils import timezone -from django.conf import settings - - -def get_client_ip(request) -> Optional[str]: - """获取客户端真实 IP 地址(支持 nginx 反向代理) - - 优先级: - 1. 当请求来自可信代理时,依次尝试 X-Forwarded-For(取第一个非可信 IP)、X-Real-IP - 2. 否则直接使用 REMOTE_ADDR - """ - remote_addr = request.META.get('REMOTE_ADDR', '') - - # 检查请求是否来自可信代理 - trusted_proxies = getattr(settings, 'TRUSTED_PROXY_IPS', None) - if trusted_proxies and remote_addr in trusted_proxies: - # X-Forwarded-For: client, proxy1, proxy2 — 取最右边的非可信 IP - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ips = [ip.strip() for ip in x_forwarded_for.split(',')] - # 从右往左找第一个非可信代理的 IP - for ip in reversed(ips): - if ip not in trusted_proxies: - return ip - # 全部都是可信代理,取最左边的 - return ips[0] - - # 尝试 X-Real-IP(nginx 默认设置) - x_real_ip = request.META.get('HTTP_X_REAL_IP') - if x_real_ip: - return x_real_ip.strip() - - # 兼容旧配置 USE_X_FORWARDED_FOR - if getattr(settings, 'USE_X_FORWARDED_FOR', False): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - return x_forwarded_for.split(',')[0].strip() - - return remote_addr or None - - -def validate_ip_address(ip: str) -> bool: - """ - 验证IP地址格式是否正确 - - Args: - ip: 待验证的IP地址字符串 - - Returns: - bool: IP地址格式是否有效 - """ - pattern = r'^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$' - return bool(re.match(pattern, ip)) - - -def validate_port(port: Union[int, str]) -> bool: - """ - 验证端口号是否有效 - - Args: - port: 待验证的端口号 - - Returns: - bool: 端口号是否有效(1-65535) - """ - try: - port_num = int(port) - return 1 <= port_num <= 65535 - except (ValueError, TypeError): - return False - - -def format_datetime(dt: datetime, format_str: str = '%Y-%m-%d %H:%M:%S') -> str: - """ - 格式化日期时间 - - Args: - dt: 日期时间对象 - format_str: 格式化字符串,默认为 '%Y-%m-%d %H:%M:%S' - - Returns: - str: 格式化后的日期时间字符串 - """ - if dt is None: - return '' - return dt.strftime(format_str) - - -def parse_datetime(dt_str: str, format_str: str = '%Y-%m-%d %H:%M:%S') -> Optional[datetime]: - """ - 解析日期时间字符串 - - Args: - dt_str: 日期时间字符串 - format_str: 格式化字符串,默认为 '%Y-%m-%d %H:%M:%S' - - Returns: - datetime: 解析后的日期时间对象,如果解析失败则返回None - """ - try: - return datetime.strptime(dt_str, format_str) - except (ValueError, TypeError): - return None - - -def get_time_range(days: int = 7) -> tuple: - """ - 获取指定天数的时间范围 - - Args: - days: 天数,默认为7天 - - Returns: - tuple: (开始时间, 结束时间) 的元组 - """ - end_time = timezone.now() - start_time = end_time - timedelta(days=days) - return start_time, end_time - - -def safe_json_loads(json_str: str, default: Any = None) -> Any: - """ - 安全地解析JSON字符串 - - Args: - json_str: JSON字符串 - default: 解析失败时的默认返回值 - - Returns: - 解析后的对象或默认值 - """ - try: - return json.loads(json_str) - except (json.JSONDecodeError, TypeError): - return default - - -def safe_json_dumps(obj: Any, default: str = '{}', **kwargs) -> str: - """ - 安全地序列化对象为JSON字符串 - - Args: - obj: 待序列化的对象 - default: 序列化失败时的默认返回值 - **kwargs: 传递给json.dumps的额外参数 - - Returns: - str: JSON字符串或默认值 - """ - try: - return json.dumps(obj, **kwargs) - except (TypeError, ValueError): - return default - - -def mask_sensitive_data(data: str, mask_char: str = '*', visible_chars: int = 4) -> str: - """ - 掩码处理敏感数据 - - Args: - data: 待处理的字符串 - mask_char: 掩码字符,默认为 '*' - visible_chars: 保留可见的字符数,默认为4 - - Returns: - str: 掩码处理后的字符串 - """ - if not data or len(data) <= visible_chars: - return mask_char * len(data) if data else data - - return data[:visible_chars] + mask_char * (len(data) - visible_chars) - - -def truncate_string(text: str, max_length: int = 100, suffix: str = '...') -> str: - """ - 截断字符串 - - Args: - text: 待截断的字符串 - max_length: 最大长度,默认为100 - suffix: 截断后添加的后缀,默认为 '...' - - Returns: - str: 截断后的字符串 - """ - if not text or len(text) <= max_length: - return text - - return text[:max_length - len(suffix)] + suffix - - -def format_file_size(size_bytes: int) -> str: - """ - 格式化文件大小 - - Args: - size_bytes: 文件大小(字节) - - Returns: - str: 格式化后的文件大小字符串 - """ - if size_bytes == 0: - return '0B' - - size_names = ['B', 'KB', 'MB', 'GB', 'TB'] - i = 0 - while size_bytes >= 1024 and i < len(size_names) - 1: - size_bytes /= 1024.0 - i += 1 - - return f'{size_bytes:.2f}{size_names[i]}' - - -def generate_random_string(length: int = 32, - include_uppercase: bool = True, - include_lowercase: bool = True, - include_digits: bool = True, - include_special_chars: bool = False) -> str: - """ - 生成随机字符串 - - Args: - length: 字符串长度,默认为32 - include_uppercase: 是否包含大写字母,默认为True - include_lowercase: 是否包含小写字母,默认为True - include_digits: 是否包含数字,默认为True - include_special_chars: 是否包含特殊字符,默认为False - - Returns: - str: 生成的随机字符串 - """ - import secrets as _secrets - import string - - chars = '' - if include_uppercase: - chars += string.ascii_uppercase - if include_lowercase: - chars += string.ascii_lowercase - if include_digits: - chars += string.digits - if include_special_chars: - chars += '!@#$%^&*()_+-=[]{}|;:,.<>?' - - if not chars: - chars = string.ascii_letters + string.digits - - return ''.join(_secrets.choice(chars) for _ in range(length)) - - -def validate_email(email: str) -> bool: - """ - 验证电子邮件地址格式 - - Args: - email: 待验证的电子邮件地址 - - Returns: - bool: 电子邮件地址格式是否有效 - """ - pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' - return bool(re.match(pattern, email)) - - -def is_valid_hostname(hostname: str) -> bool: - """ - 验证主机名是否有效 - - Args: - hostname: 待验证的主机名 - - Returns: - bool: 主机名是否有效 - """ - if not hostname or len(hostname) > 253: - return False - - # 检查是否为IP地址 - if validate_ip_address(hostname): - return True - - # 检查主机名格式 - hostname_pattern = r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$' - return bool(re.match(hostname_pattern, hostname)) - - -def get_setting(key: str, default: Any = None) -> Any: - """ - 获取Django设置值 - - Args: - key: 设置键名 - default: 默认值 - - Returns: - 设置值或默认值 - """ - return getattr(settings, key, default) - - -def chunk_list(lst: List[Any], chunk_size: int) -> List[List[Any]]: - """ - 将列表分块 - - Args: - lst: 待分块的列表 - chunk_size: 每块的大小 - - Returns: - List[List[Any]]: 分块后的列表 - """ - return [lst[i:i + chunk_size] for i in range(0, len(lst), chunk_size)] - - -def merge_dicts(*dicts: Dict[Any, Any]) -> Dict[Any, Any]: - """ - 合并多个字典 - - Args: - *dicts: 待合并的字典 - - Returns: - Dict[Any, Any]: 合并后的字典 - """ - result = {} - for d in dicts: - if isinstance(d, dict): - result.update(d) - return result - - -def deep_update_dict(base_dict: Dict[Any, Any], - update_dict: Dict[Any, Any]) -> Dict[Any, Any]: - """ - 深度更新字典 - - Args: - base_dict: 基础字典 - update_dict: 更新字典 - - Returns: - Dict[Any, Any]: 更新后的字典 - """ - for key, value in update_dict.items(): - if isinstance(value, dict) and key in base_dict and isinstance(base_dict[key], dict): - base_dict[key] = deep_update_dict(base_dict[key], value) - else: - base_dict[key] = value - return base_dict - - -def format_duration(seconds: float) -> str: - """ - 格式化持续时间 - - Args: - seconds: 持续时间(秒) - - Returns: - str: 格式化后的持续时间字符串 - """ - if seconds < 0: - return '0秒' - - hours = int(seconds // 3600) - minutes = int((seconds % 3600) // 60) - secs = int(seconds % 60) - - parts = [] - if hours > 0: - parts.append(f'{hours}小时') - if minutes > 0: - parts.append(f'{minutes}分钟') - if secs > 0 or not parts: - parts.append(f'{secs}秒') - - return ''.join(parts) - - -def is_valid_url(url: str) -> bool: - """ - 验证URL是否有效 - - Args: - url: 待验证的URL - - Returns: - bool: URL是否有效 - """ - pattern = r'^https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+[/\w .-]*/?$' - return bool(re.match(pattern, url)) - - -def sanitize_filename(filename: str) -> str: - """ - 清理文件名,移除不安全的字符 - - Args: - filename: 待清理的文件名 - - Returns: - str: 清理后的文件名 - """ - # 移除路径分隔符和其他危险字符 - unsafe_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\x00'] - for char in unsafe_chars: - filename = filename.replace(char, '_') - - # 移除前后空格 - filename = filename.strip() - - # 确保文件名不为空 - if not filename: - filename = 'unnamed' - - return filename diff --git a/utils/local_winserver_client.py b/utils/local_winserver_client.py deleted file mode 100755 index ccfbdb0..0000000 --- a/utils/local_winserver_client.py +++ /dev/null @@ -1,611 +0,0 @@ -""" -本地WinServer客户端工具 - -该模块提供了与本地Windows服务器交互的客户端实现, -用于执行本地命令和PowerShell脚本。 - -主要功能: -- 执行本地PowerShell命令和脚本 -- 管理本地用户账户 -- 提供本地系统管理功能 - -使用示例: - client = LocalWinServerClient("username", "password") - result = client.execute_command("ipconfig") - if result.success: - print(result.std_out) -""" -import logging -import subprocess -import os -import re -from dataclasses import dataclass -from typing import Optional, Dict, Any -from django.conf import settings - -logger = logging.getLogger("2c2a") - -USERNAME_PATTERN = re.compile(r'^[a-zA-Z0-9_]{1,150}$') -GROUPNAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-\s]{1,256}$') -MAX_STRING_LENGTH = 4096 - - -class CommandInjectionError(Exception): - pass - - -def validate_username(username: str) -> str: - if not username: - raise CommandInjectionError("用户名不能为空") - if len(username) > 150: - raise CommandInjectionError("用户名长度不能超过150个字符") - if not USERNAME_PATTERN.match(username): - raise CommandInjectionError("用户名格式无效: 只允许字母、数字和下划线") - return username - - -def validate_groupname(group: str) -> str: - if not group: - raise CommandInjectionError("组名不能为空") - if len(group) > 256: - raise CommandInjectionError("组名长度不能超过256个字符") - if not GROUPNAME_PATTERN.match(group): - raise CommandInjectionError("组名格式无效: 只允许字母、数字、下划线、连字符和空格") - return group - - -def validate_string_length(s: str, max_length: int = MAX_STRING_LENGTH, field_name: str = "输入") -> str: - if s and len(s) > max_length: - raise CommandInjectionError(f"{field_name}长度不能超过{max_length}个字符") - return s - - -def _escape_ps_string(s: str) -> str: - if not s: - return s - if len(s) > MAX_STRING_LENGTH: - raise CommandInjectionError(f"字符串长度超过最大限制 {MAX_STRING_LENGTH}") - return s.replace('\x00', '').replace('`', '``').replace('"', '`"').replace('$', '`$').replace('\n', '`n').replace('\r', '`r') - - -@dataclass -class LocalWinServerResult: - """ - 本地WinServer执行结果的数据类 - - 属性: - status_code: 命令执行的状态码,0表示成功 - std_out: 标准输出内容 - std_err: 标准错误内容 - success: 命令是否执行成功的布尔值 - """ - status_code: int - std_out: str - std_err: str - - @property - def success(self) -> bool: - """判断命令是否执行成功""" - return self.status_code == 0 - - -class LocalWinServerClient: - """ - 本地WinServer客户端封装类 - - 用于与本地Windows服务器进行交互,绕过网络连接限制, - 直接执行本地PowerShell命令和系统管理任务。 - - 属性: - username: 本地管理员用户名 - password: 本地管理员密码 - timeout: 操作超时时间(秒) - max_retries: 最大重试次数 - """ - - def __init__( - self, - username: str, - password: str, - timeout: Optional[int] = None, - max_retries: Optional[int] = None - ): - """ - 初始化本地WinServer客户端 - - 参数: - username: 本地管理员用户名 - password: 本地管理员密码 - timeout: 操作超时时间(秒),默认使用配置文件中的值 - max_retries: 最大重试次数,默认使用配置文件中的值 - """ - self.username = username - self.password = password - self.timeout = timeout or getattr(settings, 'WINRM_TIMEOUT', 30) - self.max_retries = max_retries or getattr(settings, 'WINRM_MAX_RETRIES', 3) - - logger.info( - f"初始化本地WinServer客户端: 用户={username}, " - f"超时={self.timeout}秒, 最大重试={self.max_retries}次" - ) - - def execute_command( - self, - command: str, - arguments: Optional[list] = None - ) -> LocalWinServerResult: - """ - 执行本地命令(通过PowerShell) - - 参数: - command: 要执行的命令 - arguments: 命令参数列表 - - 返回: - LocalWinServerResult对象,包含执行结果 - """ - # 如果是DEMO模式,模拟执行命令而不实际执行 - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info(f"DEMO模式: 模拟执行本地命令: {command}, 参数: {arguments}") - # 模拟成功执行的结果 - return LocalWinServerResult( - status_code=0, - std_out="Command executed successfully in demo mode", - std_err="" - ) - - logger.info(f"执行本地命令: {command}, 参数: {arguments}") - - try: - # 构建PowerShell命令 - if arguments: - cmd_parts = [command] + [str(arg) for arg in arguments] - ps_command = ' '.join(cmd_parts) - else: - ps_command = command - - # 使用PowerShell执行命令 - full_command = ['powershell.exe', '-Command', ps_command] - - # 执行命令 - result = subprocess.run( - full_command, - capture_output=True, - text=True, - timeout=self.timeout - ) - - local_result = LocalWinServerResult( - status_code=result.returncode, - std_out=result.stdout, - std_err=result.stderr - ) - - if local_result.success: - logger.info(f"本地命令执行成功: {command}") - else: - logger.warning( - f"本地命令执行返回非零状态码: {command}, " - f"状态码={result.returncode}, 错误={result.stderr}" - ) - - return local_result - except subprocess.TimeoutExpired: - logger.error(f"本地命令执行超时: {command}") - return LocalWinServerResult( - status_code=-1, - std_out="", - std_err=f"命令执行超时 ({self.timeout}秒)" - ) - except Exception as e: - logger.error(f"本地命令执行失败: {command}, 错误: {str(e)}") - return LocalWinServerResult( - status_code=-1, - std_out="", - std_err=str(e) - ) - - def execute_powershell( - self, - script: str, - arguments: Optional[Dict[str, Any]] = None - ) -> LocalWinServerResult: - """ - 执行本地PowerShell脚本 - - 参数: - script: 要执行的PowerShell脚本 - arguments: 脚本参数字典 - - 返回: - LocalWinServerResult对象,包含执行结果 - """ - # 如果是DEMO模式,模拟执行PowerShell而不实际执行 - if os.environ.get('2C2A_DEMO', '').lower() == '1': - logger.info("DEMO模式: 模拟执行本地PowerShell脚本") - # 模拟成功执行的结果 - return LocalWinServerResult( - status_code=0, - std_out="PowerShell script executed successfully in demo mode", - std_err="" - ) - - logger.info("执行本地PowerShell脚本") - - try: - # 使用PowerShell执行脚本 - full_command = ['powershell.exe', '-ExecutionPolicy', 'Bypass', '-Command', script] - - # 执行命令 - result = subprocess.run( - full_command, - capture_output=True, - text=True, - timeout=self.timeout - ) - - local_result = LocalWinServerResult( - status_code=result.returncode, - std_out=result.stdout, - std_err=result.stderr - ) - - if local_result.success: - logger.info(f"本地PowerShell脚本执行成功") - else: - logger.warning( - f"本地PowerShell脚本执行返回非零状态码: " - f"状态码={result.returncode}, 错误={result.stderr}" - ) - - return local_result - except subprocess.TimeoutExpired: - logger.error(f"本地PowerShell脚本执行超时") - return LocalWinServerResult( - status_code=-1, - std_out="", - std_err=f"PowerShell脚本执行超时 ({self.timeout}秒)" - ) - except Exception as e: - logger.error(f"本地PowerShell脚本执行失败: 错误: {str(e)}") - return LocalWinServerResult( - status_code=-1, - std_out="", - std_err=str(e) - ) - - def create_user( - self, - username: str, - password: str, - description: Optional[str] = None, - group: Optional[str] = None - ) -> LocalWinServerResult: - """ - 创建本地用户 - - 参数: - username: 用户名 - password: 密码 - description: 用户描述 - group: 要加入的用户组 - - 返回: - LocalWinServerResult对象,包含执行结果 - """ - validate_username(username) - validate_string_length(password, field_name="密码") - desc = _escape_ps_string(description or '') - escaped_password = _escape_ps_string(password) - escaped_username = _escape_ps_string(username) - script = f''' - $password = ConvertTo-SecureString "{escaped_password}" -AsPlainText -Force - $user = New-LocalUser -Name "{escaped_username}" -Password $password -Description "{desc}" -ErrorAction Stop - ''' - - if group: - validate_groupname(group) - escaped_group = _escape_ps_string(group) - script = script + f''' - Add-LocalGroupMember -Group "{escaped_group}" -Member "{escaped_username}" -ErrorAction Stop - ''' - - logger.info(f"创建本地用户: {username}, 组: {group}") - result = self.execute_powershell(script) - - if result.success: - logger.info(f"本地用户创建成功: {username}") - else: - logger.error(f"本地用户创建失败: {username}, 错误: {result.std_err}") - - return result - - def delete_user(self, username: str) -> LocalWinServerResult: - """ - 删除本地用户 - - 参数: - username: 要删除的用户名 - - 返回: - LocalWinServerResult对象,包含执行结果 - """ - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f''' - Remove-LocalUser -Name "{escaped_username}" -ErrorAction Stop - ''' - - logger.info(f"删除本地用户: {username}") - result = self.execute_powershell(script) - - if result.success: - logger.info(f"本地用户删除成功: {username}") - else: - logger.error(f"本地用户删除失败: {username}, 错误: {result.std_err}") - - return result - - def enable_user(self, username: str) -> LocalWinServerResult: - """ - 启用本地用户 - - 参数: - username: 用户名 - - 返回: - LocalWinServerResult对象,包含执行结果 - """ - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f''' - Enable-LocalUser -Name "{escaped_username}" -ErrorAction Stop - ''' - - logger.info(f"启用本地用户: {username}") - result = self.execute_powershell(script) - - if result.success: - logger.info(f"本地用户启用成功: {username}") - - return result - - def disable_user(self, username: str) -> LocalWinServerResult: - """ - 禁用本地用户 - - 参数: - username: 用户名 - - 返回: - LocalWinServerResult对象,包含执行结果 - """ - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f''' - Disable-LocalUser -Name "{escaped_username}" -ErrorAction Stop - ''' - - logger.info(f"禁用本地用户: {username}") - result = self.execute_powershell(script) - - if result.success: - logger.info(f"本地用户禁用成功: {username}") - - return result - - def get_user_info(self, username: str) -> LocalWinServerResult: - """ - 获取本地用户信息 - - 参数: - username: 用户名 - - 返回: - LocalWinServerResult对象,包含用户信息的JSON格式数据 - """ - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f''' - Get-LocalUser -Name "{escaped_username}" | ConvertTo-Json - ''' - - logger.info(f"获取本地用户信息: {username}") - return self.execute_powershell(script) - - def list_users(self) -> LocalWinServerResult: - """ - 列出所有本地用户 - - 返回: - LocalWinServerResult对象,包含用户列表的JSON格式数据 - """ - script = ''' - Get-LocalUser | ConvertTo-Json - ''' - - logger.info("列出所有本地用户") - return self.execute_powershell(script) - - def check_user_exists(self, username: str) -> bool: - """ - 检查本地用户是否存在 - - 参数: - username: 要检查的用户名 - - 返回: - bool: 用户存在返回True,否则返回False - """ - try: - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f''' - $user = Get-LocalUser -Name "{escaped_username}" -ErrorAction Stop - $true - ''' - result = self.execute_powershell(script) - exists = result.success and 'True' in result.std_out - logger.info(f"检查本地用户是否存在: {username}, 结果: {exists}") - return exists - except Exception as e: - logger.error(f"检查本地用户存在性时出错: {username}, 错误: {str(e)}") - return False - - def get_password_policy(self) -> Dict[str, Any]: - """ - 动态获取本地密码策略要求 - - 返回: - Dict: 包含密码策略信息的字典 - """ - try: - script = f''' - secedit /export /cfg "$env:TEMP\\secpol.cfg" | Out-Null - Get-Content "$env:TEMP\\secpol.cfg" | Where-Object {{ $_ -match '^(MinimumPasswordLength|PasswordComplexity|PasswordHistorySize|MaximumPasswordAge|MinimumPasswordAge)\\s*=' }} - Remove-Item "$env:TEMP\\secpol.cfg" -ErrorAction SilentlyContinue - ''' - result = self.execute_powershell(script) - - policy = {} - if result.success: - lines = result.std_out.strip().split("\n") - for line in lines: - line = line.strip() - if line.startswith("MinimumPasswordLength"): - try: - policy["minimum_length"] = int(line.split("=")[1].strip()) - except: - policy["minimum_length"] = 8 # 默认值 - elif line.startswith("PasswordComplexity"): - try: - policy["complexity_required"] = bool(int(line.split("=")[1].strip())) - except: - policy["complexity_required"] = True # 默认值 - elif line.startswith("PasswordHistorySize"): - try: - policy["history_size"] = int(line.split("=")[1].strip()) - except: - policy["history_size"] = 0 # 默认值 - elif line.startswith("MaximumPasswordAge"): - try: - policy["max_age_days"] = int(line.split("=")[1].strip()) - except: - policy["max_age_days"] = 0 # 默认值 - elif line.startswith("MinimumPasswordAge"): - try: - policy["min_age_days"] = int(line.split("=")[1].strip()) - except: - policy["min_age_days"] = 0 # 默认值 - - # 设置默认值 - if "minimum_length" not in policy: - policy["minimum_length"] = 8 - if "complexity_required" not in policy: - policy["complexity_required"] = True - - logger.info(f"获取本地密码策略成功: {policy}") - return policy - except Exception as e: - logger.error(f"获取本地密码策略失败: 错误: {str(e)}") - # 返回默认密码策略 - return { - "minimum_length": 8, - "complexity_required": True, - "history_size": 0, - "max_age_days": 42, - "min_age_days": 1 - } - - def generate_strong_password(self, length: Optional[int] = None) -> str: - """ - 根据密码策略生成强密码 - - 参数: - length: 密码长度,默认根据服务器策略确定 - - 返回: - str: 生成的强密码 - """ - import secrets - import string - - # 获取服务器密码策略 - policy = self.get_password_policy() - - # 确定密码长度 - actual_length = length or max(policy["minimum_length"], 12) # 默认至少12位 - - if policy["complexity_required"]: - # 密码复杂性要求:至少包含大写字母、小写字母、数字和特殊字符 - uppercase = secrets.choice(string.ascii_uppercase) - lowercase = secrets.choice(string.ascii_lowercase) - digit = secrets.choice(string.digits) - special_char = secrets.choice("!@#$%^&*()_+-=[]{}|;:,.<>?") - - # 剩余部分随机生成 - remaining_length = max(0, actual_length - 4) - alphabet = string.ascii_letters + string.digits + "!@#$%^&*()_+-=[]{}|;:,.<>?" - rest = "".join(secrets.choice(alphabet) for i in range(remaining_length)) - - # 打乱顺序以确保安全 - password_chars = list(uppercase + lowercase + digit + special_char + rest) - secrets.SystemRandom().shuffle(password_chars) - password = "".join(password_chars) - else: - # 不需要复杂性要求,简单生成随机密码 - alphabet = string.ascii_letters + string.digits - password = "".join(secrets.choice(alphabet) for i in range(actual_length)) - - logger.info(f"生成本地强密码完成,长度: {len(password)}") - return password - - def grant_admin_privileges(self, username: str) -> bool: - """ - 为指定用户授予管理员权限 - - 参数: - username: 用户名 - - 返回: - bool: 是否成功授予权限 - """ - try: - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f'net localgroup Administrators "{escaped_username}" /add' - result = self.execute_powershell(script) - if result.success: - logger.info(f"为本地用户{username}授予管理员权限成功") - return True - else: - logger.error(f"为本地用户{username}授予管理员权限失败: 错误: {result.std_err}") - return False - except Exception as e: - logger.error(f"为本地用户{username}授予管理员权限失败: 错误: {str(e)}") - return False - - def revoke_admin_privileges(self, username: str) -> bool: - """ - 撤销指定用户的管理员权限 - - 参数: - username: 用户名 - - 返回: - bool: 是否成功撤销权限 - """ - try: - validate_username(username) - escaped_username = _escape_ps_string(username) - script = f'net localgroup Administrators "{escaped_username}" /delete' - result = self.execute_powershell(script) - if result.success: - logger.info(f"撤销本地用户{username}的管理员权限成功") - return True - else: - logger.error(f"撤销本地用户{username}的管理员权限失败: 错误: {result.std_err}") - return False - except Exception as e: - logger.error(f"撤销本地用户{username}的管理员权限失败: 错误: {str(e)}") - return False \ No newline at end of file diff --git a/utils/production_checker.py b/utils/production_checker.py deleted file mode 100755 index 647c7a5..0000000 --- a/utils/production_checker.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -生产环境安全配置检查器 -确保部署前进行安全验证 -""" -import os -import sys -from django.conf import settings - - -def check_production_readiness(): - """检查生产环境配置的安全性""" - errors = [] - warnings = [] - - # 1. 检查 SECRET_KEY - if not os.environ.get('DJANGO_SECRET_KEY'): - if not settings.DEBUG: - errors.append("生产环境必须设置 DJANGO_SECRET_KEY 环境变量") - else: - warnings.append("未设置 DJANGO_SECRET_KEY,系统将生成临时密钥") - - if settings.DEBUG and not settings.DEBUG: - errors.append("生产环境不得启用 DEBUG 模式") - - if not settings.DEBUG: - allowed_hosts = settings.ALLOWED_HOSTS - if not allowed_hosts or allowed_hosts == ['*'] or 'localhost' in allowed_hosts: - errors.append("生产环境必须设置有效的 ALLOWED_HOSTS,不能使用 * 或 localhost") - - if not settings.DEBUG: - if not getattr(settings, 'SECURE_SSL_REDIRECT', False): - warnings.append("建议启用 SECURE_SSL_REDIRECT 强制 HTTPS 重定向") - - if not getattr(settings, 'SECURE_HSTS_SECONDS', 0): - warnings.append("建议启用 HSTS (HTTP Strict Transport Security)") - - if not getattr(settings, 'CSRF_COOKIE_SECURE', False): - errors.append("生产环境必须启用 CSRF_COOKIE_SECURE") - - if not getattr(settings, 'SESSION_COOKIE_SECURE', False): - errors.append("生产环境必须启用 SESSION_COOKIE_SECURE") - - # 5. 检查 WinRM 安全配置 - if hasattr(settings, 'WINRM_CLIENT_CERT_VALIDATION') and \ - settings.WINRM_CLIENT_CERT_VALIDATION == 'validate': - if not settings.WINRM_CLIENT_CERT_PATH: - warnings.append("WinRM 证书验证已启用但未指定客户端证书路径") - - # 6. 检查日志配置 - if not os.path.exists(os.path.join(settings.BASE_DIR, 'logs')): - warnings.append("日志目录不存在,将自动创建") - - # 7. 检查数据库配置 - db_engine = settings.DATABASES['default']['ENGINE'] - if 'sqlite3' in db_engine and not settings.DEBUG: - warnings.append("生产环境建议使用 PostgreSQL 或 MySQL 而不是 SQLite") - - return errors, warnings - - -def print_production_status(): - """打印生产环境状态""" - errors, warnings = check_production_readiness() - - print("\n" + "="*60) - print("2c2a 生产环境安全性检查报告") - print("="*60) - print(f"生产模式: {'是' if not settings.DEBUG else '否'}") - print(f"DEBUG 模式: {'是' if settings.DEBUG else '否'}") - print(f"秘密密钥: {'已设置' if os.environ.get('DJANGO_SECRET_KEY') else '未设置'}") - - if errors: - print("\n❌ 必须修复的错误:") - for error in errors: - print(f" - {error}") - - if warnings: - print("\n⚠️ 建议修复的警告:") - for warning in warnings: - print(f" - {warning}") - - if not errors and not warnings: - print("\n✅ 所有安全检查通过,系统已准备好部署到生产环境") - - print("\n="*60) - - # 如果有严重错误,退出程序 - if errors: - sys.exit(1) - - -if __name__ == '__main__': - print_production_status() \ No newline at end of file diff --git a/utils/provider.py b/utils/provider.py deleted file mode 100644 index bcffd62..0000000 --- a/utils/provider.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -提供商共享工具模块 - -本模块是提供商数据隔离的 SINGLE SOURCE OF TRUTH。 -所有提供商相关的身份验证和数据查询逻辑 -应统一使用此模块,替代 -apps/hosts/admin.py、apps/operations/admin.py、 -apps/tickets/admin.py 中重复的 is_provider 函数。 - -使用方式: - from utils.provider import ( - is_provider, get_provider_hosts, get_provider_products, - ) -""" - -from django.db import models - - -PROVIDER_GROUP_NAME = "主机提供商" - - -def is_provider(user): - """ - 检查用户是否属于提供商组 - - 超级管理员不属于提供商组,即使其权限更高。 - 此逻辑与 Admin 后台的数据隔离保持一致。 - - Args: - user: 用户对象 - - Returns: - bool: 如果用户属于提供商组且不是超级管理员,返回 True - """ - if user.is_superuser: - return False - return user.groups.filter(name=PROVIDER_GROUP_NAME).exists() - - -def get_provider_hosts(user, site_group=None): - """ - 获取提供商管理的主机 - - 提供商可以看到: - - 自己创建的主机 (created_by=user) - - 分配给自己的主机 (providers=user) - - Args: - user: 提供商用户对象 - site_group: 站点分组对象,如果提供则进一步过滤该站点分组下的主机 - - Returns: - QuerySet: 该提供商可见的主机查询集 - """ - from apps.hosts.models import Host - - qs = Host.objects.filter( - models.Q(created_by=user) | models.Q(providers=user) - ).distinct() - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - -def get_provider_products(user, site_group=None): - """ - 获取提供商创建的产品 - - 提供商可以看到自己创建的产品 (created_by=user) - - Args: - user: 提供商用户对象 - site_group: 站点分组对象,如果提供则进一步过滤该站点分组下的产品 - - Returns: - QuerySet: 该提供商可见的产品查询集 - """ - from apps.operations.models import Product - - qs = Product.objects.filter(created_by=user) - if site_group is not None: - qs = qs.filter(site_group=site_group) - return qs - - -def get_provider_queryset( - user, model_class, filter_field="created_by", site_group=None -): - """ - 通用的提供商数据隔离查询 - - 根据模型和过滤字段,返回该提供商可见的数据查询集。 - 对于有 providers ManyToManyField 的模型,也会包含分配给该提供商的数据。 - - Args: - user: 提供商用户对象 - model_class: Django 模型类 - filter_field: 过滤字段名,默认为 'created_by' - site_group: 站点分组对象,如果提供则进一步过滤该站点分组下的数据 - - Returns: - QuerySet: 该提供商可见的数据查询集 - - Examples: - # 获取提供商创建的主机 - get_provider_queryset(user, Host, 'created_by') - - # 获取提供商创建的产品 - get_provider_queryset(user, Product, 'created_by') - - # 获取提供商创建的开户申请(通过产品关联) - get_provider_queryset(user, AccountOpeningRequest, - 'target_product__created_by') - """ - qs = model_class.objects.filter(**{filter_field: user}) - - if hasattr(model_class, "providers"): - qs = qs | model_class.objects.filter(providers=user) - qs = qs.distinct() - - if site_group is not None: - qs = qs.filter(site_group=site_group) - - return qs diff --git a/utils/rate_limit.py b/utils/rate_limit.py deleted file mode 100755 index 66d9d18..0000000 --- a/utils/rate_limit.py +++ /dev/null @@ -1,206 +0,0 @@ -""" -限流装饰器模块 -提供 API 限流和登录保护 - -使用标准 Django cache API(get/set),兼容所有缓存后端: -- Redis(django-redis):高性能,支持分布式 -- LocMemCache:本地内存,无需额外依赖 -""" -from functools import wraps -from typing import Callable -from django.core.cache import cache -from django.conf import settings -from django.http import JsonResponse -from utils.helpers import get_client_ip -import logging - -logger = logging.getLogger('2c2a') - - -class RateLimitExceeded(Exception): - pass - - -def _cache_incr(key: str, period: int) -> int: - """ - 兼容所有缓存后端的原子计数器。 - - 使用 get/set 替代 incr/expire,确保 LocMemCache 等后端也能正常工作。 - Redis 后端下 django-redis 会自动处理并发安全。 - - Args: - key: 缓存键 - period: 过期时间(秒) - - Returns: - int: 递增后的计数值 - """ - current = cache.get(key, 0) - new_count = current + 1 - cache.set(key, new_count, timeout=period + 1) - return new_count - - -def _cache_ttl_fallback(key: str, default: int = 0) -> int: - """ - 获取缓存键的剩余 TTL,不兼容时返回默认值。 - - cache.ttl() 是 django-redis 专有方法, - LocMemCache 等后端不支持,此时返回默认值。 - """ - if hasattr(cache, 'ttl'): - try: - ttl = cache.ttl(key) - if ttl is not None and ttl > 0: - return int(ttl) - except Exception: - pass - return default - - -def rate_limit(key_prefix: str, limit: int, period: int = 60, per_user: bool = True): - """ - 限流装饰器(固定窗口计数器) - - Args: - key_prefix: 缓存键前缀 - limit: 限制次数 - period: 时间周期(秒) - per_user: 是否按用户限流 - """ - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(request, *args, **kwargs): - if per_user and hasattr(request, 'user') and request.user.is_authenticated: - key_parts = [key_prefix, request.user.username] - else: - key_parts = [key_prefix, get_client_ip(request)] - - rate_limit_key = f"rate_limit:{':'.join(key_parts)}" - - current_count = cache.get(rate_limit_key, 0) - - if current_count >= limit: - remaining_time = _cache_ttl_fallback(rate_limit_key, period) - logger.warning(f"Rate limit exceeded for {rate_limit_key} ({current_count}/{limit})") - - return JsonResponse({ - 'success': False, - 'error': { - 'type': 'RateLimitExceeded', - 'message': f'请求过于频繁,请在 {remaining_time} 秒后重试', - 'retry_after': remaining_time, - }, - }, status=429) - - _cache_incr(rate_limit_key, period) - - return func(request, *args, **kwargs) - return wrapper - return decorator - - -def login_rate_limit(): - """登录限流装饰器""" - return rate_limit(key_prefix='login', limit=settings.LOGIN_RATE_LIMIT, period=60) - - -def api_rate_limit(): - """API 通用限流装饰器""" - return rate_limit(key_prefix='api', limit=settings.API_RATE_LIMIT, period=60) - - -def register_rate_limit(view_func): - """注册限流装饰器""" - @wraps(view_func) - def wrapped_view(self, request, *args, **kwargs): - from django.contrib import messages - from django.shortcuts import redirect - - rate_limit_key = f"rate_limit:register:{get_client_ip(request)}" - limit = 5 - period = 3600 - - current_count = cache.get(rate_limit_key, 0) - - if current_count >= limit: - remaining = _cache_ttl_fallback(rate_limit_key, period) - minutes = max(1, remaining // 60) - logger.warning(f"Registration rate limit exceeded for IP {get_client_ip(request)}") - messages.error(request, f'注册过于频繁,请在 {minutes} 分钟后重试') - return redirect('accounts:register') - - _cache_incr(rate_limit_key, period) - - return view_func(self, request, *args, **kwargs) - return wrapped_view - - -def check_operation_rate_limit(operation_type: str, identifier: str, limit: int = 10, period: int = 60) -> bool: - """ - 检查操作是否达到限流 - - Args: - operation_type: 操作类型 - identifier: 操作标识符 - limit: 限制次数 - period: 时间周期(秒) - - Returns: - True 如果允许操作,False 如果达到限流 - """ - key = f"rate_limit:op:{operation_type}:{identifier}" - current_count = cache.get(key, 0) - - if current_count >= limit: - logger.warning(f"Operation rate limit exceeded: {key} ({current_count}/{limit})") - return False - - _cache_incr(key, period) - return True - - -class RateLimitMixin: - """在视图类中添加限流功能的 mixin""" - - rate_limit_key = None - rate_limit_count = None - rate_limit_period = 60 - - def dispatch(self, request, *args, **kwargs): - if self.rate_limit_key and self.rate_limit_count: - if hasattr(request, 'user') and request.user.is_authenticated: - identifier = request.user.username - else: - identifier = get_client_ip(request) - - key = f"rate_limit:view:{self.rate_limit_key}:{identifier}" - current_count = cache.get(key, 0) - - if current_count >= self.rate_limit_count: - return JsonResponse({ - 'success': False, - 'error': { - 'type': 'RateLimitExceeded', - 'message': '操作过于频繁,请稍后再试', - }, - }, status=429) - - _cache_incr(key, self.rate_limit_period) - - return super().dispatch(request, *args, **kwargs) - - -def rate_limit_ip(ip: str, key: str, limit: int, period: int = 60) -> bool: - """ - 基于 IP 的限流函数 - """ - cache_key = f"rate_limit:ip:{key}:{ip}" - current_count = cache.get(cache_key, 0) - - if current_count >= limit: - logger.warning(f"IP rate limit exceeded: {cache_key} ({current_count}/{limit})") - return False - - _cache_incr(cache_key, period) - return True diff --git a/utils/redis_helper.py b/utils/redis_helper.py deleted file mode 100644 index 15e053a..0000000 --- a/utils/redis_helper.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Redis 辅助工具模块 - -提供 Redis 可选支持:配置了 REDIS_URL 且 Redis 服务可达时自动启用, -否则静默降级到本地替代方案(LocMemCache / DB Session / SQLite Celery)。 - -延迟导入策略: -- REDIS_URL 未配置时,绝不 import redis 包 -- redis 包未安装时,静默降级,不报错 - -使用方式: - from utils.redis_helper import is_redis_available, get_redis_client - - if is_redis_available(): - client = get_redis_client() - client.set('key', 'value') -""" - -import logging -import os - -logger = logging.getLogger('2c2a') - -_redis_client = None -_redis_available = None - - -def _get_redis_url(): - return os.environ.get('REDIS_URL', '').strip() - - -def is_redis_available(): - """ - 检查 Redis 是否可用。 - - 首次调用时会尝试连接 Redis 并缓存结果, - 后续调用直接返回缓存值。 - - REDIS_URL 未配置时不 import redis,redis 包未安装也不报错。 - """ - global _redis_available - if _redis_available is not None: - return _redis_available - - url = _get_redis_url() - if not url: - logger.info('REDIS_URL not configured, using local alternatives') - _redis_available = False - return False - - try: - import redis - client = redis.Redis.from_url(url, socket_connect_timeout=3) - client.ping() - logger.info('Redis is available, will use Redis for cache/session/celery') - _redis_available = True - except Exception as e: - logger.warning( - 'REDIS_URL is configured but Redis is unreachable or redis package not installed, ' - 'falling back to local alternatives: %s', e, - ) - _redis_available = False - - return _redis_available - - -def get_redis_client(): - """ - 获取 Redis 客户端实例。 - - Returns: - redis.Redis | None: Redis 客户端,不可用时返回 None - """ - global _redis_client - if _redis_client is not None: - return _redis_client - - if not is_redis_available(): - return None - - try: - import redis - url = _get_redis_url() - _redis_client = redis.Redis.from_url(url, socket_connect_timeout=3) - return _redis_client - except Exception as e: - logger.warning('Failed to create Redis client: %s', e) - return None - - -def reset_redis_state(): - """ - 重置 Redis 状态缓存(主要用于测试) - """ - global _redis_client, _redis_available - _redis_client = None - _redis_available = None diff --git a/utils/sensitive_log_filters.py b/utils/sensitive_log_filters.py deleted file mode 100755 index bb39a19..0000000 --- a/utils/sensitive_log_filters.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -日志脱敏过滤器 -用于清理日志中的敏感信息 -""" -import re -import logging - - -class SensitiveDataFilter(logging.Filter): - """过滤日志中的敏感数据""" - - # 敏感字段正则表达式 - SENSITIVE_PATTERNS = [ - (r'(password)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - (r'(pwd)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - (r'(secret_key)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - (r'(token)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - (r'(api_key)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - (r'(access_token)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - (r'(session_key)\s*=\s*[\'"]?([^\s\'"]+)', r'\1=***'), - ] - - # IP 地址脱敏模式(保留前三段) - IP_PATTERN = r'(\b(?:[0-9]{1,3}\.){3})[0-9]{1,3}\b' - IP_REPLACEMENT = r'\1xxx' - - def filter(self, record): - """过滤日志记录中的敏感数据""" - if hasattr(record, 'msg'): - record.msg = self._sanitize_message(str(record.msg)) - - if hasattr(record, 'args') and record.args: - sanitized_args = [] - for arg in record.args: - if isinstance(arg, str): - sanitized_args.append(self._sanitize_message(arg)) - else: - sanitized_args.append(arg) - record.args = tuple(sanitized_args) - - return True - - def _sanitize_message(self, message: str) -> str: - """清理消息中的敏感信息""" - # 清理密码等敏感字段 - for pattern, replacement in self.SENSITIVE_PATTERNS: - message = re.sub(pattern, replacement, message, flags=re.IGNORECASE) - - # 清理 IP 地址 - message = re.sub(self.IP_PATTERN, self.IP_REPLACEMENT, message) - - return message - - -class AuditFilter(logging.Filter): - """审计日志过滤器,确保重要操作都被记录""" - - AUDIT_ACTIONS = [ - 'create_user', 'delete_user', 'reset_password', 'approve_request', - 'reject_request', 'modify_host', 'delete_host', 'login', 'logout', - 'view_password', 'process_opening_request' - ] - - def filter(self, record): - """确保审计相关的日志被记录""" - # 检查是否是审计操作 - if hasattr(record, 'action') and record.action in self.AUDIT_ACTIONS: - record.levelno = logging.INFO - record.levelname = logging.getLevelName(logging.INFO) - - return True \ No newline at end of file diff --git a/utils/site_group.py b/utils/site_group.py deleted file mode 100644 index c8bc261..0000000 --- a/utils/site_group.py +++ /dev/null @@ -1,154 +0,0 @@ -from django.core.cache import cache - - -# SiteGroupConfig 中可覆盖的字段名列表 -OVERRIDABLE_FIELDS = [ - "smtp_host", - "smtp_port", - "smtp_encryption", - "smtp_username", - "smtp_password", - "smtp_from_email", - "smtp_from_name", - "captcha_provider", - "captcha_type", - "login_captcha_type", - "register_captcha_type", - "email_captcha_type", - "enable_registration", - "email_suffix_whitelist", - "email_suffix_blacklist", - "site_name", - "site_icon", - "icp_number", - "police_number", -] - - -class EffectiveConfig: - """ - 合并配置:优先使用 SiteGroupConfig 的非空字段,回退到 SystemConfig。 - - 用法: - ec = get_effective_config(site_group) - ec.smtp_host # 优先站点组配置,回退全局配置 - ec.enable_registration - ec.get_captcha_config(scene='login') - ec.get_email_suffix_lists() - """ - - def __init__(self, system_config, site_group_config=None): - self._system = system_config - self._sg = site_group_config - - def __getattr__(self, name): - if name.startswith("_"): - raise AttributeError(name) - # 优先站点组配置的非空值 - if self._sg is not None: - sg_val = getattr(self._sg, name, None) - if sg_val is not None and name in OVERRIDABLE_FIELDS: - return sg_val - # 回退全局配置 - return getattr(self._system, name, None) - - def get_captcha_config(self, scene=None): - """根据场景获取验证码配置,与 SystemConfig.get_captcha_config 兼容""" - provider = self.captcha_provider - if scene == "login": - captcha_type = self.login_captcha_type or self.captcha_type - elif scene == "register": - captcha_type = self.register_captcha_type or self.captcha_type - elif scene == "email": - captcha_type = self.email_captcha_type or self.captcha_type - else: - captcha_type = self.captcha_type - return provider, captcha_type - - def get_email_suffix_lists(self): - """获取邮箱后缀白名单和黑名单列表(已解析为列表)""" - cache_key = f'effective_email_suffixes:{self._system.pk}:{getattr(self._sg, "pk", "none")}' - data = cache.get(cache_key) - if data is not None: - return data - - whitelist = [] - if self.email_suffix_whitelist: - whitelist = [ - s.strip() - for s in self.email_suffix_whitelist.strip().split("\n") - if s.strip() - ] - blacklist = [] - if self.email_suffix_blacklist: - blacklist = [ - s.strip() - for s in self.email_suffix_blacklist.strip().split("\n") - if s.strip() - ] - data = {"whitelist": whitelist, "blacklist": blacklist} - cache.set(cache_key, data, timeout=300) - return data - - def is_email_suffix_allowed(self, email): - """检查邮箱后缀是否符合当前配置""" - suffix = "@" + email.split("@")[1] if "@" in email else "" - data = self.get_email_suffix_lists() - if data["whitelist"]: - return suffix in data["whitelist"] - if data["blacklist"]: - return suffix not in data["blacklist"] - return True - - -def get_effective_config(site_group=None): - """ - 获取合并后的有效配置。 - - Args: - site_group: SiteGroup 实例或 None。None 时返回纯全局配置。 - - Returns: - EffectiveConfig 实例 - """ - from apps.dashboard.models import SystemConfig, SiteGroupConfig - - system_config = SystemConfig.get_config() - sg_config = SiteGroupConfig.get_config(site_group) if site_group else None - return EffectiveConfig(system_config, sg_config) - - -def get_site_group_queryset( - user, model_class, site_group=None, filter_field="created_by" -): - if user.is_superuser: - return model_class.objects.all() - - if _is_site_group_admin(user, site_group): - return _filter_by_site_group(model_class, site_group) - - provider_qs = _get_provider_queryset(user, model_class, filter_field) - site_group_qs = _filter_by_site_group(model_class, site_group) - return provider_qs & site_group_qs - - -def _is_site_group_admin(user, site_group): - if user.is_superuser: - return True - if site_group is None: - return False - return site_group.admins.filter(pk=user.pk).exists() - - -def _filter_by_site_group(model_class, site_group): - if site_group is not None: - return model_class.objects.filter(site_group=site_group) - return model_class.objects.filter(site_group__isnull=True) - - -def _get_provider_queryset(user, model_class, filter_field="created_by"): - qs = model_class.objects.filter(**{filter_field: user}) - if hasattr(model_class, "providers"): - qs = qs | model_class.objects.filter(providers=user) - qs = qs.distinct() - return qs diff --git a/utils/winrm_client.py b/utils/winrm_client.py deleted file mode 100755 index f5e2c20..0000000 --- a/utils/winrm_client.py +++ /dev/null @@ -1,712 +0,0 @@ -# Winrm客户端工具 -import logging -import re -import os -from dataclasses import dataclass -from typing import Optional, Dict, Any, List -from winrm import Session -from winrm.exceptions import InvalidCredentialsError -from django.conf import settings -import socket -import time -import secrets -import string -import functools - -logger = logging.getLogger("2c2a") - - -USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_]{1,150}$") -GROUPNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\s]{1,256}$") -MAX_STRING_LENGTH = 4096 - - -class CommandInjectionError(Exception): - pass - - -def validate_username(username: str) -> str: - if not username: - raise CommandInjectionError("用户名不能为空") - if len(username) > 150: - raise CommandInjectionError("用户名长度不能超过150个字符") - if not USERNAME_PATTERN.match(username): - raise CommandInjectionError(f"用户名格式无效: 只允许字母、数字和下划线") - return username - - -def validate_groupname(group: str) -> str: - if not group: - raise CommandInjectionError("组名不能为空") - if len(group) > 256: - raise CommandInjectionError("组名长度不能超过256个字符") - if not GROUPNAME_PATTERN.match(group): - raise CommandInjectionError( - f"组名格式无效: 只允许字母、数字、下划线、连字符和空格" - ) - return group - - -def validate_string_length( - s: str, max_length: int = MAX_STRING_LENGTH, field_name: str = "输入" -) -> str: - if s and len(s) > max_length: - raise CommandInjectionError(f"{field_name}长度不能超过{max_length}个字符") - return s - - -def _escape_ps_string(s: str) -> str: - if not s: - return s - if len(s) > MAX_STRING_LENGTH: - raise CommandInjectionError(f"字符串长度超过最大限制 {MAX_STRING_LENGTH}") - return ( - s.replace("\x00", "") - .replace("`", "``") - .replace('"', '`"') - .replace("$", "`$") - .replace("\n", "`n") - .replace("\r", "`r") - ) - - -def _escape_for_here_string(s: str) -> str: - if not s: - return s - s = s.replace("\x00", "") - if '@"' in s or '"@' in s: - raise CommandInjectionError("内容包含非法的 here-string 分隔符") - return s - - -@dataclass -class WinrmResult: - status_code: int - std_out: str - std_err: str - - @property - def success(self) -> bool: - return self.status_code == 0 - - -class WinrmClient: - """WinRM客户端 - 远程管理Windows主机""" - - def __init__( - self, - hostname: str, - username: Optional[str] = None, - password: Optional[str] = None, - port: int = 5985, - use_ssl: bool = False, - auth_method: str = "ntlm", - cert_pem_path: Optional[str] = None, - cert_key_path: Optional[str] = None, - timeout: Optional[int] = None, - max_retries: Optional[int] = None, - server_cert_validation: str = "validate", - ca_trust_path: Optional[str] = None, - client_cert_pem: Optional[str] = None, - client_cert_key: Optional[str] = None, - ): - """ - 初始化WinRM客户端 - - 参数: - hostname: 主机名或IP地址 - username: 登录用户名(ntlm方式必填) - password: 登录密码(ntlm方式必填) - port: WinRM服务端口,默认为5985 - use_ssl: 是否使用SSL连接,默认为False - auth_method: 认证方式 ('ntlm', 'certificate') - cert_pem_path: 客户端证书PEM文件路径(certificate方式必填) - cert_key_path: 客户端私钥PEM文件路径(certificate方式必填) - timeout: 操作超时时间(秒),默认使用配置文件中的值 - max_retries: 最大重试次数,默认使用配置文件中的值 - server_cert_validation: 服务器证书验证模式 ('ignore', 'validate') - ca_trust_path: CA证书路径(用于验证服务器证书) - client_cert_pem: 客户端证书PEM文件路径(已弃用,使用cert_pem_path) - client_cert_key: 客户端证书私钥文件路径(已弃用,使用cert_key_path) - """ - if auth_method == "certificate": - if not cert_pem_path and client_cert_pem: - cert_pem_path = client_cert_pem - if not cert_key_path and client_cert_key: - cert_key_path = client_cert_key - if not cert_pem_path or not cert_key_path: - raise ValueError("证书认证方式必须提供证书和私钥路径") - if not os.path.exists(cert_pem_path): - raise ValueError(f"客户端证书文件不存在: {cert_pem_path}") - if not os.path.exists(cert_key_path): - raise ValueError(f"客户端私钥文件不存在: {cert_key_path}") - self.auth_method = "certificate" - self.cert_pem_path = cert_pem_path - self.cert_key_path = cert_key_path - self.username = username or "" - self.password = password or "" - elif auth_method == "ntlm": - if not username: - raise ValueError("NTLM认证方式必须提供用户名") - if not password: - raise ValueError("NTLM认证方式必须提供密码") - self.auth_method = "ntlm" - self.username = username - self.password = password - self.cert_pem_path = "" - self.cert_key_path = "" - else: - raise ValueError(f"不支持的认证方式: {auth_method}") - # 检查主机名是否包含端口(例如 "hostname:port" 或 "ip:port" 格式) - if ":" in hostname and not hostname.startswith("http"): - # 分离主机名和端口 - parts = hostname.split(":", 1) - if len(parts) == 2 and parts[1].isdigit(): - # 提取主机名和端口 - actual_hostname = parts[0] - actual_port = int(parts[1]) - # 更新实例变量 - self.hostname = actual_hostname - # 如果没有显式指定端口,则使用从主机名中提取的端口 - if port == 5985: # 5985是默认WinRM端口 - self.port = actual_port - else: - # 如果已显式指定端口,则使用指定的端口 - self.port = port - else: - self.hostname = hostname - self.port = port - else: - self.hostname = hostname - self.port = port - - self.use_ssl = use_ssl - self.timeout = timeout or settings.WINRM_TIMEOUT - self.max_retries = max_retries or settings.WINRM_MAX_RETRIES - - self.server_cert_validation = server_cert_validation - self.ca_trust_path = ca_trust_path - self.client_cert_pem = client_cert_pem - self.client_cert_key = client_cert_key - - if server_cert_validation == "ignore": - logger.warning( - f"WinRM连接到 {hostname} 未启用服务器证书验证," "存在中间人攻击风险" - ) - - if use_ssl and server_cert_validation == "validate": - if not ca_trust_path: - logger.warning("SSL验证启用但未提供CA证书路径,将使用系统默认证书") - elif not os.path.exists(ca_trust_path): - logger.error(f"CA证书文件不存在: {ca_trust_path}") - raise ValueError(f"CA证书文件不存在: {ca_trust_path}") - - if self.auth_method == "certificate": - transport = "certificate" - if not self.use_ssl: - self.use_ssl = True - if self.port == 5985: - self.port = 5986 - else: - transport = "ntlm" - - protocol = "https" if self.use_ssl else "http" - self.endpoint = f"{protocol}://{self.hostname}:{self.port}/wsman" - - if not self._validate_hostname(): - raise ValueError(f"主机名无法解析: {self.hostname}") - - session_kwargs = dict( - transport=transport, - server_cert_validation=self.server_cert_validation, - ca_trust_path=self.ca_trust_path or None, - operation_timeout_sec=self.timeout, - read_timeout_sec=self.timeout + 10, - ) - if self.auth_method == "certificate": - session_kwargs["cert_pem"] = self.cert_pem_path - session_kwargs["cert_key_pem"] = self.cert_key_path - self.session = Session( - self.endpoint, - auth=(self.username, self.password), - **session_kwargs, - ) - else: - self.session = Session( - self.endpoint, - auth=(self.username, self.password), - **session_kwargs, - ) - - logger.info( - f"初始化WinRM客户端: 主机={self.hostname}, 端口={self.port}, " - f"SSL={self.use_ssl}, 认证={self.auth_method}, " - f"超时={self.timeout}秒, 最大重试={self.max_retries}次" - ) - - def _validate_hostname(self) -> bool: - """ - 验证主机名是否可以解析 - - Returns: - bool: 如果主机名可以解析则返回True,否则返回False - """ - try: - # 尝试解析主机名 - socket.gethostbyname(self.hostname) - return True - except socket.gaierror: - logger.error(f"无法解析主机名: {self.hostname}:{self.port}") - return False - except Exception as e: - logger.error(f"验证主机名时发生未知错误: {str(e)}") - return False - - def execute_command( - self, command: str, arguments: Optional[list] = None - ) -> WinrmResult: - """ - 执行远程命令 - - 参数: - command: 要执行的命令 - arguments: 命令参数列表 - - 返回: - WinrmResult对象,包含执行结果 - - 异常: - Exception: 当所有重试尝试都失败时抛出 - """ - import os - - # 如果是DEMO模式,模拟执行命令而不实际执行 - if os.environ.get("2C2A_DEMO", "").lower() == "1": - logger.info(f"DEMO模式: 模拟执行远程命令: {command}, 参数: {arguments}") - # 模拟成功执行的结果 - return WinrmResult( - status_code=0, - std_out="Command executed successfully in demo mode", - std_err="", - ) - - logger.info(f"执行远程命令: {command}, 参数: {arguments}") - - for attempt in range(self.max_retries): - try: - result = self.session.run_cmd(command, arguments or []) - winrm_result = WinrmResult( - status_code=result.status_code, - std_out=result.std_out.decode("utf-8", errors="ignore"), - std_err=result.std_err.decode("utf-8", errors="ignore"), - ) - - if winrm_result.success: - logger.info(f"命令执行成功: {command}") - else: - logger.warning( - f"命令执行返回非零状态码: {command}, " - f"状态码={result.status_code}, 错误={winrm_result.std_err}" - ) - - return winrm_result - except Exception as e: - # 检查是否是网络连接错误 - error_str = str(e) - if ( - "NameResolutionError" in error_str - or "Failed to resolve" in error_str - ): - logger.error(f"主机名解析失败: {self.hostname}") - raise Exception( - f'主机名解析失败: 无法解析主机名 "{self.hostname}". 请检查主机名拼写或网络连接.' - ) - - logger.error( - f"命令执行失败 (尝试 {attempt + 1}/{self.max_retries}): " - f"{command}, 错误: {str(e)}" - ) - - if attempt == self.max_retries - 1: - logger.error(f"命令执行最终失败: {command}") - raise Exception(f"命令执行失败: {str(e)}") - - # 在重试之间等待一段时间 - time.sleep(1) - - def execute_powershell( - self, script: str, arguments: Optional[Dict[str, Any]] = None - ) -> WinrmResult: - """ - 执行PowerShell脚本 - - 参数: - script: 要执行的PowerShell脚本 - arguments: 脚本参数字典 - - 返回: - WinrmResult对象,包含执行结果 - - 异常: - Exception: 当所有重试尝试都失败时抛出 - """ - import os - - # 如果是DEMO模式,模拟执行PowerShell而不实际执行 - if os.environ.get("2C2A_DEMO", "").lower() == "1": - logger.info("DEMO模式: 模拟执行PowerShell脚本") - # 模拟成功执行的结果 - return WinrmResult( - status_code=0, - std_out="PowerShell script executed successfully in demo mode", - std_err="", - ) - - logger.info(f"执行远程命令: {script}, 参数: {arguments}") - - for attempt in range(self.max_retries): - try: - result = self.session.run_ps(script) - winrm_result = WinrmResult( - status_code=result.status_code, - std_out=result.std_out.decode("utf-8", errors="ignore"), - std_err=result.std_err.decode("utf-8", errors="ignore"), - ) - - if winrm_result.success: - logger.info(f"PowerShell脚本执行成功") - else: - logger.warning( - f"PowerShell脚本执行返回非零状态码: " - f"状态码={result.status_code}, 错误={winrm_result.std_err}" - ) - - return winrm_result - except Exception as e: - # 检查是否是网络连接错误 - error_str = str(e) - if ( - "NameResolutionError" in error_str - or "Failed to resolve" in error_str - ): - logger.error(f"主机名解析失败: {self.hostname}") - raise Exception( - f'主机名解析失败: 无法解析主机名 "{self.hostname}". 请检查主机名拼写或网络连接.' - ) - - logger.error( - f"PowerShell脚本执行失败 (尝试 {attempt + 1}/{self.max_retries}), " - f"错误: {str(e)}" - ) - - if attempt == self.max_retries - 1: - logger.error("PowerShell脚本执行最终失败") - raise Exception(f"PowerShell执行失败: {str(e)}") - - # 在重试之间等待一段时间 - time.sleep(1) - - def create_user( - self, - username: str, - password: str, - description: Optional[str] = None, - group: Optional[str] = None, - ) -> WinrmResult: - try: - validate_username(username) - validate_string_length(password, 256, "密码") - if description: - validate_string_length(description, 512, "描述") - if group: - validate_groupname(group) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - - safe_user = _escape_ps_string(username) - safe_pass = _escape_ps_string(password) - safe_desc = _escape_ps_string(description or "") - - script = f""" -$pw = ConvertTo-SecureString "{safe_pass}" -AsPlainText -Force -New-LocalUser -Name "{safe_user}" -Password $pw -Description "{safe_desc}" -ErrorAction Stop -net user "{safe_user}" /logonpasswordchg:NO -Add-LocalGroupMember -Group "Users" -Member "{safe_user}" -ErrorAction Stop -""" - if group: - safe_group = _escape_ps_string(group) - script += f'Add-LocalGroupMember -Group "{safe_group}" -Member "{safe_user}" -ErrorAction Stop\n' - - logger.info(f"创建用户: {username}") - result = self.execute_powershell(script) - self.add_to_remote_users(username) - return result - - def create_user_with_reset_password_on_next_login( - self, - username: str, - password: str, - description: Optional[str] = None, - group: Optional[str] = None, - ) -> WinrmResult: - try: - validate_username(username) - validate_string_length(password, 256, "密码") - if description: - validate_string_length(description, 512, "描述") - if group: - validate_groupname(group) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - - safe_user = _escape_ps_string(username) - safe_pass = _escape_ps_string(password) - safe_desc = _escape_ps_string(description or "") - - script = f""" -$pw = ConvertTo-SecureString "{safe_pass}" -AsPlainText -Force -New-LocalUser -Name "{safe_user}" -Password $pw -Description "{safe_desc}" -ErrorAction Stop -net user "{safe_user}" /logonpasswordchg:NO -Add-LocalGroupMember -Group "Users" -Member "{safe_user}" -ErrorAction Stop -""" - if group: - safe_group = _escape_ps_string(group) - script += f'Add-LocalGroupMember -Group "{safe_group}" -Member "{safe_user}" -ErrorAction Stop\n' - - logger.info(f"创建用户(首登改密): {username}") - result = self.execute_powershell(script) - self.add_to_remote_users(username) - return result - - def delete_user(self, username: str) -> WinrmResult: - try: - validate_username(username) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - safe_user = _escape_ps_string(username) - script = f'Remove-LocalUser -Name "{safe_user}" -ErrorAction Stop' - logger.info(f"删除用户: {username}") - return self.execute_powershell(script) - - def enable_user(self, username: str) -> WinrmResult: - try: - validate_username(username) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - safe_user = _escape_ps_string(username) - script = f'Enable-LocalUser -Name "{safe_user}" -ErrorAction Stop' - logger.info(f"启用用户: {username}") - return self.execute_powershell(script) - - def disabled_user(self, username: str) -> WinrmResult: - try: - validate_username(username) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - safe_user = _escape_ps_string(username) - script = f'Disable-LocalUser -Name "{safe_user}" -ErrorAction Stop' - logger.info(f"禁用用户: {username}") - return self.execute_powershell(script) - - def get_user_info(self, username: str) -> WinrmResult: - try: - validate_username(username) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - safe_user = _escape_ps_string(username) - script = f'Get-LocalUser -Name "{safe_user}" | ConvertTo-Json' - return self.execute_powershell(script) - - def list_users(self) -> WinrmResult: - return self.execute_powershell("Get-LocalUser | ConvertTo-Json") - - def check_user_exists(self, username: str) -> bool: - try: - validate_username(username) - except CommandInjectionError: - return False - safe_user = _escape_ps_string(username) - try: - script = f'$u = Get-LocalUser -Name "{safe_user}" -ErrorAction Stop; $true' - result = self.execute_powershell(script) - return result.success and "True" in result.std_out - except: - return False - - def get_password_policy(self) -> Dict[str, Any]: - """ - 动态获取密码策略要求 - - 返回: - Dict: 包含密码策略信息的字典 - """ - try: - script = f""" - secedit /export /cfg "$env:TEMP\\secpol.cfg" | Out-Null - Get-Content "$env:TEMP\\secpol.cfg" | Where-Object {{ $_ -match '^(MinimumPasswordLength|PasswordComplexity|PasswordHistorySize|MaximumPasswordAge|MinimumPasswordAge)\\s*=' }} - Remove-Item "$env:TEMP\\secpol.cfg" -ErrorAction SilentlyContinue - """ - result = self.execute_powershell(script) - - policy = {} - if result.success: - lines = result.std_out.strip().split("\n") - for line in lines: - line = line.strip() - if line.startswith("MinimumPasswordLength"): - try: - policy["minimum_length"] = int(line.split("=")[1].strip()) - except: - policy["minimum_length"] = 8 # 默认值 - elif line.startswith("PasswordComplexity"): - try: - policy["complexity_required"] = bool( - int(line.split("=")[1].strip()) - ) - except: - policy["complexity_required"] = True # 默认值 - elif line.startswith("PasswordHistorySize"): - try: - policy["history_size"] = int(line.split("=")[1].strip()) - except: - policy["history_size"] = 0 # 默认值 - elif line.startswith("MaximumPasswordAge"): - try: - policy["max_age_days"] = int(line.split("=")[1].strip()) - except: - policy["max_age_days"] = 0 # 默认值 - elif line.startswith("MinimumPasswordAge"): - try: - policy["min_age_days"] = int(line.split("=")[1].strip()) - except: - policy["min_age_days"] = 0 # 默认值 - - # 设置默认值 - if "minimum_length" not in policy: - policy["minimum_length"] = 8 - if "complexity_required" not in policy: - policy["complexity_required"] = True - - logger.info(f"获取密码策略成功: {policy}") - return policy - except Exception as e: - logger.error(f"获取密码策略失败: 错误: {str(e)}") - # 返回默认密码策略 - return { - "minimum_length": 8, - "complexity_required": True, - "history_size": 0, - "max_age_days": 42, - "min_age_days": 1, - } - - def generate_strong_password(self, length: Optional[int] = None) -> str: - """ - 根据密码策略生成强密码 - - 参数: - length: 密码长度,默认根据服务器策略确定 - - 返回: - str: 生成的强密码 - """ - import secrets - import string - - # 获取服务器密码策略 - policy = self.get_password_policy() - - # 确定密码长度 - actual_length = length or max(policy["minimum_length"], 12) # 默认至少12位 - - if policy["complexity_required"]: - # 密码复杂性要求:至少包含大写字母、小写字母、数字和特殊字符 - uppercase = secrets.choice(string.ascii_uppercase) - lowercase = secrets.choice(string.ascii_lowercase) - digit = secrets.choice(string.digits) - special_char = secrets.choice("!@#$%^&*()_+-=[]{}|;:,.<>?") - - # 剩余部分随机生成 - remaining_length = max(0, actual_length - 4) - alphabet = ( - string.ascii_letters + string.digits + "!@#$%^&*()_+-=[]{}|;:,.<>?" - ) - rest = "".join(secrets.choice(alphabet) for i in range(remaining_length)) - - # 打乱顺序以确保安全 - password_chars = list(uppercase + lowercase + digit + special_char + rest) - secrets.SystemRandom().shuffle(password_chars) - password = "".join(password_chars) - else: - # 不需要复杂性要求,简单生成随机密码 - alphabet = string.ascii_letters + string.digits - password = "".join(secrets.choice(alphabet) for i in range(actual_length)) - - logger.info(f"生成强密码完成,长度: {len(password)}") - return password - - def op_user(self, username: str) -> bool: - try: - validate_username(username) - except CommandInjectionError: - return False - safe_user = _escape_ps_string(username) - try: - result = self.execute_powershell( - f'net localgroup Administrators "{safe_user}" /add' - ) - return result.success - except: - return False - - def deop_user(self, username: str) -> bool: - try: - validate_username(username) - except CommandInjectionError: - return False - safe_user = _escape_ps_string(username) - try: - result = self.execute_powershell( - f'net localgroup Administrators "{safe_user}" /delete' - ) - return result.success - except: - return False - - def reset_password(self, username: str, password: str) -> WinrmResult: - try: - validate_username(username) - validate_string_length(password, 256, "密码") - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - safe_user = _escape_ps_string(username) - safe_pass = _escape_ps_string(password) - script = f""" -$pw = ConvertTo-SecureString "{safe_pass}" -AsPlainText -Force -Set-LocalUser -Name "{safe_user}" -Password $pw -net user "{safe_user}" /logonpasswordchg:NO -""" - result = self.execute_powershell(script) - if result.success: - self.add_to_remote_users(username) - return result - - def add_to_remote_users(self, username: str) -> WinrmResult: - try: - validate_username(username) - except CommandInjectionError as e: - logger.warning(f"输入验证失败: {str(e)}") - return WinrmResult(1, "", str(e)) - safe_user = _escape_ps_string(username) - script = ( - f'Add-LocalGroupMember -Group "Remote Desktop Users" ' - f'-Member "{safe_user}" -ErrorAction SilentlyContinue' - ) - return self.execute_powershell(script) diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 0be394f..0000000 --- a/uv.lock +++ /dev/null @@ -1,1580 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "2c2a" -version = "1.0.0" -source = { editable = "." } -dependencies = [ - { name = "celery" }, - { name = "cotton-icons" }, - { name = "cryptography" }, - { name = "django" }, - { name = "django-cors-headers" }, - { name = "django-cotton" }, - { name = "django-formtools" }, - { name = "django-tianai-captcha" }, - { name = "djangorestframework" }, - { name = "djlint" }, - { name = "heroicons" }, - { name = "idna" }, - { name = "kombu" }, - { name = "markdown" }, - { name = "pillow" }, - { name = "pyjwt" }, - { name = "pyotp" }, - { name = "python-dotenv" }, - { name = "pywinrm" }, - { name = "redis" }, - { name = "requests" }, - { name = "toml" }, - { name = "whitenoise" }, -] - -[package.optional-dependencies] -kerberos = [ - { name = "gssapi" }, - { name = "krb5" }, -] - -[package.dev-dependencies] -dev = [ - { name = "black" }, - { name = "django-stubs" }, - { name = "flake8" }, - { name = "pyrefly" }, - { name = "pytest" }, - { name = "pytest-django" }, - { name = "redis" }, -] - -[package.metadata] -requires-dist = [ - { name = "celery", specifier = "==5.4.0" }, - { name = "cotton-icons", specifier = ">=0.2.0" }, - { name = "cryptography", specifier = "==46.0.3" }, - { name = "django", specifier = "==4.2.27" }, - { name = "django-cors-headers", specifier = "==4.3.1" }, - { name = "django-cotton", git = "https://github.com/2c2a/django-cotton.git?rev=feature%2Fx-prefix-tag-support" }, - { name = "django-formtools", specifier = ">=2.5.1" }, - { name = "django-tianai-captcha", git = "https://github.com/trustedinster/django-tianai-captcha.git" }, - { name = "djangorestframework", specifier = "==3.15.2" }, - { name = "djlint", specifier = ">=1.36.4" }, - { name = "gssapi", marker = "extra == 'kerberos'", specifier = ">=1.11.1" }, - { name = "heroicons", specifier = ">=2.14.0" }, - { name = "idna", specifier = "==3.15" }, - { name = "kombu", specifier = "==5.6.2" }, - { name = "krb5", marker = "extra == 'kerberos'", specifier = ">=0.9.0" }, - { name = "markdown", specifier = "==3.10.1" }, - { name = "pillow", specifier = "==12.1.0" }, - { name = "pyjwt", specifier = ">=2.8.0" }, - { name = "pyotp" }, - { name = "python-dotenv", specifier = "==1.2.1" }, - { name = "pywinrm", specifier = "==0.4.3" }, - { name = "redis", specifier = ">=5.0.0" }, - { name = "requests", specifier = "==2.32.3" }, - { name = "toml" }, - { name = "whitenoise", specifier = ">=6.12.0" }, -] -provides-extras = ["kerberos"] - -[package.metadata.requires-dev] -dev = [ - { name = "black" }, - { name = "django-stubs", specifier = ">=6.0.2" }, - { name = "flake8" }, - { name = "pyrefly", specifier = ">=0.60.0" }, - { name = "pytest" }, - { name = "pytest", specifier = ">=9.0.3" }, - { name = "pytest-django" }, - { name = "redis", specifier = ">=7.4.0" }, -] - -[[package]] -name = "amqp" -version = "5.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2" }, -] - -[[package]] -name = "asgiref" -version = "3.8.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47" }, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c" }, -] - -[[package]] -name = "billiard" -version = "4.2.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5" }, -] - -[[package]] -name = "black" -version = "26.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b" }, -] - -[[package]] -name = "celery" -version = "5.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8a/9c/cf0bce2cc1c8971bf56629d8f180e4ca35612c7e79e6e432e785261a8be4/celery-5.4.0.tar.gz", hash = "sha256:504a19140e8d3029d5acad88330c541d4c3f64c789d85f94756762d8bca7e706" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/90/c4/6a4d3772e5407622feb93dd25c86ce3c0fee746fa822a777a627d56b4f2a/celery-5.4.0-py3-none-any.whl", hash = "sha256:369631eb580cf8c51a82721ec538684994f8277637edde2dfc0dacd73ed97f64" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27" }, - { url = "https://mirrors.aliyun.com/pypi/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894" }, - { url = "https://mirrors.aliyun.com/pypi/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14" }, - { url = "https://mirrors.aliyun.com/pypi/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" }, -] - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c" }, -] - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6" }, -] - -[[package]] -name = "click-repl" -version = "0.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, -] - -[[package]] -name = "cotton-icons" -version = "0.2.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "django-cotton" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/80/46/2b75ee203aac31785d9da42f89fdf4e06c7bd6fe5a6bfa76c66d0a6071eb/cotton_icons-0.2.0.tar.gz", hash = "sha256:59f5f9945a2e92ad2d9d7578357bba7dee07b1a8a8ed34a8a56e85bb10539f8b" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b9/26/52ef398c64754f2b930019b5e9d8f5d8170c2f21038d5b7507bcd5f1a669/cotton_icons-0.2.0-py3-none-any.whl", hash = "sha256:8a06a1b2acba534e08b018b02950c62221a7406cffcafc293ea4ccfa7efa45a0" }, -] - -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926" }, - { url = "https://mirrors.aliyun.com/pypi/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459" }, - { url = "https://mirrors.aliyun.com/pypi/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936" }, - { url = "https://mirrors.aliyun.com/pypi/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df" }, - { url = "https://mirrors.aliyun.com/pypi/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c" }, -] - -[[package]] -name = "cssbeautifier" -version = "1.15.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "editorconfig" }, - { name = "jsbeautifier" }, - { name = "six" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98" }, -] - -[[package]] -name = "decorator" -version = "5.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a" }, -] - -[[package]] -name = "django" -version = "4.2.27" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ce/ff/6aa5a94b85837af893ca82227301ac6ddf4798afda86151fb2066d26ca0a/django-4.2.27.tar.gz", hash = "sha256:b865fbe0f4a3d1ee36594c5efa42b20db3c8bbb10dff0736face1c6e4bda5b92" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/dd/f5/1a2319cc090870bfe8c62ef5ad881a6b73b5f4ce7330c5cf2cb4f9536b12/django-4.2.27-py3-none-any.whl", hash = "sha256:f393a394053713e7d213984555c5b7d3caeee78b2ccb729888a0774dff6c11a8" }, -] - -[[package]] -name = "django-cors-headers" -version = "4.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "django" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8a/04/a280a98256602d3f4fffae37a9410711fb80f9d6cf199679f6e93bbdb8b3/django-cors-headers-4.3.1.tar.gz", hash = "sha256:0bf65ef45e606aff1994d35503e6b677c0b26cafff6506f8fd7187f3be840207" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fe/6a/3428ab5d1ec270e845f4ef064a7cefbf1339b4454788d77c00d36caa828c/django_cors_headers-4.3.1-py3-none-any.whl", hash = "sha256:0b1fd19297e37417fc9f835d39e45c8c642938ddba1acce0c1753d3edef04f36" }, -] - -[[package]] -name = "django-cotton" -version = "2.6.2" -source = { git = "https://github.com/2c2a/django-cotton.git?rev=feature%2Fx-prefix-tag-support#46b6a93f9b7a7e77212ae43bd2957c35b10b04dc" } -dependencies = [ - { name = "django" }, -] - -[[package]] -name = "django-formtools" -version = "2.5.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/73/f8/bb9b228fc33230186f3612a6fc96274a81bab3509817498f2632d7aa6367/django-formtools-2.5.1.tar.gz", hash = "sha256:47cb34552c6efca088863d693284d04fc36eaaf350eb21e1a1d935e0df523c93" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/12/63/91a107e3aaaf3987bad036494dfd8cc2675f4a66d22e65ffd6711f84ba70/django_formtools-2.5.1-py3-none-any.whl", hash = "sha256:bce9b64eda52cc1eef6961cc649cf75aacd1a707c2fff08d6c3efcbc8e7e761a" }, -] - -[[package]] -name = "django-stubs" -version = "6.0.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "django" }, - { name = "django-stubs-ext" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "types-pyyaml" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/03/b2/f0214d86180f937c8e3358ff831b20f0634d95bd77436b18861c647e15bc/django_stubs-6.0.2.tar.gz", hash = "sha256:56d43b5e3741563af0063e5b6283f908c625b0439aa06314268673699d1bdccd" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/49/e7/8f2aaa22eac7fa18db3aca0e7b651ccf5ac79a2021bf67e75a16934a7076/django_stubs-6.0.2-py3-none-any.whl", hash = "sha256:c3bc84d80421758f3b2ad9e1358e001d719388a8eb106e67c873e606216108d4" }, -] - -[[package]] -name = "django-stubs-ext" -version = "6.0.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "django" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/52/e0/f2e6caf627d176a51fba1ca9c34082c7ea10d3f521ff2c828532ca99fa91/django_stubs_ext-6.0.2.tar.gz", hash = "sha256:70b7b7ae837e7a6036e2facb64435550bf7cf8143c1a6e802864d4824ce6058c" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2b/d2/9cb93cd1ef94ddc97c26c902ff75a859f5f154051fec98cf8242649b26ce/django_stubs_ext-6.0.2-py3-none-any.whl", hash = "sha256:b35bdec1995bf49765cc39fa89aa7c23f120a23d0cb0152ab7fb4e48ff7d667b" }, -] - -[[package]] -name = "django-tianai-captcha" -version = "1.0.0" -source = { git = "https://github.com/trustedinster/django-tianai-captcha.git#c6ccc879afa6f95a3e63458953a33e68a7c4ec03" } -dependencies = [ - { name = "django" }, - { name = "pillow" }, -] - -[[package]] -name = "djangorestframework" -version = "3.15.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20" }, -] - -[[package]] -name = "djlint" -version = "1.36.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama" }, - { name = "cssbeautifier" }, - { name = "jsbeautifier" }, - { name = "json5" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "tqdm" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/53/71/6a3ce2b49a62e635b85dce30ccf3eb3a18fe79275d45535325a55a63d3a3/djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/47/308412dc579e277c910774f41b380308d582862b16763425583e69e0fc14/djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/6f/428dc044d1e34363265b1301dc9b53253007acd858879d54b369d233aa96/djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/13/0d488e551d73ddf369552fc6f4c7702ea683e4bc1305bcf5c1d198fbdace/djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/68/18ecd1e4d54a523e1d077f01419d669116e5dede97f97f1eb8ddb918a872/djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/03/005cf5c66e57ca2d26249f8385bc64420b2a95fea81c5eb619c925199029/djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9f/88/aea3c81343a273a87362f30442abc13351dc8ada0b10e51daa285b4dddac/djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/77/0f767ac0b72e9a664bb8c92b8940f21bc1b1e806e5bd727584d40a4ca551/djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/f5/9ae02b875604755d4d00cebf96b218b0faa3198edc630f56a139581aed87/djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/51/284443ff2f2a278f61d4ae6ae55eaf820ad9f0fd386d781cdfe91f4de495/djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/5e/791f4c5571f3f168ad26fa3757af8f7a05c623fde1134a9c4de814ee33b7/djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/11/894425add6f84deffcc6e373f2ce250f2f7b01aa58c7f230016ebe7a0085/djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd" }, -] - -[[package]] -name = "editorconfig" -version = "0.17.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" }, -] - -[[package]] -name = "flake8" -version = "7.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e" }, -] - -[[package]] -name = "gssapi" -version = "1.11.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "decorator" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/23/52/c1e90623c259a42ab0587078bb04f959867b970add46ff66750ead8fc7c5/gssapi-1.11.1.tar.gz", hash = "sha256:2049ee4b1d0c363163a1344b7282a363f9f4094e51d2c36de0cf01d4735e0ae2" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d8/d8/dfd7632e42f3028b27fdae1dea0fd967bcee1d9164e0a9fa3946490e6c7a/gssapi-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:126352502e15dc42f786a4635e5fb4dc8ae4bbc89354e85ab094c478a9e49beb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0b/25/e668f8bfebdaf132b29a26bbc4cc50c5a624a83c5271e83e69c62c2ac5d3/gssapi-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:25b3bcf75a0dd5638f02f939a9c40d1c907682ccca69ea1d05ea81ad58ea1022" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/57/391fb8511e0e9bd2f86ed342ba65d1d1dbc0bd77d54f1f75ff4e4616df49/gssapi-1.11.1-cp310-cp310-win32.whl", hash = "sha256:e5d01ac02df8fe67c32cd1684c0954e935d50158ebb956fdffbf9aad7695a3b3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/bf/37a3359ba24d9e422094b749e031116e6613fd038ec0d4c633d210ba314e/gssapi-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:0e8b4d76801f2a8f8e6d85746cb9048d47341c6706800b357c61ba09e4741c03" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/75/3cc18f2d084d19fbba38dc684588cf5f674c647e754f9cf1625bd78c39f8/gssapi-1.11.1-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2298e5909a8f2d27c29352885a24e4026cfd3fa24fc38d4a0a3743fa5a3e7667" }, - { url = "https://mirrors.aliyun.com/pypi/packages/81/f9/ac0f8c43c209d56c89655f80cd4ae43379f88370d01a7e11f264f081eef5/gssapi-1.11.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:5b60b1f8d8d3e36c025bd3494105de1dfccee578e8de001f423cc094468e3022" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f8/97/f4ea9248bfdf5fcde2c5bf0bc0e573d212748724a32a5aa1002e11edb760/gssapi-1.11.1-cp311-abi3-win32.whl", hash = "sha256:9738fe0ba163c28ccf521de7520640bde4b135c1b6e87a5ac5a90435369e89c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/ca/7f839880baf7c365768884161c246a3b6201738722fc7581a995190ec431/gssapi-1.11.1-cp311-abi3-win_amd64.whl", hash = "sha256:96a102ad1ec266e2d843468bf03149982969fc70344f303f81ea20197b80d7a1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/79/9148636b75ca5741ddc9c57c4b256adec422e3a89bca14306d53b48caac9/gssapi-1.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:82fba401e9514ad21749b8d8954e2de1c617b0a73204c8598ee84630e23c5810" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/77/f34fd81bbccf2e682073964a1b1b0a383e70d02946e472f78881d50cec6f/gssapi-1.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cb0250f27d288d4217d7f606d3b68ecb9a10fce9391106129cada96434a685b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b2/34/733a6f3372040992befd1fe62288cafea9fe25acdcf8b663ec8a7857cb69/gssapi-1.11.1-cp314-cp314t-win32.whl", hash = "sha256:b17875d236b8b030a777ee3f3ece55f3d316a91937c37abbc771afe1493703cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/03/1b71feddb85f945101c3cdc07242805c5e9b48da546f8a922129ad8299e5/gssapi-1.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:da43c0e0ae84bb9f04c4e016eac6d3826c6357f827183042ba990ccedeeab052" }, -] - -[[package]] -name = "heroicons" -version = "2.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5f/fe/ed8a483bbd518f421b891a3db4eb61c6f2a5f84886a92a80b82d0d6637b5/heroicons-2.14.0.tar.gz", hash = "sha256:e55ecc0a839cf872f55977d2dd04a1855bfdf080bed1d13db5264b0060e65a96" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/08/89/5e1511183a70edb8b10fed0507b76726fe060056c952a53a2484be96db24/heroicons-2.14.0-py3-none-any.whl", hash = "sha256:cdf7fa6de02b7bc5b4513d7f592a9a1faea150b050a3b4270a0dc445ebe930c9" }, -] - -[[package]] -name = "idna" -version = "3.15" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, -] - -[[package]] -name = "jsbeautifier" -version = "1.15.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "editorconfig" }, - { name = "six" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528" }, -] - -[[package]] -name = "json5" -version = "0.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a" }, -] - -[[package]] -name = "kombu" -version = "5.6.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "amqp" }, - { name = "packaging" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93" }, -] - -[[package]] -name = "krb5" -version = "0.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/15/15/55a01be5f1816fe6d7d36fec4c6b2cb6f5264d289a015322562c582a81b7/krb5-0.9.0.tar.gz", hash = "sha256:4cdd2c85ff4770108edaf48fedf19888cf956ff374e2e97e40f8412b048caee6" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7d/ed/473605378e398642d8f8262544774b6673178325e0166fe56ec7a7884d0a/krb5-0.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1bde451ddb3a1c064be1da967fa32b1976a06ca43969e9d80ed8b26506ac4e3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/96/00a11ef3118690cf3b3c6554127e26c16006525bf34b0e22d6b27dca3dd9/krb5-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6e612e763304bfe4f6845f581030502327da2d0a19efccc840d7c051448ee209" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1b/4e/0a35ae4821ca5f0491844d9343c816883b3218a265a4a95ecec66e76f239/krb5-0.9.0-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7a021e869833bfed44ed4c9dfefd25813fab40a381ad4482b79ea36744545f62" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/32/d2a9d977d23425777c0c5722947e6f0bc80882c7de15038bd02f9269c01a/krb5-0.9.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:2bca06e7ce1551f3eb7f674508bc34d9f44fa1c9056f24120878ec9ab8e430cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/b9/fd0079f9208738bc09cf99208fc92cdf7d8b6a2a363af9a73256efa76399/krb5-0.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ea2ee73bb5aa7818ce50895fb29c276c7fe478c6eda121a5b66b0cc45fe2d42e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ca/9f/df4e24995e0fea7792e8ab152124c65ac845e00ddfb4dd8f7d22907d122b/krb5-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f9da4558a47ec105a1a2c185bb4b2d1dd90b7c021e3116895171f913ef048d03" }, -] - -[[package]] -name = "markdown" -version = "3.10.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3" }, -] - -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" }, -] - -[[package]] -name = "pathspec" -version = "1.0.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723" }, -] - -[[package]] -name = "pillow" -version = "12.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208" }, - { url = "https://mirrors.aliyun.com/pypi/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670" }, - { url = "https://mirrors.aliyun.com/pypi/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796" }, - { url = "https://mirrors.aliyun.com/pypi/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef" }, - { url = "https://mirrors.aliyun.com/pypi/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19" }, -] - -[[package]] -name = "platformdirs" -version = "4.9.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, -] - -[[package]] -name = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955" }, -] - -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" }, -] - -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, -] - -[[package]] -name = "pyjwt" -version = "2.12.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" }, -] - -[[package]] -name = "pyotp" -version = "2.9.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f3/b2/1d5994ba2acde054a443bd5e2d384175449c7d2b6d1a0614dbca3a63abfc/pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c3/c0/c33c8792c3e50193ef55adb95c1c3c2786fe281123291c2dbf0eaab95a6f/pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612" }, -] - -[[package]] -name = "pyrefly" -version = "0.60.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c6/c7/28d14b64888e2d03815627ebff8d57a9f08389c4bbebfe70ae1ed98a1267/pyrefly-0.60.0.tar.gz", hash = "sha256:2499f5b6ff5342e86dfe1cd94bcce133519bbbc93b7ad5636195fea4f0fa3b81" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/31/99/6c9984a09220e5eb7dd5c869b7a32d25c3d06b5e8854c6eb679db1145c3e/pyrefly-0.60.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bf1691af0fee69d0c99c3c6e9d26ab6acd3c8afef96416f9ba2e74934833b7b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/b3/6216aa3c00c88e59a27eb4149851b5affe86eeea6129f4224034a32dddb0/pyrefly-0.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3e71b70c9b95545cf3b479bc55d1381b531de7b2380eb64411088a1e56b634cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9b/87/eb8dd73abd92a93952ac27a605e463c432fb250fb23186574038c7035594/pyrefly-0.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:680ee5f8f98230ea145652d7344708f5375786209c5bf03d8b911fdb0d0d4195" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0d/34/dc6aeb67b840c745fcee6db358295d554abe6ab555a7eaaf44624bd80bf1/pyrefly-0.60.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d0b20dbbe4aff15b959e8d825b7521a144c4122c11e57022e83b36568c54470" }, - { url = "https://mirrors.aliyun.com/pypi/packages/66/6b/c863fcf7ef592b7d1db91502acf0d1113be8bed7a2a7143fc6f0dd90616f/pyrefly-0.60.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2911563c8e6b2eaefff68885c94727965469a35375a409235a7a4d2b7157dc15" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/a2/25ea095ab2ecca8e62884669b11a79f14299db93071685b73a97efbaf4f3/pyrefly-0.60.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0a631d9d04705e303fe156f2e62551611bc7ef8066c34708ceebcfb3088bd55" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8e/2c/097bdc6e8d40676b28eb03710a4577bc3c7b803cd24693ac02bf15de3d67/pyrefly-0.60.0-py3-none-win32.whl", hash = "sha256:a08d69298da5626cf502d3debbb6944fd13d2f405ea6625363751f1ff570d366" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/d4/8d27fe310e830c8d11ab73db38b93f9fd2e218744b6efb1204401c9a74d5/pyrefly-0.60.0-py3-none-win_amd64.whl", hash = "sha256:56cf30654e708ae1dd635ffefcba4fa4b349dd7004a6ccc5c41e3a9bb944320c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/ad/8eea1f8fb8209f91f6dbfe48000c9d05fd0cdb1b5b3157283c9b1dada55d/pyrefly-0.60.0-py3-none-win_arm64.whl", hash = "sha256:b6d27fba970f4777063c0227c54167d83bece1804ea34f69e7118e409ba038d2" }, -] - -[[package]] -name = "pyspnego" -version = "0.12.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "sspilib", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f9/e4/8b32a91aeba6fbc6943a630c44b2fe038615e5c7dec8814316fafdcf4bf4/pyspnego-0.12.0.tar.gz", hash = "sha256:e1d9cd3520a87a1d6db8d68783b17edc4e1464eae3d51ead411a51c82dbaae67" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/01/e9/95430b8f3b747ebd3b86a66484a79ef387167655bcb15ab416f563045565/pyspnego-0.12.0-py3-none-any.whl", hash = "sha256:84cc8dae6ad21e04b37c50c1d3c743f05f193e39498f6010cc68ec1146afd007" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9" }, -] - -[[package]] -name = "pytest-django" -version = "4.12.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" }, -] - -[[package]] -name = "pytokens" -version = "0.4.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe" }, - { url = "https://mirrors.aliyun.com/pypi/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975" }, - { url = "https://mirrors.aliyun.com/pypi/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de" }, -] - -[[package]] -name = "pywinrm" -version = "0.4.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "requests" }, - { name = "requests-ntlm" }, - { name = "six" }, - { name = "xmltodict" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7c/ba/78329e124138f8edf40a41b4252baf20cafdbea92ea45d50ec712124e99b/pywinrm-0.4.3.tar.gz", hash = "sha256:995674bf5ac64b2562c9c56540473109e530d36bde10c262d5a5296121ad5565" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/5c/1a/74bdbb7a3f8a6c1d2254c39c53c2d388529a314366130147d180522c59a3/pywinrm-0.4.3-py2.py3-none-any.whl", hash = "sha256:c476c1e20dd0875da6fe4684c4d8e242dd537025907c2f11e1990c5aee5c9526" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" }, - { url = "https://mirrors.aliyun.com/pypi/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" }, - { url = "https://mirrors.aliyun.com/pypi/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, -] - -[[package]] -name = "redis" -version = "7.4.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec" }, -] - -[[package]] -name = "regex" -version = "2026.5.9" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/fe/ed/0ad2c8edf634918eb4484365d3819fa7bd7f58daf807fe7fb21812c316e5/regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44" }, - { url = "https://mirrors.aliyun.com/pypi/packages/89/a9/4ed972ad263963b860b7c3e86e0e1bcc791def47b43b8c8efe57e710f139/regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/81/075930d9fa28c4ea1f53398dd015ee7c882f623539759113cda1257f4b82/regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/c8/5cdfbf0b5dc6599e1b6131eff43262e5275d4ec3469ce10216061659aadb/regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cd/ca/ae5fd6edc59b7f84b904b31d6ec39a860cbcecd10f64bd5a062ca83a4864/regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f6/ce/a91cf555afb51f3b74a182e24ba073b91ea7bb64592fc4b315c111bb19fd/regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538" }, - { url = "https://mirrors.aliyun.com/pypi/packages/55/7f/725a0a2b245a4cf0c4bab29d0e97c74285d94136a65d1b55a6459a583502/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/2a/996efbd59ce6b5d4a09e3af6180ceb62af171f4a9a6fb557d2f0ae0d462b/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4b/0a/8731e8b8806174c9cdd5903f80a14990331c1f42fc4209b540952e9e010d/regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/9a/0b/932473194bd563f342a412ae2ffbbd6da608306a2bc4e99249a41c2b0b92/regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00" }, - { url = "https://mirrors.aliyun.com/pypi/packages/98/80/9523d196010031df25f7177ee0a467efbee436324038e5d99def17a57515/regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/07/56987b35e89edf47e4a38cf2845aeee476bfa688a6bdbd3e820cda461dc1/regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/2a/ff713fff0c566507c06a4ce2dc0ae8e7eeebc88811a95fc81cf1e7d534dd/regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/90/df6d982b03e3614785c6937ba51b57f6733d97d2ee1c9bc7531dbfab3a54/regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c7/8a/4e88a5f7c3e98489aac4dd23142723d907b2a595b4a6abcbacabefeded09/regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/40/4b224cb0582b2dca1786726e6cdabe26abbf757d7f6718332f186da155d2/regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/4d/014fbe803204cab0947ee428f09f658a29632053dde1d3c6176bb4f0fd4c/regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555" }, - { url = "https://mirrors.aliyun.com/pypi/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309" }, - { url = "https://mirrors.aliyun.com/pypi/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66" }, - { url = "https://mirrors.aliyun.com/pypi/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070" }, - { url = "https://mirrors.aliyun.com/pypi/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04" }, - { url = "https://mirrors.aliyun.com/pypi/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88" }, - { url = "https://mirrors.aliyun.com/pypi/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178" }, - { url = "https://mirrors.aliyun.com/pypi/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081" }, - { url = "https://mirrors.aliyun.com/pypi/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de" }, - { url = "https://mirrors.aliyun.com/pypi/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346" }, - { url = "https://mirrors.aliyun.com/pypi/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676" }, - { url = "https://mirrors.aliyun.com/pypi/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763" }, - { url = "https://mirrors.aliyun.com/pypi/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372" }, - { url = "https://mirrors.aliyun.com/pypi/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20" }, - { url = "https://mirrors.aliyun.com/pypi/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0" }, - { url = "https://mirrors.aliyun.com/pypi/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415" }, - { url = "https://mirrors.aliyun.com/pypi/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58" }, - { url = "https://mirrors.aliyun.com/pypi/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77" }, - { url = "https://mirrors.aliyun.com/pypi/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa" }, -] - -[[package]] -name = "requests" -version = "2.32.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" }, -] - -[[package]] -name = "requests-ntlm" -version = "1.3.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyspnego" }, - { name = "requests" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/15/74/5d4e1815107e9d78c44c3ad04740b00efd1189e5a9ec11e5275b60864e54/requests_ntlm-1.3.0.tar.gz", hash = "sha256:b29cc2462623dffdf9b88c43e180ccb735b4007228a542220e882c58ae56c668" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/9e/5d/836b97537a390cf811b0488490c389c5a614f0a93acb23f347bd37a2d914/requests_ntlm-1.3.0-py3-none-any.whl", hash = "sha256:4c7534a7d0e482bb0928531d621be4b2c74ace437e88c5a357ceb7452d25a510" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, -] - -[[package]] -name = "sqlparse" -version = "0.5.4" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb" }, -] - -[[package]] -name = "sspilib" -version = "0.5.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a7/e6/d0d74b18bed8c16949fddc0401005c072947ae7bf1bab982ed28f9ebc2d8/sspilib-0.5.0.tar.gz", hash = "sha256:b62f7f2602aa1add0505eee2417e2df24421224cb411e53bf3ae42a71b62fe98" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/6d/cb/7cc967b48d182cb012229ccc9f9e3fd5e245b7f0c80667297ddded580877/sspilib-0.5.0-cp310-cp310-win32.whl", hash = "sha256:8dab68e994d24a08f854d36ac96409b3b8cc03fdebc590925f76f9d733c3a902" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/0d/7746ade4e3c4dba6c6d9b2afe3c3903f4bcab2da6b300b5d81afee089196/sspilib-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e1947df07110ee1861009fc117bd089a7710403f3f5c488fb52a6e00b7c5b84" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8d/fb/6821418037e9d78179153e770e2b0280956f28f4bf51069dcbcc0348505d/sspilib-0.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:28d0eb944f7ff70bc99fe729d06fa230aec1649c5bc216809e359cd0c77d4840" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/1b/dd9066491168933b0f7ab6e396ac58cc024c8954e95264c38e3dc9363d7c/sspilib-0.5.0-cp311-abi3-win32.whl", hash = "sha256:fcb57b41b3200ef2e6e8846e2a13799d20b35b796267f2f75cc65e3883e8eeb6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/17/6a/a11abf90172ff580ac2f9ade3496d868e05e851c4ecf487dd5baeb966b1d/sspilib-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:ca2a21a4e90db563c2cec639c66b3a29ea53129a0c55ff1e4154a02937f6bd45" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/05/983876d281b9e7926f1c9126e72de8bd5928b1de45433163f54d4e217502/sspilib-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:6893bad16f122fc3c4bd908461b9728694465c05ca97c22f7e2094791c4ee3cb" }, - { url = "https://mirrors.aliyun.com/pypi/packages/43/f8/34e8e86883054b961c2eb88a5b42b89b2bf975723b1acca090966c2d03ff/sspilib-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:9dad272abf3f4cf0bf95d495075d2987f6ba1fb300f8d603661ccac07d11272f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c4/d8/8c4ba75f925fd9651cb855c47e0e67931a175d6fd41e569193a8d58133ac/sspilib-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7d7724d5dbb31f68e62465863dfb862fe2793281ce40d0c8f2dc60c8f07998f2" }, - { url = "https://mirrors.aliyun.com/pypi/packages/74/c3/07af17b6fcc2b02af294a8817e30441a502880a04c8d60be2d71e0a1eacc/sspilib-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8ce23ec740dee025136370ed4ae64b7d1535368321049ef960012a57c93ebe15" }, -] - -[[package]] -name = "toml" -version = "0.10.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b" }, -] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076" }, - { url = "https://mirrors.aliyun.com/pypi/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc" }, - { url = "https://mirrors.aliyun.com/pypi/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049" }, - { url = "https://mirrors.aliyun.com/pypi/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e" }, - { url = "https://mirrors.aliyun.com/pypi/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece" }, - { url = "https://mirrors.aliyun.com/pypi/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585" }, - { url = "https://mirrors.aliyun.com/pypi/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9" }, - { url = "https://mirrors.aliyun.com/pypi/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257" }, - { url = "https://mirrors.aliyun.com/pypi/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897" }, - { url = "https://mirrors.aliyun.com/pypi/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5" }, - { url = "https://mirrors.aliyun.com/pypi/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36" }, - { url = "https://mirrors.aliyun.com/pypi/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd" }, - { url = "https://mirrors.aliyun.com/pypi/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf" }, - { url = "https://mirrors.aliyun.com/pypi/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662" }, - { url = "https://mirrors.aliyun.com/pypi/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853" }, - { url = "https://mirrors.aliyun.com/pypi/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15" }, - { url = "https://mirrors.aliyun.com/pypi/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba" }, - { url = "https://mirrors.aliyun.com/pypi/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6" }, - { url = "https://mirrors.aliyun.com/pypi/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7" }, - { url = "https://mirrors.aliyun.com/pypi/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232" }, - { url = "https://mirrors.aliyun.com/pypi/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4" }, - { url = "https://mirrors.aliyun.com/pypi/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41" }, - { url = "https://mirrors.aliyun.com/pypi/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c" }, - { url = "https://mirrors.aliyun.com/pypi/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f" }, - { url = "https://mirrors.aliyun.com/pypi/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8" }, - { url = "https://mirrors.aliyun.com/pypi/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26" }, - { url = "https://mirrors.aliyun.com/pypi/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396" }, - { url = "https://mirrors.aliyun.com/pypi/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe" }, -] - -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" }, -] - -[[package]] -name = "types-pyyaml" -version = "6.0.12.20250915" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" }, -] - -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1" }, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" }, -] - -[[package]] -name = "vine" -version = "5.1.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc" }, -] - -[[package]] -name = "wcwidth" -version = "0.3.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/05/07/0b5bcc9812b1b2fd331cc88289ef4d47d428afdbbf0216bb7d53942d93d6/wcwidth-0.3.2.tar.gz", hash = "sha256:d469b3059dab6b1077def5923ed0a8bf5738bd4a1a87f686d5e2de455354c4ad" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/72/c6/1452e716c5af065c018f75d42ca97517a04ac6aae4133722e0424649a07c/wcwidth-0.3.2-py3-none-any.whl", hash = "sha256:817abc6a89e47242a349b5d100cbd244301690d6d8d2ec6335f26fe6640a6315" }, -] - -[[package]] -name = "whitenoise" -version = "6.12.0" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2" }, -] - -[[package]] -name = "xmltodict" -version = "1.0.2" -source = { registry = "https://mirrors.aliyun.com/pypi/simple" } -sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649" } -wheels = [ - { url = "https://mirrors.aliyun.com/pypi/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d" }, -] diff --git a/uv.toml b/uv.toml deleted file mode 100644 index c50b6b0..0000000 --- a/uv.toml +++ /dev/null @@ -1,25 +0,0 @@ -[[index]] -name = "tsinghua" -url = "https://pypi.tuna.tsinghua.edu.cn/simple" -default = true - -[[index]] -name = "aliyun" -url = "https://mirrors.aliyun.com/pypi/simple" - -[[index]] -name = "ustc" -url = "https://pypi.mirrors.ustc.edu.cn/simple" - -# Python 解释器下载镜像配置 -# uv 默认从 GitHub (astral-sh/python-build-standalone) 下载 Python -# 国内可通过以下方式加速: -# -# 方式1:环境变量(推荐,灵活配置) -# export UV_PYTHON_INSTALL_MIRROR=https://your-mirror.com/github/astral-sh/python-build-standalone/releases/download -# -# 方式2:使用 GitHub 代理 -# export UV_PYTHON_INSTALL_MIRROR=https://ghproxy.com/https://github.com/astral-sh/python-build-standalone/releases/download -# -# 方式3:本地镜像 -# export UV_PYTHON_INSTALL_MIRROR=file:///path/to/local/python-build-standalone