完善后台管理与测试支撑
1、为后台用户列表增加关键词和状态筛选,后端同步支持过滤与总数统计。 2、创建用户时要求填写初始密码,并补充前端校验和自动填充隔离。 3、优化项目管理列表角色展示、项目详情抽屉脏数据保护和审计详情抽屉关闭体验。 4、清理旧费用附件关联、统一测试模型注册,并更新迁移端点和注册筛选测试。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, is_system_admin, require_roles
|
||||
@@ -8,7 +8,7 @@ from app.schemas.common import PaginatedResponse
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.utils.pagination import paginate
|
||||
from app.schemas.user import UserCreate, UserRead, UserUpdate
|
||||
from app.schemas.user import UserCreate, UserRead, UserStatus, UserUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -17,12 +17,14 @@ router = APIRouter()
|
||||
async def list_users(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
keyword: str | None = Query(default=None),
|
||||
user_status: UserStatus | None = Query(default=None, alias="status"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> PaginatedResponse[UserRead]:
|
||||
users = await user_crud.list_users(db, skip=skip, limit=limit)
|
||||
total_users = await user_crud.list_users(db, skip=0, limit=10_000_000)
|
||||
return paginate(list(users), total=len(total_users))
|
||||
users = await user_crud.list_users(db, skip=skip, limit=limit, keyword=keyword, status=user_status)
|
||||
total_users = await user_crud.count_users(db, keyword=keyword, status=user_status)
|
||||
return paginate(list(users), total=total_users)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.models.document import Document
|
||||
from app.models.document_version import DocumentVersion
|
||||
from app.models.distribution import Distribution
|
||||
from app.models.drug_shipment import DrugShipment
|
||||
from app.models.fee_attachment import FeeAttachment
|
||||
from app.models.finance import FinanceItem
|
||||
from app.models.kickoff_meeting import KickoffMeeting
|
||||
from app.models.precaution import Precaution
|
||||
@@ -33,7 +32,6 @@ from app.models.visit import Visit
|
||||
from app.schemas.site import SiteCreate, SiteUpdate
|
||||
|
||||
ATTACHMENT_ROOT = Path(__file__).resolve().parent.parent / "uploads"
|
||||
FEE_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "fees"
|
||||
DOCUMENT_ATTACHMENT_ROOT = ATTACHMENT_ROOT / "documents"
|
||||
|
||||
|
||||
@@ -239,24 +237,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
fee_attachment_paths: list[str] = []
|
||||
if contract_fee_ids:
|
||||
fee_attachment_paths.extend(
|
||||
(
|
||||
await db.execute(
|
||||
select(FeeAttachment.storage_key).where(
|
||||
FeeAttachment.entity_type == "contract_fee",
|
||||
FeeAttachment.entity_id.in_(contract_fee_ids),
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
await db.execute(
|
||||
delete(FeeAttachment).where(
|
||||
FeeAttachment.entity_type == "contract_fee",
|
||||
FeeAttachment.entity_id.in_(contract_fee_ids),
|
||||
)
|
||||
)
|
||||
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
|
||||
|
||||
if distribution_ids:
|
||||
@@ -311,7 +292,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
|
||||
|
||||
await db.execute(delete(Site).where(Site.id == site_id))
|
||||
await _remove_files(attachment_paths, ATTACHMENT_ROOT)
|
||||
await _remove_files(fee_attachment_paths, FEE_ATTACHMENT_ROOT)
|
||||
await _remove_files(version_file_paths, DOCUMENT_ATTACHMENT_ROOT)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from sqlalchemy import delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import (
|
||||
@@ -79,11 +79,45 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User
|
||||
return user
|
||||
|
||||
|
||||
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> Sequence[User]:
|
||||
result = await db.execute(select(User).offset(skip).limit(limit))
|
||||
def _apply_user_filters(query, *, keyword: str | None = None, status: UserStatus | None = None):
|
||||
if keyword:
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
User.email.ilike(pattern),
|
||||
User.full_name.ilike(pattern),
|
||||
User.clinical_department.ilike(pattern),
|
||||
)
|
||||
)
|
||||
if status is not None:
|
||||
query = query.where(User.status == status)
|
||||
return query
|
||||
|
||||
|
||||
async def list_users(
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
) -> Sequence[User]:
|
||||
query = _apply_user_filters(select(User), keyword=keyword, status=status)
|
||||
result = await db.execute(query.order_by(User.created_at.desc()).offset(skip).limit(limit))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
status: UserStatus | None = None,
|
||||
) -> int:
|
||||
query = _apply_user_filters(select(func.count()).select_from(User), keyword=keyword, status=status)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def list_active_member_candidates_for_study(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
@@ -141,7 +142,7 @@ class StudySetupConfigVersionRead(BaseModel):
|
||||
parent_version_id: uuid.UUID | None = None
|
||||
merged_from_version_id: uuid.UUID | None = None
|
||||
config: StudySetupConfigData
|
||||
published_project_snapshot: "ProjectPublishSnapshot | None" = None
|
||||
published_project_snapshot: Optional[ProjectPublishSnapshot] = None
|
||||
is_current_published: bool = False
|
||||
is_active_draft_base: bool = False
|
||||
published_by: uuid.UUID | None = None
|
||||
|
||||
@@ -70,22 +70,9 @@ async def _create_test_engine():
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# Import all models to register them with Base metadata
|
||||
from app.db.base_class import Base
|
||||
import app.models.study
|
||||
import app.models.api_endpoint_permission
|
||||
import app.models.api_endpoint_registry
|
||||
import app.models.user
|
||||
import app.models.study_member
|
||||
import app.models.subject
|
||||
import app.models.visit
|
||||
import app.models.ae
|
||||
import app.models.monitoring_visit_issue
|
||||
import app.models.site
|
||||
import app.models.permission_template
|
||||
import app.models.permission_access_log
|
||||
import app.models.permission_metric_snapshot
|
||||
import app.models.security_access_log
|
||||
# Import the central model registry so Base metadata is complete regardless
|
||||
# of test collection/import order.
|
||||
from app.db.base import Base
|
||||
|
||||
# Replace PostgreSQL UUID type with custom GUID type for SQLite
|
||||
for table in Base.metadata.tables.values():
|
||||
|
||||
@@ -101,41 +101,16 @@ async def test_startup_auth_read_with_permission(db_session: AsyncSession):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 项目权限管理 (project_permissions) - 2个端点
|
||||
# 项目权限管理已迁移为系统级权限
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_permissions_get_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以查询项目权限矩阵"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="permissions:read",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
def test_project_permissions_are_not_project_matrix_permissions():
|
||||
"""项目权限配置由 system:permissions:project_config 控制,不再进入项目矩阵。"""
|
||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, SYSTEM_PERMISSIONS
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:read")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_permissions_update_with_permission(db_session: AsyncSession):
|
||||
"""验证有权限的PM可以更新项目权限矩阵"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="PM",
|
||||
endpoint_key="permissions:update",
|
||||
allowed=True,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "PM", "permissions:update")
|
||||
assert allowed is True
|
||||
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
|
||||
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
|
||||
assert "system:permissions:project_config" in SYSTEM_PERMISSIONS
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -506,17 +481,9 @@ async def test_startup_permission_denied_for_cra(db_session: AsyncSession):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_permissions_denied_for_cra(db_session: AsyncSession):
|
||||
"""验证CRA无法修改项目权限"""
|
||||
async def test_removed_project_permissions_are_denied_for_project_roles(db_session: AsyncSession):
|
||||
"""项目角色不能再通过 permissions:update 这种残留 key 获得权限管理能力。"""
|
||||
study_id = uuid.uuid4()
|
||||
perm = ApiEndpointPermission(
|
||||
study_id=study_id,
|
||||
role="CRA",
|
||||
endpoint_key="permissions:update",
|
||||
allowed=False,
|
||||
)
|
||||
db_session.add(perm)
|
||||
await db_session.commit()
|
||||
|
||||
allowed = await role_has_api_permission(db_session, study_id, "CRA", "permissions:update")
|
||||
assert allowed is False
|
||||
|
||||
@@ -190,6 +190,64 @@ async def test_admin_can_approve_user(client_and_db):
|
||||
assert refreshed.approved_by is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_list_filters_by_keyword_and_status(client_and_db):
|
||||
client, SessionLocal = client_and_db
|
||||
async with SessionLocal() as session:
|
||||
session.add_all(
|
||||
[
|
||||
User(
|
||||
email="active-filter@test.com",
|
||||
password_hash=hash_password("Password123"),
|
||||
full_name="Active Filter",
|
||||
clinical_department="Oncology",
|
||||
status=UserStatus.ACTIVE,
|
||||
),
|
||||
User(
|
||||
email="pending-filter@test.com",
|
||||
password_hash=hash_password("Password123"),
|
||||
full_name="Pending Filter",
|
||||
clinical_department="Cardiology",
|
||||
status=UserStatus.PENDING,
|
||||
),
|
||||
User(
|
||||
email="disabled-filter@test.com",
|
||||
password_hash=hash_password("Password123"),
|
||||
full_name="Disabled Filter",
|
||||
clinical_department="Oncology",
|
||||
status=UserStatus.DISABLED,
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
admin_login = await encrypted_login(client, "admin@test.com", "admin123")
|
||||
token = admin_login.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
active_resp = await client.get("/api/v1/users/?status=ACTIVE", headers=headers)
|
||||
assert active_resp.status_code == 200
|
||||
active_items = active_resp.json()["items"]
|
||||
assert all(item["status"] == "ACTIVE" for item in active_items)
|
||||
assert {item["email"] for item in active_items} >= {"admin@test.com", "active-filter@test.com"}
|
||||
assert "pending-filter@test.com" not in {item["email"] for item in active_items}
|
||||
|
||||
keyword_resp = await client.get("/api/v1/users/?keyword=Oncology", headers=headers)
|
||||
assert keyword_resp.status_code == 200
|
||||
keyword_data = keyword_resp.json()
|
||||
assert keyword_data["total"] == 2
|
||||
assert {item["email"] for item in keyword_data["items"]} == {
|
||||
"active-filter@test.com",
|
||||
"disabled-filter@test.com",
|
||||
}
|
||||
|
||||
combined_resp = await client.get("/api/v1/users/?keyword=Filter&status=PENDING", headers=headers)
|
||||
assert combined_resp.status_code == 200
|
||||
combined_data = combined_resp.json()
|
||||
assert combined_data["total"] == 1
|
||||
assert combined_data["items"][0]["email"] == "pending-filter@test.com"
|
||||
|
||||
|
||||
def test_register_request_does_not_expose_role_input():
|
||||
assert "role" not in UserRegisterRequest.model_fields
|
||||
|
||||
|
||||
Reference in New Issue
Block a user