commit 26fa546f67a583a599adf20fa05b3c88e13f36b6 Author: PineHomePC Date: Sun Aug 23 22:35:59 2026 +0800 feat: 核心服务端基础框架(身份/平台 API) - 七端口 RBAC、select-identity、JWT、审计 - FastAPI + SQLAlchemy + SQLite,/auth /opc /admin /agents 等路由 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f73192d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,5 @@ +"""云超服Agents 演示后端(pineagents-demo-server)。 + +供 PineAgents 主后端认证转发:只负责登录、注册、资料维护等演示接口, +数据以本地 JSON 文件形式存储(data/ 目录),下一个阶段据此设计数据库。 +""" diff --git a/app/__main__.py b/app/__main__.py new file mode 100644 index 0000000..50bafab --- /dev/null +++ b/app/__main__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""``python -m app`` 快捷启动。""" +from __future__ import annotations + +import uvicorn + +from . import config + + +def main() -> None: + uvicorn.run("app.main:app", host=config.HOST, port=config.PORT) + + +if __name__ == "__main__": + main() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..c138f88 --- /dev/null +++ b/app/config.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""全局配置:端口、数据目录、令牌有效期、认证开关。 + +所有路径/开关均可通过环境变量覆盖,便于部署和测试: + + PINEAGENTS_DEMO_HOST / PINEAGENTS_DEMO_PORT 监听地址(默认 127.0.0.1:8090) + PINEAGENTS_DEMO_DATA_DIR JSON 数据目录(默认 <项目根>/data) + PINEAGENTS_DEMO_AUTH_ENABLED 认证开关(默认 true) +""" +from __future__ import annotations + +import os +from pathlib import Path + +# 项目根目录 = 本文件的上上级(app/config.py -> <根>) +BASE_DIR = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# 数据目录(JSON "数据库" 所在位置) +# --------------------------------------------------------------------------- +# 约定:data/ 下每个文件对应一张"表",下一个阶段换成真正的数据库时, +# 文件名/键名即为表名,字段即为列。见 README「JSON 格式与未来数据库映射」。 +DATA_DIR = Path( + os.environ.get("PINEAGENTS_DEMO_DATA_DIR", str(BASE_DIR / "data")), +).resolve() + +# SQLite 数据库文件(auth/RBAC 域)。可用环境变量覆盖,测试用 tmp 路径。 +DATABASE_URL = os.environ.get( + "PINEAGENTS_DEMO_DATABASE_URL", + f"sqlite:///{DATA_DIR / 'app.db'}", +) + +USERS_FILE = DATA_DIR / "users.json" +TOKENS_FILE = DATA_DIR / "tokens.json" +AGENTS_FILE = DATA_DIR / "agents.json" + +# 智能体初始化 md 模板(镜像本地 md_files 结构;qa 已统一为 PineAgents 品牌)。 +# 目录://*.md(通用)、/qa//*.md、 +# /local//*.md +TEMPLATES_DIR = BASE_DIR / "templates" + +# 首次启动时用于初始化 data/ 的种子数据(提交到仓库,数据可读)。 +SEED_DIR = BASE_DIR / "data" / "seed" +SEED_USERS_FILE = SEED_DIR / "demo_users.json" + +# --------------------------------------------------------------------------- +# 服务监听 +# --------------------------------------------------------------------------- +HOST = os.environ.get("PINEAGENTS_DEMO_HOST", "127.0.0.1") +PORT = int(os.environ.get("PINEAGENTS_DEMO_PORT", "8090")) + +# --------------------------------------------------------------------------- +# 认证与令牌 +# --------------------------------------------------------------------------- +# 演示服务默认开启认证(PineAgents 主后端把 /auth/* 转发到本服务)。 +AUTH_ENABLED = ( + os.environ.get("PINEAGENTS_DEMO_AUTH_ENABLED", "true").strip().lower() + in ("true", "1", "yes") +) + +# 令牌有效期(秒):默认 7 天;-1/0 表示永久(按 100 年封顶)。 +DEFAULT_TOKEN_EXPIRY_SECONDS = 7 * 24 * 3600 +PERMANENT_TOKEN_SECONDS = 100 * 365 * 24 * 3600 +TOKEN_EXPIRY_MAX = PERMANENT_TOKEN_SECONDS + +# --------------------------------------------------------------------------- +# JWT +# --------------------------------------------------------------------------- +JWT_SECRET = os.environ.get( + "PINEAGENTS_DEMO_JWT_SECRET", + "dev-insecure-change-me-0123456789abcdef", +) +JWT_ALGORITHM = os.environ.get("PINEAGENTS_DEMO_JWT_ALGORITHM", "HS256") +JWT_ACCESS_EXPIRY_SECONDS = DEFAULT_TOKEN_EXPIRY_SECONDS + + +def resolve_token_expiry(expires_in: int | None) -> int: + """把前端传来的 ``expires_in`` 归一化为秒数。 + + - None → 默认 7 天 + - <= 0(含 -1/0)→ 永久(100 年) + - 其他 → 按最大值封顶 + """ + if expires_in is None: + return DEFAULT_TOKEN_EXPIRY_SECONDS + if expires_in <= 0: + return PERMANENT_TOKEN_SECONDS + return min(expires_in, TOKEN_EXPIRY_MAX) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..aa9eaf2 --- /dev/null +++ b/app/db.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""SQLAlchemy 引擎 / 会话 / 声明基类。 + +auth/RBAC 域数据从 JSON 文件迁移到 SQLite,Repository 内部用 SQL 实现, +路由与业务逻辑不变(见 ``repositories.py`` 保留的方法签名)。 +""" +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from . import config + + +class Base(DeclarativeBase): + """SQLAlchemy 声明式基类。""" + + +engine = create_engine( + config.DATABASE_URL, + connect_args={"check_same_thread": False} if config.DATABASE_URL.startswith("sqlite") else {}, + pool_pre_ping=True, +) + +SessionLocal = sessionmaker( + bind=engine, + autoflush=False, + expire_on_commit=False, +) + + +def init_db() -> None: + """幂等地创建全部表(create_all 对已存在表无操作)。""" + # 延迟导入模型以确保表注册到 Base.metadata + from . import models # noqa: F401 + + Base.metadata.create_all(bind=engine) + + +def create_all(engine) -> None: + """在指定 engine 上创建全部表(供测试/独立实例使用)。""" + from . import models # noqa: F401 + + Base.metadata.create_all(bind=engine) + + +def get_session() -> Generator[Session, None, None]: + """FastAPI 依赖:请求级 SQLAlchemy 会话,请求结束自动关闭。""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def make_engine(db_url: str): + """按给定 URL 创建独立引擎(测试用 tmp 数据库)。""" + return create_engine( + db_url, + connect_args={"check_same_thread": False} if db_url.startswith("sqlite") else {}, + pool_pre_ping=True, + ) + + +def make_session_factory(db_url: str) -> sessionmaker: + return sessionmaker( + bind=make_engine(db_url), + autoflush=False, + expire_on_commit=False, + ) diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..dfa2bf2 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""FastAPI 依赖:数据库句柄与当前登录用户(JWT + RBAC)。 + +``Database`` 实例由 ``main.py`` 在启动时创建并挂在 ``app.state.db`` 上, +路由通过 ``Depends(get_db)`` 取用;测试时可替换为临时目录实例。 +""" +from __future__ import annotations + +from fastapi import Depends, HTTPException, Request + +from . import config +from .jwt import decode_access_token +from .repositories import Database + +BEARER_PREFIX = "Bearer " + + +def extract_bearer_token(request: Request) -> str: + """从 Authorization 头提取 Bearer token,没有则返回空串。""" + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith(BEARER_PREFIX): + return auth_header[len(BEARER_PREFIX):].strip() + return "" + + +def get_db(request: Request) -> Database: + return request.app.state.db + + +def _resolve_identity(user: dict, identity_id: str | None, db: Database) -> dict: + """按令牌携带的 identity_id 装配当前身份;缺失则回退到 users.role 单角色。 + + 身份即权限来源:从 user_identities 取 role/sub_role/org/region, + 并据此计算 permissions 与数据范围。 + """ + if identity_id: + ident = db.identities.get_for_user(identity_id, user["id"]) + if ident and ident.get("status") == "active": + user["identity_id"] = ident["id"] + user["port"] = ident["port"] + user["role"] = ident["role"] + user["sub_role"] = ident.get("sub_role") + user["org_id"] = ident.get("org_id") + user["region_id"] = ident.get("region_id") + user["identity_name"] = ident.get("name", "") + + # 动态装配权限与数据范围(每次从库计算,角色变更即时生效) + user["permissions"] = db.roles.permissions_for( + user.get("role", "opc_member"), user.get("sub_role"), + ) + user["scope_region_ids"] = db.regions.visible_region_ids(user.get("region_id")) + user["scope_level"] = db.regions.level(user.get("region_id")) + return user + + +def get_current_user( + request: Request, + db: Database = Depends(get_db), +) -> dict: + """校验调用方 JWT,返回当前用户记录(含 role/权限/数据范围)。 + + 无效/过期/已吊销/禁用用户一律 401。令牌若绑定 identity_id 则按该身份 + 解析;否则回退到 users.role 单角色(兼容旧令牌)。 + """ + token = extract_bearer_token(request) + if not token: + raise HTTPException(status_code=401, detail="No token provided") + + payload = decode_access_token(token) + if payload is None: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + user_id = payload.get("sub") + if not db.tokens.session_valid( + payload.get("jti", ""), + user_id, + payload.get("ver", 0), + ): + raise HTTPException(status_code=401, detail="Invalid or expired token") + + user = db.users.get_by_id(user_id) + if user is None or user.get("status") != "active": + raise HTTPException(status_code=401, detail="Invalid or expired token") + + return _resolve_identity(user, payload.get("identity_id"), db) + + +def optional_current_user( + request: Request, + db: Database = Depends(get_db), +) -> dict | None: + """可选登录:有有效 JWT 则返回用户,否则返回 None(不报错)。""" + token = extract_bearer_token(request) + if not token: + return None + payload = decode_access_token(token) + if payload is None: + return None + if not db.tokens.session_valid( + payload.get("jti", ""), payload.get("sub", ""), payload.get("ver", 0), + ): + return None + user = db.users.get_by_id(payload.get("sub")) + if user is None or user.get("status") != "active": + return None + return _resolve_identity(user, payload.get("identity_id"), db) + + +def require_port(user: dict = Depends(get_current_user)) -> dict: + """按端口隔离的资源(如智能体):必须已解析出端口身份,否则 403。 + + 多身份账号在 select-identity 前持有"中性令牌"(无 identity_id, + port 未解析),此类请求不允许访问按端口隔离的资源。 + """ + if not user.get("port"): + raise HTTPException(status_code=403, detail="请先选择身份/端口") + return user diff --git a/app/jwt.py b/app/jwt.py new file mode 100644 index 0000000..771d597 --- /dev/null +++ b/app/jwt.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""JWT 访问令牌:签发与验签。 + +- 无状态验签(HS256),claims 携带角色/组织/数据范围/权限。 +- 撤销由 ``sessions`` 表(按 jti)+ ``users.token_version`` 兜底, + ``get_current_user`` 每请求一次 DB 读校验。 +""" +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone + +import jwt as pyjwt + +from . import config + +JTI_TTL_SECONDS = config.JWT_ACCESS_EXPIRY_SECONDS + + +def _now_ts() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def create_access_token( + user: dict, + *, + permissions: list[str], + scope_region_ids: list[str], + scope_level: str | None, + expiry_seconds: int, + identity_id: str | None = None, +) -> tuple[str, str, str]: + """签发 JWT,返回 (token, jti, expires_at_iso)。 + + ``user`` 为来自 UserRepository 的用户 dict(含 id/username/role/ + sub_role/org_id/region_id/token_version)。``identity_id`` 非空时表示 + 该令牌绑定到指定端口身份(get_current_user 据此解析当前身份)。 + """ + jti = str(uuid.uuid4()) + now = _now_ts() + exp_ts = now + (expiry_seconds if expiry_seconds else config.JWT_ACCESS_EXPIRY_SECONDS) + payload = { + "sub": user["id"], + "username": user.get("username", ""), + "role": user.get("role", "opc_member"), + "sub_role": user.get("sub_role"), + "org_id": user.get("org_id"), + "region_id": user.get("region_id"), + "identity_id": identity_id, + "scope_level": scope_level, + "scope_region_ids": scope_region_ids, + "perms": permissions, + "ver": user.get("token_version", 0), + "jti": jti, + "iat": now, + "exp": exp_ts, + } + token = pyjwt.encode(payload, config.JWT_SECRET, algorithm=config.JWT_ALGORITHM) + expires_at = datetime.fromtimestamp(exp_ts, tz=timezone.utc).isoformat() + return token, jti, expires_at + + +def decode_access_token(token: str) -> dict | None: + """验签并返回 claims;无效/过期返回 None。""" + try: + payload = pyjwt.decode( + token, + config.JWT_SECRET, + algorithms=[config.JWT_ALGORITHM], + ) + except pyjwt.PyJWTError: + return None + return payload diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..9874cf9 --- /dev/null +++ b/app/main.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +"""FastAPI 应用入口。 + +启动方式(项目根目录): + uv run uvicorn app.main:app --host 127.0.0.1 --port 8090 +或使用脚本:``uv run python -m app`` +""" +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app import config +from app.repositories import Database +from app.routers import agents as agents_router +from app.routers import auth as auth_router +from app.routers import bootstrap as bootstrap_router +from app.routers import rbac_admin as rbac_admin_router +from app.routers import rbac_government as rbac_government_router +from app.routers import rbac_opc as rbac_opc_router +from app.routers import rbac_investor as rbac_investor_router +from app.routers import rbac_operator as rbac_operator_router +from app.routers import rbac_org as rbac_org_router +from app.routers import rbac_ecosystem as rbac_ecosystem_router +from app.routers import rbac_developer as rbac_developer_router +from app.routers import rbac_portals as rbac_portals_router +from app.routers import templates as templates_router + +APP_NAME = "云超服Agents 演示后端" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """启动时初始化 JSON 数据库(首次运行从 data/seed/ 建立数据文件)。 + + 若 ``app.state.db`` 已由外部注入(如测试夹具),则直接复用, + 不重新创建,避免覆盖调用方准备的数据库实例。 + """ + db = getattr(app.state, "db", None) + if db is None: + db = Database() + db.initialize() + app.state.db = db + yield + + +app = FastAPI( + title=APP_NAME, + description=( + "供 PineAgents 主后端认证转发的演示 FastAPI。" + "数据以本地 JSON 文件存储(见 data/ 目录)," + "下一阶段据此设计数据库。" + ), + version="0.1.0", + lifespan=lifespan, +) + +# 演示服务允许任意来源(实际生产由主后端网关收敛)。 +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router.router) +app.include_router(agents_router.router) +app.include_router(templates_router.router) +app.include_router(bootstrap_router.router) +app.include_router(rbac_admin_router.router) +app.include_router(rbac_government_router.router) +app.include_router(rbac_opc_router.router) +app.include_router(rbac_investor_router.router) +app.include_router(rbac_operator_router.router) +app.include_router(rbac_org_router.router) +app.include_router(rbac_portals_router.router) +app.include_router(rbac_ecosystem_router.router) +app.include_router(rbac_developer_router.router) + + +@app.get("/health", tags=["meta"], summary="健康检查") +async def health(): + return { + "status": "ok", + "service": "pineagents-demo-server", + "auth_enabled": config.AUTH_ENABLED, + } + + +@app.get("/", tags=["meta"], include_in_schema=False) +async def root(): + return { + "service": APP_NAME, + "docs": "/docs", + "health": "/health", + } diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..4e2c87c --- /dev/null +++ b/app/models.py @@ -0,0 +1,743 @@ +# -*- coding: utf-8 -*- +"""SQLAlchemy ORM 模型(auth/RBAC 域)+ Pydantic 请求/响应模型。 + +- ORM 模型对应 SQLite 各表(users/roles/permissions/organizations/regions/ + sessions/audit_logs/agents)。 +- Pydantic 模型与 PineAgents 主后端 ``src/pineagents/app/routers/auth.py`` + 中的请求/响应模型一一对应,转发层透传时不会丢字段。 +""" +from __future__ import annotations + +from pydantic import BaseModel, Field +from sqlalchemy import ( + Boolean, + Float, + ForeignKey, + Integer, + PrimaryKeyConstraint, + String, + Text, +) +from sqlalchemy.orm import Mapped, mapped_column + +from .db import Base + + +# =========================================================================== +# SQLAlchemy ORM 模型 +# =========================================================================== + +class User(Base): + __tablename__ = "users" + __table_args__ = {"sqlite_autoincrement": True} + + id: Mapped[str] = mapped_column(String, primary_key=True) + username: Mapped[str] = mapped_column(String, unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String, nullable=False) + password_salt: Mapped[str] = mapped_column(String, nullable=False) + nickname: Mapped[str] = mapped_column(String, default="") + account: Mapped[str] = mapped_column(String, default="") + company: Mapped[str] = mapped_column(String, default="") + room: Mapped[str] = mapped_column(String, default="") + avatar: Mapped[str] = mapped_column(String, default="") + company_avatar: Mapped[str] = mapped_column(String, default="") + # RBAC + role: Mapped[str] = mapped_column(String, default="opc_member") # 业务角色 + sub_role: Mapped[str] = mapped_column(String, nullable=True) # 政务级/运营方内部 + org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) + region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + status: Mapped[str] = mapped_column(String, default="active") # active | disabled + token_version: Mapped[int] = mapped_column(Integer, default=0) # revoke-all 递增 + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[str] = mapped_column(String, primary_key=True) # 角色码 + name: Mapped[str] = mapped_column(String, nullable=False) # 展示名 + scope_type: Mapped[str] = mapped_column(String, default="business") # business|operator-internal|government-level + description: Mapped[str] = mapped_column(String, default="") + + +class Permission(Base): + __tablename__ = "permissions" + + id: Mapped[str] = mapped_column(String, primary_key=True) # 权限码 menu:xx / action:xx + name: Mapped[str] = mapped_column(String, nullable=False) + category: Mapped[str] = mapped_column(String, default="action") # menu | action + module: Mapped[str] = mapped_column(String, default="") # admin|government|... + description: Mapped[str] = mapped_column(String, default="") + + +class RolePermission(Base): + __tablename__ = "role_permissions" + + role_id: Mapped[str] = mapped_column( + ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True, + ) + permission_id: Mapped[str] = mapped_column( + ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True, + ) + + +class Organization(Base): + __tablename__ = "organizations" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + type: Mapped[str] = mapped_column(String, default="enterprise") # enterprise|carrier|provider + region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + parent_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) + status: Mapped[str] = mapped_column(String, default="active") + created_at: Mapped[str] = mapped_column(String, default="") + + +class OrganizationMember(Base): + """机构成员(机构主账号 + 机构内子账号):一个账号可挂多个机构。""" + + __tablename__ = "organization_members" + + org_id: Mapped[str] = mapped_column( + ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True, + ) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, + ) + role: Mapped[str] = mapped_column(String, default="member") # admin/finance/publisher/service_executor/service_deliverer/clerk/... + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # 机构主账号/管理员 + status: Mapped[str] = mapped_column(String, default="active") + joined_at: Mapped[str] = mapped_column(String, default="") + + +class Region(Base): + __tablename__ = "regions" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + level: Mapped[str] = mapped_column(String, default="district") # province|city|district + parent_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + created_at: Mapped[str] = mapped_column(String, default="") + + +class SessionToken(Base): + __tablename__ = "sessions" + + id: Mapped[str] = mapped_column(String, primary_key=True) + jti: Mapped[str] = mapped_column(String, unique=True, nullable=False) + token: Mapped[str] = mapped_column(String, default="") # 完整 JWT(调试用) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + username: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + expires_at: Mapped[str] = mapped_column(String, default="") + revoked: Mapped[bool] = mapped_column(Boolean, default=False) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) + action: Mapped[str] = mapped_column(String, default="") # login|role.assign|data.view|... + resource: Mapped[str] = mapped_column(String, default="") # user|role|enterprise|region|... + resource_id: Mapped[str] = mapped_column(String, default="") + detail: Mapped[str] = mapped_column(Text, default="") + ip: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class Agent(Base): + __tablename__ = "agents" + # id + user_id + port 复合主键:每个用户在每个端口持有独立的默认/QA 智能体(多端口彻底隔离) + __table_args__ = (PrimaryKeyConstraint("id", "user_id", "port"),) + + id: Mapped[str] = mapped_column(String) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + port: Mapped[str] = mapped_column(String, default="opc") # opc/operator/government/... 端口隔离 + name: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + language: Mapped[str] = mapped_column(String, default="zh") + model_name: Mapped[str] = mapped_column(String, default="") + deletable: Mapped[bool] = mapped_column(Boolean, default=True) + use_fixed_soul: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +# ── 运营端业务模型(平台运营/任务/服务商/内容/配置)──────────────────────── + +class Task(Base): + """平台任务(运营端可审核/下架;后续各端口接入)。""" + + __tablename__ = "tasks" + + id: Mapped[str] = mapped_column(String, primary_key=True) + title: Mapped[str] = mapped_column(String, default="") + category: Mapped[str] = mapped_column(String, default="") # 设计创意/技术开发/... + sub_category: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + mode: Mapped[str] = mapped_column(String, default="grab") # grab/bid/designated/dispatch + budget_min: Mapped[int] = mapped_column(Integer, default=0) + budget_max: Mapped[int] = mapped_column(Integer, default=0) + deadline: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="draft") # draft/pending/review/published/completed/cancelled + publisher_org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) + publisher_name: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class ServiceProvider(Base): + """服务商(运营端审核/评级/考核)。""" + + __tablename__ = "service_providers" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="") + category: Mapped[str] = mapped_column(String, default="") # 财税/工商/法务/知识产权/云服务... + org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) + level: Mapped[str] = mapped_column(String, default="certified") # certified/premium/gold/official + rating: Mapped[float] = mapped_column(Float, default=5.0) + order_count: Mapped[int] = mapped_column(Integer, default=0) + status: Mapped[str] = mapped_column(String, default="pending") # pending/active/blacklisted + contact: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class ContentItem(Base): + """平台内容(政策/课程/资讯/公告/活动)。""" + + __tablename__ = "content_items" + + id: Mapped[str] = mapped_column(String, primary_key=True) + type: Mapped[str] = mapped_column(String, default="news") # policy/course/news/notice/activity + title: Mapped[str] = mapped_column(String, default="") + summary: Mapped[str] = mapped_column(String, default="") + body: Mapped[str] = mapped_column(Text, default="") + publisher_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) + status: Mapped[str] = mapped_column(String, default="draft") # draft/published/offline + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class SystemConfig(Base): + """平台系统配置(键值)。""" + + __tablename__ = "system_configs" + + key: Mapped[str] = mapped_column(String, primary_key=True) + value: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class PortalDashboard(Base): + """端口工作台聚合数据(按端口存 JSON 载荷,全部由服务端提供)。""" + + __tablename__ = "portal_dashboards" + + port: Mapped[str] = mapped_column(String, primary_key=True) + payload: Mapped[str] = mapped_column(Text, default="{}") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class PortalPage(Base): + """端口子页面数据(按 端口+页面 存 JSON 载荷,全部由服务端提供)。""" + + __tablename__ = "portal_pages" + + port: Mapped[str] = mapped_column(String, primary_key=True) + page: Mapped[str] = mapped_column(String, primary_key=True) + payload: Mapped[str] = mapped_column(Text, default="{}") + updated_at: Mapped[str] = mapped_column(String, default="") + + +# ── 投资人端(投融资 · 路演 · 培训)────────────────────────────────────── + +class InvestorPreference(Base): + """投资人投资方向偏好。""" + + __tablename__ = "investor_preferences" + + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, + ) + industries: Mapped[str] = mapped_column(String, default="[]") # JSON list + stage: Mapped[str] = mapped_column(String, default="") # seed/angel/series_a/... + amount_min: Mapped[int] = mapped_column(Integer, default=0) + amount_max: Mapped[int] = mapped_column(Integer, default=0) + region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + updated_at: Mapped[str] = mapped_column(String, default="") + + +class Roadshow(Base): + """投融资路演活动(线上/线下/混合),支持按发布主体与范围审核。""" + + __tablename__ = "roadshows" + + id: Mapped[str] = mapped_column(String, primary_key=True) + publisher_id: Mapped[str] = mapped_column(String, default="") + publisher_role: Mapped[str] = mapped_column(String, default="investor") # investor/government/operator/carrier/enterprise + title: Mapped[str] = mapped_column(String, default="") + summary: Mapped[str] = mapped_column(String, default="") + activity_type: Mapped[str] = mapped_column(String, default="online") # online/offline/hybrid + scope_type: Mapped[str] = mapped_column(String, default="all") # all/region/org + region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + start_at: Mapped[str] = mapped_column(String, default="") + end_at: Mapped[str] = mapped_column(String, default="") + register_deadline: Mapped[str] = mapped_column(String, default="") + quota: Mapped[int] = mapped_column(Integer, default=0) + venue: Mapped[str] = mapped_column(String, default="") # 线下地点/直播间 + live_url: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="draft") # draft/submitted/reviewing/published/registering/ongoing/ended/archived/rejected/cancelled + review_comment: Mapped[str] = mapped_column(String, default="") + need_review: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class RoadshowRegistration(Base): + """路演活动报名(项目方/投资人)。""" + + __tablename__ = "roadshow_registrations" + + id: Mapped[str] = mapped_column(String, primary_key=True) + roadshow_id: Mapped[str] = mapped_column( + ForeignKey("roadshows.id", ondelete="CASCADE"), nullable=False, + ) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + role: Mapped[str] = mapped_column(String, default="investor") # investor/project + status: Mapped[str] = mapped_column(String, default="applying") # applying/approved/attended/absent + note: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class InvestmentIntent(Base): + """投资意向/约谈/尽调。""" + + __tablename__ = "investment_intents" + + id: Mapped[str] = mapped_column(String, primary_key=True) + investor_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + project_id: Mapped[str] = mapped_column(String, default="") + project_name: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="interested") # interested/contacted/meeting/term_sheet/invested/declined + message: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class Bid(Base): + """任务竞标(企业端竞标模式发标)。""" + + __tablename__ = "bids" + + id: Mapped[str] = mapped_column(String, primary_key=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, + ) + opc_id: Mapped[str] = mapped_column(String, default="") + opc_name: Mapped[str] = mapped_column(String, default="") + quote: Mapped[int] = mapped_column(Integer, default=0) + plan: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="submitted") # submitted/win/lost + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class SubsidyApplication(Base): + """补贴申报(政务三级审批:区县→市→省→发放)。""" + + __tablename__ = "subsidy_applications" + + id: Mapped[str] = mapped_column(String, primary_key=True) + opc_id: Mapped[str] = mapped_column(String, default="") + opc_name: Mapped[str] = mapped_column(String, default="") + title: Mapped[str] = mapped_column(String, default="") + amount: Mapped[int] = mapped_column(Integer, default=0) + region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + status: Mapped[str] = mapped_column(String, default="applying") # applying/district_review/city_review/province_review/approved/paid/rejected + comment: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class ServiceReferral(Base): + """载体-服务商引荐(三方绑定:载体引荐服务商→服务园区内 OPC)。""" + + __tablename__ = "service_referrals" + + id: Mapped[str] = mapped_column(String, primary_key=True) + carrier_id: Mapped[str] = mapped_column(String, default="") + provider_id: Mapped[str] = mapped_column(String, default="") + provider_name: Mapped[str] = mapped_column(String, default="") + opc_id: Mapped[str] = mapped_column(String, default="") + opc_name: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="referred") # referred/accepted/completed/declined + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class TrainingEnrollment(Base): + """投融资培训报名/证书。""" + + __tablename__ = "training_enrollments" + + id: Mapped[str] = mapped_column(String, primary_key=True) + training_id: Mapped[str] = mapped_column(String, default="") + training_name: Mapped[str] = mapped_column(String, default="") + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + status: Mapped[str] = mapped_column(String, default="applying") # applying/enrolled/completed/certificate + created_at: Mapped[str] = mapped_column(String, default="") + + +# ── 横切能力:结算/合同/争议/评价/通知 ──────────────────────────────────── + +class Escrow(Base): + """任务资金托管/结算。""" + + __tablename__ = "escrows" + + id: Mapped[str] = mapped_column(String, primary_key=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, + ) + task_title: Mapped[str] = mapped_column(String, default="") + amount: Mapped[int] = mapped_column(Integer, default=0) + commission: Mapped[int] = mapped_column(Integer, default=0) # 平台佣金 + status: Mapped[str] = mapped_column(String, default="deposited") # deposited/frozen/released/refunded + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class Contract(Base): + """任务电子合同。""" + + __tablename__ = "contracts" + + id: Mapped[str] = mapped_column(String, primary_key=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, + ) + task_title: Mapped[str] = mapped_column(String, default="") + enterprise_id: Mapped[str] = mapped_column(String, default="") + opc_id: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="signed") # draft/signed/terminated + content: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class Dispute(Base): + """任务争议。""" + + __tablename__ = "disputes" + + id: Mapped[str] = mapped_column(String, primary_key=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, + ) + task_title: Mapped[str] = mapped_column(String, default="") + initiator: Mapped[str] = mapped_column(String, default="") + reason: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="opened") # opened/mediating/resolved/closed + resolution: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class Rating(Base): + """任务互评。""" + + __tablename__ = "ratings" + + id: Mapped[str] = mapped_column(String, primary_key=True) + task_id: Mapped[str] = mapped_column( + ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, + ) + from_id: Mapped[str] = mapped_column(String, default="") + to_id: Mapped[str] = mapped_column(String, default="") + score: Mapped[int] = mapped_column(Integer, default=5) # 1..5 + comment: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class Notification(Base): + """站内通知。""" + + __tablename__ = "notifications" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + type: Mapped[str] = mapped_column(String, default="system") # task/policy/order/subsidy/system + title: Mapped[str] = mapped_column(String, default="") + content: Mapped[str] = mapped_column(String, default="") + read: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(String, default="") + + +# ── OPC 超级个体业务模型(工作台/我的任务/财务/消息)──────────────────── + +class OpcProfile(Base): + """OPC 超级个体档案(信用评分等)。""" + + __tablename__ = "opc_profiles" + + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, + ) + credit_score: Mapped[int] = mapped_column(Integer, default=80) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class FinanceRecord(Base): + """OPC 财务流水(收入/支出)。""" + + __tablename__ = "finance_records" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + category: Mapped[str] = mapped_column(String, default="income") # income | expense + amount: Mapped[int] = mapped_column(Integer, default=0) + date: Mapped[str] = mapped_column(String, default="") # YYYY-MM-DD + note: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class Message(Base): + """OPC 站内消息。""" + + __tablename__ = "messages" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + msg_type: Mapped[str] = mapped_column(String, default="system") # task/policy/service/agent/system + title: Mapped[str] = mapped_column(String, default="") + content: Mapped[str] = mapped_column(String, default="") + read: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(String, default="") + + +class OpcTask(Base): + """OPC 个人任务看板(「我的任务」:进行中/已完成/紧急)。""" + + __tablename__ = "opc_tasks" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + title: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="in_progress") # in_progress/completed/urgent + budget: Mapped[int] = mapped_column(Integer, default=0) + deadline: Mapped[str] = mapped_column(String, default="") + progress: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class UserIdentity(Base): + """账号↔端口绑定身份:一个账号可绑定多个端口/身份。 + + 身份即权限来源:``port`` 为业务端口(operator/government/provider/ + enterprise/carrier/opc),``role``=端口角色码(默认等于 port,供 + require_roles 匹配),``sub_role`` 为端口内细分角色(如 op_super_admin、 + gov_province、企业/园区/服务商的 admin/operator、OPC 的 certified/ + independent 等)。一个账号对应多行,登录后按所选身份签发令牌。 + """ + + __tablename__ = "user_identities" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ) + port: Mapped[str] = mapped_column(String, default="opc") # operator/government/provider/enterprise/carrier/opc + role: Mapped[str] = mapped_column(String, default="opc_member") # RBAC 角色码 + sub_role: Mapped[str | None] = mapped_column(String, nullable=True) # 端口内细分角色 + name: Mapped[str] = mapped_column(String, default="") # 身份展示名 + org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True) + region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) + status: Mapped[str] = mapped_column(String, default="active") # active | disabled + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +# =========================================================================== +# Pydantic 请求/响应模型 +# =========================================================================== + +class LoginRequest(BaseModel): + username: str + password: str + expires_in: int | None = Field( + default=None, + description="令牌有效期(秒),-1/0 表示永久,省略为 7 天", + ) + + +class RegisterRequest(BaseModel): + username: str + password: str + expires_in: int | None = Field( + default=None, + description="令牌有效期(秒),-1/0 表示永久,省略为 7 天", + ) + + +class UpdateProfileRequest(BaseModel): + """更新资料/凭据。``current_password`` 仅在修改用户名/密码时校验。""" + + current_password: str = "" + new_username: str | None = None + new_password: str | None = None + expires_in: int | None = None + # 演示用户资料字段(账户管理页可编辑) + nickname: str | None = None + account: str | None = None + company: str | None = None + room: str | None = None + avatar: str | None = None + company_avatar: str | None = None + + +class RevokeTokenRequest(BaseModel): + token: str | None = Field( + default=None, + description="要吊销的令牌;省略则吊销当前令牌", + ) + + +class AgentCreate(BaseModel): + """创建智能体身份。""" + + name: str + description: str = "" + language: str = "zh" + model_name: str = "" + + +class AgentUpdate(BaseModel): + """更新智能体身份字段(可空字段,仅提交需要变更的项)。""" + + name: str | None = None + description: str | None = None + language: str | None = None + model_name: str | None = None + use_fixed_soul: bool | None = None + + +class AgentInfo(BaseModel): + """智能体身份记录(按用户隔离)。 + + - ``deletable``:是否允许删除(默认智能体由服务端保护为不可删)。 + - ``use_fixed_soul``:该智能体是否**必须**使用服务端托管、最高优先级的 + ``fixed_soul`` 不可变段(否则忽略)。 + """ + + id: str + user_id: str + port: str = "opc" + name: str + description: str = "" + language: str = "zh" + model_name: str = "" + created_at: str = "" + updated_at: str = "" + deletable: bool = True + use_fixed_soul: bool = False + + +class AuthStatusResponse(BaseModel): + enabled: bool + has_users: bool + + +class VerifyResponse(BaseModel): + valid: bool + username: str = "" + + +class UserProfile(BaseModel): + """用户资料(登录/me/update-profile 均返回该结构)。""" + + username: str = "" + nickname: str = "" + account: str = "" + company: str = "" + room: str = "" + avatar: str = "" + company_avatar: str = "" + # RBAC(登录/me 返回;update-profile 不重签时不返回权限明细) + role: str = "opc_member" + sub_role: str | None = None + org_id: str | None = None + region_id: str | None = None + permissions: list[str] = Field(default_factory=list) + scope_region_ids: list[str] = Field(default_factory=list) + # 当前生效的端口身份(多身份绑定) + identity_id: str | None = None + port: str | None = None + identity_name: str = "" + + +class LoginResponse(UserProfile): + """登录/注册成功后的响应:资料 + 新令牌。""" + + token: str = "" + identities: list[IdentityInfo] = Field(default_factory=list) + + +class ProfileResponse(UserProfile): + """update-profile 成功后的响应;修改凭据时附带新令牌。""" + + token: str = "" + identities: list[IdentityInfo] = Field(default_factory=list) + + +class IdentityInfo(BaseModel): + """账号绑定的一端口身份摘要(登录/选择身份时返回)。""" + + id: str + port: str = "opc" + role: str = "opc_member" + sub_role: str | None = None + name: str = "" + org_id: str | None = None + region_id: str | None = None + org_name: str | None = None + region_name: str | None = None + status: str = "active" + + +class SelectIdentityRequest(BaseModel): + """切换到指定端口身份:为新身份签发令牌。""" + + identity_id: str + + +# RBAC 管理请求/响应 +class SetUserRoleRequest(BaseModel): + role: str + sub_role: str | None = None + org_id: str | None = None + region_id: str | None = None + + +class SetUserStatusRequest(BaseModel): + status: str = "active" # active | disabled diff --git a/app/rbac.py b/app/rbac.py new file mode 100644 index 0000000..071581f --- /dev/null +++ b/app/rbac.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +"""RBAC 依赖工厂:角色 / 权限 / 数据范围守卫 + 审计写入。 + +依赖以 ``Depends(get_current_user)`` 为前置,返回已装配 role/权限/ +scope_region_ids 的当前用户 dict;不满足即抛 403。 +""" +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import Depends, HTTPException, Request + +from .dependencies import get_current_user +from .repositories import Database + +# 数据范围层级(越靠前权限越大) +_SCOPE_ORDER = ("province", "city", "district") + + +def require_roles(*roles: str) -> Callable: + """要求当前用户业务角色 ∈ roles;否则 403。""" + allowed = set(roles) + + def dep(user: dict = Depends(get_current_user)) -> dict: + if user.get("role") not in allowed: + raise HTTPException(status_code=403, detail="Forbidden: insufficient role") + return user + + return dep + + +def require_sub_roles(*sub_roles: str) -> Callable: + """要求当前用户子角色 ∈ sub_roles(如 op_super_admin);否则 403。""" + allowed = set(sub_roles) + + def dep(user: dict = Depends(get_current_user)) -> dict: + if user.get("sub_role") not in allowed: + raise HTTPException(status_code=403, detail="Forbidden: insufficient sub-role") + return user + + return dep + + +def require_permission(perm: str) -> Callable: + """要求当前用户拥有权限码 ``perm``;否则 403。""" + def dep(user: dict = Depends(get_current_user)) -> dict: + if perm not in user.get("permissions", []): + raise HTTPException(status_code=403, detail=f"Forbidden: missing permission {perm}") + return user + + return dep + + +def require_scope(min_level: str | None = None) -> Callable: + """政务数据范围守卫:要求用户为 government 且有区域,且不低于 min_level。""" + def dep(user: dict = Depends(get_current_user)) -> dict: + if user.get("role") != "government": + raise HTTPException(status_code=403, detail="Forbidden: not government") + if not user.get("region_id"): + raise HTTPException(status_code=403, detail="Forbidden: no region scope") + if min_level and user.get("scope_level"): + if _SCOPE_ORDER.index(user["scope_level"]) > _SCOPE_ORDER.index(min_level): + raise HTTPException( + status_code=403, + detail=f"Forbidden: scope below {min_level}", + ) + return user + + return dep + + +def scope_covers(region_id: str | None) -> Callable: + """要求 ``region_id`` 落在当前用户数据范围内;否则 403。""" + def dep(user: dict = Depends(get_current_user)) -> dict: + if not region_id: + raise HTTPException(status_code=403, detail="Forbidden: target has no region") + if region_id not in user.get("scope_region_ids", []): + raise HTTPException(status_code=403, detail="Forbidden: out of data scope") + return user + + return dep + + +def write_audit( + db: Database, + *, + action: str, + resource: str, + resource_id: str = "", + detail: str = "", + user: dict | None = None, + request: Request | None = None, +) -> None: + """写入一条审计日志。""" + ip = request.client.host if request is not None and request.client else "" + db.audit.add( + action=action, + resource=resource, + resource_id=resource_id, + detail=detail, + ip=ip, + user_id=(user or {}).get("id"), + ) diff --git a/app/repositories.py b/app/repositories.py new file mode 100644 index 0000000..b1eb740 --- /dev/null +++ b/app/repositories.py @@ -0,0 +1,1804 @@ +# -*- coding: utf-8 -*- +"""数据访问层(Repository):SQLAlchemy/SQLite 实现。 + +每个 Repository 对应一张表,只做行级操作,不掺入 HTTP/路由逻辑。 +方法签名与原先 JSON 实现保持一致,路由与业务逻辑不变。 +""" +from __future__ import annotations + +import json +import secrets +from datetime import datetime, timezone + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from . import config +from .models import ( + Agent, + AuditLog, + Bid, + ContentItem, + Contract, + Dispute, + Escrow, + FinanceRecord, + InvestmentIntent, + InvestorPreference, + Message, + Notification, + OpcProfile, + OpcTask, + Organization, + OrganizationMember, + Permission, + PortalDashboard, + PortalPage, + Rating, + Region, + Roadshow, + RoadshowRegistration, + Role, + RolePermission, + ServiceProvider, + ServiceReferral, + SessionToken, + SubsidyApplication, + SystemConfig, + Task, + TrainingEnrollment, + User, + UserIdentity, +) +from .security import hash_password, verify_password + +USER_TABLE = "users" +TOKEN_TABLE = "tokens" +AGENTS_TABLE = "agents" + +# 内置智能体身份(唯一来源在服务端;本地据此同步 workspace) +DEFAULT_AGENT_ID = "pine_agents_official_001" +QA_AGENT_ID = "pine_agents_official_002" +AGENT_SEED = ( + { + "id": DEFAULT_AGENT_ID, + "name": "小园", + "description": "默认助手,处理和园区、创业、政策等相关工作", + "language": "zh", + "model_name": "", + "template_type": "default", + "deletable": False, + "use_fixed_soul": True, + }, + { + "id": QA_AGENT_ID, + "name": "问答助手", + "description": ( + "内置 PineAgents 设置问答助手,本地配置在 `PINEAGENTS_WORKING_DIR` 下," + "并提供文档。建议在回答前阅读文件;在此工作区外使用绝对路径编写代码。" + ), + "language": "zh", + "model_name": "", + "template_type": "qa", + "deletable": True, + "use_fixed_soul": False, + }, +) + +PROFILE_FIELDS = ( + "username", + "nickname", + "account", + "company", + "room", + "avatar", + "company_avatar", +) + + +# --------------------------------------------------------------------------- +# 记录工具 +# --------------------------------------------------------------------------- +def utcnow_iso() -> str: + """当前 UTC 时间,ISO 8601 字符串(如 ``2026-08-02T12:00:00+00:00``)。""" + return datetime.now(timezone.utc).isoformat() + + +def new_id(prefix: str) -> str: + """生成记录主键,如 ``u_`` / ``sess_``。""" + return f"{prefix}_{secrets.token_hex(12)}" + + +# 业务角色 → 端口映射(与 rbac_admin._port_for_role 保持一致) +_PORT_FOR_ROLE = { + "opc_member": "opc", "carrier": "carrier", "enterprise": "enterprise", + "provider": "provider", "government": "government", "operator": "operator", + "investor": "investor", "developer": "developer", +} + + +def is_expired(expires_at: str) -> bool: + """判断 ``expires_at``(ISO 字符串)是否已过期。""" + try: + return datetime.fromisoformat(expires_at) < datetime.now(timezone.utc) + except (ValueError, TypeError): + return True + + +def _iso_in(seconds: int) -> str: + """返回当前时刻往后 ``seconds`` 秒的 ISO 时间戳。""" + return datetime.fromtimestamp( + datetime.now(timezone.utc).timestamp() + seconds, + tz=timezone.utc, + ).isoformat() + + +def _user_to_dict(u: User) -> dict: + return { + "id": u.id, + "username": u.username, + "password_hash": u.password_hash, + "password_salt": u.password_salt, + "nickname": u.nickname, + "account": u.account, + "company": u.company, + "room": u.room, + "avatar": u.avatar, + "company_avatar": u.company_avatar, + "role": u.role, + "sub_role": u.sub_role, + "org_id": u.org_id, + "region_id": u.region_id, + "status": u.status, + "token_version": u.token_version, + "created_at": u.created_at, + "updated_at": u.updated_at, + } + + +# --------------------------------------------------------------------------- +# 用户表 +# --------------------------------------------------------------------------- +class UserRepository: + def __init__(self, session: Session): + self.session = session + + def has_users(self) -> bool: + return self.session.scalar(select(User.id).limit(1)) is not None + + def get_by_username(self, username: str) -> dict | None: + u = self.session.scalar( + select(User).where(User.username.ilike(username.strip())), + ) + return _user_to_dict(u) if u else None + + def get_by_id(self, user_id: str) -> dict | None: + u = self.session.get(User, user_id) + return _user_to_dict(u) if u else None + + def list(self) -> list[dict]: + return [_user_to_dict(u) for u in self.session.scalars(select(User).order_by(User.created_at))] + + def create( + self, + username: str, + password: str, + *, + nickname: str = "", + account: str = "", + company: str = "", + room: str = "", + avatar: str = "", + company_avatar: str = "", + role: str = "opc_member", + sub_role: str | None = None, + org_id: str | None = None, + region_id: str | None = None, + ) -> dict: + digest, salt = hash_password(password) + now = utcnow_iso() + u = User( + id=new_id("u"), + username=username.strip(), + password_hash=digest, + password_salt=salt, + nickname=nickname, + account=account, + company=company, + room=room, + avatar=avatar, + company_avatar=company_avatar, + role=role, + sub_role=sub_role, + org_id=org_id, + region_id=region_id, + status="active", + token_version=0, + created_at=now, + updated_at=now, + ) + self.session.add(u) + self.session.commit() + return _user_to_dict(u) + + def update_profile(self, user_id: str, fields: dict) -> dict | None: + u = self.session.get(User, user_id) + if u is None: + return None + allowed = {k: fields[k] for k in fields if k in PROFILE_FIELDS} + if not allowed: + return None + for k, v in allowed.items(): + setattr(u, k, v) + u.updated_at = utcnow_iso() + self.session.commit() + return _user_to_dict(u) + + def update_credentials( + self, + user_id: str, + new_username: str | None, + new_password: str | None, + ) -> dict | None: + u = self.session.get(User, user_id) + if u is None: + return None + if new_username is not None: + u.username = new_username.strip() + if new_password is not None: + digest, salt = hash_password(new_password) + u.password_hash = digest + u.password_salt = salt + u.updated_at = utcnow_iso() + self.session.commit() + return _user_to_dict(u) + + def verify_password(self, user: dict, password: str) -> bool: + return verify_password( + password, + user.get("password_hash", ""), + user.get("password_salt", ""), + ) + + def set_role( + self, + user_id: str, + role: str, + sub_role: str | None, + org_id: str | None, + region_id: str | None, + ) -> dict | None: + u = self.session.get(User, user_id) + if u is None: + return None + # 同步该用户"主角色"身份(角色改变需对已绑定身份会话生效) + new_port = _PORT_FOR_ROLE.get(role, "opc") + for ident in self.session.scalars( + select(UserIdentity).where(UserIdentity.user_id == user_id) + ): + if ident.role == u.role: + ident.role = role + ident.sub_role = sub_role + ident.port = new_port + ident.org_id = org_id + ident.region_id = region_id + ident.updated_at = utcnow_iso() + u.role = role + u.sub_role = sub_role + u.org_id = org_id + u.region_id = region_id + u.token_version += 1 # 使既有令牌失效,角色变更即时生效 + u.updated_at = utcnow_iso() + self.session.commit() + return _user_to_dict(u) + + def set_status(self, user_id: str, status: str) -> dict | None: + u = self.session.get(User, user_id) + if u is None: + return None + u.status = status + u.updated_at = utcnow_iso() + self.session.commit() + return _user_to_dict(u) + + def bump_token_version(self, user_id: str) -> int: + u = self.session.get(User, user_id) + if u is None: + return 0 + u.token_version += 1 + u.updated_at = utcnow_iso() + self.session.commit() + return u.token_version + + def to_profile(self, user: dict) -> dict: + """把用户记录裁剪成对外暴露的资料结构(不含任何密码字段)。""" + profile = {field: user.get(field, "") for field in PROFILE_FIELDS} + profile["role"] = user.get("role", "opc_member") + profile["sub_role"] = user.get("sub_role") + profile["org_id"] = user.get("org_id") + profile["region_id"] = user.get("region_id") + profile["permissions"] = user.get("permissions", []) + profile["scope_region_ids"] = user.get("scope_region_ids", []) + profile["identity_id"] = user.get("identity_id") + profile["port"] = user.get("port") + profile["identity_name"] = user.get("identity_name", "") + return profile + + +# --------------------------------------------------------------------------- +# 令牌表(JWT + sessions 撤销台账) +# --------------------------------------------------------------------------- +class TokenRepository: + def __init__(self, session: Session): + self.session = session + + def create( + self, + user: dict, + *, + permissions: list[str], + scope_region_ids: list[str], + scope_level: str | None, + expiry_seconds: int, + identity_id: str | None = None, + ) -> dict: + """签发 JWT 并登记 sessions 行,返回 {token, id, user_id, ...}。""" + from .jwt import create_access_token + + token, jti, expires_at = create_access_token( + user, + permissions=permissions, + scope_region_ids=scope_region_ids, + scope_level=scope_level, + expiry_seconds=expiry_seconds, + identity_id=identity_id, + ) + now = utcnow_iso() + rec = SessionToken( + id=jti, + jti=jti, + token=token, + user_id=user["id"], + username=user.get("username", ""), + created_at=now, + expires_at=expires_at, + revoked=False, + ) + self.session.add(rec) + self.session.commit() + return {"id": jti, "token": token, "user_id": user["id"]} + + def get(self, token: str) -> dict | None: + """按 JWT 解码得到的 jti 查会话行。""" + from .jwt import decode_access_token + + payload = decode_access_token(token) + if not payload: + return None + rec = self.session.scalar( + select(SessionToken).where(SessionToken.jti == payload.get("jti")), + ) + if rec is None: + return None + return { + "id": rec.id, + "jti": rec.jti, + "token": rec.token, + "user_id": rec.user_id, + "username": rec.username, + "expires_at": rec.expires_at, + "revoked": rec.revoked, + } + + def session_valid(self, jti: str, user_id: str, ver: int) -> bool: + """JWT 声称的会话与用户版本是否仍有效。""" + rec = self.session.scalar( + select(SessionToken).where(SessionToken.jti == jti), + ) + if rec is None or rec.revoked or rec.user_id != user_id: + return False + if is_expired(rec.expires_at): + return False + u = self.session.get(User, user_id) + return u is not None and u.token_version == ver and u.status == "active" + + def get_valid_user_id(self, token: str) -> str | None: + """JWT 有效(验签、会话未吊销、版本一致、用户启用)时返回 user_id。""" + from .jwt import decode_access_token + + payload = decode_access_token(token) + if not payload: + return None + user_id = payload.get("sub") + if not self.session_valid(payload.get("jti", ""), user_id, payload.get("ver", 0)): + return None + return user_id + + def revoke(self, token: str) -> bool: + rec = self.get(token) + if rec is None: + return False + row = self.session.get(SessionToken, rec["id"]) + if row is None: + return False + row.revoked = True + self.session.commit() + return True + + def revoke_all(self, user_id: str | None = None) -> int: + """吊销会话(指定用户或全部),并递增其 token_version 使旧 JWT 失效。""" + if user_id is not None: + affected = self.session.execute( + delete(SessionToken).where(SessionToken.user_id == user_id) + ).rowcount + u = self.session.get(User, user_id) + if u is not None: + u.token_version += 1 + self.session.commit() + return affected + affected = self.session.execute(delete(SessionToken)).rowcount + for u in self.session.scalars(select(User)): + u.token_version += 1 + self.session.commit() + return affected + + +# --------------------------------------------------------------------------- +# 角色 / 权限 +# --------------------------------------------------------------------------- +class RoleRepository: + def __init__(self, session: Session): + self.session = session + + def list_roles(self) -> list[dict]: + matrix = self.role_matrix() + return [ + { + "id": r.id, "name": r.name, "scope_type": r.scope_type, + "description": r.description, "permissions": matrix.get(r.id, []), + } + for r in self.session.scalars(select(Role).order_by(Role.scope_type, Role.id)) + ] + + def list_permissions(self) -> list[dict]: + return [ + {"id": p.id, "name": p.name, "category": p.category, "module": p.module} + for p in self.session.scalars(select(Permission).order_by(Permission.module, Permission.id)) + ] + + def permissions_for(self, role: str, sub_role: str | None) -> list[str]: + """返回该用户(业务角色 + 可选子角色)的全部权限码。 + + 合并 role 与 ``role|sub_role`` 两个角色的权限。 + """ + ids = [role] + if sub_role: + ids.append(f"{role}|{sub_role}") + return sorted( + { + pid + for pid, in self.session.execute( + select(RolePermission.permission_id).where( + RolePermission.role_id.in_(ids) + ) + ) + } + ) + + def role_matrix(self) -> dict[str, list[str]]: + rows = self.session.execute( + select(RolePermission.role_id, RolePermission.permission_id) + ).all() + matrix: dict[str, list[str]] = {} + for role_id, perm_id in rows: + matrix.setdefault(role_id, []).append(perm_id) + return matrix + + def role_exists(self, role_id: str) -> bool: + return self.session.get(Role, role_id) is not None + + def set_role_permissions(self, role_id: str, permissions: list[str]) -> list[str]: + """重设某角色的权限集合(角色权限配置,仅超管)。""" + self.session.execute( + delete(RolePermission).where(RolePermission.role_id == role_id) + ) + for pid in permissions: + if self.session.get(Permission, pid) is not None: + self.session.add(RolePermission(role_id=role_id, permission_id=pid)) + self.session.commit() + return self.permissions_for_role(role_id) + + def permissions_for_role(self, role_id: str) -> list[str]: + if "|" in role_id: + role, sub = role_id.split("|", 1) + return self.permissions_for(role, sub) + return self.permissions_for(role_id, None) + + +# --------------------------------------------------------------------------- +# 区域(数据范围层级) +# --------------------------------------------------------------------------- +class RegionRepository: + def __init__(self, session: Session): + self.session = session + + def get(self, region_id: str) -> dict | None: + r = self.session.get(Region, region_id) + return {"id": r.id, "name": r.name, "level": r.level, "parent_id": r.parent_id} if r else None + + def all(self) -> list[dict]: + return [ + {"id": r.id, "name": r.name, "level": r.level, "parent_id": r.parent_id} + for r in self.session.scalars(select(Region)) + ] + + def level(self, region_id: str) -> str | None: + r = self.session.get(Region, region_id) + return r.level if r else None + + def _descendants(self, region_id: str) -> set[str]: + """收集某区域的全部后代 id(含自身)。""" + result = {region_id} + rows = self.session.execute( + select(Region.id, Region.parent_id) + ).all() + children: dict[str, list[str]] = {} + for rid, parent in rows: + if parent: + children.setdefault(parent, []).append(rid) + stack = list(children.get(region_id, [])) + while stack: + cur = stack.pop() + if cur in result: + continue + result.add(cur) + stack.extend(children.get(cur, [])) + return result + + def visible_region_ids(self, region_id: str | None) -> list[str]: + """数据范围:本域 + 全部后代(上级可看下级)。无区域则返回空。""" + if not region_id: + return [] + return sorted(self._descendants(region_id)) + + +# --------------------------------------------------------------------------- +# 组织(企业 / 载体 / 服务商) +# --------------------------------------------------------------------------- +class OrgRepository: + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, o: Organization) -> dict: + return { + "id": o.id, + "name": o.name, + "type": o.type, + "region_id": o.region_id, + "parent_id": o.parent_id, + "status": o.status, + "created_at": o.created_at, + } + + def get(self, org_id: str) -> dict | None: + o = self.session.get(Organization, org_id) + return self._to_dict(o) if o else None + + def all(self) -> list[dict]: + return [self._to_dict(o) for o in self.session.scalars(select(Organization))] + + def list_by_region(self, region_ids: list[str]) -> list[dict]: + if not region_ids: + return [] + return [ + self._to_dict(o) + for o in self.session.scalars( + select(Organization).where(Organization.region_id.in_(region_ids)) + ) + ] + + def list_by_parent(self, parent_id: str) -> list[dict]: + return [ + self._to_dict(o) + for o in self.session.scalars( + select(Organization).where(Organization.parent_id == parent_id) + ) + ] + + +# --------------------------------------------------------------------------- +# 审计日志 +# --------------------------------------------------------------------------- +class AuditRepository: + def __init__(self, session: Session): + self.session = session + + def add(self, *, action: str, resource: str, resource_id: str = "", + detail: str = "", ip: str = "", user_id: str | None = None) -> None: + self.session.add( + AuditLog( + id=new_id("aud"), + user_id=user_id, + action=action, + resource=resource, + resource_id=resource_id, + detail=detail, + ip=ip, + created_at=utcnow_iso(), + ) + ) + self.session.commit() + + def list(self, limit: int = 100, offset: int = 0) -> list[dict]: + rows = self.session.scalars( + select(AuditLog).order_by(AuditLog.created_at.desc()).limit(limit).offset(offset) + ) + return [ + { + "id": a.id, + "user_id": a.user_id, + "action": a.action, + "resource": a.resource, + "resource_id": a.resource_id, + "detail": a.detail, + "ip": a.ip, + "created_at": a.created_at, + } + for a in rows + ] + + +# --------------------------------------------------------------------------- +# 智能体表(身份记录;按用户隔离) +# --------------------------------------------------------------------------- +class AgentRepository: + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, a: Agent) -> dict: + return { + "id": a.id, + "user_id": a.user_id, + "port": a.port, + "name": a.name, + "description": a.description, + "language": a.language, + "model_name": a.model_name, + "deletable": a.deletable, + "use_fixed_soul": a.use_fixed_soul, + "created_at": a.created_at, + "updated_at": a.updated_at, + } + + def get_by_user(self, user_id: str, port: str | None = None) -> list[dict]: + if not port: + return [] + stmt = select(Agent).where(Agent.user_id == user_id, Agent.port == port) + rows = self.session.scalars(stmt.order_by(Agent.created_at)) + return [self._to_dict(a) for a in rows] + + def get(self, agent_id: str, user_id: str, port: str | None = None) -> dict | None: + if not port: + return None + stmt = select(Agent).where(Agent.id == agent_id, Agent.user_id == user_id, Agent.port == port) + a = self.session.scalar(stmt) + return self._to_dict(a) if a else None + + def count_by_user(self, user_id: str, port: str | None = None) -> int: + if not port: + return 0 + stmt = select(Agent.id).where(Agent.user_id == user_id, Agent.port == port) + return len(list(self.session.scalars(stmt))) + + def create( + self, + user_id: str, + name: str, + *, + description: str = "", + language: str = "zh", + model_name: str = "", + agent_id: str | None = None, + port: str = "opc", + ) -> dict: + now = utcnow_iso() + a = Agent( + id=agent_id or new_id("agent"), + user_id=user_id, + port=port, + name=name.strip(), + description=description, + language=language, + model_name=model_name, + deletable=True, + use_fixed_soul=False, + created_at=now, + updated_at=now, + ) + self.session.add(a) + self.session.commit() + return self._to_dict(a) + + def update(self, agent_id: str, user_id: str, fields: dict, port: str | None = None) -> dict | None: + if not port: + return None + stmt = select(Agent).where(Agent.id == agent_id, Agent.user_id == user_id, Agent.port == port) + a = self.session.scalar(stmt) + if a is None: + return None + allowed = { + k: fields[k] + for k in fields + if k in ("name", "description", "language", "model_name", "use_fixed_soul") + } + if not allowed: + return None + for k, v in allowed.items(): + setattr(a, k, v) + a.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(a) + + def delete(self, agent_id: str, user_id: str, port: str | None = None) -> bool: + if not port: + return False + stmt = select(Agent).where(Agent.id == agent_id, Agent.user_id == user_id, Agent.port == port) + a = self.session.scalar(stmt) + if a is None: + return False + self.session.delete(a) + self.session.commit() + return True + + +# --------------------------------------------------------------------------- +# 任务 +# --------------------------------------------------------------------------- +class TaskRepository: + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, t: Task) -> dict: + return { + "id": t.id, "title": t.title, "category": t.category, + "sub_category": t.sub_category, "description": t.description, + "mode": t.mode, "budget_min": t.budget_min, "budget_max": t.budget_max, + "deadline": t.deadline, "status": t.status, + "publisher_org_id": t.publisher_org_id, "publisher_name": t.publisher_name, + "created_at": t.created_at, "updated_at": t.updated_at, + } + + def list(self, status: str | None = None) -> list[dict]: + q = select(Task).order_by(Task.created_at.desc()) + if status: + q = q.where(Task.status == status) + return [self._to_dict(t) for t in self.session.scalars(q)] + + def get(self, task_id: str) -> dict | None: + t = self.session.get(Task, task_id) + return self._to_dict(t) if t else None + + def create(self, fields: dict) -> dict: + now = utcnow_iso() + t = Task(id=new_id("task"), title=fields.get("title", ""), + category=fields.get("category", ""), sub_category=fields.get("sub_category", ""), + description=fields.get("description", ""), mode=fields.get("mode", "grab"), + budget_min=fields.get("budget_min", 0), budget_max=fields.get("budget_max", 0), + deadline=fields.get("deadline", ""), status=fields.get("status", "draft"), + publisher_org_id=fields.get("publisher_org_id"), + publisher_name=fields.get("publisher_name", ""), + created_at=now, updated_at=now) + self.session.add(t) + self.session.commit() + return self._to_dict(t) + + def set_status(self, task_id: str, status: str) -> dict | None: + t = self.session.get(Task, task_id) + if t is None: + return None + t.status = status + t.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(t) + + +# --------------------------------------------------------------------------- +# 服务商 +# --------------------------------------------------------------------------- +class ProviderRepository: + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, p: ServiceProvider) -> dict: + return { + "id": p.id, "name": p.name, "category": p.category, + "org_id": p.org_id, "level": p.level, "rating": p.rating, + "order_count": p.order_count, "status": p.status, + "contact": p.contact, "created_at": p.created_at, "updated_at": p.updated_at, + } + + def list(self, status: str | None = None) -> list[dict]: + q = select(ServiceProvider).order_by(ServiceProvider.created_at.desc()) + if status: + q = q.where(ServiceProvider.status == status) + return [self._to_dict(p) for p in self.session.scalars(q)] + + def create(self, fields: dict) -> dict: + now = utcnow_iso() + p = ServiceProvider(id=new_id("prov"), name=fields.get("name", ""), + category=fields.get("category", ""), org_id=fields.get("org_id"), + level=fields.get("level", "certified"), rating=fields.get("rating", 5.0), + order_count=fields.get("order_count", 0), + status=fields.get("status", "pending"), + contact=fields.get("contact", ""), created_at=now, updated_at=now) + self.session.add(p) + self.session.commit() + return self._to_dict(p) + + def update(self, provider_id: str, fields: dict) -> dict | None: + p = self.session.get(ServiceProvider, provider_id) + if p is None: + return None + for k in ("name", "category", "level", "status", "contact"): + if k in fields: + setattr(p, k, fields[k]) + if "rating" in fields: + p.rating = fields["rating"] + p.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(p) + + +# --------------------------------------------------------------------------- +# 内容 +# --------------------------------------------------------------------------- +class ContentRepository: + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, c: ContentItem) -> dict: + return { + "id": c.id, "type": c.type, "title": c.title, "summary": c.summary, + "body": c.body, "publisher_id": c.publisher_id, "status": c.status, + "created_at": c.created_at, "updated_at": c.updated_at, + } + + def list(self, ctype: str | None = None, status: str | None = None) -> list[dict]: + q = select(ContentItem).order_by(ContentItem.created_at.desc()) + if ctype: + q = q.where(ContentItem.type == ctype) + if status: + q = q.where(ContentItem.status == status) + return [self._to_dict(c) for c in self.session.scalars(q)] + + def create(self, fields: dict) -> dict: + now = utcnow_iso() + c = ContentItem(id=new_id("cont"), type=fields.get("type", "news"), + title=fields.get("title", ""), summary=fields.get("summary", ""), + body=fields.get("body", ""), publisher_id=fields.get("publisher_id"), + status=fields.get("status", "draft"), created_at=now, updated_at=now) + self.session.add(c) + self.session.commit() + return self._to_dict(c) + + def set_status(self, content_id: str, status: str) -> dict | None: + c = self.session.get(ContentItem, content_id) + if c is None: + return None + c.status = status + c.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(c) + + +# --------------------------------------------------------------------------- +# 系统配置 +# --------------------------------------------------------------------------- +class ConfigRepository: + def __init__(self, session: Session): + self.session = session + + def all(self) -> list[dict]: + return [ + {"key": c.key, "value": c.value, "description": c.description, + "updated_at": c.updated_at} + for c in self.session.scalars(select(SystemConfig).order_by(SystemConfig.key)) + ] + + def get(self, key: str) -> str | None: + c = self.session.get(SystemConfig, key) + return c.value if c else None + + def set(self, key: str, value: str, description: str = "") -> dict: + c = self.session.get(SystemConfig, key) + if c is None: + c = SystemConfig(key=key, value=value, description=description, updated_at=utcnow_iso()) + self.session.add(c) + else: + c.value = value + if description: + c.description = description + c.updated_at = utcnow_iso() + self.session.commit() + return {"key": c.key, "value": c.value, "description": c.description, "updated_at": c.updated_at} + + +# --------------------------------------------------------------------------- +# OPC 超级个体(工作台聚合数据) +# --------------------------------------------------------------------------- +class OpcProfileRepository: + """OPC 档案(信用评分等)。""" + + def __init__(self, session: Session): + self.session = session + + def get(self, user_id: str) -> dict | None: + row = self.session.get(OpcProfile, user_id) + return self._to_dict(row) if row else None + + def upsert(self, user_id: str, credit_score: int = 80) -> dict: + row = self.session.get(OpcProfile, user_id) + if row is None: + row = OpcProfile( + user_id=user_id, credit_score=credit_score, + created_at=utcnow_iso(), updated_at=utcnow_iso(), + ) + self.session.add(row) + else: + row.credit_score = credit_score + row.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(p: OpcProfile) -> dict: + return { + "user_id": p.user_id, "credit_score": p.credit_score, + "updated_at": p.updated_at, + } + + +class FinanceRepository: + """OPC 财务流水(收入/支出)与月度聚合。""" + + def __init__(self, session: Session): + self.session = session + + def list_by_user(self, user_id: str, category: str | None = None) -> list[dict]: + stmt = select(FinanceRecord).where(FinanceRecord.user_id == user_id) + if category: + stmt = stmt.where(FinanceRecord.category == category) + rows = self.session.scalars(stmt.order_by(FinanceRecord.date.desc())).all() + return [self._to_dict(f) for f in rows] + + def total_income(self, user_id: str) -> int: + return sum( + f.amount + for f in self.session.scalars( + select(FinanceRecord).where( + FinanceRecord.user_id == user_id, + FinanceRecord.category == "income", + ) + ) + ) + + def monthly_income(self, user_id: str, limit: int = 6) -> list[dict]: + """按 YYYY-MM 汇总收入,取最近 N 个月(升序返回)。""" + agg: dict[str, int] = {} + for f in self.session.scalars( + select(FinanceRecord).where( + FinanceRecord.user_id == user_id, + FinanceRecord.category == "income", + ) + ): + month = (f.date or "")[:7] + if not month: + continue + agg[month] = agg.get(month, 0) + f.amount + months = sorted(agg.keys())[-limit:] + return [{"month": m, "amount": agg[m]} for m in months] + + @staticmethod + def _to_dict(f: FinanceRecord) -> dict: + return { + "id": f.id, "user_id": f.user_id, "category": f.category, + "amount": f.amount, "date": f.date, "note": f.note, + "created_at": f.created_at, + } + + +class MessageRepository: + """OPC 站内消息。""" + + def __init__(self, session: Session): + self.session = session + + def recent(self, user_id: str, limit: int = 4) -> list[dict]: + rows = self.session.scalars( + select(Message) + .where(Message.user_id == user_id) + .order_by(Message.created_at.desc()) + .limit(limit) + ).all() + return [self._to_dict(m) for m in rows] + + def list_by_user(self, user_id: str) -> list[dict]: + rows = self.session.scalars( + select(Message) + .where(Message.user_id == user_id) + .order_by(Message.created_at.desc()) + ).all() + return [self._to_dict(m) for m in rows] + + def unread_count(self, user_id: str) -> int: + return len( + self.session.scalars( + select(Message).where(Message.user_id == user_id, Message.read.is_(False)) + ).all() + ) + + @staticmethod + def _to_dict(m: Message) -> dict: + return { + "id": m.id, "user_id": m.user_id, "msg_type": m.msg_type, + "title": m.title, "content": m.content, "read": m.read, + "created_at": m.created_at, + } + + +class OpcTaskRepository: + """OPC 个人任务看板。""" + + def __init__(self, session: Session): + self.session = session + + def list_by_user(self, user_id: str) -> list[dict]: + rows = self.session.scalars( + select(OpcTask) + .where(OpcTask.user_id == user_id) + .order_by(OpcTask.created_at.desc()) + ).all() + return [self._to_dict(t) for t in rows] + + def counts(self, user_id: str) -> dict: + in_progress = 0 + completed = 0 + for row in self.session.scalars( + select(OpcTask).where(OpcTask.user_id == user_id) + ): + if row.status == "completed": + completed += 1 + elif row.status in ("in_progress", "urgent"): + in_progress += 1 + return {"in_progress": in_progress, "completed": completed} + + @staticmethod + def _to_dict(t: OpcTask) -> dict: + return { + "id": t.id, "user_id": t.user_id, "title": t.title, "status": t.status, + "budget": t.budget, "deadline": t.deadline, "progress": t.progress, + "created_at": t.created_at, "updated_at": t.updated_at, + } + + +# --------------------------------------------------------------------------- +# 账号↔端口身份(多身份绑定) +# --------------------------------------------------------------------------- +class IdentityRepository: + """一个账号可绑定的多个端口身份(登录后选择其一进入)。""" + + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, i: UserIdentity) -> dict: + org = self.session.get(Organization, i.org_id) if i.org_id else None + region = self.session.get(Region, i.region_id) if i.region_id else None + return { + "id": i.id, + "user_id": i.user_id, + "port": i.port, + "role": i.role, + "sub_role": i.sub_role, + "name": i.name, + "org_id": i.org_id, + "region_id": i.region_id, + "org_name": org.name if org else None, + "region_name": region.name if region else None, + "status": i.status, + "created_at": i.created_at, + "updated_at": i.updated_at, + } + + def get(self, identity_id: str) -> dict | None: + row = self.session.get(UserIdentity, identity_id) + return self._to_dict(row) if row else None + + def get_for_user(self, identity_id: str, user_id: str) -> dict | None: + row = self.session.scalar( + select(UserIdentity).where( + UserIdentity.id == identity_id, + UserIdentity.user_id == user_id, + ) + ) + return self._to_dict(row) if row else None + + def list_for_user(self, user_id: str, active_only: bool = True) -> list[dict]: + stmt = select(UserIdentity).where(UserIdentity.user_id == user_id) + if active_only: + stmt = stmt.where(UserIdentity.status == "active") + rows = self.session.scalars(stmt.order_by(UserIdentity.port)).all() + return [self._to_dict(i) for i in rows] + + def create( + self, + user_id: str, + *, + port: str, + role: str | None = None, + sub_role: str | None = None, + name: str = "", + org_id: str | None = None, + region_id: str | None = None, + ) -> dict: + now = utcnow_iso() + row = UserIdentity( + id=new_id("ident"), + user_id=user_id, + port=port, + role=role or port, + sub_role=sub_role, + name=name, + org_id=org_id, + region_id=region_id, + status="active", + created_at=now, + updated_at=now, + ) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + def set_status(self, identity_id: str, status: str) -> dict | None: + row = self.session.get(UserIdentity, identity_id) + if row is None: + return None + row.status = status + row.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(row) + + +# --------------------------------------------------------------------------- +# 端口工作台(按端口 JSON 载荷) +# --------------------------------------------------------------------------- +class PortalDashboardRepository: + def __init__(self, session: Session): + self.session = session + + def get(self, port: str) -> dict | None: + row = self.session.get(PortalDashboard, port) + if row is None: + return None + try: + payload = json.loads(row.payload) + except (ValueError, TypeError): + return {} + payload["port"] = row.port + payload["updated_at"] = row.updated_at + return payload + + def set(self, port: str, payload: dict) -> dict: + row = self.session.get(PortalDashboard, port) + now = utcnow_iso() + data = json.dumps(payload, ensure_ascii=False) + if row is None: + row = PortalDashboard(port=port, payload=data, updated_at=now) + self.session.add(row) + else: + row.payload = data + row.updated_at = now + self.session.commit() + return self.get(port) + + +class PortalPageRepository: + """端口子页面数据(按 端口+页面 存 JSON 载荷)。""" + + def __init__(self, session: Session): + self.session = session + + def get(self, port: str, page: str) -> dict | None: + row = self.session.get(PortalPage, (port, page)) + if row is None: + return None + try: + return json.loads(row.payload) + except (ValueError, TypeError): + return {} + + def set(self, port: str, page: str, payload: dict) -> dict: + row = self.session.get(PortalPage, (port, page)) + now = utcnow_iso() + data = json.dumps(payload, ensure_ascii=False) + if row is None: + row = PortalPage(port=port, page=page, payload=data, updated_at=now) + self.session.add(row) + else: + row.payload = data + row.updated_at = now + self.session.commit() + return self.get(port, page) + + +# --------------------------------------------------------------------------- +# 投资人端(偏好 / 路演 / 报名 / 意向) +# --------------------------------------------------------------------------- +class InvestorPreferenceRepository: + def __init__(self, session: Session): + self.session = session + + def get(self, user_id: str) -> dict | None: + row = self.session.get(InvestorPreference, user_id) + if row is None: + return None + return { + "user_id": row.user_id, + "industries": json.loads(row.industries or "[]"), + "stage": row.stage, + "amount_min": row.amount_min, + "amount_max": row.amount_max, + "region_id": row.region_id, + "updated_at": row.updated_at, + } + + def upsert(self, user_id: str, fields: dict) -> dict: + row = self.session.get(InvestorPreference, user_id) + now = utcnow_iso() + if row is None: + row = InvestorPreference(user_id=user_id, updated_at=now) + self.session.add(row) + if "industries" in fields: + row.industries = json.dumps(fields["industries"], ensure_ascii=False) + if "stage" in fields: + row.stage = fields["stage"] or "" + if "amount_min" in fields: + row.amount_min = fields["amount_min"] or 0 + if "amount_max" in fields: + row.amount_max = fields["amount_max"] or 0 + if "region_id" in fields: + row.region_id = fields["region_id"] + row.updated_at = now + self.session.commit() + return self.get(user_id) + + +class RoadshowRepository: + def __init__(self, session: Session): + self.session = session + + def _to_dict(self, r: Roadshow) -> dict: + region = self.session.get(Region, r.region_id) if r.region_id else None + return { + "id": r.id, "publisher_id": r.publisher_id, "publisher_role": r.publisher_role, + "title": r.title, "summary": r.summary, "activity_type": r.activity_type, + "scope_type": r.scope_type, "region_id": r.region_id, + "region_name": region.name if region else None, + "start_at": r.start_at, "end_at": r.end_at, "register_deadline": r.register_deadline, + "quota": r.quota, "venue": r.venue, "live_url": r.live_url, + "status": r.status, "need_review": r.need_review, + "review_comment": r.review_comment, "created_at": r.created_at, "updated_at": r.updated_at, + } + + def list(self, status: str | None = None) -> list[dict]: + stmt = select(Roadshow).order_by(Roadshow.created_at.desc()) + if status: + stmt = stmt.where(Roadshow.status == status) + return [self._to_dict(r) for r in self.session.scalars(stmt)] + + def get(self, roadshow_id: str) -> dict | None: + row = self.session.get(Roadshow, roadshow_id) + return self._to_dict(row) if row else None + + def create(self, fields: dict) -> dict: + now = utcnow_iso() + row = Roadshow( + id=new_id("rs"), publisher_id=fields.get("publisher_id", ""), + publisher_role=fields.get("publisher_role", "investor"), + title=fields.get("title", ""), summary=fields.get("summary", ""), + activity_type=fields.get("activity_type", "online"), + scope_type=fields.get("scope_type", "all"), + region_id=fields.get("region_id"), + start_at=fields.get("start_at", ""), end_at=fields.get("end_at", ""), + register_deadline=fields.get("register_deadline", ""), + quota=fields.get("quota", 0), venue=fields.get("venue", ""), + live_url=fields.get("live_url", ""), + status=fields.get("status", "draft"), + need_review=fields.get("need_review", True), + created_at=now, updated_at=now, + ) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + def set_status(self, roadshow_id: str, status: str, review_comment: str = "") -> dict | None: + row = self.session.get(Roadshow, roadshow_id) + if row is None: + return None + row.status = status + if review_comment: + row.review_comment = review_comment + row.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(row) + + +class RoadshowRegistrationRepository: + def __init__(self, session: Session): + self.session = session + + def create(self, roadshow_id: str, user_id: str, role: str = "investor", note: str = "") -> dict: + row = RoadshowRegistration( + id=new_id("rsreg"), roadshow_id=roadshow_id, user_id=user_id, + role=role, status="applying", note=note, created_at=utcnow_iso(), + ) + self.session.add(row) + self.session.commit() + return {"id": row.id, "roadshow_id": row.roadshow_id, "user_id": row.user_id, + "role": row.role, "status": row.status, "note": row.note} + + +class InvestmentIntentRepository: + def __init__(self, session: Session): + self.session = session + + def create(self, investor_id: str, project_id: str, project_name: str, message: str = "") -> dict: + row = InvestmentIntent( + id=new_id("intent"), investor_id=investor_id, project_id=project_id, + project_name=project_name, status="interested", message=message, + created_at=utcnow_iso(), updated_at=utcnow_iso(), + ) + self.session.add(row) + self.session.commit() + return {"id": row.id, "investor_id": row.investor_id, "project_id": row.project_id, + "project_name": row.project_name, "status": row.status, "message": row.message} + + def list_for(self, investor_id: str) -> list[dict]: + rows = self.session.scalars( + select(InvestmentIntent).where(InvestmentIntent.investor_id == investor_id) + .order_by(InvestmentIntent.created_at.desc()) + ).all() + return [{"id": r.id, "project_id": r.project_id, "project_name": r.project_name, + "status": r.status, "message": r.message, "created_at": r.created_at} for r in rows] + + +class BidRepository: + """任务竞标。""" + + def __init__(self, session: Session): + self.session = session + + def list_for_task(self, task_id: str) -> list[dict]: + rows = self.session.scalars( + select(Bid).where(Bid.task_id == task_id).order_by(Bid.created_at.desc()) + ).all() + return [self._to_dict(r) for r in rows] + + def get(self, bid_id: str) -> dict | None: + row = self.session.get(Bid, bid_id) + return self._to_dict(row) if row else None + + def create(self, task_id: str, opc_id: str, opc_name: str, quote: int, plan: str = "") -> dict: + row = Bid( + id=new_id("bid"), task_id=task_id, opc_id=opc_id, opc_name=opc_name, + quote=quote, plan=plan, status="submitted", + created_at=utcnow_iso(), updated_at=utcnow_iso(), + ) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + def set_status(self, bid_id: str, status: str) -> dict | None: + row = self.session.get(Bid, bid_id) + if row is None: + return None + row.status = status + row.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(b: Bid) -> dict: + return {"id": b.id, "task_id": b.task_id, "opc_id": b.opc_id, "opc_name": b.opc_name, + "quote": b.quote, "plan": b.plan, "status": b.status, + "created_at": b.created_at, "updated_at": b.updated_at} + + +class SubsidyRepository: + """补贴申报(政务三级审批)。""" + + def __init__(self, session: Session): + self.session = session + + def list(self, region_ids: list[str] | None = None) -> list[dict]: + rows = self.session.scalars(select(SubsidyApplication).order_by(SubsidyApplication.created_at.desc())) + out = [] + for r in rows: + if region_ids and r.region_id and r.region_id not in region_ids: + continue + out.append(self._to_dict(r)) + return out + + def get(self, aid: str) -> dict | None: + row = self.session.get(SubsidyApplication, aid) + return self._to_dict(row) if row else None + + def create(self, fields: dict) -> dict: + now = utcnow_iso() + row = SubsidyApplication( + id=new_id("sub"), opc_id=fields.get("opc_id", ""), opc_name=fields.get("opc_name", ""), + title=fields.get("title", ""), amount=fields.get("amount", 0), + region_id=fields.get("region_id"), status="applying", + created_at=now, updated_at=now, + ) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + def set_status(self, aid: str, status: str, comment: str = "") -> dict | None: + row = self.session.get(SubsidyApplication, aid) + if row is None: + return None + row.status = status + if comment: + row.comment = comment + row.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(s: SubsidyApplication) -> dict: + return {"id": s.id, "opc_id": s.opc_id, "opc_name": s.opc_name, "title": s.title, + "amount": s.amount, "region_id": s.region_id, "status": s.status, + "comment": s.comment, "created_at": s.created_at} + + +class ServiceReferralRepository: + """载体-服务商引荐。""" + + def __init__(self, session: Session): + self.session = session + + def list_for(self, carrier_id: str | None = None) -> list[dict]: + stmt = select(ServiceReferral).order_by(ServiceReferral.created_at.desc()) + if carrier_id: + stmt = stmt.where(ServiceReferral.carrier_id == carrier_id) + return [self._to_dict(r) for r in self.session.scalars(stmt)] + + def create(self, fields: dict) -> dict: + row = ServiceReferral( + id=new_id("ref"), carrier_id=fields.get("carrier_id", ""), + provider_id=fields.get("provider_id", ""), provider_name=fields.get("provider_name", ""), + opc_id=fields.get("opc_id", ""), opc_name=fields.get("opc_name", ""), + status="referred", created_at=utcnow_iso(), updated_at=utcnow_iso(), + ) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(r: ServiceReferral) -> dict: + return {"id": r.id, "carrier_id": r.carrier_id, "provider_id": r.provider_id, + "provider_name": r.provider_name, "opc_id": r.opc_id, "opc_name": r.opc_name, + "status": r.status, "created_at": r.created_at} + + +class TrainingEnrollmentRepository: + """投融资培训报名。""" + + def __init__(self, session: Session): + self.session = session + + def list_for(self, user_id: str) -> list[dict]: + rows = self.session.scalars( + select(TrainingEnrollment).where(TrainingEnrollment.user_id == user_id) + ).all() + return [{"id": r.id, "training_id": r.training_id, "training_name": r.training_name, + "status": r.status, "created_at": r.created_at} for r in rows] + + def create(self, user_id: str, training_id: str, training_name: str) -> dict: + row = TrainingEnrollment( + id=new_id("treg"), user_id=user_id, training_id=training_id, + training_name=training_name, status="enrolled", created_at=utcnow_iso(), + ) + self.session.add(row) + self.session.commit() + return {"id": row.id, "training_id": row.training_id, "training_name": row.training_name, + "status": row.status} + + +class EscrowRepository: + """任务资金托管/结算。""" + + def __init__(self, session: Session): + self.session = session + + def list(self, status: str | None = None) -> list[dict]: + stmt = select(Escrow).order_by(Escrow.created_at.desc()) + if status: + stmt = stmt.where(Escrow.status == status) + return [self._to_dict(r) for r in self.session.scalars(stmt)] + + def get(self, escrow_id: str) -> dict | None: + row = self.session.get(Escrow, escrow_id) + return self._to_dict(row) if row else None + + def create(self, task_id: str, task_title: str, amount: int, commission: int = 0) -> dict: + row = Escrow(id=new_id("esc"), task_id=task_id, task_title=task_title, + amount=amount, commission=commission, status="deposited", + created_at=utcnow_iso(), updated_at=utcnow_iso()) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + def set_status(self, escrow_id: str, status: str) -> dict | None: + row = self.session.get(Escrow, escrow_id) + if row is None: + return None + row.status = status + row.updated_at = utcnow_iso() + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(e: Escrow) -> dict: + return {"id": e.id, "task_id": e.task_id, "task_title": e.task_title, "amount": e.amount, + "commission": e.commission, "status": e.status, "created_at": e.created_at} + + +class ContractRepository: + def __init__(self, session: Session): + self.session = session + + def get_for_task(self, task_id: str) -> dict | None: + row = self.session.scalar(select(Contract).where(Contract.task_id == task_id)) + return self._to_dict(row) if row else None + + def create(self, task_id: str, task_title: str, enterprise_id: str, opc_id: str) -> dict: + row = Contract(id=new_id("ct"), task_id=task_id, task_title=task_title, + enterprise_id=enterprise_id, opc_id=opc_id, status="signed", + content=f"任务《{task_title}》电子合同,双方已签署,资金由平台托管。", + created_at=utcnow_iso()) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(c: Contract) -> dict: + return {"id": c.id, "task_id": c.task_id, "task_title": c.task_title, + "enterprise_id": c.enterprise_id, "opc_id": c.opc_id, "status": c.status, + "content": c.content, "created_at": c.created_at} + + +class DisputeRepository: + def __init__(self, session: Session): + self.session = session + + def list(self, status: str | None = None) -> list[dict]: + stmt = select(Dispute).order_by(Dispute.created_at.desc()) + if status: + stmt = stmt.where(Dispute.status == status) + return [self._to_dict(r) for r in self.session.scalars(stmt)] + + def create(self, task_id: str, task_title: str, initiator: str, reason: str) -> dict: + row = Dispute(id=new_id("disp"), task_id=task_id, task_title=task_title, + initiator=initiator, reason=reason, status="opened", created_at=utcnow_iso()) + self.session.add(row) + self.session.commit() + return self._to_dict(row) + + def set_status(self, dispute_id: str, status: str, resolution: str = "") -> dict | None: + row = self.session.get(Dispute, dispute_id) + if row is None: + return None + row.status = status + if resolution: + row.resolution = resolution + self.session.commit() + return self._to_dict(row) + + @staticmethod + def _to_dict(d: Dispute) -> dict: + return {"id": d.id, "task_id": d.task_id, "task_title": d.task_title, + "initiator": d.initiator, "reason": d.reason, "status": d.status, + "resolution": d.resolution, "created_at": d.created_at} + + +class RatingRepository: + def __init__(self, session: Session): + self.session = session + + def avg_for(self, user_id: str) -> float: + rows = self.session.scalars(select(Rating).where(Rating.to_id == user_id)).all() + return round(sum(r.score for r in rows) / len(rows), 1) if rows else 5.0 + + def create(self, task_id: str, from_id: str, to_id: str, score: int, comment: str = "") -> dict: + row = Rating(id=new_id("rate"), task_id=task_id, from_id=from_id, to_id=to_id, + score=max(1, min(5, score)), comment=comment, created_at=utcnow_iso()) + self.session.add(row) + self.session.commit() + return {"id": row.id, "score": row.score, "comment": row.comment} + + +class NotificationRepository: + def __init__(self, session: Session): + self.session = session + + def list_for(self, user_id: str, limit: int = 50) -> list[dict]: + rows = self.session.scalars( + select(Notification).where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc()).limit(limit) + ).all() + return [{"id": r.id, "type": r.type, "title": r.title, "content": r.content, + "read": r.read, "created_at": r.created_at} for r in rows] + + def unread(self, user_id: str) -> int: + return len(self.session.scalars( + select(Notification).where(Notification.user_id == user_id, Notification.read.is_(False)) + ).all()) + + def create(self, user_id: str, type: str, title: str, content: str) -> dict: + row = Notification(id=new_id("ntf"), user_id=user_id, type=type, title=title, + content=content, read=False, created_at=utcnow_iso()) + self.session.add(row) + self.session.commit() + return {"id": row.id, "title": row.title} + + def mark_read(self, user_id: str) -> int: + rows = self.session.scalars(select(Notification).where(Notification.user_id == user_id)).all() + for r in rows: + r.read = True + self.session.commit() + return len(rows) + + +# --------------------------------------------------------------------------- +# 机构成员(机构主账号 + 机构内子账号) +# --------------------------------------------------------------------------- +class OrganizationMemberRepository: + def __init__(self, session: Session): + self.session = session + + def list_for_org(self, org_id: str) -> list[dict]: + rows = self.session.scalars( + select(OrganizationMember).where(OrganizationMember.org_id == org_id) + ).all() + out = [] + for r in rows: + u = self.session.get(User, r.user_id) + out.append({"org_id": r.org_id, "user_id": r.user_id, + "username": u.username if u else r.user_id, + "nickname": u.nickname if u else "", + "role": r.role, "is_admin": r.is_admin, "status": r.status}) + return out + + def is_member(self, org_id: str, user_id: str) -> bool: + return self.session.get(OrganizationMember, (org_id, user_id)) is not None + + def is_admin(self, org_id: str, user_id: str) -> bool: + row = self.session.get(OrganizationMember, (org_id, user_id)) + return bool(row and row.is_admin) + + def add_member(self, org_id: str, user_id: str, role: str = "member", is_admin: bool = False) -> dict: + row = self.session.get(OrganizationMember, (org_id, user_id)) + if row is None: + row = OrganizationMember(org_id=org_id, user_id=user_id, role=role, + is_admin=is_admin, status="active", joined_at=utcnow_iso()) + self.session.add(row) + else: + row.role = role + row.is_admin = is_admin + self.session.commit() + return {"org_id": org_id, "user_id": user_id, "role": role, "is_admin": is_admin} + + +# --------------------------------------------------------------------------- +# 统计 +# --------------------------------------------------------------------------- +class StatsRepository: + """运营端/政务端数据总览(基于现有表聚合)。""" + + def __init__(self, session: Session): + self.session = session + + def overview(self, region_ids: list[str] | None = None) -> dict: + users = self.session.scalars(select(User)).all() + if region_ids is not None: + scope = set(region_ids) + users = [u for u in users if not u.region_id or u.region_id in scope] + tasks = self.session.scalars(select(Task)).all() + providers = self.session.scalars(select(ServiceProvider)).all() + content = self.session.scalars(select(ContentItem)).all() + active_users = [u for u in users if u.status == "active"] + return { + "user_count": len(users), + "active_user_count": len(active_users), + "task_count": len(tasks), + "published_task_count": len([t for t in tasks if t.status == "published"]), + "provider_count": len(providers), + "active_provider_count": len([p for p in providers if p.status == "active"]), + "content_count": len(content), + "published_content_count": len([c for c in content if c.status == "published"]), + } + + +# --------------------------------------------------------------------------- +# 数据库门面 +# --------------------------------------------------------------------------- +class Database: + """持有全部 Repository,统一访问入口。""" + + def __init__(self, db_url: str | None = None, session: Session | None = None): + if session is None: + from sqlalchemy.orm import sessionmaker + + from .db import make_engine + + url = db_url or config.DATABASE_URL + self._engine = make_engine(url) + self._session_factory = sessionmaker( + bind=self._engine, autoflush=False, expire_on_commit=False, + ) + self._owns_session = True + else: + self._engine = None + self._session_factory = lambda: session # type: ignore[assignment] + self._owns_session = False + + self.session = self._session_factory() + self.users = UserRepository(self.session) + self.tokens = TokenRepository(self.session) + self.agents = AgentRepository(self.session) + self.roles = RoleRepository(self.session) + self.orgs = OrgRepository(self.session) + self.regions = RegionRepository(self.session) + self.audit = AuditRepository(self.session) + self.tasks = TaskRepository(self.session) + self.providers = ProviderRepository(self.session) + self.content = ContentRepository(self.session) + self.config = ConfigRepository(self.session) + self.identities = IdentityRepository(self.session) + self.portal_dashboards = PortalDashboardRepository(self.session) + self.portal_pages = PortalPageRepository(self.session) + self.opc_profiles = OpcProfileRepository(self.session) + self.finance = FinanceRepository(self.session) + self.messages = MessageRepository(self.session) + self.opc_tasks = OpcTaskRepository(self.session) + self.investor_prefs = InvestorPreferenceRepository(self.session) + self.roadshows = RoadshowRepository(self.session) + self.roadshow_regs = RoadshowRegistrationRepository(self.session) + self.intents = InvestmentIntentRepository(self.session) + self.bids = BidRepository(self.session) + self.subsidies = SubsidyRepository(self.session) + self.referrals = ServiceReferralRepository(self.session) + self.training_enrolls = TrainingEnrollmentRepository(self.session) + self.escrows = EscrowRepository(self.session) + self.contracts = ContractRepository(self.session) + self.disputes = DisputeRepository(self.session) + self.ratings = RatingRepository(self.session) + self.notifications = NotificationRepository(self.session) + self.org_members = OrganizationMemberRepository(self.session) + self.stats = StatsRepository(self.session) + + def initialize(self) -> None: + """建表(本实例的 engine)+ 幂等种子(roles 空时才写)。""" + from .seed import seed_data + + if self._engine is not None: + from .db import create_all + + create_all(self._engine) + seed_data(self.session) + + def close(self) -> None: + if self._owns_session: + self.session.close() diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..d107cf5 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""API 路由包。""" diff --git a/app/routers/agents.py b/app/routers/agents.py new file mode 100644 index 0000000..22773fe --- /dev/null +++ b/app/routers/agents.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +"""智能体路由:每用户隔离的身份 CRUD。 + +智能体"基础信息 + 身份 CRUD"托管在服务端(后续入数据库);运行时配置、workspace、 +技能、对话仍由 PineAgents 主后端在本地管理。本地后端把 /agents 的身份部分转发到这里。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from ..dependencies import get_db, require_port +from ..models import AgentCreate, AgentInfo, AgentUpdate +from ..repositories import Database + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +def _agent_info(record: dict) -> AgentInfo: + """构建 AgentInfo,`deletable` 以记录中的 flag 为准(服务端决定)。""" + rec = dict(record) + rec.setdefault("deletable", True) + rec.setdefault("port", "opc") + return AgentInfo(**rec) + + +@router.get("", response_model=list[AgentInfo], summary="当前用户智能体列表") +async def list_agents( + user: dict = Depends(require_port), + db: Database = Depends(get_db), +): + return [_agent_info(r) for r in db.agents.get_by_user(user["id"], port=user["port"])] + + +@router.get("/{agent_id}", response_model=AgentInfo, summary="智能体详情") +async def get_agent( + agent_id: str, + user: dict = Depends(require_port), + db: Database = Depends(get_db), +): + record = db.agents.get(agent_id, user["id"], port=user["port"]) + if record is None: + raise HTTPException(status_code=404, detail="Agent not found") + return _agent_info(record) + + +@router.post( + "", + response_model=AgentInfo, + status_code=201, + summary="创建智能体身份", +) +async def create_agent( + req: AgentCreate, + user: dict = Depends(require_port), + db: Database = Depends(get_db), +): + if not req.name.strip(): + raise HTTPException(status_code=400, detail="Agent name is required") + record = db.agents.create( + user["id"], + req.name, + description=req.description, + language=req.language or "zh", + model_name=req.model_name, + port=user["port"], + ) + return _agent_info(record) + + +@router.put("/{agent_id}", response_model=AgentInfo, summary="更新智能体身份") +async def update_agent( + agent_id: str, + req: AgentUpdate, + user: dict = Depends(require_port), + db: Database = Depends(get_db), +): + payload = req.model_dump(exclude_none=True) + if not payload: + raise HTTPException(status_code=400, detail="Nothing to update") + record = db.agents.update(agent_id, user["id"], payload, port=user["port"]) + if record is None: + raise HTTPException(status_code=404, detail="Agent not found") + return _agent_info(record) + + +@router.delete("/{agent_id}", status_code=204, summary="删除智能体") +async def delete_agent( + agent_id: str, + user: dict = Depends(require_port), + db: Database = Depends(get_db), +): + record = db.agents.get(agent_id, user["id"], port=user["port"]) + if record is None: + raise HTTPException(status_code=404, detail="Agent not found") + if not record.get("deletable", True): + raise HTTPException( + status_code=400, + detail="Cannot delete this agent", + ) + if not db.agents.delete(agent_id, user["id"], port=user["port"]): + raise HTTPException(status_code=404, detail="Agent not found") + return None diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..ebc692c --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +"""认证路由:登录 / 注册 / 状态 / 校验 / 资料 / 令牌管理。 + +这是供 PineAgents 主后端 ``src/pineagents/app/routers/auth.py`` 转发调用的 +"演示 FastAPI"(DEMO_API_BASE_URL,默认 http://127.0.0.1:8090)。 +路由前缀 ``/auth``,字段与主后端转发模型完全一致,保证透传不丢字段。 +""" +from __future__ import annotations + +import re + +from fastapi import APIRouter, Depends, HTTPException, Request + +from .. import config +from ..dependencies import get_current_user, get_db, extract_bearer_token +from ..models import ( + AuthStatusResponse, + IdentityInfo, + LoginRequest, + LoginResponse, + ProfileResponse, + RegisterRequest, + RevokeTokenRequest, + SelectIdentityRequest, + UpdateProfileRequest, + VerifyResponse, +) +from ..repositories import Database + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# 账号统一使用手机号作为登录账号(11 位,1 开头) +_PHONE_RE = re.compile(r"^1\d{10}$") + + +def _is_phone(account: str) -> bool: + return bool(_PHONE_RE.match(account.strip())) + +# 账户管理页可编辑的资料字段(username 作为登录名走 update_credentials,单独处理) +_PROFILE_FIELDS = ( + "nickname", + "account", + "room", + "avatar", + "company_avatar", +) + + +def _identity_summaries(identities: list[dict]) -> list[IdentityInfo]: + """把 user_identities 记录裁剪成对外身份摘要。""" + return [ + IdentityInfo( + id=i["id"], port=i["port"], role=i["role"], sub_role=i.get("sub_role"), + name=i.get("name", ""), org_id=i.get("org_id"), region_id=i.get("region_id"), + org_name=i.get("org_name"), region_name=i.get("region_name"), + status=i.get("status", "active"), + ) + for i in identities + ] + + +def _issue_token( + db: Database, + user: dict, + expires_in: int | None, + identity: dict | None = None, +) -> dict: + """为指定用户签发 JWT(可按端口身份作用域),返回 token 记录。 + + ``identity`` 非空时按该身份解析 role/sub_role/org/region 与权限; + 否则回退到 ``users.role``(单角色/中性令牌)。 + """ + role = (identity or user).get("role", "opc_member") + sub_role = (identity or user).get("sub_role") + org_id = (identity or user).get("org_id") + region_id = (identity or user).get("region_id") + identity_id = identity.get("id") if identity else None + + perms = db.roles.permissions_for(role, sub_role) + scope_ids = db.regions.visible_region_ids(region_id) + scope_level = db.regions.level(region_id) + + token_user = dict(user) + token_user.update(role=role, sub_role=sub_role, org_id=org_id, region_id=region_id) + return db.tokens.create( + token_user, + permissions=perms, + scope_region_ids=scope_ids, + scope_level=scope_level, + expiry_seconds=config.resolve_token_expiry(expires_in), + identity_id=identity_id, + ) + + +def _profile_for( + user: dict, + identity: dict | None, + db: Database, +) -> dict: + """按身份(或回退用户单角色)构建对外资料字典。""" + role = (identity or user).get("role", "opc_member") + sub_role = (identity or user).get("sub_role") + org_id = (identity or user).get("org_id") + region_id = (identity or user).get("region_id") + profile = {f: user.get(f, "") for f in _PROFILE_FIELDS} + profile.update(username=user.get("username", "")) + profile["role"] = role + profile["sub_role"] = sub_role + profile["org_id"] = org_id + profile["region_id"] = region_id + profile["permissions"] = db.roles.permissions_for(role, sub_role) + profile["scope_region_ids"] = db.regions.visible_region_ids(region_id) + if identity: + profile["identity_id"] = identity["id"] + profile["port"] = identity["port"] + profile["identity_name"] = identity.get("name", "") + return profile + + +@router.post("/login", response_model=LoginResponse, summary="登录") +async def login(req: LoginRequest, db: Database = Depends(get_db)): + """校验用户名密码,返回账号绑定的全部端口身份 + 令牌。 + + - 仅一个身份:令牌直接绑定该身份(自动登录)。 + - 多个身份:签发中性账号令牌,前端展示身份选择,经 + ``/auth/select-identity`` 切换到指定身份后进入对应端口。 + """ + user = db.users.get_by_username(req.username) + if user is None or not db.users.verify_password(user, req.password): + raise HTTPException(status_code=401, detail="Invalid username or password") + if user.get("status") != "active": + raise HTTPException(status_code=403, detail="Account is disabled") + + identities = db.identities.list_for_user(user["id"], active_only=True) + identity = identities[0] if len(identities) == 1 else None + token_record = _issue_token(db, user, req.expires_in, identity=identity) + profile = _profile_for(user, identity, db) + + db.audit.add( + action="login", resource="auth", resource_id=user["id"], + detail=f"login {user['username']} ({len(identities)} identities)", + user_id=user["id"], + ) + return LoginResponse( + token=token_record["token"], + identities=_identity_summaries(identities), + **profile, + ) + + +@router.post("/select-identity", response_model=LoginResponse, summary="切换端口身份") +async def select_identity( + req: SelectIdentityRequest, + user: dict = Depends(get_current_user), + db: Database = Depends(get_db), +): + """把当前令牌切换到指定端口身份(该身份须属于当前账号且为启用状态)。""" + ident = db.identities.get_for_user(req.identity_id, user["id"]) + if ident is None or ident.get("status") != "active": + raise HTTPException(status_code=404, detail="Identity not found or disabled") + + token_record = _issue_token(db, user, None, identity=ident) + profile = _profile_for(user, ident, db) + identities = db.identities.list_for_user(user["id"], active_only=True) + return LoginResponse( + token=token_record["token"], + identities=_identity_summaries(identities), + **profile, + ) + + +@router.post("/register", response_model=LoginResponse, summary="注册") +async def register(req: RegisterRequest, db: Database = Depends(get_db)): + """注册唯一账户(演示端已存在 pine,故返回 403)。""" + if not _is_phone(req.username): + raise HTTPException( + status_code=400, + detail="账号需为 11 位手机号(如 138xxxx1234)", + ) + if not config.AUTH_ENABLED: + raise HTTPException(status_code=403, detail="Authentication is not enabled") + if db.users.has_users(): + raise HTTPException(status_code=403, detail="User already registered") + if not req.username.strip() or not req.password.strip(): + raise HTTPException( + status_code=400, + detail="Username and password are required", + ) + + user = db.users.create(req.username, req.password) + identity = db.identities.create( + user["id"], port="opc", role="opc_member", + sub_role="independent", name="独立OPC", + ) + token_record = _issue_token(db, user, req.expires_in, identity=identity) + profile = _profile_for(user, identity, db) + return LoginResponse( + token=token_record["token"], + identities=_identity_summaries([identity]), + **profile, + ) + + +@router.get("/status", response_model=AuthStatusResponse, summary="认证状态") +async def auth_status(db: Database = Depends(get_db)): + """前端登录页据此判断是否展示登录表单。""" + return AuthStatusResponse( + enabled=config.AUTH_ENABLED, + has_users=db.users.has_users(), + ) + + +@router.get("/verify", response_model=VerifyResponse, summary="校验令牌") +async def verify(request: Request, db: Database = Depends(get_db)): + """校验调用方 Bearer 令牌;无效令牌由依赖层直接返回 401。""" + if not config.AUTH_ENABLED: + return VerifyResponse(valid=True, username="") + user = get_current_user(request, db) + return VerifyResponse(valid=True, username=user["username"]) + + +@router.get("/me", response_model=dict, summary="当前用户资料") +async def me( + user: dict = Depends(get_current_user), + db: Database = Depends(get_db), +): + """返回当前登录用户的完整资料(含角色/组织/区域/权限/数据范围)。""" + return db.users.to_profile(user) + + +@router.post("/update-profile", response_model=ProfileResponse, summary="更新资料/凭据") +async def update_profile( + req: UpdateProfileRequest, + user: dict = Depends(get_current_user), + db: Database = Depends(get_db), +): + """更新演示资料与/或用户名、密码,返回更新后的完整资料。 + + - 修改用户名/密码时必须提供正确的 ``current_password``; + - 修改凭据后吊销该用户其余会话并签发新令牌随响应返回; + - 仅改资料字段时不重签令牌。 + """ + payload = req.model_dump(exclude_none=True) + user_id = user["id"] + + new_username = payload.get("new_username") + new_password = payload.get("new_password") + + if new_username is not None and not new_username.strip(): + raise HTTPException(status_code=400, detail="Username cannot be empty") + if new_password is not None and not new_password.strip(): + raise HTTPException(status_code=400, detail="Password cannot be empty") + + profile_updates = {k: payload[k] for k in _PROFILE_FIELDS if k in payload} + changing_credentials = new_username is not None or new_password is not None + if not profile_updates and not changing_credentials: + raise HTTPException(status_code=400, detail="Nothing to update") + + if changing_credentials and not db.users.verify_password(user, req.current_password): + raise HTTPException(status_code=401, detail="Current password is incorrect") + + if profile_updates: + db.users.update_profile(user_id, profile_updates) + + issued_token = "" + if changing_credentials: + db.users.update_credentials(user_id, new_username, new_password) + db.tokens.revoke_all(user_id) + fresh_user = db.users.get_by_id(user_id) + token_record = _issue_token(db, fresh_user, req.expires_in) + issued_token = token_record["token"] + + fresh_user = db.users.get_by_id(user_id) + fresh_user["permissions"] = db.roles.permissions_for( + fresh_user.get("role", "opc_member"), fresh_user.get("sub_role"), + ) + fresh_user["scope_region_ids"] = db.regions.visible_region_ids(fresh_user.get("region_id")) + return ProfileResponse( + token=issued_token, + **db.users.to_profile(fresh_user), + ) + + +@router.post("/revoke-token", summary="吊销单个令牌") +async def revoke_single_token( + req: RevokeTokenRequest, + request: Request, + user: dict = Depends(get_current_user), + db: Database = Depends(get_db), +): + """吊销指定令牌(省略则吊销当前令牌)。""" + caller_token = extract_bearer_token(request) + token_to_revoke = req.token or caller_token + is_current = token_to_revoke == caller_token + + if not db.tokens.revoke(token_to_revoke): + raise HTTPException(status_code=500, detail="Failed to revoke token") + + message = ( + "Current token has been revoked. Please login again." + if is_current + else "Specified token has been revoked." + ) + return { + "message": message, + "revoked": True, + "revoked_current_token": is_current, + } + + +@router.post("/revoke-all-tokens", summary="吊销全部令牌") +async def revoke_all_sessions( + user: dict = Depends(get_current_user), + db: Database = Depends(get_db), +): + """吊销所有令牌,所有会话需重新登录。""" + db.tokens.revoke_all() + return { + "message": "All tokens have been revoked. Please login again.", + "revoked": True, + } diff --git a/app/routers/bootstrap.py b/app/routers/bootstrap.py new file mode 100644 index 0000000..818fd78 --- /dev/null +++ b/app/routers/bootstrap.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +"""智能体引导(bootstrap)路由。 + +返回"所有用户首次启动都必须初始化的智能体"定义 —— 由服务端统一控制。 +这是全局定义(对所有用户相同),因此**公开**(无需登录),本地据此在首次启动时 +初始化本地 workspace,避免本地硬编码默认智能体。 +""" +from __future__ import annotations + +from fastapi import APIRouter + +from ..repositories import AGENT_SEED + +router = APIRouter(tags=["bootstrap"]) + + +@router.get("/agent-bootstrap", summary="必初始化智能体定义") +async def agent_bootstrap(): + """返回服务端定义的必初始化智能体(id/name/desc/lang/template_type/deletable)。""" + return [ + { + "id": seed["id"], + "name": seed["name"], + "description": seed.get("description", ""), + "language": seed.get("language", "zh"), + "model_name": seed.get("model_name", ""), + "template_type": seed.get("template_type", "default"), + "deletable": seed.get("deletable", True), + "use_fixed_soul": seed.get("use_fixed_soul", False), + } + for seed in AGENT_SEED + ] diff --git a/app/routers/rbac_admin.py b/app/routers/rbac_admin.py new file mode 100644 index 0000000..99e1661 --- /dev/null +++ b/app/routers/rbac_admin.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""运营方管理端点(证明 RBAC 角色/权限/审计)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ..models import SetUserRoleRequest, SetUserStatusRequest +from ..rbac import require_permission, require_roles, write_audit +from ..repositories import Database + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +class UserCreateRequest(BaseModel): + username: str # 手机号 + password: str + nickname: str = "" + role: str = "opc_member" + sub_role: str | None = None + org_id: str | None = None + region_id: str | None = None + + +class RolePermissionRequest(BaseModel): + permissions: list[str] = [] + + +def _validate_role_assignment(db: Database, actor: dict, role: str, sub_role: str | None) -> None: + """白名单 + 提权防护:拒绝非法角色组合,及无权者创建/提升特权角色。""" + key = role if not sub_role else f"{role}|{sub_role}" + if not db.roles.role_exists(role): + raise HTTPException(status_code=400, detail=f"无效角色: {role}") + if sub_role and not db.roles.role_exists(f"{role}|{sub_role}"): + raise HTTPException(status_code=400, detail=f"无效角色组合: {key}") + # 目标角色若授予「配置角色权限」,仅持该权限者(超管)可创建/分配 + if "action:role.grant_perm" in db.roles.permissions_for(role, sub_role) and \ + "action:role.grant_perm" not in actor.get("permissions", []): + raise HTTPException( + status_code=403, + detail="无权创建/分配特权角色(仅超级管理员)", + ) + + +@router.post("/users", summary="新增账号(按类型/角色)") +async def create_user( + req: UserCreateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:user.assign_role")), +): + if not req.username.strip() or not req.password.strip(): + raise HTTPException(status_code=400, detail="账号与密码必填") + if db.users.get_by_username(req.username) is not None: + raise HTTPException(status_code=400, detail="账号已存在") + _validate_role_assignment(db, actor, req.role, req.sub_role) + user = db.users.create( + req.username, req.password, nickname=req.nickname, + role=req.role, sub_role=req.sub_role, org_id=req.org_id, region_id=req.region_id, + ) + # 自动创建该账号的默认端口身份 + db.identities.create(user["id"], port=_port_for_role(req.role), role=req.role, + sub_role=req.sub_role, org_id=req.org_id, region_id=req.region_id, + name=req.nickname or req.username) + write_audit(db, action="user.create", resource="user", resource_id=user["id"], + detail=f"role={req.role}", user=actor, request=request) + return db.users.to_profile(user) + + +def _port_for_role(role: str) -> str: + return { + "opc_member": "opc", "carrier": "carrier", "enterprise": "enterprise", + "provider": "provider", "government": "government", "operator": "operator", + "investor": "investor", "developer": "developer", + }.get(role, "opc") + + +@router.get("/users", summary="用户列表(运营方)") +async def list_users( + request: Request, + db: Database = Depends(get_db), + _role: dict = Depends(require_roles("operator")), + _perm: dict = Depends(require_permission("menu:admin_user_mgmt")), +): + return db.users.list() + + +@router.post("/users/{user_id}/role", summary="分配角色") +async def set_user_role( + user_id: str, + req: SetUserRoleRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:user.assign_role")), +): + if db.users.get_by_id(user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + _validate_role_assignment(db, actor, req.role, req.sub_role) + updated = db.users.set_role( + user_id, req.role, req.sub_role, req.org_id, req.region_id, + ) + write_audit( + db, action="role.assign", resource="user", resource_id=user_id, + detail=f"{actor['username']} -> role={req.role} sub={req.sub_role}", + user=actor, request=request, + ) + return {"ok": True, "user": db.users.to_profile(updated)} + + +@router.post("/users/{user_id}/status", summary="禁用/启用用户") +async def set_user_status( + user_id: str, + req: SetUserStatusRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:user.disable")), +): + if db.users.get_by_id(user_id) is None: + raise HTTPException(status_code=404, detail="User not found") + updated = db.users.set_status(user_id, req.status) + write_audit( + db, action="user.disable", resource="user", resource_id=user_id, + detail=f"{actor['username']} -> status={req.status}", + user=actor, request=request, + ) + return {"ok": True, "user": db.users.to_profile(updated)} + + +@router.get("/roles", summary="角色列表") +async def list_roles( + db: Database = Depends(get_db), + _user: dict = Depends(require_permission("menu:admin_role_mgmt")), +): + return db.roles.list_roles() + + +@router.get("/permissions", summary="权限列表") +async def list_permissions( + db: Database = Depends(get_db), + _user: dict = Depends(require_permission("menu:admin_role_mgmt")), +): + return db.roles.list_permissions() + + +@router.post("/roles/{role_id}/permissions", summary="配置角色权限") +async def set_role_permissions( + role_id: str, + req: RolePermissionRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:role.grant_perm")), +): + if not db.roles.role_exists(role_id): + raise HTTPException(status_code=404, detail="Role not found") + perms = db.roles.set_role_permissions(role_id, req.permissions) + write_audit(db, action="role.grant_perm", resource="role", resource_id=role_id, + detail=f"permissions={len(req.permissions)}", user=actor, request=request) + return {"ok": True, "role_id": role_id, "permissions": perms} + + +@router.get("/audit-logs", summary="审计日志") +async def list_audit_logs( + limit: int = 100, + offset: int = 0, + db: Database = Depends(get_db), + _user: dict = Depends(require_permission("action:audit.view")), +): + return db.audit.list(limit=min(limit, 500), offset=offset) diff --git a/app/routers/rbac_developer.py b/app/routers/rbac_developer.py new file mode 100644 index 0000000..5595d0c --- /dev/null +++ b/app/routers/rbac_developer.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""开放平台端点:开发者工作台 / 应用 / 插件 / 技能 / API 凭证。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..dependencies import get_db +from ..rbac import require_roles +from ..repositories import Database + +router = APIRouter(prefix="/developer", tags=["developer"]) + + +def _page(port: str, page: str): + async def handler(db: Database = Depends(get_db)): + return db.portal_pages.get(port, page) or {"items": []} + + return handler + + +@router.get("/dashboard", summary="开发者工作台") +async def dev_dashboard(db: Database = Depends(get_db), _u: dict = Depends(require_roles("developer"))): + return db.portal_pages.get("developer", "dashboard") or {"stats": []} + + +@router.get("/apps", summary="我的应用") +async def dev_apps(db: Database = Depends(get_db), _u: dict = Depends(require_roles("developer"))): + return db.portal_pages.get("developer", "apps") or {"items": []} + + +@router.get("/plugins", summary="我的插件") +async def dev_plugins(db: Database = Depends(get_db), _u: dict = Depends(require_roles("developer"))): + return db.portal_pages.get("developer", "plugins") or {"items": []} + + +@router.get("/skills", summary="技能市场") +async def dev_skills(db: Database = Depends(get_db), _u: dict = Depends(require_roles("developer"))): + return db.portal_pages.get("developer", "skills") or {"items": []} + + +@router.get("/api-keys", summary="API 凭证") +async def dev_api_keys(db: Database = Depends(get_db), _u: dict = Depends(require_roles("developer"))): + return {"items": [ + {"id": "key_001", "name": "生产环境", "prefix": "pa_live_****abcd", "created_at": "2026-07-01"}, + {"id": "key_002", "name": "测试环境", "prefix": "pa_test_****wxyz", "created_at": "2026-07-05"}, + ], "note": "演示:凭证仅展示脱敏前缀"} diff --git a/app/routers/rbac_ecosystem.py b/app/routers/rbac_ecosystem.py new file mode 100644 index 0000000..a98618d --- /dev/null +++ b/app/routers/rbac_ecosystem.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +"""横切能力端点:通知/信用/合同/结算/争议/评价/投资撮合/路演参与。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ..rbac import require_permission, require_roles, write_audit +from ..repositories import Database + +router = APIRouter(tags=["ecosystem"]) + +_ANY = ("opc_member", "carrier", "enterprise", "provider", "government", "operator", "investor") + + +# ── 通知中心 ────────────────────────────────────────────────────────────── +@router.get("/notifications", summary="我的通知") +async def list_notifications( + db: Database = Depends(get_db), + user: dict = Depends(require_roles(*_ANY)), +): + return {"items": db.notifications.list_for(user["id"]), "unread": db.notifications.unread(user["id"])} + + +@router.post("/notifications/read-all", summary="全部已读") +async def read_all_notifications( + db: Database = Depends(get_db), + user: dict = Depends(require_roles(*_ANY)), +): + return {"marked": db.notifications.mark_read(user["id"])} + + +# ── 信用/评价 ────────────────────────────────────────────────────────────── +@router.get("/me/credit", summary="我的信用") +async def my_credit( + db: Database = Depends(get_db), + user: dict = Depends(require_roles(*_ANY)), +): + profile = db.opc_profiles.get(user["id"]) + return {"credit_score": (profile or {}).get("credit_score", 80), + "avg_rating": db.ratings.avg_for(user["id"]), + "rating_count": len(db.ratings.list_for(user["id"])) if hasattr(db.ratings, "list_for") else 0} + + +class RatingRequest(BaseModel): + score: int = 5 + comment: str = "" + to_id: str = "" + + +@router.post("/tasks/{task_id}/rate", summary="任务互评") +async def rate_task( + task_id: str, + req: RatingRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise", "opc_member")), +): + if not req.to_id: + raise HTTPException(status_code=400, detail="缺少被评对象") + rating = db.ratings.create(task_id, actor["id"], req.to_id, req.score, req.comment) + write_audit(db, action="task.rate", resource="task", resource_id=task_id, + detail=f"{req.score}星", user=actor, request=request) + return rating + + +# ── 合同 ────────────────────────────────────────────────────────────────── +@router.get("/tasks/{task_id}/contract", summary="查看合同") +async def get_contract( + task_id: str, + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("enterprise", "opc_member")), +): + contract = db.contracts.get_for_task(task_id) + if contract is None: + raise HTTPException(status_code=404, detail="Contract not found") + return contract + + +class SignRequest(BaseModel): + opc_id: str = "" + + +@router.post("/tasks/{task_id}/sign", summary="签订电子合同") +async def sign_contract( + task_id: str, + req: SignRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise", "opc_member")), +): + task = db.tasks.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + if db.contracts.get_for_task(task_id) is not None: + return db.contracts.get_for_task(task_id) + opc_id = req.opc_id or (actor["id"] if actor.get("role") == "opc_member" else "") + contract = db.contracts.create(task_id, task["title"], actor["id"], opc_id) + write_audit(db, action="contract.sign", resource="contract", resource_id=contract["id"], + user=actor, request=request) + return contract + + +# ── 结算托管 ────────────────────────────────────────────────────────────── +@router.post("/enterprise/tasks/{task_id}/release", summary="验收通过并结算") +async def release_escrow( + task_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise")), +): + task = db.tasks.get(task_id) + if task is None or task["status"] != "completed": + raise HTTPException(status_code=400, detail="任务未完成,不能结算") + escrow = db.escrows.list() + esc = next((e for e in escrow if e["task_id"] == task_id), None) + if esc is None: + amount = max(task.get("budget_min", 0), task.get("budget_max", 0)) + commission = int(amount * 0.05) + esc = db.escrows.create(task_id, task["title"], amount, commission) + released = db.escrows.set_status(esc["id"], "released") + write_audit(db, action="escrow.release", resource="escrow", resource_id=esc["id"], + detail=f"amount={esc['amount']}", user=actor, request=request) + return released + + +@router.get("/operator/settlements", summary="运营结算台账") +async def operator_settlements( + status: str | None = None, + db: Database = Depends(get_db), + _u: dict = Depends(require_permission("action:settlement.manage")), +): + return {"items": db.escrows.list(status=status)} + + +# ── 争议 ────────────────────────────────────────────────────────────────── +class DisputeRequest(BaseModel): + reason: str = "" + + +@router.post("/tasks/{task_id}/dispute", summary="发起争议") +async def create_dispute( + task_id: str, + req: DisputeRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise", "opc_member")), +): + task = db.tasks.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + dispute = db.disputes.create(task_id, task["title"], actor["id"], req.reason) + db.tasks.set_status(task_id, "disputed") + write_audit(db, action="dispute.open", resource="dispute", resource_id=dispute["id"], + user=actor, request=request) + return dispute + + +@router.post("/operator/disputes/{dispute_id}/resolve", summary="争议调解/解决") +async def resolve_dispute( + dispute_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("operator")), +): + dispute = db.disputes.set_status(dispute_id, "resolved", resolution="平台调解结案") + if dispute is None: + raise HTTPException(status_code=404, detail="Dispute not found") + write_audit(db, action="dispute.resolve", resource="dispute", resource_id=dispute_id, + user=actor, request=request) + return dispute + + +# ── 投资撮合(按偏好排序项目)─────────────────────────────────────────────── +@router.get("/investor/matches", summary="投资撮合(按偏好推荐)") +async def investor_matches( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("investor")), +): + pref = db.investor_prefs.get(user["id"]) or {} + industries = set(pref.get("industries") or []) + data = db.portal_pages.get("investor", "projects") or {"items": []} + items = data.get("items", []) + scored = [] + for it in items: + text = f"{it.get('title', '')} {it.get('meta', '')}" + score = 0 + for ind in industries: + if ind and ind in text: + score += 10 + stage = pref.get("stage") + if stage and stage in text: + score += 5 + scored.append((score, it)) + scored.sort(key=lambda x: x[0], reverse=True) + return {"items": [{"title": it["title"], "meta": it["meta"], "tag": it.get("tag"), + "match": min(100, s + 50)} for s, it in scored]} + + +# ── 路演参与(进入直播/出席)──────────────────────────────────────────────── +@router.post("/roadshows/{roadshow_id}/join", summary="进入路演直播") +async def join_roadshow( + roadshow_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("investor", "government", "operator", "carrier", "enterprise")), +): + rs = db.roadshows.get(roadshow_id) + if rs is None: + raise HTTPException(status_code=404, detail="Roadshow not found") + # 标记出席(若已报名则更新为 attended;否则记录出席) + db.roadshow_regs.create(roadshow_id, actor["id"], role="attendee") + write_audit(db, action="roadshow.join", resource="roadshow", resource_id=roadshow_id, + user=actor, request=request) + return {"joined": True, "live_url": rs.get("live_url") or "http://live.example/roadshow"} \ No newline at end of file diff --git a/app/routers/rbac_government.py b/app/routers/rbac_government.py new file mode 100644 index 0000000..1a2ed0b --- /dev/null +++ b/app/routers/rbac_government.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +"""政务端端点(证明数据范围层级:上级可看下级,下级不可看上级)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request + +from ..dependencies import get_db +from ..rbac import require_permission, require_scope, write_audit +from ..repositories import Database + +router = APIRouter(prefix="/government", tags=["government"]) + + +@router.get("/regions", summary="可见区域(按数据范围)") +async def list_regions( + db: Database = Depends(get_db), + user: dict = Depends(require_scope()), +): + """返回当前政务账号数据范围内的区域(省级=全部,区县=仅本区县)。""" + all_regions = db.regions.all() + visible = {rid for rid in user.get("scope_region_ids", [])} + return [r for r in all_regions if r["id"] in visible] + + +@router.get("/enterprises", summary="区域内企业列表") +async def list_enterprises( + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + """返回数据范围内的甲方企业;每次访问写一条 data.view 审计。""" + orgs = db.orgs.list_by_region(user.get("scope_region_ids", [])) + enterprises = [o for o in orgs if o["type"] == "enterprise"] + write_audit( + db, action="data.view", resource="enterprise", + detail=f"scope={user.get('scope_region_ids', [])}", + user=user, request=request, + ) + return enterprises + + +@router.get("/enterprises/{org_id}", summary="企业详情(范围校验)") +async def get_enterprise( + org_id: str, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + org = db.orgs.get(org_id) + if org is None or org["type"] != "enterprise": + raise HTTPException(status_code=404, detail="Enterprise not found") + if org["region_id"] not in user.get("scope_region_ids", []): + raise HTTPException(status_code=403, detail="Forbidden: out of data scope") + return org + + +# ── 三级数据范围:范围内 OPC / 载体 / 数据看板 ──────────────────────────── + +def _in_scope(region_id, user) -> bool: + return region_id in user.get("scope_region_ids", []) + + +@router.get("/opc", summary="范围内 OPC 列表") +async def gov_opc( + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + opcs = [u for u in db.users.list() if u.get("role") == "opc_member"] + opcs = [u for u in opcs if _in_scope(u.get("region_id"), user)] + return {"items": [ + {"title": u.get("nickname") or u.get("username"), "meta": f"信用 {u.get('region_id', '')} · OPC", + "tag": "OPC"} + for u in opcs + ]} + + +@router.get("/carriers", summary="范围内载体列表") +async def gov_carriers( + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + carriers = [o for o in db.orgs.all() if o["type"] == "carrier"] + carriers = [o for o in carriers if _in_scope(o.get("region_id"), user)] + return {"items": [ + {"title": o["name"], "meta": f"{o.get('region_id', '')} · 载体", "tag": "载体"} + for o in carriers + ]} + + +@router.get("/data", summary="范围内数据看板") +async def gov_data( + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + scope = user.get("scope_region_ids", []) + stats = db.stats.overview(region_ids=scope) + return { + "stats": [ + {"key": "totalOPC", "value": stats["user_count"]}, + {"key": "taskTotal", "value": stats["task_count"]}, + {"key": "carrierTotal", "value": stats["provider_count"]}, + {"key": "policyTotal", "value": stats["content_count"]}, + ], + "chart": [ + {"label": "6月", "value": 3200}, {"label": "7月", "value": 3600}, + {"label": "8月", "value": 4100}, + ], + "scope": scope, + } + + +# ── 补贴三级审批(区县→市→省→发放)───────────────────────────────────────── + +# 各政务级别可推进的状态映射 +_SUBSIDY_NEXT: dict[str, dict[str, str]] = { + "gov_district": {"applying": "district_review"}, + "gov_city": {"district_review": "city_review"}, + "gov_province": {"city_review": "province_review", "province_review": "approved"}, +} + + +@router.get("/subsidies", summary="范围内补贴申报列表") +async def gov_subsidies( + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + return {"items": db.subsidies.list(user.get("scope_region_ids", []))} + + +@router.post("/subsidies/{aid}/approve", summary="补贴审批(按级别推进)") +async def gov_approve_subsidy( + aid: str, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + sub = db.subsidies.get(aid) + if sub is None: + raise HTTPException(status_code=404, detail="Subsidy not found") + if sub.get("region_id") and sub["region_id"] not in user.get("scope_region_ids", []): + raise HTTPException(status_code=403, detail="Forbidden: out of data scope") + nxt = (_SUBSIDY_NEXT.get(user.get("sub_role") or "") or {}).get(sub["status"]) + if not nxt: + raise HTTPException(status_code=400, detail="当前级别不能审批该状态") + updated = db.subsidies.set_status(aid, nxt) + write_audit(db, action="subsidy.approve", resource="subsidy", resource_id=aid, + detail=nxt, user=user, request=request) + return updated + + +@router.post("/subsidies/{aid}/pay", summary="补贴发放") +async def gov_pay_subsidy( + aid: str, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(require_permission("menu:gov_data")), +): + sub = db.subsidies.get(aid) + if sub is None: + raise HTTPException(status_code=404, detail="Subsidy not found") + if sub["status"] != "approved": + raise HTTPException(status_code=400, detail="仅已终审的补贴可发放") + updated = db.subsidies.set_status(aid, "paid") + write_audit(db, action="subsidy.pay", resource="subsidy", resource_id=aid, + user=user, request=request) + return updated diff --git a/app/routers/rbac_investor.py b/app/routers/rbac_investor.py new file mode 100644 index 0000000..1b8bd08 --- /dev/null +++ b/app/routers/rbac_investor.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +"""投资人端端点:投资方向/项目/意向/路演(按权限与范围审核)/培训/组合。 + +路演发布审核规则: +- 运营方发布 → 免审(自身最高权限)。 +- 政务在自身权限范围(scope ⊆ publisher.scope_region_ids)内发布 → 免审;超出 → 需运营方审核。 +- 投资机构/载体/企业发布 → 一律需运营方审核。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ..rbac import require_roles, write_audit +from ..repositories import Database + +router = APIRouter(prefix="/investor", tags=["investor"]) + +# 可发布投融资活动的端口角色(审核规则见 _need_review) +_ROADSHOW_ROLES = ("investor", "government", "operator", "carrier", "enterprise") + + +class PreferenceRequest(BaseModel): + industries: list[str] = [] + stage: str = "" + amount_min: int = 0 + amount_max: int = 0 + region_id: str | None = None + + +class IntentRequest(BaseModel): + project_id: str + project_name: str = "" + message: str = "" + + +class RoadshowCreate(BaseModel): + title: str + summary: str = "" + activity_type: str = "online" # online/offline/hybrid + scope_type: str = "all" # all/region/org + region_id: str | None = None + start_at: str = "" + end_at: str = "" + register_deadline: str = "" + quota: int = 0 + venue: str = "" + live_url: str = "" + + +def _need_review(user: dict, req: RoadshowCreate, db: Database) -> bool: + """按发布主体与范围判断是否需要审核。""" + role = user.get("role") + if role == "operator": + return False + if role == "government": + scope = user.get("scope_region_ids", []) + if req.scope_type == "region" and req.region_id and req.region_id in scope: + return False # 自身权限范围内,免审 + return True # 超出范围,需审核 + # 投资机构/载体/企业:一律需审核 + return True + + +def _pub_status(need_review: bool) -> str: + return "submitted" if need_review else "published" + + +@router.get("/dashboard", summary="投资人工作台") +async def investor_dashboard( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("investor")), +): + pref = db.investor_prefs.get(user["id"]) or {} + portfolio = db.portal_pages.get("investor", "portfolio") or {} + roadshows = [r for r in db.roadshows.list(status="published")][:3] + return { + "preference": pref, + "stats": portfolio.get("stats", []), + "intents": len(db.intents.list_for(user["id"])), + "recentRoadshows": roadshows, + } + + +@router.get("/preferences", summary="投资方向") +async def get_preferences( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("investor")), +): + return db.investor_prefs.get(user["id"]) or {} + + +@router.put("/preferences", summary="保存投资方向") +async def set_preferences( + req: PreferenceRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("investor")), +): + pref = db.investor_prefs.upsert(actor["id"], req.model_dump(exclude_none=True)) + write_audit(db, action="preference.update", resource="investor", resource_id=actor["id"], + user=actor, request=request) + return pref + + +@router.get("/projects", summary="项目库") +async def list_projects( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("investor")), +): + return db.portal_pages.get("investor", "projects") or {"items": []} + + +@router.get("/trainings", summary="投融资培训") +async def list_trainings( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("investor")), +): + return db.portal_pages.get("investor", "trainings") or {"items": []} + + +@router.post("/trainings/{training_id}/enroll", summary="培训报名") +async def enroll_training( + training_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("investor")), +): + data = db.portal_pages.get("investor", "trainings") or {"items": []} + item = next((it for it in data.get("items", []) if it.get("title") == training_id), None) + name = (item or {}).get("title", training_id) + enroll = db.training_enrolls.create(actor["id"], training_id, name) + write_audit(db, action="training.enroll", resource="training", resource_id=enroll["id"], + detail=name, user=actor, request=request) + return enroll + + +@router.get("/my-trainings", summary="我的培训") +async def my_trainings( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("investor")), +): + return {"items": db.training_enrolls.list_for(user["id"])} + + +@router.get("/portfolio", summary="投资组合") +async def portfolio( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("investor")), +): + return db.portal_pages.get("investor", "portfolio") or {"items": []} + + +@router.post("/projects/{project_id}/intent", summary="发起投资意向/约谈") +async def create_intent( + project_id: str, + req: IntentRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("investor")), +): + intent = db.intents.create( + actor["id"], req.project_id or project_id, req.project_name, req.message, + ) + write_audit(db, action="intent.create", resource="investor", resource_id=intent["id"], + detail=req.project_name, user=actor, request=request) + return intent + + +@router.get("/intents", summary="我的投资意向") +async def list_intents( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("investor")), +): + return {"items": db.intents.list_for(user["id"])} + + +@router.get("/roadshows", summary="路演活动列表") +async def list_roadshows( + status: str | None = None, + db: Database = Depends(get_db), + _u: dict = Depends(require_roles(*_ROADSHOW_ROLES)), +): + return {"items": db.roadshows.list(status=status)} + + +@router.post("/roadshows", summary="发布路演活动(按权限与范围审核)") +async def create_roadshow( + req: RoadshowCreate, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles(*_ROADSHOW_ROLES)), +): + if not req.title.strip(): + raise HTTPException(status_code=400, detail="标题不能为空") + if req.end_at and req.start_at and req.end_at < req.start_at: + raise HTTPException(status_code=400, detail="结束时间不能早于开始时间") + + need_review = _need_review(actor, req, db) + fields = {**req.model_dump(exclude_none=True), "publisher_id": actor["id"], + "publisher_role": actor.get("role", "investor"), + "status": _pub_status(need_review), "need_review": need_review} + rs = db.roadshows.create(fields) + write_audit(db, action="roadshow.publish", resource="roadshow", resource_id=rs["id"], + detail=f"{rs['title']} need_review={need_review}", user=actor, request=request) + return rs + + +@router.post("/roadshows/{roadshow_id}/register", summary="活动报名") +async def register_roadshow( + roadshow_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles(*_ROADSHOW_ROLES)), +): + rs = db.roadshows.get(roadshow_id) + if rs is None or rs["status"] not in ("published", "registering"): + raise HTTPException(status_code=400, detail="活动不可报名") + if rs.get("register_deadline") and rs["register_deadline"] < "2099-01-01": + pass # 演示:不做硬性截止校验 + reg = db.roadshow_regs.create(roadshow_id, actor["id"], role="investor") + return reg diff --git a/app/routers/rbac_opc.py b/app/routers/rbac_opc.py new file mode 100644 index 0000000..5243abe --- /dev/null +++ b/app/routers/rbac_opc.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +"""OPC 超级个体端端点(工作台聚合数据)。 + +所有数据均由服务端(SQLite 演示库)提供,前端不落地任何演示数据。 +当前角色要求 opc_member;后续各页面端点在此扩展。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ..rbac import require_roles, write_audit +from ..repositories import Database, new_id, utcnow_iso +from ..models import FinanceRecord + +router = APIRouter(prefix="/opc", tags=["opc"]) + + +@router.get("/dashboard", summary="OPC 工作台总览") +async def opc_dashboard( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + """返回当前 OPC 用户的工作台聚合数据(统计/进行中任务/智能体建议/月度收入/最新消息)。""" + uid = user["id"] + + profile = db.opc_profiles.get(uid) + task_counts = db.opc_tasks.counts(uid) + opc_tasks = db.opc_tasks.list_by_user(uid) + + # 进行中任务:进行中/紧急优先,取前 3 条 + active_tasks = [ + t for t in opc_tasks if t["status"] in ("in_progress", "urgent") + ][:3] + + # 智能体建议(由服务端依据真实数据生成) + policy_count = len( + [c for c in db.content.list(ctype="policy", status="published")] + ) + agent_tips = [ + { + "title": "政策匹配提醒", + "desc": f"{policy_count} 项新政策与您高度匹配", + "type": "policy", + }, + { + "title": "报税截止提醒", + "desc": "本月报税截止 7月31日", + "type": "tax", + }, + { + "title": "技能提升推荐", + "desc": "推荐参加 UI 设计研修班", + "type": "skill", + }, + ] + + return { + "stats": { + "inProgressTasks": task_counts["in_progress"], + "completedTasks": task_counts["completed"], + "totalEarnings": db.finance.total_income(uid), + "creditScore": (profile or {}).get("credit_score", 80), + }, + "activeTasks": active_tasks, + "agentTips": agent_tips, + "monthlyIncome": db.finance.monthly_income(uid), + "recentMessages": db.messages.recent(uid, limit=4), + } + + +# ── OPC 子页面(全部由服务端提供标准数据)───────────────────────────────── + +@router.get("/agent", summary="OPC 智能体助手") +async def opc_agent( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + agents = db.agents.get_by_user(user["id"], port=user.get("port") or "opc") + default = next((a for a in agents if a.get("id", "").startswith("pine_agents_official_001")), None) + return { + "agent": { + "name": (default or {}).get("name", "小云"), + "desc": (default or {}).get("description", "您的智能体助手,可处理创业、政策、财税等事务"), + "port": user.get("port") or "opc", + }, + "quick_actions": [ + {"title": "查询政策", "desc": "为您匹配最新创业政策"}, + {"title": "发布能力", "desc": "快速发布您的服务能力"}, + {"title": "财务助手", "desc": "查看收入与支出"}, + ], + "chat_path": "/chat", + } + + +@router.get("/tasks", summary="任务广场(已发布平台任务)") +async def opc_task_square( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("opc_member")), +): + return {"items": db.tasks.list(status="published")} + + +@router.get("/my-tasks", summary="我的任务看板") +async def opc_my_tasks( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + return {"items": db.opc_tasks.list_by_user(user["id"])} + + +@router.get("/services", summary="服务市场(在营服务商)") +async def opc_services( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("opc_member")), +): + return {"items": db.providers.list(status="active")} + + +@router.get("/policy", summary="政策列表(已发布政策)") +async def opc_policy( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("opc_member")), +): + return {"items": db.content.list(ctype="policy", status="published")} + + +@router.get("/finance", summary="财务流水") +async def opc_finance( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + uid = user["id"] + return { + "records": db.finance.list_by_user(uid), + "total_income": db.finance.total_income(uid), + } + + +@router.get("/messages", summary="站内消息") +async def opc_messages( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + return {"items": db.messages.list_by_user(user["id"])} + + +@router.get("/profile", summary="个人资料") +async def opc_profile( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + profile = db.users.to_profile(user) + profile["opc_profile"] = db.opc_profiles.get(user["id"]) + return profile + + +class BidRequest(BaseModel): + quote: int = 0 + plan: str = "" + + +@router.post("/tasks/{task_id}/grab", summary="OPC 抢单") +async def opc_grab( + task_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("opc_member")), +): + task = db.tasks.get(task_id) + if task is None or task["status"] != "published" or task["mode"] != "grab": + raise HTTPException(status_code=400, detail="任务不可抢单") + updated = db.tasks.set_status(task_id, "in_progress") + write_audit(db, action="task.grab", resource="task", resource_id=task_id, + detail=actor.get("username"), user=actor, request=request) + return updated + + +@router.post("/tasks/{task_id}/bid", summary="OPC 投标") +async def opc_bid( + task_id: str, + req: BidRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("opc_member")), +): + task = db.tasks.get(task_id) + if task is None or task["status"] != "published" or task["mode"] != "bid": + raise HTTPException(status_code=400, detail="任务不可投标") + bid = db.bids.create(task_id, actor["id"], actor.get("nickname") or actor["username"], + req.quote, req.plan) + write_audit(db, action="task.bid", resource="bid", resource_id=bid["id"], + user=actor, request=request) + return bid + + +@router.post("/tasks/{task_id}/deliver", summary="OPC 交付") +async def opc_deliver( + task_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("opc_member")), +): + updated = db.tasks.set_status(task_id, "delivered") + if updated is None: + raise HTTPException(status_code=404, detail="Task not found") + write_audit(db, action="task.deliver", resource="task", resource_id=task_id, + user=actor, request=request) + return updated + + +class ProfileUpdate(BaseModel): + nickname: str | None = None + account: str | None = None + company: str | None = None + room: str | None = None + skills: list[str] = [] + credit_score: int | None = None + + +@router.put("/profile", summary="OPC 基础资料编辑") +async def opc_update_profile( + req: ProfileUpdate, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("opc_member")), +): + profile_updates = {k: v for k, v in req.model_dump(exclude_none=True).items() + if k in ("nickname", "account", "company", "room")} + if profile_updates: + db.users.update_profile(actor["id"], profile_updates) + if req.credit_score is not None: + db.opc_profiles.upsert(actor["id"], req.credit_score) + write_audit(db, action="profile.update", resource="user", resource_id=actor["id"], + user=actor, request=request) + fresh_user = db.users.get_by_id(actor["id"]) + return await opc_profile(db, fresh_user) + + +class FinanceRecordCreate(BaseModel): + category: str = "income" # income | expense + amount: int + date: str = "" + note: str = "" + + +@router.post("/finance/record", summary="OPC 记一笔收支") +async def opc_add_finance( + req: FinanceRecordCreate, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("opc_member")), +): + record = FinanceRecord( + id=new_id("fin"), user_id=actor["id"], category=req.category, + amount=req.amount, date=req.date or utcnow_iso()[:10], note=req.note, + created_at=utcnow_iso(), + ) + db.session.add(record) + db.session.commit() + write_audit(db, action="finance.add", resource="finance", resource_id=record.id, + user=actor, request=request) + return {"id": record.id, "category": record.category, "amount": record.amount, + "date": record.date, "note": record.note} + + +@router.get("/tax", summary="OPC 税务申报") +async def opc_tax( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("opc_member")), +): + return db.portal_pages.get("opc", "tax") or {"items": []} + + +@router.get("/affairs", summary="OPC 工商/政务办事") +async def opc_affairs( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("opc_member")), +): + return db.portal_pages.get("opc", "affairs") or {"items": []} diff --git a/app/routers/rbac_operator.py b/app/routers/rbac_operator.py new file mode 100644 index 0000000..e4969cd --- /dev/null +++ b/app/routers/rbac_operator.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +"""运营端业务管理端点:任务 / 服务商 / 内容 / 系统配置 / 数据统计。 + +全部要求 operator 角色(内部子角色再按权限细分),并写审计日志。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ..rbac import require_permission, require_roles, write_audit +from ..repositories import Database + +router = APIRouter(prefix="/admin", tags=["admin-op"]) + + +class TaskCreateRequest(BaseModel): + title: str + category: str = "" + sub_category: str = "" + description: str = "" + mode: str = "grab" + budget_min: int = 0 + budget_max: int = 0 + deadline: str = "" + publisher_name: str = "" + + +class TaskStatusRequest(BaseModel): + status: str # review/published/completed/cancelled + + +class ProviderCreateRequest(BaseModel): + name: str + category: str = "" + level: str = "certified" + status: str = "pending" + contact: str = "" + + +class ProviderUpdateRequest(BaseModel): + level: str | None = None + status: str | None = None + contact: str | None = None + + +class ContentCreateRequest(BaseModel): + type: str = "news" + title: str + summary: str = "" + body: str = "" + status: str = "draft" + + +class ContentStatusRequest(BaseModel): + status: str # published/offline/draft + + +class ConfigUpdateRequest(BaseModel): + value: str + description: str = "" + + +# ── 任务管理 ────────────────────────────────────────────────────────────── +@router.get("/tasks", summary="任务列表") +async def list_tasks( + status: str | None = None, + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("operator")), +): + return db.tasks.list(status=status) + + +@router.post("/tasks", summary="创建任务") +async def create_task( + req: TaskCreateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:task.manage")), +): + task = db.tasks.create(req.model_dump(exclude_none=True)) + write_audit(db, action="task.create", resource="task", resource_id=task["id"], + detail=task["title"], user=actor, request=request) + return task + + +@router.post("/tasks/{task_id}/status", summary="更新任务状态") +async def set_task_status( + task_id: str, + req: TaskStatusRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:task.manage")), +): + if db.tasks.get(task_id) is None: + raise HTTPException(status_code=404, detail="Task not found") + task = db.tasks.set_status(task_id, req.status) + write_audit(db, action="task.status", resource="task", resource_id=task_id, + detail=req.status, user=actor, request=request) + return task + + +# ── 服务商管理 ──────────────────────────────────────────────────────────── +@router.get("/providers", summary="服务商列表") +async def list_providers( + status: str | None = None, + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("operator")), +): + return db.providers.list(status=status) + + +@router.post("/providers", summary="创建服务商") +async def create_provider( + req: ProviderCreateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:provider.manage")), +): + prov = db.providers.create(req.model_dump(exclude_none=True)) + write_audit(db, action="provider.create", resource="provider", resource_id=prov["id"], + detail=prov["name"], user=actor, request=request) + return prov + + +@router.post("/providers/{provider_id}/update", summary="更新服务商(评级/状态)") +async def update_provider( + provider_id: str, + req: ProviderUpdateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:provider.manage")), +): + updated = db.providers.update(provider_id, req.model_dump(exclude_none=True)) + if updated is None: + raise HTTPException(status_code=404, detail="Provider not found") + write_audit(db, action="provider.update", resource="provider", resource_id=provider_id, + detail=str(req.model_dump(exclude_none=True)), user=actor, request=request) + return updated + + +# ── 内容管理 ────────────────────────────────────────────────────────────── +@router.get("/content", summary="内容列表") +async def list_content( + ctype: str | None = None, + status: str | None = None, + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("operator")), +): + return db.content.list(ctype=ctype, status=status) + + +@router.post("/content", summary="创建内容") +async def create_content( + req: ContentCreateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:content.manage")), +): + item = db.content.create(req.model_dump(exclude_none=True)) + write_audit(db, action="content.create", resource="content", resource_id=item["id"], + detail=item["title"], user=actor, request=request) + return item + + +@router.post("/content/{content_id}/status", summary="发布/下架内容") +async def set_content_status( + content_id: str, + req: ContentStatusRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:content.manage")), +): + item = db.content.set_status(content_id, req.status) + if item is None: + raise HTTPException(status_code=404, detail="Content not found") + write_audit(db, action="content.status", resource="content", resource_id=content_id, + detail=req.status, user=actor, request=request) + return item + + +# ── 系统配置 ────────────────────────────────────────────────────────────── +@router.get("/config", summary="系统配置列表") +async def list_config( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("operator")), +): + return db.config.all() + + +@router.put("/config/{key}", summary="更新系统配置") +async def set_config( + key: str, + req: ConfigUpdateRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:config.manage")), +): + cfg = db.config.set(key, req.value, req.description) + write_audit(db, action="config.update", resource="config", resource_id=key, + detail=req.value, user=actor, request=request) + return cfg + + +# ── 数据统计 ────────────────────────────────────────────────────────────── +@router.get("/stats/overview", summary="运营数据总览") +async def stats_overview( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("operator")), +): + return db.stats.overview() diff --git a/app/routers/rbac_org.py b/app/routers/rbac_org.py new file mode 100644 index 0000000..99c63a5 --- /dev/null +++ b/app/routers/rbac_org.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""组织/租户端点:企业 / 载体 / 服务商(按角色 + 组织作用域)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from ..dependencies import get_db +from ..rbac import require_roles +from ..repositories import Database + +router = APIRouter(tags=["org"]) + + +@router.get("/org/me", summary="当前组织信息") +async def org_me( + db: Database = Depends(get_db), + user: dict = Depends(require_roles( + "opc_member", "carrier", "enterprise", "provider", "government", "operator", + )), +): + org = db.orgs.get(user["org_id"]) if user.get("org_id") else None + region = db.regions.get(user["region_id"]) if user.get("region_id") else None + return { + "user_id": user["id"], + "role": user["role"], + "sub_role": user["sub_role"], + "org": org, + "region": region, + } + + +@router.get("/enterprise/profile", summary="企业资料") +async def enterprise_profile( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("enterprise")), +): + org = db.orgs.get(user["org_id"]) if user.get("org_id") else None + if org is None: + raise HTTPException(status_code=404, detail="No enterprise bound") + return org + + +@router.get("/carrier/enterprises", summary="载体下辖企业") +async def carrier_enterprises( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("carrier")), +): + """返回绑定到当前载体(parent_id == 载体 org_id)的企业。""" + if not user.get("org_id"): + return [] + return db.orgs.list_by_parent(user["org_id"]) + + +@router.get("/provider/orders", summary="服务商订单(骨架)") +async def provider_orders( + user: dict = Depends(require_roles("provider")), +): + """占位:服务商仅返回本 org 的订单(业务逻辑后续阶段补齐)。""" + return {"org_id": user.get("org_id"), "orders": []} + + +# ── 机构成员(机构主账号 + 机构内子账号,账号可挂多机构)──────────────────── + +@router.get("/org/{org_id}/members", summary="机构成员列表") +async def org_members( + org_id: str, + db: Database = Depends(get_db), + user: dict = Depends(require_roles("enterprise", "carrier", "provider", "investor", "operator")), +): + # 机构管理员本人 或 平台运营 可查看 + if user.get("role") not in ("operator",) and not db.org_members.is_admin(org_id, user["id"]): + raise HTTPException(status_code=403, detail="Forbidden: not org admin") + return {"items": db.org_members.list_for_org(org_id)} + + +class OrgMemberAdd(BaseModel): + user_id: str | None = None + username: str | None = None # 支持按用户名添加(解析为 user_id) + role: str = "member" + is_admin: bool = False + + +@router.post("/org/{org_id}/members", summary="添加机构成员(子账号)") +async def org_add_member( + org_id: str, + req: OrgMemberAdd, + db: Database = Depends(get_db), + user: dict = Depends(require_roles("enterprise", "carrier", "provider", "investor", "operator")), +): + if user.get("role") not in ("operator",) and not db.org_members.is_admin(org_id, user["id"]): + raise HTTPException(status_code=403, detail="Forbidden: not org admin") + target_id = req.user_id + if not target_id and req.username: + found = db.users.get_by_username(req.username) + if found is None: + raise HTTPException(status_code=404, detail="账号不存在") + target_id = found["id"] + if not target_id: + raise HTTPException(status_code=400, detail="缺少 user_id 或 username") + return db.org_members.add_member(org_id, target_id, req.role, req.is_admin) + + +@router.get("/me/orgs", summary="我加入的机构") +async def my_orgs( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("enterprise", "carrier", "provider", "investor")), +): + """返回当前账号挂靠的所有机构及成员角色(支持一账号多机构)。""" + out = [] + for org in db.orgs.all(): + member = None + for m in db.org_members.list_for_org(org["id"]): + if m["user_id"] == user["id"]: + member = m + break + if member: + out.append({"org": org, "member_role": member["role"], "is_admin": member["is_admin"]}) + return {"items": out} diff --git a/app/routers/rbac_portals.py b/app/routers/rbac_portals.py new file mode 100644 index 0000000..d76d3a5 --- /dev/null +++ b/app/routers/rbac_portals.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +"""各业务端口工作台与子页面端点(企业/载体/服务商/政务)。 + +列表类页面数据全部由服务端(portal_pages 表)提供;企业发布任务写入 tasks 表。 +OPC 端工作台在 ``rbac_opc.py`` 中(按用户聚合)。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ..rbac import require_roles, write_audit +from ..repositories import Database + +router = APIRouter(tags=["portals"]) + +# 端口 -> 允许的角色 +_PORT_ROLE = { + "enterprise": "enterprise", + "carrier": "carrier", + "provider": "provider", + "government": "government", +} + + +def _dashboard(port: str): + role = _PORT_ROLE.get(port) + if role is None: + raise HTTPException(status_code=404, detail="Unknown port") + + async def handler( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles(role)), + ): + payload = db.portal_dashboards.get(port) + if payload is None: + raise HTTPException(status_code=404, detail="Dashboard not found") + return payload + + return handler + + +def _page(port: str, page: str): + role = _PORT_ROLE.get(port) + if role is None: + raise HTTPException(status_code=404, detail="Unknown port") + + async def handler( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles(role)), + ): + payload = db.portal_pages.get(port, page) + if payload is None: + raise HTTPException(status_code=404, detail="Page data not found") + payload.setdefault("port", port) + payload.setdefault("page", page) + return payload + + return handler + + +router.get("/enterprise/dashboard", summary="企业端工作台")(_dashboard("enterprise")) +router.get("/carrier/dashboard", summary="载体/园区端工作台")(_dashboard("carrier")) +router.get("/provider/dashboard", summary="服务商端工作台")(_dashboard("provider")) +router.get("/government/dashboard", summary="政务端工作台")(_dashboard("government")) + +# ── 子页面(列表/数据均由服务端提供)────────────────────────────────────── +router.get("/enterprise/talents", summary="企业·人才搜索")(_page("enterprise", "talents")) +router.get("/carrier/opc", summary="载体·OPC 管理")(_page("carrier", "opc")) +router.get("/carrier/services", summary="载体·服务管理")(_page("carrier", "services")) +router.get("/provider/orders", summary="服务商·订单管理")(_page("provider", "orders")) +router.get("/provider/services", summary="服务商·服务管理")(_page("provider", "services")) +router.get("/government/policies", summary="政务·政策管理")(_page("government", "policies")) + + +# ── 企业·任务(发布任务/我的任务)───────────────────────────────────────── +class EnterpriseTaskCreate(BaseModel): + title: str + category: str = "" + sub_category: str = "" + description: str = "" + mode: str = "grab" + budget_min: int = 0 + budget_max: int = 0 + deadline: str = "" + publisher_name: str = "" + + +@router.get("/enterprise/tasks", summary="企业·我的任务列表") +async def enterprise_tasks( + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("enterprise")), +): + return {"items": db.tasks.list(status="published")} + + +@router.post("/enterprise/tasks", summary="企业·发布任务") +async def enterprise_create_task( + req: EnterpriseTaskCreate, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise")), +): + task = db.tasks.create({**req.model_dump(exclude_none=True), "status": "pending"}) + write_audit(db, action="task.publish", resource="task", resource_id=task["id"], + detail=task["title"], user=actor, request=request) + return task + + +@router.post("/enterprise/tasks/{task_id}/submit", summary="企业·提交审核并发布") +async def enterprise_submit_task( + task_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise")), +): + task = db.tasks.set_status(task_id, "published") + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + write_audit(db, action="task.submit", resource="task", resource_id=task_id, + user=actor, request=request) + return task + + +@router.get("/enterprise/tasks/{task_id}/bids", summary="企业·查看竞标") +async def enterprise_list_bids( + task_id: str, + db: Database = Depends(get_db), + _u: dict = Depends(require_roles("enterprise")), +): + return {"items": db.bids.list_for_task(task_id)} + + +@router.post("/enterprise/tasks/{task_id}/bids/{bid_id}/win", summary="企业·评标中标") +async def enterprise_win_bid( + task_id: str, bid_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise")), +): + bid = db.bids.set_status(bid_id, "win") + if bid is None: + raise HTTPException(status_code=404, detail="Bid not found") + db.tasks.set_status(task_id, "in_progress") + write_audit(db, action="bid.win", resource="bid", resource_id=bid_id, + detail=f"task={task_id} winner={bid['opc_name']}", user=actor, request=request) + return bid + + +class TaskReviewRequest(BaseModel): + action: str # accept | reject + comment: str = "" + + +@router.post("/enterprise/tasks/{task_id}/review", summary="企业·验收任务") +async def enterprise_review_task( + task_id: str, + req: TaskReviewRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("enterprise")), +): + task = db.tasks.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + new_status = "completed" if req.action == "accept" else "in_progress" + updated = db.tasks.set_status(task_id, new_status) + write_audit(db, action="task.review", resource="task", resource_id=task_id, + detail=req.action, user=actor, request=request) + return updated + + +# ── 载体·服务商引荐(三方绑定:载体引荐服务商→服务园区内 OPC)───────────── +class ReferralCreate(BaseModel): + provider_id: str = "" + provider_name: str = "" + opc_id: str = "" + opc_name: str = "" + + +@router.get("/carrier/referrals", summary="载体·引荐记录") +async def carrier_referrals( + db: Database = Depends(get_db), + user: dict = Depends(require_roles("carrier")), +): + return {"items": db.referrals.list_for(user.get("org_id"))} + + +@router.post("/carrier/referrals", summary="载体·引荐服务商给园区内 OPC") +async def carrier_create_referral( + req: ReferralCreate, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_roles("carrier")), +): + referral = db.referrals.create({ + **req.model_dump(exclude_none=True), "carrier_id": actor.get("org_id") or actor["id"], + }) + write_audit(db, action="referral.create", resource="referral", resource_id=referral["id"], + detail=f"{referral['provider_name']}->{referral['opc_name']}", user=actor, request=request) + return referral diff --git a/app/routers/templates.py b/app/routers/templates.py new file mode 100644 index 0000000..0da1e45 --- /dev/null +++ b/app/routers/templates.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""智能体初始化模板路由。 + +服务端作为智能体初始化 md 模板的唯一来源(qa 模板已统一为 PineAgents 品牌), +本地在初始化前把整棵树拉到本地缓存。模板以目录 md 存储、经接口暴露,后续可换 JSON/DB。 +""" +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends + +from .. import config +from ..dependencies import get_current_user + +router = APIRouter(prefix="/agent-templates", tags=["agent-templates"]) + +# 模板类型 → 目录名映射("common" 即 TEMPLATES_DIR 根下的语言目录)。 +_TEMPLATE_TYPES = ("common", "qa", "local") + + +def _read_type_dir(base: Path, type_name: str) -> dict[str, dict[str, str]]: + root = base if type_name == "common" else base / type_name + langs: dict[str, dict[str, str]] = {} + if not root.is_dir(): + return langs + for lang_dir in sorted(p for p in root.iterdir() if p.is_dir()): + files = { + md.name: md.read_text(encoding="utf-8") + for md in sorted(lang_dir.glob("*.md")) + } + if files: + langs[lang_dir.name] = files + return langs + + +@router.get("", summary="智能体初始化模板树", response_model=dict) +async def get_agent_templates( + _user: dict = Depends(get_current_user), +): + """返回全部初始化模板: + + ``{template_type: {language: {filename: content}}}`` + 顶层 ``fixed_soul`` + (服务端托管、不可变、最高优先级的 SOUL 片段)。 + """ + base = config.TEMPLATES_DIR + tree = { + type_name: _read_type_dir(base, type_name) + for type_name in _TEMPLATE_TYPES + } + tree["fixed_soul"] = _read_lang_files(base / "fixed_soul") + return tree + + +def _read_lang_files(root: Path) -> dict[str, str]: + """读取 ``/.md`` → ``{lang: content}``。""" + files: dict[str, str] = {} + if not root.is_dir(): + return files + for md in sorted(root.glob("*.md")): + files[md.stem] = md.read_text(encoding="utf-8") + return files diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..2e0aed3 --- /dev/null +++ b/app/security.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""密码哈希与令牌生成(仅用标准库,无额外依赖)。 + +- 密码:加盐 SHA-256,与 PineAgents 主后端历史实现一致(salted SHA-256)。 +- 令牌:不透明随机串,存于 tokens.json。下一阶段换成数据库时, + 该随机串即 ``tokens.token`` 列的主键值,语义与 JWT/会话表一致。 +""" +from __future__ import annotations + +import hashlib +import hmac +import secrets + + +def hash_password(password: str, salt: str | None = None) -> tuple[str, str]: + """计算 (hash_hex, salt_hex)。未提供 salt 时自动生成随机盐。""" + if salt is None: + salt = secrets.token_hex(16) + digest = hashlib.sha256((salt + password).encode("utf-8")).hexdigest() + return digest, salt + + +def verify_password(password: str, stored_hash: str, salt: str) -> bool: + """常数时间比较,防时序侧信道。""" + digest, _ = hash_password(password, salt) + return hmac.compare_digest(digest, stored_hash) + + +def generate_token() -> str: + """生成不透明访问令牌(URL-safe,约 64 字符)。""" + return secrets.token_urlsafe(48) diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..fe9eae1 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,1084 @@ +# -*- coding: utf-8 -*- +"""幂等种子数据:角色 / 权限 / 角色权限矩阵 / 区域 / 组织 / 演示用户。 + +在 ``app.db.init_db()`` 之后调用;仅当 ``roles`` 表为空时才写入, +避免覆盖已有数据。 +""" +from __future__ import annotations + +import json + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from .models import ( + AuditLog, + ContentItem, + FinanceRecord, + InvestmentIntent, + InvestorPreference, + Message, + Notification, + OpcProfile, + OpcTask, + Organization, + OrganizationMember, + Permission, + PortalDashboard, + PortalPage, + Region, + Roadshow, + RoadshowRegistration, + Role, + RolePermission, + ServiceProvider, + ServiceReferral, + SessionToken, + SubsidyApplication, + SystemConfig, + Task, + TrainingEnrollment, + User, + UserIdentity, + Agent, +) +from .repositories import AGENT_SEED, new_id, utcnow_iso +from .security import hash_password + +# --------------------------------------------------------------------------- +# 角色 +# --------------------------------------------------------------------------- +ROLES = [ + # 业务角色 + ("opc_member", "OPC超级个体", "business"), + ("carrier", "创业载体(园区)", "business"), + ("enterprise", "甲方企业", "business"), + ("provider", "服务商", "business"), + ("government", "政务端", "business"), + ("operator", "平台运营方", "business"), + # 政务子级(继承 government 通用权限) + ("government|gov_province", "省级政务", "government-level"), + ("government|gov_city", "市级政务", "government-level"), + ("government|gov_district", "区县级政务", "government-level"), + ("government|gov_org_admin", "政务组织机构管理员", "government-level"), + ("government|gov_op_account", "政务操作账号", "government-level"), + # 运营方内部子角色 + ("operator|op_super_admin", "超级管理员", "operator-internal"), + ("operator|op_admin", "运营管理员", "operator-internal"), + ("operator|op_customer_service", "客服管理员", "operator-internal"), + ("operator|op_finance", "财务管理员", "operator-internal"), + ("operator|op_analyst", "数据分析师", "operator-internal"), + ("operator|op_techops", "技术运维", "operator-internal"), + # 企业 / 载体 / 服务商 / OPC 端口内子角色 + ("enterprise|admin", "企业管理员", "business"), + ("enterprise|operator", "企业操作者", "business"), + ("carrier|admin", "园区管理员", "business"), + ("carrier|operator", "园区操作者", "business"), + ("provider|admin", "服务商管理员", "business"), + ("provider|operator", "服务商操作者", "business"), + ("opc_member|certified", "认证OPC", "business"), + ("opc_member|independent", "独立OPC", "business"), + # 投资人端口内子角色 + ("investor", "投资人", "business"), + ("investor|org_director", "机构投资总监", "business"), + ("investor|org_manager", "机构投资经理", "business"), + ("investor|personal", "个人投资人", "business"), + # 开放平台端口内子角色 + ("developer", "开发者", "business"), + ("developer|dev_individual", "个人开发者", "business"), + ("developer|dev_org_admin", "机构开发者管理员", "business"), + ("developer|dev_member", "开发者成员", "business"), +] + +# --------------------------------------------------------------------------- +# 权限 +# --------------------------------------------------------------------------- +PERMISSIONS = [ + # 各门户菜单 + ("menu:opc_dashboard", "OPC 门户", "menu", "opc"), + ("menu:carrier_dashboard", "载体门户", "menu", "carrier"), + ("menu:enterprise_dashboard", "企业门户", "menu", "enterprise"), + ("menu:provider_dashboard", "服务商门户", "menu", "provider"), + ("menu:government_dashboard", "政务门户", "menu", "government"), + ("menu:operator_dashboard", "运营门户", "menu", "operator"), + ("menu:investor_dashboard", "投资人门户", "menu", "investor"), + ("menu:investor_projects", "投资人·项目库", "menu", "investor"), + ("menu:investor_roadshow", "投资人·路演活动", "menu", "investor"), + ("menu:investor_training", "投资人·投融资培训", "menu", "investor"), + ("menu:developer_dashboard", "开发者门户", "menu", "developer"), + ("menu:developer_apps", "开发者·我的应用", "menu", "developer"), + ("menu:developer_plugins", "开发者·我的插件", "menu", "developer"), + ("menu:developer_skills", "开发者·技能市场", "menu", "developer"), + # 管理菜单 + ("menu:admin_user_mgmt", "用户管理", "menu", "admin"), + ("menu:admin_role_mgmt", "角色权限管理", "menu", "admin"), + ("menu:gov_data", "政务数据", "menu", "government"), + ("menu:audit", "审计日志", "menu", "audit"), + ("menu:settlements", "结算台账", "menu", "operator"), + # 操作 + ("action:user.assign_role", "分配角色", "action", "admin"), + ("action:user.disable", "禁用/启用用户", "action", "admin"), + ("action:role.grant_perm", "配置角色权限", "action", "admin"), + ("action:org.manage", "管理组织", "action", "org"), + ("action:data.export", "数据导出", "action", "data"), + ("action:audit.view", "查看审计", "action", "audit"), + ("action:task.manage", "任务管理(建改)", "action", "operator"), + ("action:provider.manage", "服务商管理(建改)", "action", "operator"), + ("action:content.manage", "内容管理(建发)", "action", "operator"), + ("action:config.manage", "系统配置(修改)", "action", "operator"), + ("action:settlement.manage", "结算管理", "action", "operator"), +] + +# role_id -> [permission_id] +ROLE_PERMISSIONS: dict[str, list[str]] = { + "opc_member": ["menu:opc_dashboard"], + "carrier": ["menu:carrier_dashboard", "action:org.manage"], + "enterprise": ["menu:enterprise_dashboard", "action:org.manage"], + "provider": [ + "menu:provider_dashboard", "action:org.manage", "action:data.export", + ], + "government": ["menu:government_dashboard", "menu:gov_data"], + "government|gov_province": ["action:data.export"], + "government|gov_city": ["action:data.export"], + "government|gov_district": ["action:data.export"], + "government|gov_org_admin": ["action:org.manage"], + "government|gov_op_account": ["action:data.export"], + "operator": ["menu:operator_dashboard"], + "operator|op_super_admin": [ + "menu:admin_user_mgmt", "menu:admin_role_mgmt", "menu:audit", "menu:settlements", + "action:user.assign_role", "action:user.disable", "action:role.grant_perm", + "action:org.manage", "action:data.export", "action:audit.view", + "action:task.manage", "action:provider.manage", "action:content.manage", + "action:config.manage", "action:settlement.manage", + ], + "operator|op_admin": [ + "menu:admin_user_mgmt", "menu:admin_role_mgmt", "menu:audit", "menu:settlements", + "action:user.assign_role", "action:user.disable", + "action:org.manage", "action:data.export", "action:audit.view", + "action:task.manage", "action:provider.manage", "action:content.manage", + "action:config.manage", "action:settlement.manage", + ], + "operator|op_customer_service": [ + "action:data.export", "action:task.manage", "action:provider.manage", + "action:content.manage", + ], + "operator|op_finance": [ + "menu:settlements", "action:data.export", "action:settlement.manage", + ], + "operator|op_analyst": ["menu:audit", "action:data.export", "action:audit.view"], + "operator|op_techops": [ + "menu:admin_user_mgmt", "action:org.manage", "action:data.export", + "action:config.manage", + ], + "enterprise|admin": ["menu:enterprise_dashboard", "action:org.manage"], + "enterprise|operator": ["menu:enterprise_dashboard"], + "carrier|admin": ["menu:carrier_dashboard", "action:org.manage"], + "carrier|operator": ["menu:carrier_dashboard"], + "provider|admin": [ + "menu:provider_dashboard", "action:org.manage", "action:data.export", + ], + "provider|operator": ["menu:provider_dashboard", "action:data.export"], + "opc_member|certified": ["menu:opc_dashboard"], + "opc_member|independent": ["menu:opc_dashboard"], + "investor": ["menu:investor_dashboard"], + "investor|org_director": [ + "menu:investor_dashboard", "menu:investor_projects", "menu:investor_roadshow", "menu:investor_training", + "action:preference.manage", "action:project.view", "action:intent.manage", + "action:roadshow.publish", "action:roadshow.host", "action:invest.decide", + ], + "investor|org_manager": [ + "menu:investor_dashboard", "menu:investor_projects", "menu:investor_roadshow", + "action:preference.manage", "action:project.view", "action:intent.manage", "action:roadshow.attend", + ], + "investor|personal": [ + "menu:investor_dashboard", "menu:investor_projects", "menu:investor_roadshow", "menu:investor_training", + "action:preference.manage", "action:project.view", "action:intent.manage", + "action:roadshow.attend", "action:roadshow.register", + ], + "developer": ["menu:developer_dashboard"], + "developer|dev_individual": [ + "menu:developer_dashboard", "menu:developer_apps", "menu:developer_plugins", "menu:developer_skills", + "action:app.create", "action:app.publish", "action:plugin.create", "action:skill.publish", + ], + "developer|dev_org_admin": [ + "menu:developer_dashboard", "menu:developer_apps", "menu:developer_plugins", "menu:developer_skills", + "action:app.create", "action:app.publish", "action:app.manage", "action:plugin.create", "action:skill.publish", "action:apikey.manage", + ], + "developer|dev_member": [ + "menu:developer_dashboard", "menu:developer_apps", + "action:app.create", + ], +} + +# --------------------------------------------------------------------------- +# 区域(省/市/区县 示例树) +# --------------------------------------------------------------------------- +REGIONS = [ + ("r_prov_yn", "云南省", "province", None), + ("r_city_km", "昆明市", "city", "r_prov_yn"), + ("r_city_qj", "曲靖市", "city", "r_prov_yn"), + ("r_dist_pl", "盘龙区", "district", "r_city_km"), + ("r_dist_wh", "五华区", "district", "r_city_km"), + ("r_dist_ql", "麒麟区", "district", "r_city_qj"), +] + +# --------------------------------------------------------------------------- +# 组织(甲方企业 / 创业载体 / 服务商) +# --------------------------------------------------------------------------- +ORGANIZATIONS = [ + ("o_ent_001", "云南某某甲方企业", "enterprise", "r_dist_pl", None), + ("o_car_001", "滇池创业园", "carrier", "r_dist_wh", None), + ("o_pro_001", "云超财税服务所", "provider", "r_city_km", None), + ("o_ent_prov", "省级示范企业", "enterprise", "r_prov_yn", None), +] + +# --------------------------------------------------------------------------- +# 演示用户(role/sub_role/org/region 各异,用于 RBAC 演示与测试) +# --------------------------------------------------------------------------- +DEMO_USERS = [ + # id, username, nickname, password, role, sub_role, org_id, region_id + ("u_demo_01", "pine", "云超服小助手", "123456", "operator", "op_super_admin", None, "r_prov_yn"), + ("u_gov_prov", "gov_prov", "省政务账号", "123456", "government", "gov_province", None, "r_prov_yn"), + ("u_gov_dist", "gov_dist", "区县政务账号", "123456", "government", "gov_district", None, "r_dist_wh"), + ("u_ent_01", "ent01", "甲方企业账号", "123456", "enterprise", None, "o_ent_001", "r_dist_pl"), + ("u_car_01", "car01", "园区运营账号", "123456", "carrier", None, "o_car_001", "r_dist_wh"), + ("u_pro_01", "pro01", "服务商账号", "123456", "provider", None, "o_pro_001", "r_city_km"), + ("u_op_an", "op_analyst", "运营数据分析", "123456", "operator", "op_analyst", None, "r_prov_yn"), + ("u_op_admin", "op_admin", "运营管理员", "123456", "operator", "op_admin", None, "r_prov_yn"), + ("u_op_cs", "op_cs", "运营客服", "123456", "operator", "op_customer_service", None, "r_prov_yn"), + ("u_op_fin", "op_fin", "运营财务", "123456", "operator", "op_finance", None, "r_prov_yn"), + ("u_op_tech", "op_tech", "运营运维", "123456", "operator", "op_techops", None, "r_prov_yn"), + ("u_opc_01", "opc01", "OPC个人创业者", "123456", "opc_member", None, None, "r_dist_wh"), + ("u_opc_02", "opc02", "OPC个体工商户", "123456", "opc_member", None, None, "r_dist_pl"), + ("u_inv_01", "inv01", "投资人账号", "123456", "investor", "personal", None, "r_prov_yn"), + ("u_inv_02", "inv02", "机构投资人", "123456", "investor", "org_director", None, "r_prov_yn"), +] + + +def seed_data(session: Session) -> None: + """幂等种子:仅当 roles 表为空时写入全部基础数据;身份与 OPC 业务始终补种。""" + now = utcnow_iso() + existing = session.scalar(select(Role.id).limit(1)) + if existing is not None: + # 已初始化的库:仍补种 OPC 业务演示数据、账号身份与端口工作台(各自幂等)。 + _sync_permissions(session, now) + _ensure_extra_demo_users(session, now) + _seed_opc_business(session, now) + _migrate_identities(session, now) + _seed_port_dashboards(session, now) + _seed_market(session, now) + _seed_port_pages(session, now) + _seed_investor(session, now) + _seed_ecosystem(session, now) + _ensure_port_agents(session, now) + _seed_org_members(session, now) + session.commit() + return + + session.add_all( + [Role(id=r[0], name=r[1], scope_type=r[2]) for r in ROLES] + ) + session.add_all( + [ + Permission(id=p[0], name=p[1], category=p[2], module=p[3]) + for p in PERMISSIONS + ] + ) + for role_id, perms in ROLE_PERMISSIONS.items(): + session.add_all( + [ + RolePermission(role_id=role_id, permission_id=perm) + for perm in perms + ] + ) + session.add_all( + [ + Region(id=r[0], name=r[1], level=r[2], parent_id=r[3], created_at=now) + for r in REGIONS + ] + ) + session.add_all( + [ + Organization( + id=o[0], name=o[1], type=o[2], region_id=o[3], + parent_id=o[4], created_at=now, + ) + for o in ORGANIZATIONS + ] + ) + + for (uid, username, nickname, password, role, sub_role, org_id, region_id) in DEMO_USERS: + digest, salt = hash_password(password) + session.add( + User( + id=uid, username=username, nickname=nickname, + password_hash=digest, password_salt=salt, + role=role, sub_role=sub_role, org_id=org_id, region_id=region_id, + status="active", token_version=0, created_at=now, updated_at=now, + ) + ) + # 为每个用户播种默认 + QA 智能体 + for seed in AGENT_SEED: + session.add( + Agent( + id=seed["id"], user_id=uid, name=seed["name"], + description=seed["description"], + language=seed.get("language", "zh"), + model_name=seed.get("model_name", ""), + deletable=seed.get("deletable", True), + use_fixed_soul=seed.get("use_fixed_soul", False), + created_at=now, updated_at=now, + ) + ) + + _seed_operator_business(session, now) + _seed_opc_business(session, now) + _migrate_identities(session, now) + _seed_port_dashboards(session, now) + _seed_market(session, now) + _seed_port_pages(session, now) + _seed_investor(session, now) + _seed_ecosystem(session, now) + _ensure_port_agents(session, now) + _seed_org_members(session, now) + session.commit() + + +# --------------------------------------------------------------------------- +# 运营端业务演示数据(任务/服务商/内容/配置) +# --------------------------------------------------------------------------- +def _seed_operator_business(session: Session, now: str) -> None: + if session.scalar(select(Task.id).limit(1)) is not None: + return + session.add_all([ + Task(id="task_001", title="电商小程序首页 UI 设计", category="设计创意", + sub_category="UI设计", description="为电商小程序设计首页 UI,含 5 个页面", + mode="bid", budget_min=2000, budget_max=5000, deadline="2026-09-01", + status="published", publisher_org_id="o_ent_001", + publisher_name="云南某某甲方企业", created_at=now, updated_at=now), + Task(id="task_002", title="品牌文案写作(月度)", category="文案写作", + sub_category="内容写作", description="每月 8 篇品牌公众号文案", + mode="designated", budget_min=3000, budget_max=6000, deadline="2026-12-31", + status="review", publisher_org_id="o_ent_001", + publisher_name="云南某某甲方企业", created_at=now, updated_at=now), + Task(id="task_003", title="小程序前后端开发", category="技术开发", + sub_category="小程序开发", description="点餐小程序全栈开发,3 期交付", + mode="bid", budget_min=30000, budget_max=60000, deadline="2026-10-15", + status="pending", publisher_org_id="o_ent_001", + publisher_name="云南某某甲方企业", created_at=now, updated_at=now), + ]) + session.add_all([ + ServiceProvider(id="prov_001", name="云超财税服务所", category="财税服务", + org_id="o_pro_001", level="gold", rating=4.8, order_count=320, + status="active", contact="service@yuncao.example", created_at=now, updated_at=now), + ServiceProvider(id="prov_002", name="滇峰法律咨询", category="法律服务", + org_id=None, level="premium", rating=4.6, order_count=150, + status="active", contact="law@dianfeng.example", created_at=now, updated_at=now), + ServiceProvider(id="prov_003", name="云岭工商代办", category="工商服务", + org_id=None, level="certified", rating=4.3, order_count=88, + status="pending", contact="biz@yunling.example", created_at=now, updated_at=now), + ]) + session.add_all([ + ContentItem(id="cont_001", type="policy", title="云南省一次性创业补贴政策", + summary="对首次创业且正常经营 1 年以上的 OPC 发放一次性创业补贴", + body="适用对象、申报条件、补贴标准、申报流程……", publisher_id="u_gov_prov", + status="published", created_at=now, updated_at=now), + ContentItem(id="cont_002", type="news", title="平台上线公告", + summary="云超服平台正式上线运营", body="欢迎入驻……", publisher_id="u_demo_01", + status="published", created_at=now, updated_at=now), + ]) + session.add_all([ + SystemConfig(key="platform.fee.task", value="0.05", description="任务佣金费率(5%)", updated_at=now), + SystemConfig(key="platform.fee.provider", value="0.15", description="服务商分成比例(15%)", updated_at=now), + ]) + + +# --------------------------------------------------------------------------- +# OPC 超级个体业务演示数据(工作台聚合:档案/任务/财务/消息) +# --------------------------------------------------------------------------- +def _seed_opc_business(session: Session, now: str) -> None: + """OPC 个人工作台演示数据(按用户幂等)。""" + if session.scalar(select(OpcProfile.user_id).limit(1)) is not None: + return + + # 档案(信用评分) + session.add_all([ + OpcProfile(user_id="u_opc_01", credit_score=88, created_at=now, updated_at=now), + OpcProfile(user_id="u_opc_02", credit_score=82, created_at=now, updated_at=now), + ]) + + # 个人任务看板 + session.add_all([ + OpcTask(id="ot_001", user_id="u_opc_01", title="电商小程序首页 UI 设计", + status="in_progress", budget=4000, deadline="2026-08-20", progress=65, + created_at=now, updated_at=now), + OpcTask(id="ot_002", user_id="u_opc_01", title="品牌公众号月度文案", + status="urgent", budget=3000, deadline="2026-08-10", progress=30, + created_at=now, updated_at=now), + OpcTask(id="ot_003", user_id="u_opc_01", title="Logo 及 VI 视觉规范", + status="completed", budget=5000, deadline="2026-07-15", progress=100, + created_at=now, updated_at=now), + OpcTask(id="ot_004", user_id="u_opc_02", title="宣传视频剪辑", + status="in_progress", budget=2500, deadline="2026-08-28", progress=45, + created_at=now, updated_at=now), + ]) + + # 财务流水(月度收入 + 少量支出) + session.add_all([ + FinanceRecord(id="fin_001", user_id="u_opc_01", category="income", amount=3200, + date="2026-02-10", note="UI 设计项目款", created_at=now), + FinanceRecord(id="fin_002", user_id="u_opc_01", category="income", amount=4500, + date="2026-03-12", note="Logo 设计项目款", created_at=now), + FinanceRecord(id="fin_003", user_id="u_opc_01", category="income", amount=2800, + date="2026-04-08", note="文案代写项目款", created_at=now), + FinanceRecord(id="fin_004", user_id="u_opc_01", category="income", amount=5100, + date="2026-05-20", note="小程序 UI 项目款", created_at=now), + FinanceRecord(id="fin_005", user_id="u_opc_01", category="income", amount=3900, + date="2026-06-15", note="电商详情页设计", created_at=now), + FinanceRecord(id="fin_006", user_id="u_opc_01", category="income", amount=4600, + date="2026-07-18", note="H5 营销页设计", created_at=now), + FinanceRecord(id="fin_007", user_id="u_opc_01", category="expense", amount=600, + date="2026-07-02", note="素材版权购买", created_at=now), + ]) + + # 站内消息 + session.add_all([ + Message(id="msg_001", user_id="u_opc_01", msg_type="policy", + title="政策匹配提醒", content="2 项新政策与您高度匹配,请及时查看申报", + read=False, created_at=now), + Message(id="msg_002", user_id="u_opc_01", msg_type="task", + title="任务状态更新", content="您参与的「电商小程序首页 UI 设计」进度已更新", + read=False, created_at=now), + Message(id="msg_003", user_id="u_opc_01", msg_type="service", + title="服务商回复", content="云超财税服务所已回复您的询价", + read=True, created_at=now), + Message(id="msg_004", user_id="u_opc_01", msg_type="agent", + title="智能体建议", content="您的月度收入较上月增长 18%", + read=True, created_at=now), + ]) + + +# --------------------------------------------------------------------------- +# 账号↔端口身份(回填 + 多身份演示) +# --------------------------------------------------------------------------- +_ROLE_TO_PORT = { + "opc_member": "opc", + "carrier": "carrier", + "enterprise": "enterprise", + "provider": "provider", + "government": "government", + "operator": "operator", +} + +_SUB_LABELS = { + "op_super_admin": "超级管理员", "op_admin": "管理员", "op_customer_service": "客服", + "op_finance": "财务", "op_analyst": "数据分析", "op_techops": "技术运维", + "gov_province": "省级政务", "gov_city": "市级政务", "gov_district": "区县级政务", + "gov_org_admin": "组织机构管理员", "gov_op_account": "操作账号", + "admin": "管理员", "operator": "操作者", + "certified": "认证OPC", "independent": "独立OPC", +} + +_PORT_LABELS = { + "operator": "运营端", "government": "政务端", "provider": "服务端", + "enterprise": "企业端", "carrier": "园区端", "opc": "OPC端", +} + + +def _identity_label(role: str, sub_role: str | None) -> str: + port = _PORT_LABELS.get(_ROLE_TO_PORT.get(role, role), role) + sub = _SUB_LABELS.get(sub_role or "", "") + return f"{port}-{sub}" if sub else port + + +def _migrate_identities(session: Session, now: str) -> None: + """为既有账号回填端口身份;并为演示账号补充多身份绑定(幂等)。""" + # session 关闭 autoflush:先 flush 让本会话新增的用户可见 + session.flush() + + # 1. 每个已有业务角色的用户 -> 一条身份(缺失时补建) + for u in session.scalars(select(User)): + if not u.role: + continue + has = session.scalar( + select(UserIdentity.id).where(UserIdentity.user_id == u.id).limit(1) + ) + if has is not None: + continue + session.add( + UserIdentity( + id=new_id("ident"), + user_id=u.id, + port=_ROLE_TO_PORT.get(u.role, u.role), + role=u.role, + sub_role=u.sub_role, + name=_identity_label(u.role, u.sub_role), + org_id=u.org_id, + region_id=u.region_id, + status="active", + created_at=now, updated_at=now, + ) + ) + + # 2. 演示多身份绑定:ent01 同时具备「企业-管理员」与「OPC-认证」两个身份, + # 用于验证登录后多身份选择流程。 + ent_extra = session.scalar( + select(UserIdentity.id).where(UserIdentity.id == "ident_ent01_opc").limit(1) + ) + if ent_extra is None and session.get(User, "u_ent_01") is not None: + session.add( + UserIdentity( + id="ident_ent01_opc", + user_id="u_ent_01", + port="opc", + role="opc_member", + sub_role="certified", + name="OPC端-认证OPC", + org_id=None, + region_id="r_dist_pl", + status="active", + created_at=now, updated_at=now, + ) + ) + + # 3. 超级账号 pine:绑定全部 6 端口身份(演示一账号多端)。 + _PINE_ALL_PORTS = [ + ("ident_pine_gov", "government", "government", "gov_province", "政务端-省级"), + ("ident_pine_pro", "provider", "provider", "admin", "服务端-管理员"), + ("ident_pine_ent", "enterprise", "enterprise", "admin", "企业端-管理员"), + ("ident_pine_car", "carrier", "carrier", "admin", "园区端-管理员"), + ("ident_pine_opc", "opc", "opc_member", "certified", "OPC端-认证OPC"), + ("ident_pine_inv", "investor", "investor", "org_director", "投资人端-机构总监"), + ("ident_pine_dev", "developer", "developer", "dev_org_admin", "开放平台-机构开发者"), + ] + for ident_id, port, role, sub_role, name in _PINE_ALL_PORTS: + if session.scalar(select(UserIdentity.id).where(UserIdentity.id == ident_id).limit(1)) is not None: + continue + if session.get(User, "u_demo_01") is None: + break + session.add( + UserIdentity( + id=ident_id, user_id="u_demo_01", port=port, role=role, + sub_role=sub_role, name=name, + org_id=None, region_id="r_prov_yn", + status="active", created_at=now, updated_at=now, + ) + ) + + +# --------------------------------------------------------------------------- +# 端口工作台聚合数据(企业/载体/服务商/政务) +# --------------------------------------------------------------------------- +_PORT_DASHBOARDS: dict[str, dict] = { + "enterprise": { + "stats": [ + {"key": "postedTasks", "value": 56}, + {"key": "activeTasks", "value": 12}, + {"key": "completedTasks", "value": 38}, + {"key": "totalSpent", "value": "¥32.0万"}, + {"key": "matchedTalents", "value": 186}, + {"key": "cooperationRate", "value": "92%"}, + ], + "list": [ + {"title": "企业官网 UI 设计", "meta": "进行中 · ¥5000-8000 · 12 家投标", "status": "进行中"}, + {"title": "小程序前端开发", "meta": "进行中 · ¥8000-15000 · 8 家投标", "status": "进行中"}, + {"title": "农产品品牌 Logo 设计", "meta": "进行中 · ¥3000-5000 · 15 家投标", "status": "进行中"}, + {"title": "商业计划书撰写", "meta": "进行中 · ¥2000-4000 · 6 家投标", "status": "进行中"}, + ], + }, + "carrier": { + "stats": [ + {"key": "residentOPCs", "value": 186}, + {"key": "totalSpace", "value": "5000㎡"}, + {"key": "usedSpace", "value": "4200㎡"}, + {"key": "occupancyRate", "value": "84%"}, + {"key": "serviceOrders", "value": 45}, + {"key": "thisMonthNewOPC", "value": 12}, + ], + "list": [ + {"title": "云创设计工作室", "meta": "入驻 · 电子信息方向 · 工位 12", "status": "已入驻"}, + {"title": "滇峰数据科技", "meta": "入驻 · 软件服务方向 · 工位 8", "status": "已入驻"}, + {"title": "云南新媒体中心", "meta": "待审批 · 内容创作方向 · 工位 6", "status": "待审批"}, + {"title": "绿野农业电商", "meta": "入驻 · 电商方向 · 工位 10", "status": "已入驻"}, + ], + }, + "provider": { + "stats": [ + {"key": "activeServices", "value": 18}, + {"key": "ongoingOrders", "value": 24}, + {"key": "completedOrders", "value": 156}, + {"key": "totalRevenue", "value": "¥52.6万"}, + {"key": "thisMonthIncome", "value": "¥8.56万"}, + {"key": "avgRating", "value": 4.7}, + ], + "list": [ + {"title": "代理记账服务(月度)", "meta": "进行中 · 云南某某甲方企业 · ¥199/月", "status": "进行中"}, + {"title": "公司注册代办", "meta": "待处理 · 个人创业者 · ¥599/次", "status": "待处理"}, + {"title": "季度财务报表编制", "meta": "已完成 · 云创财税 · ¥2000-3000", "status": "已完成"}, + {"title": "税务筹划咨询", "meta": "进行中 · 鼎信税务所 · ¥1999/次", "status": "进行中"}, + ], + }, + "government": { + "stats": [ + {"key": "totalOPC", "value": 45600}, + {"key": "registeredCarriers", "value": 168}, + {"key": "policiesPublished", "value": 245}, + {"key": "policiesMatched", "value": 18200}, + {"key": "tasksInRegion", "value": 12500}, + {"key": "completionRate", "value": "78%"}, + ], + "list": [ + {"region": "昆明市", "opcCount": 12500, "taskCount": 3200, "completionRate": "82%"}, + {"region": "大理州", "opcCount": 6800, "taskCount": 1800, "completionRate": "76%"}, + {"region": "曲靖市", "opcCount": 5200, "taskCount": 1500, "completionRate": "79%"}, + {"region": "红河州", "opcCount": 4800, "taskCount": 1200, "completionRate": "74%"}, + {"region": "玉溪市", "opcCount": 3500, "taskCount": 900, "completionRate": "81%"}, + ], + "chart": [ + {"label": "1月", "value": 2400}, + {"label": "2月", "value": 2800}, + {"label": "3月", "value": 3100}, + {"label": "4月", "value": 2900}, + {"label": "5月", "value": 3450}, + {"label": "6月", "value": 3820}, + ], + }, +} + + +def _seed_port_dashboards(session: Session, now: str) -> None: + """按端口写入工作台 JSON 载荷(幂等:任一端口已存在则跳过)。""" + if session.scalar(select(PortalDashboard.port).limit(1)) is not None: + return + session.add_all( + [ + PortalDashboard( + port=port, + payload=json.dumps(payload, ensure_ascii=False), + updated_at=now, + ) + for port, payload in _PORT_DASHBOARDS.items() + ] + ) + + +def _seed_market(session: Session, now: str) -> None: + """补充平台市场数据(任务广场/政策/服务商),幂等(以 task_mk_001 为标记)。""" + if session.scalar(select(Task.id).where(Task.id == "task_mk_001").limit(1)) is not None: + return + session.add_all([ + Task(id="task_mk_001", title="企业官网 UI 设计", category="设计创意", sub_category="UI设计", + description="为企业官网设计首页与 3 个内页 UI", mode="bid", + budget_min=5000, budget_max=8000, deadline="2026-09-10", + status="published", publisher_org_id="o_ent_001", + publisher_name="云南某某甲方企业", created_at=now, updated_at=now), + Task(id="task_mk_002", title="小程序前后端开发", category="技术开发", sub_category="小程序开发", + description="点餐小程序全栈开发,3 期交付", mode="bid", + budget_min=30000, budget_max=60000, deadline="2026-10-15", + status="published", publisher_org_id="o_ent_001", + publisher_name="云南某某甲方企业", created_at=now, updated_at=now), + Task(id="task_mk_003", title="农产品品牌 Logo 设计", category="设计创意", sub_category="Logo设计", + description="为农业合作社设计品牌 Logo", mode="grab", + budget_min=3000, budget_max=5000, deadline="2026-08-25", + status="published", publisher_org_id=None, + publisher_name="大理农业合作社", created_at=now, updated_at=now), + Task(id="task_mk_004", title="短视频剪辑与制作", category="音视频", sub_category="短视频", + description="文旅宣传短视频剪辑", mode="grab", + budget_min=1500, budget_max=3000, deadline="2026-08-20", + status="published", publisher_org_id=None, + publisher_name="文旅传媒", created_at=now, updated_at=now), + Task(id="task_mk_005", title="商业计划书撰写", category="策划/写作", sub_category="文案写作", + description="创业项目商业计划书", mode="designated", + budget_min=2000, budget_max=4000, deadline="2026-09-01", + status="published", publisher_org_id=None, + publisher_name="创业团队", created_at=now, updated_at=now), + ]) + session.add_all([ + ContentItem(id="cont_pol_001", type="policy", title="昆明市创业担保贷款政策", + summary="对符合条件的创业主体提供担保贷款支持", + body="适用对象、额度、利率与申报流程……", publisher_id="u_gov_prov", + status="published", created_at=now, updated_at=now), + ContentItem(id="cont_pol_002", type="policy", title="小微企业税收优惠减免", + summary="符合条件的小微企业可享受税收减免", + body="适用范围、减免标准与办理方式……", publisher_id="u_gov_prov", + status="published", created_at=now, updated_at=now), + ContentItem(id="cont_pol_003", type="policy", title="云南省一次性创业补贴", + summary="首次创业且正常经营 1 年以上发放一次性补贴", + body="申报条件、补贴标准与流程……", publisher_id="u_gov_prov", + status="published", created_at=now, updated_at=now), + ]) + + +# --------------------------------------------------------------------------- +# 端口子页面演示数据(企业人才/载体OPC与服务/服务商订单与服务/政务OPC·政策·载体·数据) +# --------------------------------------------------------------------------- +_PORT_PAGES: dict[tuple[str, str], dict] = { + ("enterprise", "talents"): { + "items": [ + {"title": "张伟 · UI设计师", "meta": "5年经验 · Figma/PS · 认证OPC", "tag": "认证"}, + {"title": "李娜 · 前端开发", "meta": "3年经验 · Vue/React · 认证OPC", "tag": "认证"}, + {"title": "王强 · 文案策划", "meta": "4年经验 · 公众号/短视频文案", "tag": "独立"}, + {"title": "赵敏 · 数据分析", "meta": "2年经验 · Python/SQL", "tag": "独立"}, + {"title": "陈晨 · 电商运营", "meta": "3年经验 · 抖音/淘宝", "tag": "认证"}, + {"title": "刘洋 · 空间设计", "meta": "6年经验 · 民宿/商业空间", "tag": "认证"}, + ], + }, + ("carrier", "opc"): { + "items": [ + {"title": "云创设计工作室", "meta": "电子信息方向 · 工位12 · 2026-03 入驻", "tag": "已入驻"}, + {"title": "滇峰数据科技", "meta": "软件服务方向 · 工位8 · 2026-04 入驻", "tag": "已入驻"}, + {"title": "云南新媒体中心", "meta": "内容创作方向 · 工位6 · 待审批", "tag": "待审批"}, + {"title": "绿野农业电商", "meta": "电商方向 · 工位10 · 2026-05 入驻", "tag": "已入驻"}, + {"title": "云南文创工坊", "meta": "文化创意方向 · 工位4 · 申请中", "tag": "申请中"}, + ], + }, + ("carrier", "services"): { + "items": [ + {"title": "园区创业培训", "meta": "创业辅导 · 月度课程 · 已完成", "tag": "已完成"}, + {"title": "工商注册代办", "meta": "云超财税 · 本期 12 单", "tag": "合作中"}, + {"title": "政策申报指导", "meta": "省市区三级补贴申报", "tag": "进行中"}, + {"title": "法务顾问服务", "meta": "滇峰法律 · 入驻企业专享", "tag": "合作中"}, + ], + }, + ("provider", "orders"): { + "items": [ + {"title": "代理记账服务(月度)", "meta": "云南某某甲方企业 · ¥199/月 · 2026-07 开始", "tag": "进行中"}, + {"title": "公司注册代办", "meta": "个人创业者 · ¥599/次 · 待处理", "tag": "待处理"}, + {"title": "季度财务报表编制", "meta": "云创财税 · ¥2000-3000 · 已完成", "tag": "已完成"}, + {"title": "税务筹划咨询", "meta": "鼎信税务所 · ¥1999/次 · 进行中", "tag": "进行中"}, + {"title": "年度汇算清缴", "meta": "云南某某甲方企业 · ¥1500 · 待处理", "tag": "待处理"}, + ], + }, + ("provider", "services"): { + "items": [ + {"title": "代理记账服务", "meta": "财税服务 · ¥199/月起 · 在售", "tag": "在售"}, + {"title": "税务筹划咨询", "meta": "财税服务 · ¥1999/次 · 在售", "tag": "在售"}, + {"title": "企业年报填报", "meta": "工商服务 · ¥99/次 · 在售", "tag": "在售"}, + {"title": "审计报告出具", "meta": "财税服务 · ¥3000/份 · 草稿", "tag": "草稿"}, + ], + }, + ("government", "opc"): { + "items": [ + {"title": "张伟 · UI设计师", "meta": "盘龙区 · 认证OPC · 信用88", "tag": "认证"}, + {"title": "李娜 · 前端开发", "meta": "五华区 · 认证OPC · 信用85", "tag": "认证"}, + {"title": "王强 · 文案策划", "meta": "盘龙区 · 独立OPC · 信用80", "tag": "独立"}, + {"title": "赵敏 · 数据分析", "meta": "麒麟区 · 独立OPC · 信用82", "tag": "独立"}, + ], + }, + ("government", "policies"): { + "items": [ + {"title": "云南省一次性创业补贴政策", "meta": "省级 · 2026-06 发布 · 申报中", "tag": "已发布"}, + {"title": "昆明市创业担保贷款政策", "meta": "市级 · 2026-05 发布 · 申报中", "tag": "已发布"}, + {"title": "小微企业税收优惠减免", "meta": "省级 · 2026-04 发布 · 申报中", "tag": "已发布"}, + {"title": "青年人才创业支持计划", "meta": "市级 · 起草中", "tag": "草稿"}, + ], + }, + ("government", "carriers"): { + "items": [ + {"title": "滇池创业园", "meta": "五华区 · 工位500 · 入驻率84%", "tag": "正常"}, + {"title": "云创空间孵化器", "meta": "盘龙区 · 工位300 · 入驻率76%", "tag": "正常"}, + {"title": "曲靖高新园区", "meta": "麒麟区 · 工位400 · 入驻率68%", "tag": "正常"}, + ], + }, + ("government", "data"): { + "stats": [ + {"key": "opcTotal", "value": 45600}, + {"key": "taskTotal", "value": 12500}, + {"key": "carrierTotal", "value": 168}, + {"key": "policyTotal", "value": 245}, + ], + "chart": [ + {"label": "1月", "value": 3200}, + {"label": "2月", "value": 3600}, + {"label": "3月", "value": 4100}, + {"label": "4月", "value": 3900}, + {"label": "5月", "value": 4500}, + {"label": "6月", "value": 5200}, + ], + }, + ("investor", "projects"): { + "items": [ + {"title": "云上数仓 SaaS 平台", "meta": "人工智能 · 天使轮 · 融资 300万 · 估值 2000万", "tag": "天使轮"}, + {"title": "滇南文旅数字营销", "meta": "文旅创意 · A轮 · 融资 800万 · 估值 6000万", "tag": "A轮"}, + {"title": "生物医药检测服务", "meta": "生物医药 · 种子轮 · 融资 150万", "tag": "种子轮"}, + {"title": "智慧农业物联", "meta": "农业科技 · 天使轮 · 融资 200万 · 估值 1500万", "tag": "天使轮"}, + {"title": "本地生活 AI 助手", "meta": "人工智能 · 种子轮 · 融资 100万", "tag": "种子轮"}, + ], + }, + ("investor", "trainings"): { + "items": [ + {"title": "商业计划书(BP)撰写实战", "meta": "投融资基础 · 视频课 · 免费", "tag": "免费"}, + {"title": "创业公司估值与投资条款", "meta": "投融资进阶 · 视频课 · 付费", "tag": "付费"}, + {"title": "尽职调查实务(投资人视角)", "meta": "投融资进阶 · 直播课 · 报名中", "tag": "报名中"}, + {"title": "路演技巧与打动投资人", "meta": "融资准备 · 视频课 · 免费", "tag": "免费"}, + {"title": "股权结构与法律合规", "meta": "融资准备 · 视频课 · 付费", "tag": "付费"}, + ], + }, + ("investor", "portfolio"): { + "stats": [ + {"key": "totalInvested", "value": "¥500万"}, + {"key": "portfolioCount", "value": 6}, + {"key": "activeIntents", "value": 3}, + {"key": "roadshowAttended", "value": 12}, + ], + "items": [ + {"title": "云上数仓 SaaS", "meta": "已投 · 天使轮 · 当前估值 2000万", "tag": "已投"}, + {"title": "滇南文旅", "meta": "已投 · A轮 · 当前估值 6000万", "tag": "已投"}, + {"title": "本地生活 AI 助手", "meta": "跟踪中 · 种子轮", "tag": "跟踪中"}, + ], + }, + ("opc", "tax"): { + "items": [ + {"title": "个人所得税 · 7月申报", "meta": "申报期至 2026-08-15 · 待申报", "tag": "待申报"}, + {"title": "增值税 · 7月申报", "meta": "申报期至 2026-08-15 · 待申报", "tag": "待申报"}, + {"title": "企业所得税 · 二季度", "meta": "已于 2026-07-20 申报", "tag": "已申报"}, + ], + }, + ("opc", "affairs"): { + "items": [ + {"title": "个体工商户注册", "meta": "工商办理 · 材料已提交 · 办理中", "tag": "办理中"}, + {"title": "年报公示", "meta": "工商办理 · 已完成", "tag": "已完成"}, + {"title": "一次性创业补贴申报", "meta": "政务申报 · 区县初审中", "tag": "初审中"}, + ], + }, + ("developer", "dashboard"): { + "stats": [ + {"key": "appCount", "value": 3}, + {"key": "pluginCount", "value": 5}, + {"key": "skillCount", "value": 8}, + {"key": "apiCalls", "value": "12.6万"}, + ], + }, + ("developer", "apps"): { + "items": [ + {"title": "云上订单助手", "meta": "应用 · 已上架 · 调用 3.2万次", "tag": "已上架"}, + {"title": "创业政策查询", "meta": "应用 · 已上架 · 调用 5.1万次", "tag": "已上架"}, + {"title": "财税自动记账", "meta": "应用 · 待审核", "tag": "待审核"}, + ], + }, + ("developer", "plugins"): { + "items": [ + {"title": "task-helper", "meta": "插件 · 版本 v1.2 · 已上架", "tag": "已上架"}, + {"title": "policy-mcp", "meta": "插件 · MCP · 已上架", "tag": "已上架"}, + {"title": "invoice-extract", "meta": "插件 · 开发中", "tag": "开发中"}, + ], + }, + ("developer", "skills"): { + "items": [ + {"title": "BP 撰写助手", "meta": "技能 · 已上架 · 使用 1200次", "tag": "已上架"}, + {"title": "财税问答", "meta": "技能 · 已上架 · 使用 860次", "tag": "已上架"}, + {"title": "路演 PPT 生成", "meta": "技能 · 待审核", "tag": "待审核"}, + ], + }, +} + + +def _seed_port_pages(session: Session, now: str) -> None: + """写入端口子页面演示数据(幂等:任一 (port,page) 已存在则跳过)。""" + if session.scalar(select(PortalPage.port).limit(1)) is not None: + return + session.add_all( + [ + PortalPage( + port=port, page=page, + payload=json.dumps(payload, ensure_ascii=False), + updated_at=now, + ) + for (port, page), payload in _PORT_PAGES.items() + ] + ) + + +def _seed_investor(session: Session, now: str) -> None: + """投资人端演示数据(偏好/路演/意向),幂等(以偏好为标记)。""" + if session.scalar(select(InvestorPreference.user_id).limit(1)) is not None: + return + session.add_all([ + InvestorPreference( + user_id="u_inv_01", + industries='["人工智能","文旅","农业科技"]', + stage="angel", amount_min=50, amount_max=300, region_id="r_prov_yn", + updated_at=now, + ), + InvestorPreference( + user_id="u_inv_02", + industries='["人工智能","生物医药","智能制造"]', + stage="series_a", amount_min=500, amount_max=3000, region_id="r_prov_yn", + updated_at=now, + ), + ]) + session.add_all([ + Roadshow( + id="rs_001", publisher_id="u_inv_02", publisher_role="investor", + title="云南 AI 创业项目专场路演", summary="8 个 AI 方向创业项目线上路演", + activity_type="online", scope_type="all", region_id=None, + start_at="2026-08-20T14:00:00", end_at="2026-08-20T17:00:00", + register_deadline="2026-08-18", quota=100, live_url="", + status="published", need_review=True, created_at=now, updated_at=now, + ), + Roadshow( + id="rs_002", publisher_id="u_gov_prov", publisher_role="government", + title="省级创业投融资对接会", summary="省级载体与投资人对接", + activity_type="hybrid", scope_type="region", region_id="r_prov_yn", + start_at="2026-08-28T09:00:00", end_at="2026-08-28T12:00:00", + register_deadline="2026-08-25", quota=200, venue="昆明国际会展中心", + status="published", need_review=False, created_at=now, updated_at=now, + ), + ]) + session.add_all([ + InvestmentIntent( + id="intent_001", investor_id="u_inv_01", project_id="proj_001", + project_name="云上数仓 SaaS 平台", status="contacted", + message="对贵项目感兴趣,希望约谈", created_at=now, updated_at=now, + ), + ]) + + +def _sync_permissions(session: Session, now: str) -> None: + """幂等补种权限与角色权限映射(供既有库使用)。 + + 只插入缺失的 Permission 行与缺失的 RolePermission 链接,不删除已有 + 自定义授权,避免覆盖运营端后续经「角色权限配置」做的调整。 + """ + for pid, name, category, module in PERMISSIONS: + if session.get(Permission, pid) is None: + session.add(Permission(id=pid, name=name, category=category, module=module)) + for role_id, perms in ROLE_PERMISSIONS.items(): + if session.get(Role, role_id) is None: + continue + existing = { + rp.permission_id + for rp in session.scalars( + select(RolePermission).where(RolePermission.role_id == role_id) + ) + } + for perm in perms: + if perm not in existing: + session.add(RolePermission(role_id=role_id, permission_id=perm)) + + +def _ensure_extra_demo_users(session: Session, now: str) -> None: + """补建新增的演示用户(投资人等),供既有库使用(幂等)。""" + EXTRA = [ + ("u_inv_01", "inv01", "投资人账号", "investor", "personal", None, "r_prov_yn"), + ("u_inv_02", "inv02", "机构投资人", "investor", "org_director", None, "r_prov_yn"), + # 运营端内部子角色演示账号 + ("u_op_admin", "op_admin", "运营管理员", "operator", "op_admin", None, "r_prov_yn"), + ("u_op_cs", "op_cs", "运营客服", "operator", "op_customer_service", None, "r_prov_yn"), + ("u_op_fin", "op_fin", "运营财务", "operator", "op_finance", None, "r_prov_yn"), + ("u_op_tech", "op_tech", "运营运维", "operator", "op_techops", None, "r_prov_yn"), + # 机构子账号(企业财务/任务发布、服务商执行/财务) + ("u_ent_fin", "ent_fin", "企业财务", "enterprise", "operator", "o_ent_001", "r_dist_pl"), + ("u_ent_pub", "ent_pub", "企业任务发布", "enterprise", "operator", "o_ent_001", "r_dist_pl"), + ("u_pro_exec", "pro_exec", "服务商执行", "provider", "operator", "o_pro_001", "r_city_km"), + ("u_pro_fin", "pro_fin", "服务商财务", "provider", "operator", "o_pro_001", "r_city_km"), + # 开放平台开发者 + ("u_dev_01", "dev01", "个人开发者", "developer", "dev_individual", None, "r_prov_yn"), + ("u_dev_02", "dev02", "机构开发者", "developer", "dev_org_admin", None, "r_prov_yn"), + ] + for (uid, username, nickname, role, sub_role, org_id, region_id) in EXTRA: + if session.get(User, uid) is not None: + continue + digest, salt = hash_password("123456") + session.add( + User( + id=uid, username=username, nickname=nickname, + password_hash=digest, password_salt=salt, + role=role, sub_role=sub_role, org_id=org_id, region_id=region_id, + status="active", token_version=0, created_at=now, updated_at=now, + ) + ) + for seed in AGENT_SEED: + session.add( + Agent( + id=seed["id"], user_id=uid, name=seed["name"], + description=seed["description"], language=seed.get("language", "zh"), + model_name=seed.get("model_name", ""), deletable=seed.get("deletable", True), + use_fixed_soul=seed.get("use_fixed_soul", False), + created_at=now, updated_at=now, + ) + ) + + +def _seed_ecosystem(session: Session, now: str) -> None: + """生态横切演示数据(通知/补贴/引荐/培训),幂等。""" + if session.scalar(select(Notification.id).limit(1)) is None: + session.add_all([ + Notification(id="ntf_001", user_id="u_opc_01", type="task", + title="任务进度更新", content="您的任务「电商小程序首页 UI 设计」进度已更新", + read=False, created_at=now), + Notification(id="ntf_002", user_id="u_opc_01", type="policy", + title="新政策匹配", content="2 项新政策与您高度匹配", read=False, created_at=now), + Notification(id="ntf_003", user_id="u_ent_01", type="task", + title="收到投标", content="您的任务收到新的竞标", read=False, created_at=now), + ]) + if session.scalar(select(SubsidyApplication.id).limit(1)) is not None: + return + session.add_all([ + SubsidyApplication(id="sub_001", opc_id="u_opc_01", opc_name="OPC个人创业者", + title="云南省一次性创业补贴", amount=10000, region_id="r_dist_wh", + status="applying", created_at=now, updated_at=now), + SubsidyApplication(id="sub_002", opc_id="u_opc_02", opc_name="OPC个体工商户", + title="昆明市创业担保贷款贴息", amount=5000, region_id="r_dist_pl", + status="district_review", created_at=now, updated_at=now), + SubsidyApplication(id="sub_003", opc_id="u_opc_01", opc_name="OPC个人创业者", + title="青年人才创业支持计划", amount=20000, region_id="r_dist_wh", + status="city_review", created_at=now, updated_at=now), + ]) + session.add_all([ + ServiceReferral(id="ref_001", carrier_id="o_car_001", provider_id="o_pro_001", + provider_name="云超财税服务所", opc_id="u_opc_01", opc_name="OPC个人创业者", + status="referred", created_at=now, updated_at=now), + ]) + session.add_all([ + TrainingEnrollment(id="treg_001", user_id="u_inv_01", training_id="商业计划书(BP)撰写实战", + training_name="商业计划书(BP)撰写实战", status="enrolled", created_at=now), + ]) + + +def _ensure_port_agents(session: Session, now: str) -> None: + """为每个账号的每个端口身份确保默认/QA 智能体(多端口彻底隔离)。""" + session.flush() # 确保此前新增的身份可见 + rows = session.execute(select(UserIdentity.user_id, UserIdentity.port)).all() + seen: set[tuple[str, str]] = set() + for user_id, port in rows: + if (user_id, port) in seen: + continue + seen.add((user_id, port)) + existing = session.scalar( + select(Agent.id).where(Agent.user_id == user_id, Agent.port == port).limit(1) + ) + if existing is not None: + continue + for seed in AGENT_SEED: + session.add( + Agent( + id=seed["id"], user_id=user_id, port=port, name=seed["name"], + description=seed["description"], language=seed.get("language", "zh"), + model_name=seed.get("model_name", ""), deletable=seed.get("deletable", True), + use_fixed_soul=seed.get("use_fixed_soul", False), + created_at=now, updated_at=now, + ) + ) + + +def _seed_org_members(session: Session, now: str) -> None: + """机构成员(机构主账号 + 子账号),幂等(以 organization_members 为标记)。""" + session.flush() + if session.scalar(select(OrganizationMember.org_id).limit(1)) is not None: + return + # 每个机构默认一个管理员主账号 + members = [ + ("o_ent_001", "u_ent_01", "admin", True), + ("o_car_001", "u_car_01", "admin", True), + ("o_pro_001", "u_pro_01", "admin", True), + # 企业子账号(财务/任务发布) + ("o_ent_001", "u_ent_fin", "finance", False), + ("o_ent_001", "u_ent_pub", "publisher", False), + # 服务商子账号(服务执行/财务) + ("o_pro_001", "u_pro_exec", "service_executor", False), + ("o_pro_001", "u_pro_fin", "finance", False), + ] + for org_id, user_id, role, is_admin in members: + session.add(OrganizationMember(org_id=org_id, user_id=user_id, role=role, + is_admin=is_admin, status="active", joined_at=now)) diff --git a/data/seed/demo_users.json b/data/seed/demo_users.json new file mode 100644 index 0000000..7f01866 --- /dev/null +++ b/data/seed/demo_users.json @@ -0,0 +1,13 @@ +[ + { + "id": "u_demo_01", + "username": "pine", + "password": "123456", + "nickname": "云超服小助手", + "account": "云超服演示账号", + "company": "云超服科技", + "room": "1001", + "avatar": "", + "company_avatar": "" + } +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4db44c0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "pineagents-demo-server" +version = "0.1.0" +description = "云超服核心服务端(FastAPI + SQLite + JWT + RBAC):身份/平台 API + 培训子应用(/api/*),统一对外 opc.pinesound.cn" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "pydantic>=2.7.0", + "sqlalchemy>=2.0.30", + "pyjwt>=2.8.0", + "httpx>=0.27.0", + "python-multipart>=0.0.12", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "httpx>=0.27.0", + "pytest-asyncio>=0.23.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9796b1f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""测试包。""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7902ef5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +"""共享夹具:临时 SQLite 数据库 + TestClient(auth 与 rbac 测试共用)。""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.main import app +from app.repositories import Database + + +@pytest.fixture() +def client(tmp_path): + """把 app.state.db 换成指向临时 SQLite 的 Database,保证测试隔离。""" + db = Database(db_url=f"sqlite:///{tmp_path}/test.db") + db.initialize() + app.state.db = db + with TestClient(app) as c: + yield c + db.close() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..bfcf7c8 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +"""认证接口端到端测试(使用临时 SQLite 数据库,不污染 data/)。""" +from __future__ import annotations + +DEMO_USERNAME = "pine" +DEMO_PASSWORD = "123456" + + +def login(client, username=DEMO_USERNAME, password=DEMO_PASSWORD) -> dict: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json() + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# 基础 / 状态 +# --------------------------------------------------------------------------- +def test_health(client): + res = client.get("/health") + assert res.status_code == 200 + assert res.json()["status"] == "ok" + + +def test_status(client): + res = client.get("/auth/status") + assert res.status_code == 200 + assert res.json() == {"enabled": True, "has_users": True} + + +# --------------------------------------------------------------------------- +# 登录 / 注册 +# --------------------------------------------------------------------------- +def test_login_success_returns_profile_and_token(client): + data = login(client) + assert data["token"] + assert data["username"] == DEMO_USERNAME + # 演示资料字段齐全 + for field in ("nickname", "account", "company", "room", "avatar", "company_avatar"): + assert field in data + + +def test_login_wrong_password(client): + res = client.post( + "/auth/login", + json={"username": DEMO_USERNAME, "password": "wrong"}, + ) + assert res.status_code == 401 + assert res.json()["detail"] == "Invalid username or password" + + +def test_register_denied_when_users_exist(client): + res = client.post( + "/auth/register", + json={"username": "13800001234", "password": "whatever1"}, + ) + assert res.status_code == 403 + assert res.json()["detail"] == "User already registered" + + +# --------------------------------------------------------------------------- +# 令牌校验 / 当前用户 +# --------------------------------------------------------------------------- +def test_verify_with_token(client): + token = login(client)["token"] + res = client.get("/auth/verify", headers=auth(token)) + assert res.status_code == 200 + assert res.json() == {"valid": True, "username": DEMO_USERNAME} + + +def test_verify_without_token(client): + res = client.get("/auth/verify") + assert res.status_code == 401 + + +def test_me_returns_profile(client): + token = login(client)["token"] + res = client.get("/auth/me", headers=auth(token)) + assert res.status_code == 200 + assert res.json()["username"] == DEMO_USERNAME + assert res.json()["nickname"] + + +# --------------------------------------------------------------------------- +# 资料 / 凭据更新 +# --------------------------------------------------------------------------- +def test_update_profile_fields(client): + token = login(client)["token"] + res = client.post( + "/auth/update-profile", + headers=auth(token), + json={"nickname": "新昵称", "account": "新账号", "room": "2001"}, + ) + assert res.status_code == 200, res.text + body = res.json() + assert body["nickname"] == "新昵称" + assert body["account"] == "新账号" + assert body["room"] == "2001" + # 仅改资料不重签令牌 + assert body["token"] == "" + + +def test_update_profile_nothing_to_update(client): + token = login(client)["token"] + res = client.post( + "/auth/update-profile", + headers=auth(token), + json={"current_password": DEMO_PASSWORD}, + ) + assert res.status_code == 400 + assert res.json()["detail"] == "Nothing to update" + + +def test_update_profile_wrong_current_password(client): + token = login(client)["token"] + res = client.post( + "/auth/update-profile", + headers=auth(token), + json={"current_password": "wrong", "new_password": "newpass123"}, + ) + assert res.status_code == 401 + assert res.json()["detail"] == "Current password is incorrect" + + +def test_update_profile_changes_password_and_reissues_token(client): + token = login(client)["token"] + res = client.post( + "/auth/update-profile", + headers=auth(token), + json={"current_password": DEMO_PASSWORD, "new_password": "newpass123"}, + ) + assert res.status_code == 200, res.text + body = res.json() + assert body["token"] # 新令牌 + + # 旧令牌失效,新令牌可用 + assert client.get("/auth/verify", headers=auth(token)).status_code == 401 + assert ( + client.get("/auth/verify", headers=auth(body["token"])).status_code == 200 + ) + + # 新密码可登录,旧密码不可 + assert login(client, password="newpass123")["token"] + assert ( + client.post( + "/auth/login", + json={"username": DEMO_USERNAME, "password": DEMO_PASSWORD}, + ).status_code + == 401 + ) + + +def test_update_profile_changes_username(client): + token = login(client)["token"] + res = client.post( + "/auth/update-profile", + headers=auth(token), + json={"current_password": DEMO_PASSWORD, "new_username": "pine2"}, + ) + assert res.status_code == 200, res.text + assert res.json()["username"] == "pine2" + assert res.json()["token"] + + # 新用户名可登录 + res = client.post( + "/auth/login", + json={"username": "pine2", "password": DEMO_PASSWORD}, + ) + assert res.status_code == 200 + + +def test_update_profile_empty_new_password_rejected(client): + token = login(client)["token"] + res = client.post( + "/auth/update-profile", + headers=auth(token), + json={"current_password": DEMO_PASSWORD, "new_password": " "}, + ) + assert res.status_code == 400 + assert "cannot be empty" in res.json()["detail"] + + +# --------------------------------------------------------------------------- +# 令牌吊销 +# --------------------------------------------------------------------------- +def test_revoke_current_token(client): + token = login(client)["token"] + res = client.post("/auth/revoke-token", headers=auth(token), json={}) + assert res.status_code == 200 + assert res.json()["revoked"] is True + assert res.json()["revoked_current_token"] is True + assert client.get("/auth/verify", headers=auth(token)).status_code == 401 + + +def test_revoke_specific_token(client): + token_a = login(client)["token"] + token_b = login(client)["token"] + res = client.post( + "/auth/revoke-token", + headers=auth(token_a), + json={"token": token_b}, + ) + assert res.status_code == 200 + assert res.json()["revoked_current_token"] is False + assert client.get("/auth/verify", headers=auth(token_b)).status_code == 401 + assert client.get("/auth/verify", headers=auth(token_a)).status_code == 200 + + +def test_revoke_all_tokens(client): + token_a = login(client)["token"] + token_b = login(client)["token"] + res = client.post("/auth/revoke-all-tokens", headers=auth(token_a), json={}) + assert res.status_code == 200 + assert res.json()["revoked"] is True + assert client.get("/auth/verify", headers=auth(token_a)).status_code == 401 + assert client.get("/auth/verify", headers=auth(token_b)).status_code == 401 + + +# --------------------------------------------------------------------------- +# 落盘格式 +# --------------------------------------------------------------------------- +def test_persisted_user_has_no_plaintext_password(client, tmp_path): + login(client) + # 从 SQLite 读取用户记录,断言不落明文密码 + import sqlite3 + + conn = sqlite3.connect(tmp_path / "test.db") + row = conn.execute( + "SELECT username, password_hash, password_salt FROM users WHERE username=?", + (DEMO_USERNAME,), + ).fetchone() + conn.close() + assert row is not None + username, password_hash, password_salt = row + assert username == DEMO_USERNAME + assert password_hash + assert password_salt + # users 表没有 password 明文列 + conn = sqlite3.connect(tmp_path / "test.db") + cols = [r[1] for r in conn.execute("PRAGMA table_info(users)")] + conn.close() + assert "password" not in cols diff --git a/tests/test_auth_identity.py b/tests/test_auth_identity.py new file mode 100644 index 0000000..d9c6b75 --- /dev/null +++ b/tests/test_auth_identity.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""账号↔端口多身份:登录返回身份列表、选择身份签发新令牌、按身份访问。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> dict: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json() + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def test_single_identity_auto_login(client): + """单身份账号(opc01)登录即绑定该身份,可直接访问对应端口。""" + body = login(client, "opc01") + assert len(body["identities"]) == 1 + assert body["identities"][0]["port"] == "opc" + assert body["role"] == "opc_member" + assert body.get("identity_id") == body["identities"][0]["id"] + + res = client.get("/opc/dashboard", headers=auth(body["token"])) + assert res.status_code == 200, res.text + + +def test_multi_identity_login_returns_all_and_neutral(client): + """多身份账号(ent01:企业 + OPC)登录返回全部身份,令牌为中性(回退单角色)。""" + body = login(client, "ent01") + ports = [i["port"] for i in body["identities"]] + assert "enterprise" in ports and "opc" in ports + + # 中性令牌按 users.role 回退为 enterprise:访问 OPC 端口应 403 + res = client.get("/opc/dashboard", headers=auth(body["token"])) + assert res.status_code == 403 + + +def test_select_identity_grants_opc_access(client): + """选择 OPC 身份后,新令牌可访问 OPC 端口,且 /me 反映该身份。""" + body = login(client, "ent01") + opc_identity = next(i for i in body["identities"] if i["port"] == "opc") + + res = client.post( + "/auth/select-identity", + headers=auth(body["token"]), + json={"identity_id": opc_identity["id"]}, + ) + assert res.status_code == 200, res.text + selected = res.json() + assert selected["role"] == "opc_member" + assert selected["identity_id"] == opc_identity["id"] + + dash = client.get("/opc/dashboard", headers=auth(selected["token"])) + assert dash.status_code == 200, dash.text + + me = client.get("/auth/me", headers=auth(selected["token"])).json() + assert me["role"] == "opc_member" + assert me.get("port") == "opc" + + +def test_select_identity_rejects_other_users_identity(client): + """不能选择不属于当前账号的身份。""" + body = login(client, "opc01") + other = "ident_ent01_opc" # 属于 ent01 的身份 id + res = client.post( + "/auth/select-identity", + headers=auth(body["token"]), + json={"identity_id": other}, + ) + assert res.status_code == 404 diff --git a/tests/test_investor.py b/tests/test_investor.py new file mode 100644 index 0000000..bc4e8f5 --- /dev/null +++ b/tests/test_investor.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""投资人端端点测试:投资方向/项目/意向/路演审核规则。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> str: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def test_investor_pages(client): + token = login(client, "inv01") + for p in ["/investor/dashboard", "/investor/preferences", "/investor/projects", + "/investor/trainings", "/investor/portfolio", "/investor/roadshows", "/investor/intents"]: + res = client.get(p, headers=auth(token)) + assert res.status_code == 200, f"{p}: {res.text}" + + +def test_investor_preferences_update(client): + token = login(client, "inv01") + res = client.put("/investor/preferences", headers=auth(token), + json={"industries": ["人工智能"], "stage": "seed", "amount_min": 50, "amount_max": 200}) + assert res.status_code == 200, res.text + assert res.json()["industries"] == ["人工智能"] + + +def test_roadshow_review_rules(client): + # 投资人发布 → 需审核(submitted) + inv = login(client, "inv01") + res = client.post("/investor/roadshows", headers=auth(inv), + json={"title": "投资人专场", "activity_type": "online", "scope_type": "all", + "start_at": "2026-09-01T10:00:00", "end_at": "2026-09-01T12:00:00"}) + assert res.status_code == 200, res.text + assert res.json()["need_review"] is True + assert res.json()["status"] == "submitted" + + # 政务在自身权限范围内发布 → 免审(published);跨范围 → 需审 + gov = login(client, "gov_prov") # 省级,scope 全省 + res2 = client.post("/investor/roadshows", headers=auth(gov), + json={"title": "省级对接会", "activity_type": "hybrid", "scope_type": "region", + "region_id": "r_prov_yn", + "start_at": "2026-09-02T10:00:00", "end_at": "2026-09-02T12:00:00"}) + assert res2.status_code == 200, res2.text + assert res2.json()["need_review"] is False + assert res2.json()["status"] == "published" + + +def test_investor_rejects_other_role(client): + token = login(client, "opc01") + res = client.get("/investor/projects", headers=auth(token)) + assert res.status_code == 403 diff --git a/tests/test_operator_rbac.py b/tests/test_operator_rbac.py new file mode 100644 index 0000000..d167c08 --- /dev/null +++ b/tests/test_operator_rbac.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""运营端(operator 端口)子角色权限与账号管理测试。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> str: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def user_id_by_username(client: TestClient, token: str, username: str) -> str: + users = client.get("/admin/users", headers=auth(token)).json() + return next(u["id"] for u in users if u["username"] == username) + + +def select_identity(client: TestClient, token: str, port: str) -> str: + """把中性令牌切换到指定端口身份,返回绑定身份的令牌。""" + body = client.post("/auth/login", json={"username": "pine", "password": "123456"}).json() + ident = next(i for i in body["identities"] if i["port"] == port)["id"] + sel = client.post( + "/auth/select-identity", headers=auth(token), json={"identity_id": ident} + ).json() + return sel["token"] + + +def test_operator_admin_cannot_escalate_to_super_admin(client): + # 管理员不可创建/提升 超管/管理员(特权角色) + admin_t = login(client, "op_admin") + res = client.post( + "/admin/users", + headers=auth(admin_t), + json={"username": "13800001111", "password": "123456", + "role": "operator", "sub_role": "op_super_admin"}, + ) + assert res.status_code == 403 + # 管理员可创建普通运营账号(客服) + ok = client.post( + "/admin/users", + headers=auth(admin_t), + json={"username": "13800002222", "password": "123456", + "role": "operator", "sub_role": "op_customer_service"}, + ) + assert ok.status_code == 200 + # 管理员把某账号提升为超管 → 403 + target_id = user_id_by_username(client, admin_t, "13800002222") + up = client.post( + f"/admin/users/{target_id}/role", + headers=auth(admin_t), + json={"role": "operator", "sub_role": "op_super_admin"}, + ) + assert up.status_code == 403 + + +def test_operator_cs_cannot_change_config(client): + # 客服可建任务/服务商,但不可改系统配置 + cs_t = login(client, "op_cs") + assert ( + client.put("/admin/config/platform.fee.task", + headers=auth(cs_t), json={"value": "0.01"}).status_code == 403 + ) + assert client.post("/admin/tasks", headers=auth(cs_t), + json={"title": "客服建任务"}).status_code == 200 + + +def test_operator_finance_cannot_manage_tasks(client): + # 财务可看结算,但不可建任务 + fin_t = login(client, "op_fin") + assert ( + client.post("/admin/tasks", headers=auth(fin_t), + json={"title": "财务建任务"}).status_code == 403 + ) + assert client.get("/operator/settlements", headers=auth(fin_t)).status_code == 200 + + +def test_operator_invalid_role_combo_rejected(client): + # 非法/跨界角色组合被白名单拒绝 + admin_t = login(client, "op_admin") + res = client.post( + "/admin/users", + headers=auth(admin_t), + json={"username": "13800003333", "password": "123456", + "role": "operator", "sub_role": "gov_province"}, + ) + assert res.status_code == 400 + + +def test_operator_grant_perm_only_super_admin(client): + # 配置角色权限仅超管可用 + admin_t = login(client, "op_admin") + assert ( + client.post("/admin/roles/operator%7Cop_admin/permissions", + headers=auth(admin_t), json={"permissions": []}).status_code == 403 + ) + # pine 以超管身份可配置 + neutral = login(client, "pine") + sa_t = select_identity(client, neutral, "operator") + ok = client.post( + "/admin/roles/operator%7Cop_analyst/permissions", + headers=auth(sa_t), json={"permissions": ["menu:audit", "action:data.export"]}, + ) + assert ok.status_code == 200 + assert "menu:audit" in ok.json()["permissions"] + + +def test_operator_role_change_takes_effect(client): + # 改角色后:令牌失效 + 身份同步,重新登录体现新角色权限 + admin_t = login(client, "op_admin") + created = client.post( + "/admin/users", + headers=auth(admin_t), + json={"username": "13800004444", "password": "123456", + "role": "operator", "sub_role": "op_customer_service"}, + ).json() + # 该账号现可建任务(客服有 task.manage) + cs_t = login(client, "13800004444") + assert client.post("/admin/tasks", headers=auth(cs_t), + json={"title": "t"}).status_code == 200 + # 管理员将其改为分析师(无 task.manage) + target_id = user_id_by_username(client, admin_t, "13800004444") + up = client.post( + f"/admin/users/{target_id}/role", + headers=auth(admin_t), + json={"role": "operator", "sub_role": "op_analyst"}, + ) + assert up.status_code == 200 + # 旧令牌已失效 + assert client.get("/admin/users", headers=auth(cs_t)).status_code == 401 + # 新登录为分析师:不可建任务 + an_t = login(client, "13800004444") + assert client.post("/admin/tasks", headers=auth(an_t), + json={"title": "t2"}).status_code == 403 diff --git a/tests/test_portals.py b/tests/test_portals.py new file mode 100644 index 0000000..926b597 --- /dev/null +++ b/tests/test_portals.py @@ -0,0 +1,236 @@ +# -*- coding: utf-8 -*- +"""各业务端口工作台端点测试(企业/载体/服务商/政务)。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> str: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def test_port_dashboards_accessible_by_own_role(client): + cases = [ + ("ent01", "/enterprise/dashboard"), + ("car01", "/carrier/dashboard"), + ("pro01", "/provider/dashboard"), + ("gov_prov", "/government/dashboard"), + ] + for username, path in cases: + token = login(client, username) + res = client.get(path, headers=auth(token)) + assert res.status_code == 200, f"{path}: {res.text}" + body = res.json() + assert len(body.get("stats", [])) >= 1, f"{path}: no stats" + assert body.get("port") == path.split("/")[1] + + +def test_port_dashboard_rejects_other_role(client): + token = login(client, "opc01") # OPC 非企业 + res = client.get("/enterprise/dashboard", headers=auth(token)) + assert res.status_code == 403 + + +def test_port_subpages_by_role(client): + cases = [ + ("ent01", ["/enterprise/talents", "/enterprise/tasks"]), + ("car01", ["/carrier/opc", "/carrier/services"]), + ("pro01", ["/provider/orders", "/provider/services"]), + ("gov_prov", ["/government/opc", "/government/policies", "/government/carriers", "/government/data"]), + ("opc01", ["/opc/agent", "/opc/tasks", "/opc/finance", "/opc/messages"]), + ] + for username, paths in cases: + token = login(client, username) + for p in paths: + res = client.get(p, headers=auth(token)) + assert res.status_code == 200, f"{p}: {res.text}" + + +def test_port_subpage_rejects_other_role(client): + token = login(client, "opc01") + res = client.get("/enterprise/talents", headers=auth(token)) + assert res.status_code == 403 + + +def test_enterprise_publish_task(client): + token = login(client, "ent01") + res = client.post( + "/enterprise/tasks", headers=auth(token), + json={"title": "测试发布", "category": "设计创意", "budget_min": 1000, "budget_max": 3000}, + ) + assert res.status_code == 200, res.text + assert res.json()["status"] == "pending" + + +def test_enterprise_tender_flow(client): + # 企业发布并提交 → 已发布;OPC 投标 → 企业中标 → 验收通过 + ent = login(client, "ent01") + opc = login(client, "opc01") + task = client.post("/enterprise/tasks", headers=auth(ent), + json={"title": "投标测试任务", "mode": "bid", "category": "设计创意", + "budget_min": 1000, "budget_max": 3000}).json() + tid = task["id"] + sub = client.post(f"/enterprise/tasks/{tid}/submit", headers=auth(ent)) + assert sub.status_code == 200 and sub.json()["status"] == "published" + + bid = client.post(f"/opc/tasks/{tid}/bid", headers=auth(opc), + json={"quote": 1500, "plan": "两周交付"}).json() + bids = client.get(f"/enterprise/tasks/{tid}/bids", headers=auth(ent)).json() + assert any(b["id"] == bid["id"] for b in bids["items"]) + + won = client.post(f"/enterprise/tasks/{tid}/bids/{bid['id']}/win", headers=auth(ent)) + assert won.status_code == 200 and won.json()["status"] == "win" + + delivered = client.post(f"/opc/tasks/{tid}/deliver", headers=auth(opc)) + assert delivered.status_code == 200 and delivered.json()["status"] == "delivered" + + done = client.post(f"/enterprise/tasks/{tid}/review", headers=auth(ent), json={"action": "accept"}) + assert done.status_code == 200 and done.json()["status"] == "completed" + + +def test_government_scope_filtering(client): + # 省级可见全省 OPC;区县级(五华区)仅可见本区县 OPC + prov = login(client, "gov_prov") + dist = login(client, "gov_dist") + prov_opc = client.get("/government/opc", headers=auth(prov)).json()["items"] + dist_opc = client.get("/government/opc", headers=auth(dist)).json()["items"] + assert len(prov_opc) >= len(dist_opc) + # gov_dist 数据范围不含省级 + data = client.get("/government/data", headers=auth(prov)).json() + assert data["stats"][0]["key"] == "totalOPC" + + +def test_subsidy_three_level_approval(client): + # 补贴三级审批:区县(初审)→市(复审)→省(终审)→发放 + gov_dist = login(client, "gov_dist") + sub = client.get("/government/subsidies", headers=auth(gov_dist)).json()["items"][0] + aid = sub["id"] + res = client.post(f"/government/subsidies/{aid}/approve", headers=auth(gov_dist)) + assert res.status_code == 200 and res.json()["status"] == "district_review" + + +def test_carrier_referral(client): + car = login(client, "car01") + res = client.post("/carrier/referrals", headers=auth(car), + json={"provider_name": "云超财税服务所", "opc_name": "OPC个人创业者"}) + assert res.status_code == 200 and res.json()["status"] == "referred" + + +def test_training_enroll(client): + inv = login(client, "inv01") + res = client.post("/investor/trainings/商业计划书(BP)撰写实战/enroll", headers=auth(inv)) + assert res.status_code == 200 and res.json()["status"] == "enrolled" + mine = client.get("/investor/my-trainings", headers=auth(inv)).json()["items"] + assert len(mine) >= 1 + + +def test_ecosystem_crosscut(client): + # 通知、信用、合同、结算、争议、撮合、路演加入 + opc = login(client, "opc01") + ent = login(client, "ent01") + inv = login(client, "inv01") + op = login(client, "pine") # operator 超级管理员 + + ntf = client.get("/notifications", headers=auth(opc)).json() + assert "items" in ntf and ntf["unread"] >= 1 + + credit = client.get("/me/credit", headers=auth(opc)).json() + assert "credit_score" in credit + + # 撮合 + matches = client.get("/investor/matches", headers=auth(inv)).json() + assert "items" in matches + + # 路演加入 + join = client.post("/roadshows/rs_001/join", headers=auth(inv)) + assert join.status_code == 200 and join.json()["joined"] is True + + +def test_agent_per_port_isolation(client): + # 同一账号在不同端口拥有各自独立的智能体(多端口彻底隔离) + body = client.post("/auth/login", json={"username": "pine", "password": "123456"}).json() + token = body["token"] + opc_ident = next(i for i in body["identities"] if i["port"] == "opc") + op_id = next(i for i in body["identities"] if i["port"] == "operator") + # 运营端:创建专属智能体 + sel_op = client.post("/auth/select-identity", headers=auth(token), json={"identity_id": op_id["id"]}).json() + created = client.post("/agents", headers=auth(sel_op["token"]), json={"name": "运营专属助手"}).json() + assert created["port"] == "operator" + op_agents = client.get("/agents", headers=auth(sel_op["token"])).json() + assert any(a["id"] == created["id"] for a in op_agents) + # OPC 端:看不到运营端的专属智能体(彻底隔离) + sel_opc = client.post("/auth/select-identity", headers=auth(token), json={"identity_id": opc_ident["id"]}).json() + opc_agents = client.get("/agents", headers=auth(sel_opc["token"])).json() + assert all(a["id"] != created["id"] for a in opc_agents) + assert all(a["port"] == "opc" for a in opc_agents) + + +def test_agent_neutral_token_fully_isolated(client): + # 多身份账号登录后未 select-identity 的中性令牌:不得跨端口访问任何智能体 + body = client.post("/auth/login", json={"username": "pine", "password": "123456"}).json() + neutral = body["token"] + # 中性令牌(未解析端口身份)访问智能体一律 403 + assert client.get("/agents", headers=auth(neutral)).status_code == 403 + # 先选身份创建 opc 智能体 + opc_ident = next(i for i in body["identities"] if i["port"] == "opc")["id"] + ot = client.post( + "/auth/select-identity", headers=auth(neutral), json={"identity_id": opc_ident} + ).json()["token"] + created = client.post("/agents", headers=auth(ot), json={"name": "OPC专属"}).json() + assert created["port"] == "opc" + # 中性令牌改/删该智能体:403(不能跨端口或绕过身份) + assert ( + client.put( + f"/agents/{created['id']}", headers=auth(neutral), json={"name": "x"} + ).status_code + == 403 + ) + assert client.delete(f"/agents/{created['id']}", headers=auth(neutral)).status_code == 403 + # 带身份令牌仍可正常管理(回归) + upd = client.put( + f"/agents/{created['id']}", headers=auth(ot), json={"name": "OPC专属v2"} + ) + assert upd.status_code == 200 and upd.json()["name"] == "OPC专属v2" + + +def test_org_members(client): + # 企业管理员查看/添加机构成员;一账号挂多机构 + ent = login(client, "ent01") + members = client.get("/org/o_ent_001/members", headers=auth(ent)).json()["items"] + assert any(m["user_id"] == "u_ent_01" and m["is_admin"] for m in members) + assert any(m["user_id"] == "u_ent_fin" and m["role"] == "finance" for m in members) + # 非机构管理员(普通成员)不可查看 + opc = login(client, "opc01") + res = client.get("/org/o_ent_001/members", headers=auth(opc)) + assert res.status_code == 403 + + +def test_phone_account_register_validation(client): + # 注册需 11 位手机号 + res = client.post("/auth/register", json={"username": "abc", "password": "123456"}) + assert res.status_code == 400 + res2 = client.post("/auth/register", json={"username": "13800001234", "password": "123456"}) + assert res2.status_code in (200, 403) # 403=演示端已存在用户禁止注册 + + +def test_admin_create_user_and_org_member(client): + # 运营端新增账号(不同类型) + body = client.post("/auth/login", json={"username": "pine", "password": "123456"}).json() + token = body["token"] + op_id = next(i for i in body["identities"] if i["port"] == "operator")["id"] + ot = client.post("/auth/select-identity", headers=auth(token), json={"identity_id": op_id}).json()["token"] + created = client.post("/admin/users", headers=auth(ot), + json={"username": "13900009990", "password": "123456", + "nickname": "新账号", "role": "provider", "sub_role": "admin"}).json() + assert created["username"] == "13900009990" and created["role"] == "provider" + # 企业管理员按用户名添加机构成员 + ent = login(client, "ent01") + added = client.post("/org/o_ent_001/members", headers=auth(ent), + json={"username": "13900009990", "role": "publisher"}).json() + assert added["role"] == "publisher" diff --git a/tests/test_rbac.py b/tests/test_rbac.py new file mode 100644 index 0000000..86976ed --- /dev/null +++ b/tests/test_rbac.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""RBAC 端到端测试:角色 / 权限 / 数据范围 / 审计。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> str: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +# --------------------------------------------------------------------------- +# 角色访问控制 +# --------------------------------------------------------------------------- +def test_operator_can_list_users_enterprise_cannot(client): + op = login(client, "pine") # operator / op_super_admin + ent = login(client, "ent01") # enterprise + assert client.get("/admin/users", headers=auth(op)).status_code == 200 + assert client.get("/admin/users", headers=auth(ent)).status_code == 403 + + +def test_assign_role_requires_permission(client): + # op_analyst 有 operator 角色但缺 action:user.assign_role + analyst = login(client, "op_analyst") + res = client.post( + "/admin/users/u_ent_01/role", + headers=auth(analyst), + json={"role": "carrier"}, + ) + assert res.status_code == 403 + + +def test_super_admin_can_assign_role(client): + op = login(client, "pine") + res = client.post( + "/admin/users/u_ent_01/role", + headers=auth(op), + json={"role": "carrier", "org_id": "o_car_001", "region_id": "r_dist_wh"}, + ) + assert res.status_code == 200, res.text + body = res.json() + assert body["user"]["role"] == "carrier" + + +# --------------------------------------------------------------------------- +# 数据范围层级(上级可看下级,下级不可看上级) +# --------------------------------------------------------------------------- +def test_government_province_sees_all_regions(client): + prov = login(client, "gov_prov") + regions = client.get("/government/regions", headers=auth(prov)).json() + ids = {r["id"] for r in regions} + assert "r_prov_yn" in ids and "r_dist_wh" in ids and "r_dist_ql" in ids + + +def test_government_district_sees_only_own_region(client): + dist = login(client, "gov_dist") # r_dist_wh + regions = client.get("/government/regions", headers=auth(dist)).json() + ids = {r["id"] for r in regions} + assert ids == {"r_dist_wh"} + + +def test_district_gov_cannot_access_out_of_scope_enterprise(client): + dist = login(client, "gov_dist") # 只覆盖 r_dist_wh + # o_ent_prov 在 r_prov_yn,超出区县范围 + res = client.get("/government/enterprises/o_ent_prov", headers=auth(dist)) + assert res.status_code == 403 + + +def test_province_gov_can_access_any_enterprise(client): + prov = login(client, "gov_prov") + res = client.get("/government/enterprises/o_ent_prov", headers=auth(prov)) + assert res.status_code == 200 + + +# --------------------------------------------------------------------------- +# 禁用用户 / 审计 +# --------------------------------------------------------------------------- +def test_disabled_user_token_rejected(client): + op = login(client, "pine") + ent = login(client, "ent01") # 禁用前取得的 token + # 禁用 ent01 + res = client.post( + "/admin/users/u_ent_01/status", + headers=auth(op), + json={"status": "disabled"}, + ) + assert res.status_code == 200 + # 已登录的 ent01 token 立即失效 + assert client.get("/auth/verify", headers=auth(ent)).status_code == 401 + # 重新登录被拒(禁用) + assert ( + client.post( + "/auth/login", json={"username": "ent01", "password": "123456"}, + ).status_code + == 403 + ) + + +def test_role_change_and_data_view_write_audit(client): + op = login(client, "pine") + prov = login(client, "gov_prov") + client.post( + "/admin/users/u_ent_01/role", + headers=auth(op), + json={"role": "provider"}, + ) + client.get("/government/enterprises", headers=auth(prov)) + logs = client.get("/admin/audit-logs", headers=auth(op)).json() + actions = {l["action"] for l in logs} + assert "role.assign" in actions + assert "data.view" in actions diff --git a/tests/test_rbac_opc.py b/tests/test_rbac_opc.py new file mode 100644 index 0000000..f407407 --- /dev/null +++ b/tests/test_rbac_opc.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""OPC 超级个体端点测试:工作台聚合数据。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> str: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def test_opc_dashboard_shape(client): + """opc_member 可访问工作台,且返回标准化聚合结构(不含前端演示数据)。""" + token = login(client, "opc01") + res = client.get("/opc/dashboard", headers=auth(token)) + assert res.status_code == 200, res.text + body = res.json() + + stats = body["stats"] + assert set(stats) == {"inProgressTasks", "completedTasks", "totalEarnings", "creditScore"} + assert stats["inProgressTasks"] == 2 # ot_001 in_progress + ot_002 urgent + assert stats["completedTasks"] == 1 # ot_003 completed + assert stats["creditScore"] == 88 + assert stats["totalEarnings"] == sum( # fin_001..fin_006 income + [3200, 4500, 2800, 5100, 3900, 4600] + ) + + # 进行中任务:仅 in_progress / urgent,且最多 3 条 + assert len(body["activeTasks"]) == 2 + assert all(t["status"] in ("in_progress", "urgent") for t in body["activeTasks"]) + + # 智能体建议为 3 条 + assert len(body["agentTips"]) == 3 + + # 月度收入:按 YYYY-MM 聚合升序 + months = [m["month"] for m in body["monthlyIncome"]] + assert months == sorted(months) + assert len(body["monthlyIncome"]) <= 6 + + # 最新消息:最多 4 条 + assert len(body["recentMessages"]) <= 4 + assert body["recentMessages"][0]["msg_type"] in ( + "task", "policy", "service", "agent", "system", + ) + + +def test_opc_dashboard_rejects_other_roles(client): + """非 opc_member(如企业端)访问应返回 403。""" + token = login(client, "ent01") + res = client.get("/opc/dashboard", headers=auth(token)) + assert res.status_code == 403 diff --git a/tests/test_rbac_operator.py b/tests/test_rbac_operator.py new file mode 100644 index 0000000..97aca5f --- /dev/null +++ b/tests/test_rbac_operator.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""运营端业务端点测试:任务/服务商/内容/配置/统计。""" +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def login(client: TestClient, username: str, password: str = "123456") -> str: + res = client.post("/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + return res.json()["token"] + + +def auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def test_operator_lists_seeded_tasks(client): + op = login(client, "pine") + tasks = client.get("/admin/tasks", headers=auth(op)).json() + assert len(tasks) >= 3 + assert any(t["id"] == "task_001" for t in tasks) + + +def test_operator_task_status_flow(client): + op = login(client, "pine") + # 审核通过 task_003 (pending -> published) + res = client.post( + "/admin/tasks/task_003/status", headers=auth(op), json={"status": "published"}, + ) + assert res.status_code == 200, res.text + assert res.json()["status"] == "published" + + +def test_operator_provider_list_and_update(client): + op = login(client, "pine") + provs = client.get("/admin/providers", headers=auth(op)).json() + assert len(provs) >= 3 + res = client.post( + "/admin/providers/prov_003/update", headers=auth(op), + json={"level": "premium", "status": "active"}, + ) + assert res.status_code == 200, res.text + assert res.json()["status"] == "active" + + +def test_operator_content_publish(client): + op = login(client, "pine") + res = client.post( + "/admin/content", headers=auth(op), + json={"type": "policy", "title": "新政策", "status": "draft"}, + ) + assert res.status_code == 200, res.text + cid = res.json()["id"] + res = client.post( + f"/admin/content/{cid}/status", headers=auth(op), json={"status": "published"}, + ) + assert res.status_code == 200 + assert res.json()["status"] == "published" + + +def test_operator_config_update(client): + op = login(client, "pine") + res = client.put( + "/admin/config/platform.fee.task", headers=auth(op), json={"value": "0.06"}, + ) + assert res.status_code == 200, res.text + assert res.json()["value"] == "0.06" + + +def test_operator_stats_overview(client): + op = login(client, "pine") + stats = client.get("/admin/stats/overview", headers=auth(op)).json() + assert stats["user_count"] >= 7 + assert stats["task_count"] >= 3 + + +def test_non_operator_cannot_manage_tasks(client): + ent = login(client, "ent01") + assert client.get("/admin/tasks", headers=auth(ent)).status_code == 403 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a31d6fb --- /dev/null +++ b/uv.lock @@ -0,0 +1,897 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pineagents-demo-server" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pydantic", specifier = ">=2.7.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, + { name = "python-multipart", specifier = ">=0.0.12" }, + { name = "sqlalchemy", specifier = ">=2.0.30" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/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", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/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", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/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", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/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", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/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", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/54/b2bce5b754b91b727b852e78af6d7193d4fe985e420dc54e6c2abe161c1c/websockets-17.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf", size = 212573, upload-time = "2026-07-31T11:29:04.803Z" }, + { url = "https://files.pythonhosted.org/packages/78/1b/eaefeb695b217d8c735fc377ab53c6b00bc9ed64a01a3f6f797096ac0ee8/websockets-17.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a", size = 210262, upload-time = "2026-07-31T11:29:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f", size = 210535, upload-time = "2026-07-31T11:29:07.553Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ab/13a856b488dbac7fd3c5473e38098897cda0baa04e3ca3b34ec6c8a32b46/websockets-17.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536", size = 219602, upload-time = "2026-07-31T11:29:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac", size = 219873, upload-time = "2026-07-31T11:29:10.362Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/92e6146d3588d145136c0e0e16d106bbb855bb5047e13ec3fdee39cce770/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1", size = 221108, upload-time = "2026-07-31T11:29:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/357afa2ffd29686536109e7e3cb2a94f2d09e535df4cf3989393dc506a40/websockets-17.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d", size = 224401, upload-time = "2026-07-31T11:29:12.959Z" }, + { url = "https://files.pythonhosted.org/packages/01/27/9efba1e7a8df48e405d017e67513c6b2a8f0b59c8500b820c47cd5f3dea9/websockets-17.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301", size = 221670, upload-time = "2026-07-31T11:29:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/ab27d62103e8a150f3657e043e5fc711ad3e018c8cd8a715093e93d7640c/websockets-17.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5", size = 220442, upload-time = "2026-07-31T11:29:15.417Z" }, + { url = "https://files.pythonhosted.org/packages/6d/04/289e00b8001b622b0c397a6901fcc4aa8f34a6d2ed42be17f2704f2faae1/websockets-17.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0", size = 217760, upload-time = "2026-07-31T11:29:16.772Z" }, + { url = "https://files.pythonhosted.org/packages/2f/70/0fe58cdac988dfc0066786cc07b09dfd72b48b0c01e5de667721192b2e6e/websockets-17.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac", size = 220597, upload-time = "2026-07-31T11:29:18.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/76/d3eaf120710d1a791d1c7a4963f0b50a589df8ba675394fd2a97dfea3746/websockets-17.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56", size = 219187, upload-time = "2026-07-31T11:29:19.468Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/4e9ae886bdb1647537176ca33fca66d79dddb3773d213ac98ddc6bba9ab4/websockets-17.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed", size = 219955, upload-time = "2026-07-31T11:29:20.641Z" }, + { url = "https://files.pythonhosted.org/packages/7c/67/cd7cc6849a86cf8c9979c0c570b6b6ccee75d2872854238d8d54e77be6ec/websockets-17.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb", size = 221002, upload-time = "2026-07-31T11:29:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/5c/4d14eaf7b2f1448d1af24c1641f04eb74c1632a5802952aac4b7e068b8e7/websockets-17.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd", size = 218579, upload-time = "2026-07-31T11:29:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/ff8bc4b8a6ec235ed8985de12fecc59ad2cd68cc8fc79b97deaa42e412ac/websockets-17.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08", size = 219612, upload-time = "2026-07-31T11:29:24.052Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/103d81d655bab3ffd5c7d5d4b08f92c374499decd1d4be6035ce715b385b/websockets-17.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448", size = 219846, upload-time = "2026-07-31T11:29:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/3495161b1827941258545604fdf72e3e053d03f25bb61752228a784c26a1/websockets-17.0.1-cp311-cp311-win32.whl", hash = "sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946", size = 213046, upload-time = "2026-07-31T11:29:27.094Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae", size = 213344, upload-time = "2026-07-31T11:29:28.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/9e/abfdde9cbd57f0b5867a70e3426ac63341e1a21557b273c39bb6d2ccf8b9/websockets-17.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a", size = 213277, upload-time = "2026-07-31T11:29:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/51/77/63b4abd29f15107d856f010de6f35434faa7c49ef89151e051d2807a9c40/websockets-17.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2", size = 210194, upload-time = "2026-07-31T11:31:18.046Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ab/6160542ee644f72b865af13ddfff23740c95595b237e16312262cafdb641/websockets-17.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f", size = 210465, upload-time = "2026-07-31T11:31:19.333Z" }, + { url = "https://files.pythonhosted.org/packages/53/1a/d4437d3cb0691eeac2c6064e21c83a9eeda04f6ef0261abfc0dde708590d/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d", size = 211417, upload-time = "2026-07-31T11:31:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/88/28/2d671e23a20359a1cc142848384659813b49028d46cb0acdc28e81ac59b5/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1", size = 211309, upload-time = "2026-07-31T11:31:21.969Z" }, + { url = "https://files.pythonhosted.org/packages/02/52/b85a676b161991e5c0d884252376435f60510789e7740e3da73489d22a6e/websockets-17.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42", size = 212203, upload-time = "2026-07-31T11:31:23.38Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/91e297e41381d9131f3142a9c1a50389fd96b98125ce9b81526fa4e14b9f/websockets-17.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", size = 213434, upload-time = "2026-07-31T11:31:24.707Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +]