Merge origin/develop into feature/a-card-operations
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
"""数据资源路由共享的 payload / 可见性辅助。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from common.db.models import DataResources, StorageObjects
|
||||
from common.storage import workspaces_root
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.dependencies import RequestContext
|
||||
from backend.api.scripts import _escape_like_pattern, normalize_user_path
|
||||
|
||||
|
||||
def build_list_resources_descendant_prefix(parent_path: str) -> str:
|
||||
"""Return the escaped materialized-path prefix for direct children."""
|
||||
normalized = normalize_user_path(parent_path)
|
||||
escaped = _escape_like_pattern(normalized)
|
||||
return f"{escaped}/" if escaped else ""
|
||||
|
||||
|
||||
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
|
||||
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。"""
|
||||
script_dir = PurePosixPath(script_path).parent.as_posix()
|
||||
if not script_dir or script_dir == ".":
|
||||
return resource_relative
|
||||
return os.path.relpath(resource_relative, start=script_dir)
|
||||
|
||||
|
||||
def resource_directory(
|
||||
object_key: str,
|
||||
workspace_id: str,
|
||||
owner_user_id: str,
|
||||
) -> str:
|
||||
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。"""
|
||||
prefix = f"{workspace_id}/{owner_user_id}/"
|
||||
if not object_key.startswith(prefix):
|
||||
return ""
|
||||
tail = object_key[len(prefix):]
|
||||
directory, _, _ = tail.rpartition("/")
|
||||
return directory
|
||||
|
||||
|
||||
def resource_payload(
|
||||
resource: DataResources,
|
||||
storage_object: StorageObjects,
|
||||
owner_display_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
workspace_prefix = f"{resource.workspace_id}/"
|
||||
user_prefix = f"{resource.owner_user_id}/"
|
||||
jupyter_accessible_path = ""
|
||||
absolute_path = ""
|
||||
if storage_object.object_key and storage_object.object_key.startswith(
|
||||
workspace_prefix
|
||||
):
|
||||
remainder = storage_object.object_key[len(workspace_prefix):]
|
||||
if remainder.startswith(user_prefix):
|
||||
tail = remainder[len(user_prefix):]
|
||||
jupyter_accessible_path = tail
|
||||
absolute_path = (
|
||||
workspaces_root()
|
||||
/ resource.workspace_id
|
||||
/ resource.owner_user_id
|
||||
/ Path(tail)
|
||||
).as_posix()
|
||||
else:
|
||||
jupyter_accessible_path = remainder
|
||||
elif storage_object.object_key:
|
||||
jupyter_accessible_path = storage_object.object_key
|
||||
return {
|
||||
"resource_id": resource.resource_id,
|
||||
"workspace_id": resource.workspace_id,
|
||||
"storage_object_id": resource.storage_object_id,
|
||||
"owner_user_id": resource.owner_user_id,
|
||||
"owner_display_name": owner_display_name,
|
||||
"resource_name": resource.resource_name,
|
||||
"description": resource.description,
|
||||
"visibility": resource.visibility,
|
||||
"status": resource.status,
|
||||
"created_at": resource.created_at.isoformat(),
|
||||
"updated_at": resource.updated_at.isoformat(),
|
||||
"file": {
|
||||
"file_name": storage_object.file_name,
|
||||
"file_extension": storage_object.file_extension,
|
||||
"mime_type": storage_object.mime_type,
|
||||
"size_bytes": storage_object.size_bytes,
|
||||
"content_hash": storage_object.content_hash,
|
||||
"object_status": storage_object.object_status,
|
||||
},
|
||||
"jupyter_accessible_path": jupyter_accessible_path,
|
||||
"absolute_path": absolute_path,
|
||||
}
|
||||
|
||||
|
||||
def can_view(resource: DataResources, context: RequestContext) -> bool:
|
||||
if resource.owner_user_id == context.user.user_id:
|
||||
return True
|
||||
if resource.visibility in {"workspace", "public"}:
|
||||
return True
|
||||
return context.is_admin
|
||||
|
||||
|
||||
async def get_visible_resource(
|
||||
resource_id: str,
|
||||
context: RequestContext,
|
||||
session: AsyncSession,
|
||||
) -> tuple[DataResources, StorageObjects]:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DataResources, StorageObjects)
|
||||
.join(
|
||||
StorageObjects,
|
||||
StorageObjects.storage_object_id
|
||||
== DataResources.storage_object_id,
|
||||
)
|
||||
.where(
|
||||
DataResources.resource_id == resource_id,
|
||||
DataResources.workspace_id
|
||||
== context.workspace.workspace_id,
|
||||
DataResources.status == "active",
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None or not can_view(row[0], context):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
|
||||
return row
|
||||
+110
-16
@@ -12,6 +12,8 @@ The user-facing flow is:
|
||||
``backend.api.dependencies.request_context``
|
||||
3. POST /api/v1/auth/logout — clear the cookie
|
||||
4. GET /api/v1/auth/me — return the current user
|
||||
5. PATCH /api/v1/auth/me — update display_name / email
|
||||
6. POST /api/v1/auth/password — change password (clears session cookie)
|
||||
|
||||
Service-to-service calls do not use these endpoints — they live on the
|
||||
shared Docker network and have no application-layer auth. See
|
||||
@@ -24,15 +26,16 @@ from typing import Any
|
||||
|
||||
from common.auth.jwt import JwtError, issue_jwt, verify_jwt_token
|
||||
from common.auth.membership import resolve_is_system_admin
|
||||
from common.auth.passwords import verify_password
|
||||
from common.auth.passwords import hash_password, verify_password
|
||||
from common.config import settings
|
||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||
from common.ids import new_ulid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.dependencies import database_session, load_user_permissions
|
||||
from backend.api.dependencies import current_user, database_session, load_user_permissions
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
@@ -95,6 +98,29 @@ def _workspace_payload(
|
||||
}
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
current_password: str = Field(min_length=1, max_length=72)
|
||||
new_password: str = Field(min_length=8, max_length=72)
|
||||
|
||||
|
||||
async def _platform_role_code(session: AsyncSession, user: Users) -> str | None:
|
||||
if user.platform_role_id is None:
|
||||
return None
|
||||
platform_role_row = await session.scalar(
|
||||
select(Roles).where(Roles.role_id == user.platform_role_id)
|
||||
)
|
||||
return platform_role_row.role_code if platform_role_row is not None else None
|
||||
|
||||
|
||||
# 校验账号密码,设置登录 Cookie,并返回用户可进入的工作区列表。
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
@@ -167,13 +193,7 @@ async def login(
|
||||
# (``Users.platform_role_id``); the iteration over rows above was
|
||||
# a legacy way to find the "highest" workspace role and is no
|
||||
# longer correct now that admin/developer are platform-only.
|
||||
user_role_code: str | None = None
|
||||
if user.platform_role_id is not None:
|
||||
platform_role_row = await session.scalar(
|
||||
select(Roles).where(Roles.role_id == user.platform_role_id)
|
||||
)
|
||||
if platform_role_row is not None:
|
||||
user_role_code = platform_role_row.role_code
|
||||
user_role_code = await _platform_role_code(session, user)
|
||||
|
||||
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
|
||||
_set_session_cookie(request, response, token)
|
||||
@@ -257,13 +277,7 @@ async def me(
|
||||
|
||||
workspaces = [_workspace_payload(ws, role) for ws, role, _ in rows]
|
||||
default_workspace_id = workspaces[0]["workspace_id"] if workspaces else None
|
||||
user_role_code: str | None = None
|
||||
if user.platform_role_id is not None:
|
||||
platform_role_row = await session.scalar(
|
||||
select(Roles).where(Roles.role_id == user.platform_role_id)
|
||||
)
|
||||
if platform_role_row is not None:
|
||||
user_role_code = platform_role_row.role_code
|
||||
user_role_code = await _platform_role_code(session, user)
|
||||
|
||||
is_system_admin = await resolve_is_system_admin(session, user)
|
||||
permissions = await load_user_permissions(session, user)
|
||||
@@ -284,6 +298,86 @@ async def me(
|
||||
}
|
||||
|
||||
|
||||
# 当前登录用户修改显示名 / 邮箱(不可改 username、角色、密码)。
|
||||
@router.patch("/me")
|
||||
async def update_me(
|
||||
payload: ProfileUpdate,
|
||||
user: Users = Depends(current_user),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update the authenticated user's display_name and/or email."""
|
||||
if payload.display_name is None and payload.email is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"至少提供一个可修改字段",
|
||||
)
|
||||
|
||||
if payload.display_name is not None:
|
||||
user.display_name = payload.display_name.strip()
|
||||
|
||||
if payload.email is not None:
|
||||
normalized = payload.email.strip() or None
|
||||
if normalized is not None:
|
||||
duplicate = await session.scalar(
|
||||
select(Users.user_id).where(
|
||||
Users.email == normalized,
|
||||
Users.user_id != user.user_id,
|
||||
Users.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "邮箱已存在")
|
||||
user.email = normalized
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(user)
|
||||
|
||||
user_role_code = await _platform_role_code(session, user)
|
||||
is_system_admin = await resolve_is_system_admin(session, user)
|
||||
permissions = await load_user_permissions(session, user)
|
||||
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {
|
||||
"user": _user_payload(
|
||||
user,
|
||||
user_role_code,
|
||||
is_system_admin=is_system_admin,
|
||||
permissions=permissions,
|
||||
),
|
||||
},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
# 当前登录用户修改密码;成功后清除 Cookie,需重新登录。
|
||||
@router.post("/password")
|
||||
async def change_password(
|
||||
payload: PasswordChange,
|
||||
response: Response,
|
||||
user: Users = Depends(current_user),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Change password after verifying the current one; clears the session cookie."""
|
||||
if not verify_password(payload.current_password, user.password_hash):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "当前密码不正确")
|
||||
if payload.current_password == payload.new_password:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"新密码不能与当前密码相同",
|
||||
)
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
await session.flush()
|
||||
_clear_session_cookie(response)
|
||||
|
||||
return {
|
||||
"request_id": new_ulid(),
|
||||
"data": {"password_changed": True},
|
||||
"meta": {},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COOKIE_NAME",
|
||||
"COOKIE_TTL_SECONDS",
|
||||
|
||||
@@ -73,6 +73,7 @@ from backend.api.platform._deps import (
|
||||
system_admin_context,
|
||||
)
|
||||
from backend.api.platform.employees import router as employees_router
|
||||
from backend.api.platform.members import router as members_router
|
||||
from backend.api.platform.roles import router as roles_router
|
||||
from backend.api.platform.workspaces import router as workspaces_router
|
||||
|
||||
@@ -81,6 +82,7 @@ from backend.api.platform.workspaces import router as workspaces_router
|
||||
router = APIRouter()
|
||||
router.include_router(employees_router)
|
||||
router.include_router(workspaces_router)
|
||||
router.include_router(members_router)
|
||||
router.include_router(roles_router)
|
||||
|
||||
# 保留 system_admin_context 的 re-export,供其他文件使用
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Cursor (keyset) pagination helpers for platform list endpoints.
|
||||
|
||||
Cursor encodes the sort key ``(created_at, id)`` as a URL-safe base64
|
||||
string. Clients pass it back via ``?cursor=`` to fetch the next page.
|
||||
Invalid cursors raise HTTP 400 — never silently treated as page 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
DEFAULT_PAGE_LIMIT = 10
|
||||
MAX_PAGE_LIMIT = 200
|
||||
|
||||
|
||||
def encode_cursor(created_at: datetime.datetime, row_id: str) -> str:
|
||||
"""Encode ``(created_at, id)`` into an opaque cursor string."""
|
||||
if created_at.tzinfo is not None:
|
||||
created_at = created_at.replace(tzinfo=None)
|
||||
raw = f"{created_at.isoformat()}|{row_id}".encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(cursor: str) -> tuple[datetime.datetime, str]:
|
||||
"""Decode a cursor; raise 400 on malformed input."""
|
||||
try:
|
||||
padded = cursor + "=" * (-len(cursor) % 4)
|
||||
raw = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
|
||||
ts_part, _, row_id = raw.partition("|")
|
||||
if not ts_part or not row_id:
|
||||
raise ValueError("missing parts")
|
||||
created_at = datetime.datetime.fromisoformat(ts_part)
|
||||
if created_at.tzinfo is not None:
|
||||
created_at = created_at.replace(tzinfo=None)
|
||||
return created_at, row_id
|
||||
except (ValueError, TypeError, UnicodeDecodeError) as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"无效的分页 cursor",
|
||||
) from exc
|
||||
|
||||
|
||||
def page_meta(
|
||||
*,
|
||||
limit: int,
|
||||
page_count: int,
|
||||
total_count: int,
|
||||
next_cursor: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"limit": limit,
|
||||
"page_count": page_count,
|
||||
"total_count": total_count,
|
||||
"has_more": next_cursor is not None,
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
@@ -11,9 +11,9 @@ from typing import Any, Literal
|
||||
from common.auth.passwords import hash_password
|
||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||
from common.ids import new_ulid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy import func, or_, select, tuple_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.dependencies import database_session
|
||||
@@ -25,6 +25,13 @@ from backend.api.platform._deps import (
|
||||
_load_role_by_code,
|
||||
system_admin_context,
|
||||
)
|
||||
from backend.api.platform._pagination import (
|
||||
DEFAULT_PAGE_LIMIT,
|
||||
MAX_PAGE_LIMIT,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
page_meta,
|
||||
)
|
||||
|
||||
# 平台角色 code 的字符串约束:与 RoleCreate.role_code 一致。
|
||||
# 之所以从 Literal["admin","developer"] 放宽为 str,是因为 ``listPlatformRoles``
|
||||
@@ -91,22 +98,63 @@ def platform_employee_payload(
|
||||
# 列出整个平台的非删除用户;不局限于某一个工作区。
|
||||
@router.get("/employees")
|
||||
async def list_platform_employees(
|
||||
limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT),
|
||||
cursor: str | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List every non-soft-deleted platform user."""
|
||||
"""List non-soft-deleted platform users with cursor pagination + search."""
|
||||
base_filters = [Users.is_deleted == 0]
|
||||
keyword = (q or "").strip()
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
base_filters.append(
|
||||
or_(
|
||||
Users.display_name.like(like),
|
||||
Users.username.like(like),
|
||||
Users.email.like(like),
|
||||
)
|
||||
)
|
||||
|
||||
total_count = int(
|
||||
await session.scalar(
|
||||
select(func.count()).select_from(Users).where(*base_filters)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
page_filters = list(base_filters)
|
||||
if cursor is not None:
|
||||
cursor_ts, cursor_id = decode_cursor(cursor)
|
||||
page_filters.append(
|
||||
tuple_(Users.created_at, Users.user_id) > (cursor_ts, cursor_id)
|
||||
)
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Users, Roles)
|
||||
.outerjoin(Roles, Roles.role_id == Users.platform_role_id)
|
||||
.where(Users.is_deleted == 0)
|
||||
.where(*page_filters)
|
||||
.order_by(Users.created_at, Users.user_id)
|
||||
.limit(limit + 1)
|
||||
)
|
||||
).all()
|
||||
has_more = len(rows) > limit
|
||||
page_rows = rows[:limit]
|
||||
next_cursor = None
|
||||
if has_more and page_rows:
|
||||
last_user = page_rows[-1][0]
|
||||
next_cursor = encode_cursor(last_user.created_at, last_user.user_id)
|
||||
return _envelope(
|
||||
context.request_id,
|
||||
[platform_employee_payload(user, role) for user, role in rows],
|
||||
{"count": len(rows)},
|
||||
[platform_employee_payload(user, role) for user, role in page_rows],
|
||||
page_meta(
|
||||
limit=limit,
|
||||
page_count=len(page_rows),
|
||||
total_count=total_count,
|
||||
next_cursor=next_cursor,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -348,3 +396,30 @@ async def delete_platform_employee(
|
||||
)
|
||||
|
||||
|
||||
class PlatformEmployeePasswordReset(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
new_password: str = Field(min_length=8, max_length=72)
|
||||
|
||||
|
||||
# 系统管理员重置指定员工密码(不需要旧密码)。
|
||||
@router.post("/employees/{user_id}/reset-password")
|
||||
async def reset_platform_employee_password(
|
||||
user_id: str,
|
||||
payload: PlatformEmployeePasswordReset,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Reset a platform employee's password. Caller must be a system admin."""
|
||||
user = await session.get(Users, user_id)
|
||||
if user is None or user.is_deleted != 0:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
await session.flush()
|
||||
return _envelope(
|
||||
context.request_id,
|
||||
{"user_id": user_id, "password_reset": True},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
"""Workspace membership CRUD endpoints.
|
||||
|
||||
``GET .../members`` admits system admins or active workspace members;
|
||||
write endpoints require ``system_admin_context``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from common.db.models import Roles, Users, WorkspaceMembers
|
||||
from common.ids import new_ulid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.dependencies import current_user, database_session
|
||||
from backend.api.platform._deps import (
|
||||
SystemAdminContext,
|
||||
_count_active_admins,
|
||||
_envelope,
|
||||
_is_system_admin,
|
||||
system_admin_context,
|
||||
)
|
||||
from backend.api.platform.workspaces import (
|
||||
_load_workspace,
|
||||
member_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
||||
|
||||
LIST_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class MemberCreate(BaseModel):
|
||||
"""Add a user to a workspace. Role is inherited from the user's
|
||||
platform role (Users.platform_role_id) — not set here."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_id: str = Field(min_length=26, max_length=26)
|
||||
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
"""Update a workspace membership's status. Role cannot be changed
|
||||
via this endpoint — workspace role is always inherited from the
|
||||
user's platform role. To change a member's role, PATCH
|
||||
/platform/employees/{user_id} instead."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
member_status: Literal["active", "disabled", "locked"] | None = None
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List active and historical (non-soft-deleted) members of a workspace.
|
||||
|
||||
Accessible to system admins (any workspace) and to active members of the
|
||||
workspace itself. The script explorer calls this to seed the per-owner
|
||||
directory-tree groups for non-admin users; visibility filters on the
|
||||
scripts/data-resources endpoints still keep each peer's private content
|
||||
hidden, so this only exposes membership (names), not private files.
|
||||
"""
|
||||
user = await current_user(request, session)
|
||||
is_system_admin = await _is_system_admin(session, user)
|
||||
if not is_system_admin:
|
||||
membership = await session.scalar(
|
||||
select(WorkspaceMembers).where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user.user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
)
|
||||
)
|
||||
if membership is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"需要系统管理员或该工作区成员权限",
|
||||
)
|
||||
await _load_workspace(session, workspace_id)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Users, Roles, WorkspaceMembers)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.user_id == Users.user_id,
|
||||
)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
.order_by(WorkspaceMembers.joined_at, Users.user_id)
|
||||
.limit(LIST_PAGE_SIZE)
|
||||
)
|
||||
).all()
|
||||
request_id = request.headers.get("X-Request-ID") or new_ulid()
|
||||
return _envelope(
|
||||
request_id,
|
||||
[member_payload(u, r, m) for u, r, m in rows],
|
||||
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/workspaces/{workspace_id}/members",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
payload: MemberCreate,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Add a user to a workspace. The new row starts with member_status='active'.
|
||||
|
||||
The role is inherited from the target user's ``platform_role_id``;
|
||||
the request body does NOT take a ``role_code``. To change a member's
|
||||
role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
|
||||
"""
|
||||
await _load_workspace(session, workspace_id)
|
||||
user = await session.get(Users, payload.user_id)
|
||||
if user is None or user.is_deleted != 0:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"用户状态为 {user.status},无法加入 workspace",
|
||||
)
|
||||
if user.platform_role_id is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"目标用户尚未分配平台角色,无法加入 workspace;"
|
||||
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
|
||||
)
|
||||
role = await session.scalar(
|
||||
select(Roles).where(
|
||||
Roles.role_id == user.platform_role_id,
|
||||
Roles.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"用户的平台角色行不存在或已被删除",
|
||||
)
|
||||
# ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
|
||||
# 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
|
||||
# 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
|
||||
# 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
|
||||
existing = await session.scalar(
|
||||
select(WorkspaceMembers).where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == payload.user_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.is_deleted == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"用户已是该 workspace 成员;workspace 角色继承自平台角色,"
|
||||
"要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
|
||||
)
|
||||
# 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
|
||||
# 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
|
||||
# ``deleted_at`` 标记本轮已不在软删状态.
|
||||
existing.is_deleted = 0
|
||||
existing.deleted_at = None
|
||||
existing.role_id = role.role_id
|
||||
existing.member_status = "active"
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return _envelope(
|
||||
context.request_id, member_payload(user, role, existing),
|
||||
)
|
||||
membership = WorkspaceMembers(
|
||||
workspace_id=workspace_id,
|
||||
user_id=payload.user_id,
|
||||
role_id=role.role_id,
|
||||
member_status="active",
|
||||
)
|
||||
session.add(membership)
|
||||
await session.flush()
|
||||
await session.refresh(membership)
|
||||
return _envelope(context.request_id, member_payload(user, role, membership))
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
|
||||
async def update_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
payload: MemberUpdate,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update a workspace membership's status. Role is not editable here.
|
||||
|
||||
Workspace role is always inherited from the user's platform role
|
||||
(``Users.platform_role_id``). To change role, PATCH
|
||||
``/api/v1/platform/employees/{user_id}`` instead.
|
||||
|
||||
Last-admin guard still applies to ``member_status`` changes: setting
|
||||
the only active admin to ``disabled``/``locked`` would leave the
|
||||
workspace without admin coverage.
|
||||
"""
|
||||
await _load_workspace(session, workspace_id)
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Users, Roles, WorkspaceMembers)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.user_id == Users.user_id,
|
||||
)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
|
||||
user, role, membership = row
|
||||
|
||||
if payload.member_status is not None and payload.member_status != membership.member_status:
|
||||
if (
|
||||
role.role_code == "admin"
|
||||
and payload.member_status != "active"
|
||||
):
|
||||
remaining = await _count_active_admins(
|
||||
session, workspace_id, exclude_user_id=user_id,
|
||||
)
|
||||
if remaining == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"workspace 必须保留至少一个 admin",
|
||||
)
|
||||
membership.member_status = payload.member_status
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(membership)
|
||||
return _envelope(context.request_id, member_payload(user, role, membership))
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Soft-delete a workspace membership.
|
||||
|
||||
System admins cannot remove themselves — the only escape is to delete
|
||||
the entire workspace, which cascades membership soft-deletion.
|
||||
"""
|
||||
await _load_workspace(session, workspace_id)
|
||||
if user_id == context.user.user_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
|
||||
)
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Roles, WorkspaceMembers)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
|
||||
role, membership = row
|
||||
if role.role_code == "admin" and membership.member_status == "active":
|
||||
remaining = await _count_active_admins(
|
||||
session, workspace_id, exclude_user_id=user_id,
|
||||
)
|
||||
if remaining == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"workspace 必须保留至少一个 admin",
|
||||
)
|
||||
membership.is_deleted = 1
|
||||
membership.deleted_at = datetime.datetime.utcnow()
|
||||
await session.flush()
|
||||
return _envelope(
|
||||
context.request_id,
|
||||
{"workspace_id": workspace_id, "user_id": user_id, "removed": True},
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Workspace & membership CRUD endpoints.
|
||||
Five workspace endpoints plus five membership endpoints, gated by
|
||||
``system_admin_context`` (except ``GET .../members``, which also admits workspace
|
||||
members). Last-admin guards and soft-delete cascades live here.
|
||||
"""Workspace CRUD endpoints.
|
||||
|
||||
Membership endpoints live in ``members.py``. Soft-delete cascades for
|
||||
workspace DELETE still live here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,29 +12,29 @@ from typing import Any, Literal
|
||||
|
||||
from common.db.models import Roles, Users, WorkspaceMembers, Workspaces
|
||||
from common.ids import new_ulid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, or_, select, tuple_, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.dependencies import current_user, database_session
|
||||
from backend.api.dependencies import database_session
|
||||
from backend.api.platform._deps import (
|
||||
SystemAdminContext,
|
||||
_count_active_admins,
|
||||
_envelope,
|
||||
_is_system_admin,
|
||||
_load_role_by_code,
|
||||
system_admin_context,
|
||||
)
|
||||
from backend.api.platform._pagination import (
|
||||
DEFAULT_PAGE_LIMIT,
|
||||
MAX_PAGE_LIMIT,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
page_meta,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform"])
|
||||
|
||||
WORKSPACE_CODE_PATTERN = re.compile(r"^[a-z0-9-]{3,32}$")
|
||||
LIST_PAGE_SIZE = 100
|
||||
|
||||
WORKSPACE_EDITABLE_STATUS = ("active", "archived")
|
||||
MEMBER_ROLE_CODES = ("admin", "developer")
|
||||
MEMBER_STATUS_VALUES = ("active", "disabled", "locked")
|
||||
|
||||
# 创建工作区时前端提交的请求体;禁止未声明字段。
|
||||
class WorkspaceCreate(BaseModel):
|
||||
@@ -55,24 +55,6 @@ class WorkspaceUpdate(BaseModel):
|
||||
# 'disabled' is rejected here on purpose — soft delete must go through DELETE.
|
||||
status: Literal["active", "archived"] | None = None
|
||||
|
||||
class MemberCreate(BaseModel):
|
||||
"""Add a user to a workspace. Role is inherited from the user's
|
||||
platform role (Users.platform_role_id) — not set here."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
user_id: str = Field(min_length=26, max_length=26)
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
"""Update a workspace membership's status. Role cannot be changed
|
||||
via this endpoint — workspace role is always inherited from the
|
||||
user's platform role. To change a member's role, PATCH
|
||||
/platform/employees/{user_id} instead."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
member_status: Literal["active", "disabled", "locked"] | None = None
|
||||
|
||||
def workspace_payload(workspace: Workspaces) -> dict[str, Any]:
|
||||
return {
|
||||
"workspace_id": workspace.workspace_id,
|
||||
@@ -114,28 +96,66 @@ async def _load_workspace(session: AsyncSession, workspace_id: str) -> Workspace
|
||||
|
||||
@router.get("/workspaces")
|
||||
async def list_workspaces(
|
||||
limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT),
|
||||
cursor: str | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List active/archived workspaces. Soft-deleted rows are filtered out.
|
||||
"""List active/archived workspaces with cursor pagination + search."""
|
||||
base_filters = [
|
||||
Workspaces.status != "disabled",
|
||||
Workspaces.is_deleted == 0,
|
||||
]
|
||||
keyword = (q or "").strip()
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
base_filters.append(
|
||||
or_(
|
||||
Workspaces.workspace_name.like(like),
|
||||
Workspaces.workspace_code.like(like),
|
||||
Workspaces.description.like(like),
|
||||
)
|
||||
)
|
||||
|
||||
total_count = int(
|
||||
await session.scalar(
|
||||
select(func.count()).select_from(Workspaces).where(*base_filters)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
page_filters = list(base_filters)
|
||||
if cursor is not None:
|
||||
cursor_ts, cursor_id = decode_cursor(cursor)
|
||||
page_filters.append(
|
||||
tuple_(Workspaces.created_at, Workspaces.workspace_id)
|
||||
> (cursor_ts, cursor_id)
|
||||
)
|
||||
|
||||
Silent ``pageSize=100`` cap — YAGNI on real pagination until needed.
|
||||
"""
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Workspaces)
|
||||
.where(
|
||||
Workspaces.status != "disabled",
|
||||
Workspaces.is_deleted == 0,
|
||||
)
|
||||
.where(*page_filters)
|
||||
.order_by(Workspaces.created_at, Workspaces.workspace_id)
|
||||
.limit(LIST_PAGE_SIZE)
|
||||
.limit(limit + 1)
|
||||
)
|
||||
).scalars().all()
|
||||
has_more = len(rows) > limit
|
||||
page_rows = list(rows[:limit])
|
||||
next_cursor = None
|
||||
if has_more and page_rows:
|
||||
last = page_rows[-1]
|
||||
next_cursor = encode_cursor(last.created_at, last.workspace_id)
|
||||
return _envelope(
|
||||
context.request_id,
|
||||
[workspace_payload(w) for w in rows],
|
||||
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
|
||||
[workspace_payload(w) for w in page_rows],
|
||||
page_meta(
|
||||
limit=limit,
|
||||
page_count=len(page_rows),
|
||||
total_count=total_count,
|
||||
next_cursor=next_cursor,
|
||||
),
|
||||
)
|
||||
|
||||
@router.post("/workspaces", status_code=status.HTTP_201_CREATED)
|
||||
@@ -252,246 +272,3 @@ async def delete_workspace(
|
||||
await session.flush()
|
||||
await session.refresh(workspace)
|
||||
return _envelope(context.request_id, workspace_payload(workspace))
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List active and historical (non-soft-deleted) members of a workspace.
|
||||
|
||||
Accessible to system admins (any workspace) and to active members of the
|
||||
workspace itself. The script explorer calls this to seed the per-owner
|
||||
directory-tree groups for non-admin users; visibility filters on the
|
||||
scripts/data-resources endpoints still keep each peer's private content
|
||||
hidden, so this only exposes membership (names), not private files.
|
||||
"""
|
||||
user = await current_user(request, session)
|
||||
is_system_admin = await _is_system_admin(session, user)
|
||||
if not is_system_admin:
|
||||
membership = await session.scalar(
|
||||
select(WorkspaceMembers).where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user.user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
WorkspaceMembers.member_status == "active",
|
||||
)
|
||||
)
|
||||
if membership is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"需要系统管理员或该工作区成员权限",
|
||||
)
|
||||
await _load_workspace(session, workspace_id)
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(Users, Roles, WorkspaceMembers)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.user_id == Users.user_id,
|
||||
)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
.order_by(WorkspaceMembers.joined_at, Users.user_id)
|
||||
.limit(LIST_PAGE_SIZE)
|
||||
)
|
||||
).all()
|
||||
request_id = request.headers.get("X-Request-ID") or new_ulid()
|
||||
return _envelope(
|
||||
request_id,
|
||||
[member_payload(u, r, m) for u, r, m in rows],
|
||||
{"count": len(rows), "page_size": LIST_PAGE_SIZE},
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/workspaces/{workspace_id}/members",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_member(
|
||||
workspace_id: str,
|
||||
payload: MemberCreate,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Add a user to a workspace. The new row starts with member_status='active'.
|
||||
|
||||
The role is inherited from the target user's ``platform_role_id``;
|
||||
the request body does NOT take a ``role_code``. To change a member's
|
||||
role, PATCH ``/api/v1/platform/employees/{user_id}`` instead.
|
||||
"""
|
||||
await _load_workspace(session, workspace_id)
|
||||
user = await session.get(Users, payload.user_id)
|
||||
if user is None or user.is_deleted != 0:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
|
||||
if user.status != "active":
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
f"用户状态为 {user.status},无法加入 workspace",
|
||||
)
|
||||
if user.platform_role_id is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"目标用户尚未分配平台角色,无法加入 workspace;"
|
||||
"请先 PATCH /api/v1/platform/employees/{user_id} 设置 role_code",
|
||||
)
|
||||
role = await session.scalar(
|
||||
select(Roles).where(
|
||||
Roles.role_id == user.platform_role_id,
|
||||
Roles.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
if role is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"用户的平台角色行不存在或已被删除",
|
||||
)
|
||||
# ``WorkspaceMembers`` 的主键是 ``(workspace_id, user_id)`` 复合 PK,
|
||||
# 而 ``remove_member`` / ``delete_platform_employee`` 都是软删除 (保留行,
|
||||
# 仅置 ``is_deleted=1``). 因此这里必须按主键查整行,而不是只看活跃行:
|
||||
# 否则软删行会被 active-duplicate 检查漏过,然后 INSERT 直接撞 PK.
|
||||
existing = await session.scalar(
|
||||
select(WorkspaceMembers).where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == payload.user_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.is_deleted == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"用户已是该 workspace 成员;workspace 角色继承自平台角色,"
|
||||
"要变更请 PATCH /api/v1/platform/employees/{user_id} 修改 role_code",
|
||||
)
|
||||
# 复活软删除行. 保留 ``joined_at`` 作为历史记录;``role_id`` 重新继承
|
||||
# 当前用户的平台角色 (用户在中间可能改过 platform_role);清掉
|
||||
# ``deleted_at`` 标记本轮已不在软删状态.
|
||||
existing.is_deleted = 0
|
||||
existing.deleted_at = None
|
||||
existing.role_id = role.role_id
|
||||
existing.member_status = "active"
|
||||
await session.flush()
|
||||
await session.refresh(existing)
|
||||
return _envelope(
|
||||
context.request_id, member_payload(user, role, existing),
|
||||
)
|
||||
membership = WorkspaceMembers(
|
||||
workspace_id=workspace_id,
|
||||
user_id=payload.user_id,
|
||||
role_id=role.role_id,
|
||||
member_status="active",
|
||||
)
|
||||
session.add(membership)
|
||||
await session.flush()
|
||||
await session.refresh(membership)
|
||||
return _envelope(context.request_id, member_payload(user, role, membership))
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{user_id}")
|
||||
async def update_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
payload: MemberUpdate,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update a workspace membership's status. Role is not editable here.
|
||||
|
||||
Workspace role is always inherited from the user's platform role
|
||||
(``Users.platform_role_id``). To change role, PATCH
|
||||
``/api/v1/platform/employees/{user_id}`` instead.
|
||||
|
||||
Last-admin guard still applies to ``member_status`` changes: setting
|
||||
the only active admin to ``disabled``/``locked`` would leave the
|
||||
workspace without admin coverage.
|
||||
"""
|
||||
await _load_workspace(session, workspace_id)
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Users, Roles, WorkspaceMembers)
|
||||
.join(
|
||||
WorkspaceMembers,
|
||||
WorkspaceMembers.user_id == Users.user_id,
|
||||
)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
|
||||
user, role, membership = row
|
||||
|
||||
if payload.member_status is not None and payload.member_status != membership.member_status:
|
||||
if (
|
||||
role.role_code == "admin"
|
||||
and payload.member_status != "active"
|
||||
):
|
||||
remaining = await _count_active_admins(
|
||||
session, workspace_id, exclude_user_id=user_id,
|
||||
)
|
||||
if remaining == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"workspace 必须保留至少一个 admin",
|
||||
)
|
||||
membership.member_status = payload.member_status
|
||||
|
||||
await session.flush()
|
||||
await session.refresh(membership)
|
||||
return _envelope(context.request_id, member_payload(user, role, membership))
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
context: SystemAdminContext = Depends(system_admin_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Soft-delete a workspace membership.
|
||||
|
||||
System admins cannot remove themselves — the only escape is to delete
|
||||
the entire workspace, which cascades membership soft-deletion.
|
||||
"""
|
||||
await _load_workspace(session, workspace_id)
|
||||
if user_id == context.user.user_id:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"系统管理员不能把自己从 workspace 移除;如需退出,请删除整个 workspace",
|
||||
)
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Roles, WorkspaceMembers)
|
||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
||||
.where(
|
||||
WorkspaceMembers.workspace_id == workspace_id,
|
||||
WorkspaceMembers.user_id == user_id,
|
||||
WorkspaceMembers.is_deleted == 0,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "成员不存在")
|
||||
role, membership = row
|
||||
if role.role_code == "admin" and membership.member_status == "active":
|
||||
remaining = await _count_active_admins(
|
||||
session, workspace_id, exclude_user_id=user_id,
|
||||
)
|
||||
if remaining == 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"workspace 必须保留至少一个 admin",
|
||||
)
|
||||
membership.is_deleted = 1
|
||||
membership.deleted_at = datetime.datetime.utcnow()
|
||||
await session.flush()
|
||||
return _envelope(
|
||||
context.request_id,
|
||||
{"workspace_id": workspace_id, "user_id": user_id, "removed": True},
|
||||
)
|
||||
|
||||
|
||||
@@ -7,17 +7,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
from common.db.models import DataResources, StorageObjects, Users
|
||||
from common.ids import new_ulid
|
||||
from common.storage import workspaces_root
|
||||
from common.storage.schemas import (
|
||||
CreateUploadRequest,
|
||||
DownloadUrlRequest,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status
|
||||
from sqlalchemy import func, or_, select
|
||||
@@ -28,8 +24,15 @@ from backend.api.dependencies import (
|
||||
database_session,
|
||||
request_context,
|
||||
)
|
||||
from backend.api.scripts import _escape_like_pattern, normalize_user_path
|
||||
from backend.schemas.common import DownloadUrlRequest
|
||||
from backend.api._resources_common import (
|
||||
build_list_resources_descendant_prefix as _build_list_resources_descendant_prefix,
|
||||
can_view,
|
||||
compute_jupyter_relative_path,
|
||||
get_visible_resource,
|
||||
resource_directory,
|
||||
resource_payload,
|
||||
)
|
||||
from backend.api.scripts import _escape_like_pattern
|
||||
from backend.schemas.resources import (
|
||||
CompleteResourceUploadRequest,
|
||||
CreateResourceUploadRequest,
|
||||
@@ -37,7 +40,6 @@ from backend.schemas.resources import (
|
||||
)
|
||||
from backend.services.storage import (
|
||||
acquire_named_lock,
|
||||
create_download_url_payload,
|
||||
create_upload_record,
|
||||
release_named_lock,
|
||||
soft_delete_object,
|
||||
@@ -47,119 +49,6 @@ from backend.services.storage import (
|
||||
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
|
||||
|
||||
|
||||
def _build_list_resources_descendant_prefix(parent_path: str) -> str:
|
||||
"""Return the escaped materialized-path prefix for direct children
|
||||
of ``parent_path`` against ``StorageObjects.object_key``.
|
||||
|
||||
The full object_key is ``{ws_id}/{owner_user_id}/{jupyter_path}``.
|
||||
``list_resources`` prepends ``{ws_id}/{owner_user_id}`` (the requester
|
||||
by default, or the ``owner_user_id`` query param) to this prefix and
|
||||
applies ``LIKE '{ws_id}/{owner}/{prefix}%' AND NOT LIKE '...%/%'`` so
|
||||
only that owner's direct children under ``parent_path`` match. LIKE
|
||||
wildcards in parent_path are escaped so folder names containing ``_``
|
||||
or ``%`` do not act as wildcards.
|
||||
"""
|
||||
normalized = normalize_user_path(parent_path)
|
||||
escaped = _escape_like_pattern(normalized)
|
||||
return f"{escaped}/" if escaped else ""
|
||||
|
||||
|
||||
def compute_jupyter_relative_path(script_path: str, resource_relative: str) -> str:
|
||||
"""从当前脚本所在目录算到资源文件的 Jupyter 相对路径。
|
||||
|
||||
script_path / resource_relative 都是相对于 user root_dir 的 POSIX 路径。
|
||||
"""
|
||||
script_dir = PurePosixPath(script_path).parent.as_posix()
|
||||
if not script_dir or script_dir == ".":
|
||||
return resource_relative
|
||||
return os.path.relpath(resource_relative, start=script_dir)
|
||||
|
||||
|
||||
def resource_directory(
|
||||
object_key: str,
|
||||
workspace_id: str,
|
||||
owner_user_id: str,
|
||||
) -> str:
|
||||
"""从 object_key 解析资源所在目录(相对于用户根目录,根目录返回 "")。
|
||||
|
||||
object_key 形如 ``{ws_id}/{user_id}/{target_path}/{file_name}``;
|
||||
不匹配该前缀的键(如无 ws/user 前缀的旧数据)统一视为根目录。
|
||||
"""
|
||||
prefix = f"{workspace_id}/{owner_user_id}/"
|
||||
if not object_key.startswith(prefix):
|
||||
return ""
|
||||
tail = object_key[len(prefix):]
|
||||
directory, _, _ = tail.rpartition("/")
|
||||
return directory
|
||||
|
||||
|
||||
def resource_payload(
|
||||
resource: DataResources,
|
||||
storage_object: StorageObjects,
|
||||
owner_display_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
# ``object_key`` now follows ``{ws_id}/{user_id}/{target_path}/{file_name}``
|
||||
# (target_path may be empty). Legacy objects still live under
|
||||
# ``.resources/{file_name}`` and must remain readable. Both shapes share
|
||||
# the same derivation: strip the workspace/user prefix and use the rest
|
||||
# as the Jupyter-relative path.
|
||||
workspace_prefix = f"{resource.workspace_id}/"
|
||||
user_prefix = f"{resource.owner_user_id}/"
|
||||
jupyter_accessible_path = ""
|
||||
absolute_path = ""
|
||||
if storage_object.object_key and storage_object.object_key.startswith(
|
||||
workspace_prefix
|
||||
):
|
||||
remainder = storage_object.object_key[len(workspace_prefix):]
|
||||
if remainder.startswith(user_prefix):
|
||||
tail = remainder[len(user_prefix):]
|
||||
jupyter_accessible_path = tail
|
||||
absolute_path = (
|
||||
workspaces_root()
|
||||
/ resource.workspace_id
|
||||
/ resource.owner_user_id
|
||||
/ Path(tail)
|
||||
).as_posix()
|
||||
else:
|
||||
jupyter_accessible_path = remainder
|
||||
elif storage_object.object_key:
|
||||
jupyter_accessible_path = storage_object.object_key
|
||||
return {
|
||||
"resource_id": resource.resource_id,
|
||||
"workspace_id": resource.workspace_id,
|
||||
"storage_object_id": resource.storage_object_id,
|
||||
"owner_user_id": resource.owner_user_id,
|
||||
"owner_display_name": owner_display_name,
|
||||
"resource_name": resource.resource_name,
|
||||
"description": resource.description,
|
||||
"visibility": resource.visibility,
|
||||
"status": resource.status,
|
||||
"created_at": resource.created_at.isoformat(),
|
||||
"updated_at": resource.updated_at.isoformat(),
|
||||
"file": {
|
||||
"file_name": storage_object.file_name,
|
||||
"file_extension": storage_object.file_extension,
|
||||
"mime_type": storage_object.mime_type,
|
||||
"size_bytes": storage_object.size_bytes,
|
||||
"content_hash": storage_object.content_hash,
|
||||
"object_status": storage_object.object_status,
|
||||
},
|
||||
"jupyter_accessible_path": jupyter_accessible_path,
|
||||
"absolute_path": absolute_path,
|
||||
}
|
||||
|
||||
|
||||
def can_view(resource: DataResources, context: RequestContext) -> bool:
|
||||
# 同一 workspace 内:owner 永远可见自己的资源(含 private);
|
||||
# 其他成员只见 visibility in {workspace, public} 的资源;
|
||||
# admin 全部可见。
|
||||
if resource.owner_user_id == context.user.user_id:
|
||||
return True
|
||||
if resource.visibility in {"workspace", "public"}:
|
||||
return True
|
||||
return context.is_admin
|
||||
|
||||
|
||||
# 根据当前脚本位置计算资源的相对路径,便于 Notebook 中用相对路径读取文件。
|
||||
@router.post("/{resource_id}/jupyter-relative-path")
|
||||
async def resource_jupyter_relative_path(
|
||||
@@ -465,31 +354,6 @@ async def list_resources(
|
||||
}
|
||||
|
||||
|
||||
async def get_visible_resource(
|
||||
resource_id: str,
|
||||
context: RequestContext,
|
||||
session: AsyncSession,
|
||||
) -> tuple[DataResources, StorageObjects]:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(DataResources, StorageObjects)
|
||||
.join(
|
||||
StorageObjects,
|
||||
StorageObjects.storage_object_id
|
||||
== DataResources.storage_object_id,
|
||||
)
|
||||
.where(
|
||||
DataResources.resource_id == resource_id,
|
||||
DataResources.workspace_id
|
||||
== context.workspace.workspace_id,
|
||||
DataResources.status == "active",
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None or not can_view(row[0], context):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "resource not found")
|
||||
return row
|
||||
|
||||
|
||||
# 查询单个数据资源的元数据与其关联文件信息。
|
||||
@router.get("/{resource_id}")
|
||||
@@ -510,28 +374,6 @@ async def get_resource(
|
||||
}
|
||||
|
||||
|
||||
# 为资源文件生成带时效的下载链接。
|
||||
@router.post("/{resource_id}/download-url")
|
||||
async def resource_download_url(
|
||||
resource_id: str,
|
||||
payload: DownloadUrlRequest,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
resource, _ = await get_visible_resource(
|
||||
resource_id,
|
||||
context,
|
||||
session,
|
||||
)
|
||||
data = await create_download_url_payload(
|
||||
await session.get(StorageObjects, resource.storage_object_id),
|
||||
DownloadUrlRequest(expires_seconds=payload.expires_seconds),
|
||||
request,
|
||||
)
|
||||
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
|
||||
|
||||
|
||||
# 软删除数据资源及其关联对象,遵循存储层的回收站策略。
|
||||
@router.delete("/{resource_id}")
|
||||
async def delete_resource(
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""数据资源下载、同源内容流与表格抽样预览接口。
|
||||
|
||||
与 ``resources.py`` 共用前缀 ``/api/v1/data-resources``,在 ``main`` 中并列挂载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from common.db.models import StorageObjects
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.api.dependencies import (
|
||||
RequestContext,
|
||||
database_session,
|
||||
request_context,
|
||||
)
|
||||
from backend.api._resources_common import get_visible_resource
|
||||
from backend.schemas.common import DownloadUrlRequest
|
||||
from backend.services.storage import create_download_url_payload
|
||||
|
||||
router = APIRouter(prefix="/api/v1/data-resources", tags=["data-resources"])
|
||||
|
||||
_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
|
||||
_PREVIEW_DEFAULT_LIMIT = 100
|
||||
_PREVIEW_MAX_LIMIT = 500
|
||||
_TABLE_EXTENSIONS = {".csv", ".tsv"}
|
||||
|
||||
|
||||
def _extension_of(file_name: str | None, resource_name: str | None) -> str:
|
||||
for candidate in (file_name, resource_name):
|
||||
if not candidate:
|
||||
continue
|
||||
lower = candidate.lower()
|
||||
for ext in _TABLE_EXTENSIONS:
|
||||
if lower.endswith(ext):
|
||||
return ext
|
||||
return ""
|
||||
|
||||
|
||||
def _decode_preview_text(raw: bytes) -> str:
|
||||
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
async def _read_prefix_bytes(store: Any, object_key: str, max_bytes: int) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in store.get_stream(object_key):
|
||||
if not chunk:
|
||||
continue
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total >= max_bytes:
|
||||
break
|
||||
data = b"".join(chunks)
|
||||
return data[:max_bytes]
|
||||
|
||||
|
||||
def _parse_table_preview(
|
||||
text: str,
|
||||
*,
|
||||
delimiter: str,
|
||||
limit: int,
|
||||
byte_truncated: bool,
|
||||
) -> dict[str, Any]:
|
||||
reader = csv.reader(io.StringIO(text), delimiter=delimiter)
|
||||
try:
|
||||
header = next(reader)
|
||||
except StopIteration:
|
||||
return {
|
||||
"kind": "table",
|
||||
"columns": [],
|
||||
"rows": [],
|
||||
"row_count": 0,
|
||||
"truncated": byte_truncated,
|
||||
"delimiter": delimiter,
|
||||
}
|
||||
|
||||
columns = [str(cell) if cell is not None else "" for cell in header]
|
||||
if not any(columns):
|
||||
columns = [f"col_{index + 1}" for index in range(max(len(header), 1))]
|
||||
|
||||
rows: list[list[str]] = []
|
||||
truncated = byte_truncated
|
||||
for row in reader:
|
||||
if len(rows) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
cells = [str(cell) if cell is not None else "" for cell in row]
|
||||
if len(cells) < len(columns):
|
||||
cells.extend([""] * (len(columns) - len(cells)))
|
||||
elif len(cells) > len(columns):
|
||||
cells = cells[: len(columns)]
|
||||
rows.append(cells)
|
||||
|
||||
return {
|
||||
"kind": "table",
|
||||
"columns": columns,
|
||||
"rows": rows,
|
||||
"row_count": len(rows),
|
||||
"truncated": truncated,
|
||||
"delimiter": delimiter,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{resource_id}/download-url")
|
||||
async def resource_download_url(
|
||||
resource_id: str,
|
||||
payload: DownloadUrlRequest,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> dict[str, Any]:
|
||||
resource, _ = await get_visible_resource(
|
||||
resource_id,
|
||||
context,
|
||||
session,
|
||||
)
|
||||
data = await create_download_url_payload(
|
||||
await session.get(StorageObjects, resource.storage_object_id),
|
||||
DownloadUrlRequest(expires_seconds=payload.expires_seconds),
|
||||
request,
|
||||
)
|
||||
return {"request_id": context.request_id, "data": data["data"], "meta": {}}
|
||||
|
||||
|
||||
@router.get("/{resource_id}/content")
|
||||
async def resource_content(
|
||||
resource_id: str,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
) -> StreamingResponse:
|
||||
"""同源流式读取资源字节,供前端预览器加载。"""
|
||||
resource, storage_object = await get_visible_resource(
|
||||
resource_id,
|
||||
context,
|
||||
session,
|
||||
)
|
||||
if storage_object.object_status != "available":
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
|
||||
if (
|
||||
not storage_object.bucket_name
|
||||
or not storage_object.object_key
|
||||
or storage_object.bucket_name not in request.app.state.object_stores
|
||||
):
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"object does not support content download",
|
||||
)
|
||||
|
||||
file_name = storage_object.file_name or resource.resource_name or "file"
|
||||
media_type = storage_object.mime_type or "application/octet-stream"
|
||||
store = request.app.state.object_stores[storage_object.bucket_name]
|
||||
stream = store.get_stream(storage_object.object_key)
|
||||
headers = {
|
||||
"Content-Disposition": f"inline; filename*=UTF-8''{quote(file_name)}",
|
||||
"Cache-Control": "private, no-store",
|
||||
}
|
||||
if storage_object.size_bytes is not None:
|
||||
headers["Content-Length"] = str(storage_object.size_bytes)
|
||||
|
||||
return StreamingResponse(
|
||||
stream,
|
||||
media_type=media_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{resource_id}/preview")
|
||||
async def resource_preview(
|
||||
resource_id: str,
|
||||
request: Request,
|
||||
context: RequestContext = Depends(request_context),
|
||||
session: AsyncSession = Depends(database_session),
|
||||
limit: int = Query(default=_PREVIEW_DEFAULT_LIMIT, ge=1, le=_PREVIEW_MAX_LIMIT),
|
||||
) -> dict[str, Any]:
|
||||
"""表格类数据资源抽样预览(csv / tsv)。"""
|
||||
resource, storage_object = await get_visible_resource(
|
||||
resource_id,
|
||||
context,
|
||||
session,
|
||||
)
|
||||
if storage_object.object_status != "available":
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
|
||||
if (
|
||||
not storage_object.bucket_name
|
||||
or not storage_object.object_key
|
||||
or storage_object.bucket_name not in request.app.state.object_stores
|
||||
):
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"object does not support preview",
|
||||
)
|
||||
|
||||
extension = _extension_of(storage_object.file_name, resource.resource_name)
|
||||
if extension not in _TABLE_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
"仅支持预览 .csv / .tsv 表格文件",
|
||||
)
|
||||
|
||||
store = request.app.state.object_stores[storage_object.bucket_name]
|
||||
raw = await _read_prefix_bytes(
|
||||
store,
|
||||
storage_object.object_key,
|
||||
_PREVIEW_MAX_BYTES,
|
||||
)
|
||||
byte_truncated = (
|
||||
storage_object.size_bytes is not None
|
||||
and storage_object.size_bytes > len(raw)
|
||||
) or len(raw) >= _PREVIEW_MAX_BYTES
|
||||
text = _decode_preview_text(raw)
|
||||
delimiter = "\t" if extension == ".tsv" else ","
|
||||
payload = _parse_table_preview(
|
||||
text,
|
||||
delimiter=delimiter,
|
||||
limit=limit,
|
||||
byte_truncated=byte_truncated,
|
||||
)
|
||||
return {"request_id": context.request_id, "data": payload, "meta": {}}
|
||||
@@ -40,6 +40,7 @@ from backend.api.jupyter import router as jupyter_router
|
||||
from backend.api.operations import router as operations_router
|
||||
from backend.api.platform import router as platform_router
|
||||
from backend.api.resources import router as resources_router
|
||||
from backend.api.resources_content import router as resources_content_router
|
||||
from backend.api.schedules.runs import router as schedule_runs_router
|
||||
from backend.api.schedules.schedules import router as schedules_router
|
||||
from backend.api.scripts import router as scripts_router
|
||||
@@ -143,6 +144,7 @@ app = create_service_app(
|
||||
app.include_router(auth_router)
|
||||
app.include_router(jupyter_router)
|
||||
app.include_router(resources_router)
|
||||
app.include_router(resources_content_router)
|
||||
app.include_router(schedule_runs_router)
|
||||
app.include_router(schedules_router)
|
||||
app.include_router(scripts_router)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Unit tests for auth profile / password request schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.api.auth import PasswordChange, ProfileUpdate
|
||||
from backend.api.platform.employees import PlatformEmployeePasswordReset
|
||||
|
||||
|
||||
def test_profile_update_allows_partial_fields() -> None:
|
||||
only_name = ProfileUpdate(display_name="张三")
|
||||
assert only_name.display_name == "张三"
|
||||
assert only_name.email is None
|
||||
|
||||
only_email = ProfileUpdate(email="a@example.com")
|
||||
assert only_email.email == "a@example.com"
|
||||
|
||||
|
||||
def test_profile_update_forbids_unknown_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ProfileUpdate(display_name="张三", username="hacked") # type: ignore[call-arg]
|
||||
|
||||
|
||||
def test_password_change_enforces_new_password_length() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PasswordChange(current_password="old-pass", new_password="short")
|
||||
|
||||
ok = PasswordChange(current_password="old-pass-1", new_password="new-pass-12")
|
||||
assert ok.new_password == "new-pass-12"
|
||||
|
||||
|
||||
def test_admin_password_reset_schema() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PlatformEmployeePasswordReset(new_password="1234567")
|
||||
|
||||
payload = PlatformEmployeePasswordReset(new_password="reset-pass-9")
|
||||
assert payload.new_password == "reset-pass-9"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Unit tests for platform cursor pagination helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.api.platform._pagination import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
page_meta,
|
||||
)
|
||||
|
||||
|
||||
def test_encode_decode_roundtrip() -> None:
|
||||
created_at = datetime.datetime(2026, 3, 15, 12, 30, 45, 123000)
|
||||
row_id = "01HXY9C5B8N3K4P7Q6RT2V0J8D"
|
||||
cursor = encode_cursor(created_at, row_id)
|
||||
decoded_ts, decoded_id = decode_cursor(cursor)
|
||||
assert decoded_ts == created_at
|
||||
assert decoded_id == row_id
|
||||
|
||||
|
||||
def test_decode_strips_timezone() -> None:
|
||||
created_at = datetime.datetime(2026, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)
|
||||
cursor = encode_cursor(created_at, "abc")
|
||||
decoded_ts, decoded_id = decode_cursor(cursor)
|
||||
assert decoded_ts.tzinfo is None
|
||||
assert decoded_id == "abc"
|
||||
|
||||
|
||||
def test_decode_invalid_cursor_raises_400() -> None:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
decode_cursor("not-a-valid-cursor!!!")
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_page_meta_has_more() -> None:
|
||||
meta = page_meta(
|
||||
limit=10,
|
||||
page_count=10,
|
||||
total_count=25,
|
||||
next_cursor="abc",
|
||||
)
|
||||
assert meta["has_more"] is True
|
||||
assert meta["next_cursor"] == "abc"
|
||||
assert meta["total_count"] == 25
|
||||
|
||||
meta_end = page_meta(
|
||||
limit=10,
|
||||
page_count=5,
|
||||
total_count=25,
|
||||
next_cursor=None,
|
||||
)
|
||||
assert meta_end["has_more"] is False
|
||||
Reference in New Issue
Block a user