完善后台管理与测试支撑

1、为后台用户列表增加关键词和状态筛选,后端同步支持过滤与总数统计。

2、创建用户时要求填写初始密码,并补充前端校验和自动填充隔离。

3、优化项目管理列表角色展示、项目详情抽屉脏数据保护和审计详情抽屉关闭体验。

4、清理旧费用附件关联、统一测试模型注册,并更新迁移端点和注册筛选测试。
This commit is contained in:
Cheng Zhou
2026-06-04 11:12:40 +08:00
parent 90ca061db8
commit 41968384b5
17 changed files with 337 additions and 145 deletions
+7 -5
View File
@@ -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)
-20
View File
@@ -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()
+37 -3
View File
@@ -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,
+2 -1
View File
@@ -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