清理全局角色残留并修复项目权限判断

This commit is contained in:
Cheng Zhou
2026-05-29 10:20:42 +08:00
parent 63457aab11
commit 44d69c2d7b
63 changed files with 739 additions and 443 deletions
@@ -0,0 +1,44 @@
"""remove user global role
Revision ID: 20260529_01
Revises: 20260528_01
Create Date: 2026-05-29 10:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260529_01"
down_revision: Union[str, None] = "20260528_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = {column["name"] for column in inspector.get_columns("users")}
if "is_admin" not in columns:
op.add_column("users", sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()))
if "role" in columns:
op.execute("UPDATE users SET is_admin = true WHERE role::text = 'ADMIN'")
op.drop_column("users", "role")
op.execute("DROP TYPE IF EXISTS user_role")
def downgrade() -> None:
op.execute("CREATE TYPE user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'QA', 'CTA')")
op.add_column(
"users",
sa.Column("role", sa.Enum("ADMIN", "PM", "CRA", "PV", "QA", "CTA", name="user_role"), nullable=True),
)
op.execute("UPDATE users SET role = CASE WHEN is_admin THEN 'ADMIN'::user_role ELSE 'PV'::user_role END")
op.alter_column("users", "role", nullable=False)
op.drop_column("users", "is_admin")
+6 -6
View File
@@ -3,9 +3,9 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles from app.core.deps import get_db_session, is_system_admin, require_roles
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import User, UserRole, UserStatus from app.models.user import User, UserStatus
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
router = APIRouter(prefix="/users") router = APIRouter(prefix="/users")
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/users")
async def list_users_for_review( async def list_users_for_review(
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"), status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])), current_user=Depends(require_roles(["ADMIN"])),
) -> AdminUserListResponse: ) -> AdminUserListResponse:
users = await user_crud.list_users_by_status(db, status=status_filter) users = await user_crud.list_users_by_status(db, status=status_filter)
return AdminUserListResponse(items=list(users), total=len(users)) return AdminUserListResponse(items=list(users), total=len(users))
@@ -25,7 +25,7 @@ async def _get_review_user(db: AsyncSession, user_id: uuid.UUID) -> User:
user = await user_crud.get_by_id(db, user_id) user = await user_crud.get_by_id(db, user_id)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.role == UserRole.ADMIN: if is_system_admin(user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="管理员账号不允许审核")
return user return user
@@ -35,7 +35,7 @@ async def approve_user(
user_id: uuid.UUID, user_id: uuid.UUID,
review: UserAdminReviewRequest, review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])), current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse: ) -> UserResponse:
if review.action != "approve": if review.action != "approve":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
@@ -51,7 +51,7 @@ async def reject_user(
user_id: uuid.UUID, user_id: uuid.UUID,
review: UserAdminReviewRequest, review: UserAdminReviewRequest,
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(require_roles([UserRole.ADMIN.value])), current_user=Depends(require_roles(["ADMIN"])),
) -> UserResponse: ) -> UserResponse:
if review.action not in ("reject", "approve"): if review.action not in ("reject", "approve"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
+4 -4
View File
@@ -5,7 +5,7 @@ from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import ae as ae_crud from app.crud import ae as ae_crud
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import member as member_crud from app.crud import member as member_crud
@@ -79,7 +79,7 @@ async def create_ae(
action="CREATE_AE", action="CREATE_AE",
detail=f"AE {ae.id} 已创建", detail=f"AE {ae.id} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
data = AERead.model_validate(ae) data = AERead.model_validate(ae)
data.is_overdue = _is_overdue(data) data.is_overdue = _is_overdue(data)
@@ -249,7 +249,7 @@ async def update_ae(
action=action, action=action,
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str), detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
data = AERead.model_validate(updated) data = AERead.model_validate(updated)
data.is_overdue = _is_overdue(data) data.is_overdue = _is_overdue(data)
@@ -281,5 +281,5 @@ async def delete_ae(
action="DELETE_AE", action="DELETE_AE",
detail=f"AE {ae_id} 已删除", detail=f"AE {ae_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+5 -7
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_member, require_system_permission
from app.core.project_permissions import ( from app.core.project_permissions import (
get_api_endpoint_permissions, get_api_endpoint_permissions,
replace_api_endpoint_permissions, replace_api_endpoint_permissions,
@@ -163,8 +163,7 @@ async def get_my_study_api_permissions(
db: Annotated[AsyncSession, Depends(get_db_session)] = None, db: Annotated[AsyncSession, Depends(get_db_session)] = None,
): ):
"""获取当前用户当前项目角色的有效权限。""" """获取当前用户当前项目角色的有效权限。"""
role_value = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) if is_system_admin(current_user):
if role_value == "ADMIN":
return { return {
"ADMIN": { "ADMIN": {
endpoint_key: {"allowed": True} endpoint_key: {"allowed": True}
@@ -192,7 +191,7 @@ async def get_my_study_api_permissions(
) )
async def get_study_api_permissions( async def get_study_api_permissions(
study_id: uuid.UUID, study_id: uuid.UUID,
_=Depends(require_study_roles(["PM"])), _=Depends(require_system_permission("system:permissions:project_config")),
db: Annotated[AsyncSession, Depends(get_db_session)] = None, db: Annotated[AsyncSession, Depends(get_db_session)] = None,
): ):
"""获取项目的接口级权限矩阵 """获取项目的接口级权限矩阵
@@ -233,7 +232,7 @@ async def get_study_api_permissions(
async def update_study_api_permissions( async def update_study_api_permissions(
study_id: uuid.UUID, study_id: uuid.UUID,
payload: dict[str, dict[str, bool]], payload: dict[str, dict[str, bool]],
_=Depends(require_study_roles(["PM"])), _=Depends(require_system_permission("system:permissions:project_config")),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: Annotated[AsyncSession, Depends(get_db_session)] = None, db: Annotated[AsyncSession, Depends(get_db_session)] = None,
): ):
@@ -248,13 +247,12 @@ async def update_study_api_permissions(
""" """
# 验证输入 # 验证输入
configurable_roles = set(await _get_configurable_roles(db, study_id)) configurable_roles = set(await _get_configurable_roles(db, study_id))
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
for role in payload.keys(): for role in payload.keys():
if role == "ADMIN": if role == "ADMIN":
continue continue
if role not in configurable_roles: if role not in configurable_roles:
raise ValueError(f"无效的角色: {role}") raise ValueError(f"无效的角色: {role}")
if role == "PM" and current_role != "ADMIN": if role == "PM" and not is_system_admin(current_user):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="仅系统管理员可修改项目负责人权限", detail="仅系统管理员可修改项目负责人权限",
+9 -14
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_study_member, require_study_not_locked from app.core.deps import get_current_user, get_db_session, get_operator_role_label, get_study_member, is_system_admin, require_study_not_locked
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import attachment as attachment_crud from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
@@ -71,10 +71,6 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else user.role
def _attachment_operation(action: str) -> str: def _attachment_operation(action: str) -> str:
if action in {"create", "read", "delete"}: if action in {"create", "read", "delete"}:
return action return action
@@ -154,8 +150,7 @@ async def _ensure_attachment_permission(
membership=None, membership=None,
) -> None: ) -> None:
parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action) parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action)
role_value = _role_value(current_user) if is_system_admin(current_user):
if role_value == "ADMIN":
return return
if membership is None: if membership is None:
@@ -238,7 +233,7 @@ async def upload_attachment(
action="UPLOAD_FILE", action="UPLOAD_FILE",
detail=f"文件已上传:{file.filename}", detail=f"文件已上传:{file.filename}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return AttachmentRead( return AttachmentRead(
id=attachment.id, id=attachment.id,
@@ -349,7 +344,7 @@ async def _authorize_global(request: Request, db: AsyncSession, study_id: uuid.U
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if _role_value(user) == "ADMIN": if is_system_admin(user):
return user, None return user, None
membership = await member_crud.get_member(db, study_id, user.id) membership = await member_crud.get_member(db, study_id, user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
@@ -429,7 +424,7 @@ async def global_delete_attachment(
db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership db, attachment.study_id, attachment.entity_type, attachment.entity_id, "delete", user, membership
) )
can_delete = ( can_delete = (
_role_value(user) == "ADMIN" is_system_admin(user)
or attachment.uploaded_by == user.id or attachment.uploaded_by == user.id
or membership is not None or membership is not None
) )
@@ -444,7 +439,7 @@ async def global_delete_attachment(
action="DELETE_ATTACHMENT", action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}", detail=f"文件已删除:{attachment.filename}",
operator_id=user.id, operator_id=user.id,
operator_role=user.role, operator_role=await get_operator_role_label(db, attachment.study_id, user),
) )
@@ -476,11 +471,11 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
membership = None membership = None
if _role_value(current_user) != "ADMIN": if not is_system_admin(current_user):
membership = await get_study_member(study_id, current_user=current_user, db=db) membership = await get_study_member(study_id, current_user=current_user, db=db)
can_delete = ( can_delete = (
_role_value(current_user) == "ADMIN" is_system_admin(current_user)
or attachment.uploaded_by == current_user.id or attachment.uploaded_by == current_user.id
or membership is not None or membership is not None
) )
@@ -496,5 +491,5 @@ async def delete_attachment(
action="DELETE_ATTACHMENT", action="DELETE_ATTACHMENT",
detail=f"文件已删除:{attachment.filename}", detail=f"文件已删除:{attachment.filename}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+3 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_roles, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.schemas.audit import AuditEventCreate, AuditLogRead from app.schemas.audit import AuditEventCreate, AuditLogRead
@@ -57,11 +57,11 @@ async def delete_audit_log(
@router.post( @router.post(
"/events", "/events",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_member())],
) )
async def create_audit_event( async def create_audit_event(
study_id: uuid.UUID, study_id: uuid.UUID,
payload: AuditEventCreate, payload: AuditEventCreate,
_export_permission=Depends(require_api_permission("audit_logs:export")),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
): ):
@@ -76,6 +76,6 @@ async def create_audit_event(
action=payload.action, action=payload.action,
detail=payload.detail, detail=payload.detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return {"ok": True} return {"ok": True}
+1 -4
View File
@@ -11,7 +11,7 @@ from app.core.login_crypto import create_login_challenge, decrypt_login_payload,
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import UserRole, UserStatus from app.models.user import UserStatus
from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate from app.schemas.user import Token, UserRead, UserRegisterRequest, UserSelfUpdate, UserUpdate
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -57,7 +57,6 @@ def issue_user_token(db_user) -> Token:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
access_token = create_access_token( access_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None, expires_minutes=None,
session_start=session_start, session_start=session_start,
) )
@@ -191,7 +190,6 @@ async def extend_access_token(
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
new_token = create_access_token( new_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None, expires_minutes=None,
session_start=session_start, session_start=session_start,
) )
@@ -210,7 +208,6 @@ async def unlock_session(
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
access_token = create_access_token( access_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None, expires_minutes=None,
session_start=session_start, session_start=session_start,
) )
+8 -13
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, is_system_admin, require_api_permission
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import member as member_crud from app.crud import member as member_crud
from app.models.milestone import Milestone from app.models.milestone import Milestone
@@ -16,13 +16,10 @@ from app.crud import overview as overview_crud
router = APIRouter() router = APIRouter()
def _role_value(user) -> str: @router.get("/progress", response_model=StudyProgressRead)
return user.role.value if hasattr(user.role, "value") else str(user.role)
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
async def get_progress( async def get_progress(
study_id: uuid.UUID, study_id: uuid.UUID,
_=Depends(require_api_permission("project_overview:read")),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> StudyProgressRead: ) -> StudyProgressRead:
milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id) milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id)
@@ -42,10 +39,11 @@ async def get_progress(
) )
@router.get("/lost-visits", response_model=list[VisitLostItem], dependencies=[Depends(require_study_member())]) @router.get("/lost-visits", response_model=list[VisitLostItem])
async def list_lost_visits( async def list_lost_visits(
study_id: uuid.UUID, study_id: uuid.UUID,
limit: int = 20, limit: int = 20,
_=Depends(require_api_permission("project_overview:read")),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> list[VisitLostItem]: ) -> list[VisitLostItem]:
@@ -68,18 +66,15 @@ async def list_lost_visits(
return items return items
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[ @router.get("/center-summary", response_model=list[CenterSummaryItem])
Depends(require_study_member()),
Depends(require_api_permission("project_overview:read"))
])
async def get_center_summary( async def get_center_summary(
study_id: uuid.UUID, study_id: uuid.UUID,
_=Depends(require_api_permission("project_overview:read")),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> list[CenterSummaryItem]: ) -> list[CenterSummaryItem]:
role_value = _role_value(current_user)
membership = None membership = None
if role_value != "ADMIN": if not is_system_admin(current_user):
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
return [] return []
+1 -8
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,7 +11,6 @@ from app.schemas.common import PaginatedResponse
from app.schemas.distribution import DistributionCreate, DistributionRead from app.schemas.distribution import DistributionCreate, DistributionRead
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead from app.schemas.document_version import DocumentVersionRead
from app.models.user import UserRole
from app.services import document_service from app.services import document_service
from app.utils.pagination import paginate from app.utils.pagination import paginate
@@ -73,9 +72,6 @@ async def delete_document(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> DocumentSummary: ) -> DocumentSummary:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除文档")
doc = await document_service.delete_document(db, document_id, current_user) doc = await document_service.delete_document(db, document_id, current_user)
return DocumentSummary.model_validate(doc) return DocumentSummary.model_validate(doc)
@@ -123,9 +119,6 @@ async def delete_version(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> None: ) -> None:
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可删除版本")
await document_service.delete_version(db, version_id, current_user) await document_service.delete_version(db, version_id, current_user)
+4 -4
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import drug_shipment as shipment_crud from app.crud import drug_shipment as shipment_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -56,7 +56,7 @@ async def create_shipment(
action="CREATE_DRUG_SHIPMENT", action="CREATE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment.id} 已创建", detail=f"药品运输 {shipment.id} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return DrugShipmentRead.model_validate(shipment) return DrugShipmentRead.model_validate(shipment)
@@ -152,7 +152,7 @@ async def update_shipment(
action="UPDATE_DRUG_SHIPMENT", action="UPDATE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment_id} 已更新", detail=f"药品运输 {shipment_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return DrugShipmentRead.model_validate(shipment) return DrugShipmentRead.model_validate(shipment)
@@ -185,5 +185,5 @@ async def delete_shipment(
action="DELETE_DRUG_SHIPMENT", action="DELETE_DRUG_SHIPMENT",
detail=f"药品运输 {shipment_id} 已删除", detail=f"药品运输 {shipment_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+5 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
@@ -17,8 +17,7 @@ router = APIRouter()
def _is_system_admin(current_user) -> bool: def _is_system_admin(current_user) -> bool:
role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role return is_system_admin(current_user)
return role == "ADMIN"
@router.post( @router.post(
@@ -51,7 +50,7 @@ async def create_category(
action="CREATE_FAQ_CATEGORY", action="CREATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category.name} 已创建", detail=f"FAQ 分类 {category.name} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return CategoryRead.model_validate(category) return CategoryRead.model_validate(category)
@@ -113,7 +112,7 @@ async def update_category(
action="UPDATE_FAQ_CATEGORY", action="UPDATE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已更新", detail=f"FAQ 分类 {category_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return CategoryRead.model_validate(updated) return CategoryRead.model_validate(updated)
@@ -151,5 +150,5 @@ async def delete_category(
action="DELETE_FAQ_CATEGORY", action="DELETE_FAQ_CATEGORY",
detail=f"FAQ 分类 {category_id} 已删除", detail=f"FAQ 分类 {category_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+9 -12
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import faq_category as category_crud from app.crud import faq_category as category_crud
@@ -27,8 +27,7 @@ router = APIRouter()
def _is_system_admin(current_user) -> bool: def _is_system_admin(current_user) -> bool:
role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role return is_system_admin(current_user)
return role == "ADMIN"
@router.post( @router.post(
@@ -75,7 +74,7 @@ async def create_faq(
action="CREATE_FAQ_ITEM", action="CREATE_FAQ_ITEM",
detail="FAQ 已创建", detail="FAQ 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return FaqRead.model_validate(item) return FaqRead.model_validate(item)
@@ -108,8 +107,7 @@ async def list_faqs(
if not study_id: if not study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if not is_system_admin(current_user):
if role_value != "ADMIN":
member = await member_crud.get_member(db, study_id, current_user.id) member = await member_crud.get_member(db, study_id, current_user.id)
if not member or not member.is_active: if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
@@ -158,8 +156,7 @@ async def get_faq(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
if not item.study_id: if not item.study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if not item.is_active and not is_system_admin(current_user):
if not item.is_active and role_value != "ADMIN":
member = await member_crud.get_member(db, item.study_id, current_user.id) member = await member_crud.get_member(db, item.study_id, current_user.id)
if not member or not member.is_active: if not member or not member.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
@@ -203,7 +200,7 @@ async def update_faq(
action=action, action=action,
detail=detail, detail=detail,
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return FaqRead.model_validate(updated) return FaqRead.model_validate(updated)
@@ -359,7 +356,7 @@ async def create_reply(
action="CREATE_FAQ_REPLY", action="CREATE_FAQ_REPLY",
detail="FAQ 已回复", detail="FAQ 已回复",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
data = FaqReplyRead.model_validate(reply) data = FaqReplyRead.model_validate(reply)
if quote: if quote:
@@ -404,7 +401,7 @@ async def delete_faq(
action="DELETE_FAQ_ITEM", action="DELETE_FAQ_ITEM",
detail="FAQ 已删除", detail="FAQ 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -457,5 +454,5 @@ async def delete_reply(
action="DELETE_FAQ_REPLY", action="DELETE_FAQ_REPLY",
detail="FAQ 回复已删除", detail="FAQ 回复已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+13 -19
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import contract_fee as contract_fee_crud from app.crud import contract_fee as contract_fee_crud
@@ -22,7 +22,7 @@ from app.schemas.contract_fee_payment import (
ContractFeePaymentUpdate, ContractFeePaymentUpdate,
) )
from app.schemas.fee_common import FeeApiResponse from app.schemas.fee_common import FeeApiResponse
from app.schemas.fee_attachment import FeeAttachmentRead from app.schemas.attachment import AttachmentRead
from app.schemas.user import UserDisplay from app.schemas.user import UserDisplay
from app.models.attachment import Attachment from app.models.attachment import Attachment
@@ -33,8 +33,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren
study = await study_crud.get(db, project_id) study = await study_crud.get(db, project_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if is_system_admin(current_user):
if role_value == "ADMIN":
return None return None
membership = await member_crud.get_member(db, project_id, current_user.id) membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
@@ -163,7 +162,7 @@ async def get_contract_fee(
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
users_map = await user_crud.get_users_by_ids(db, user_ids) users_map = await user_crud.get_users_by_ids(db, user_ids)
attachments_map: dict[str, list[FeeAttachmentRead]] = { attachments_map: dict[str, list[AttachmentRead]] = {
"contract": [], "contract": [],
"voucher": [], "voucher": [],
"invoice": [], "invoice": [],
@@ -173,16 +172,11 @@ async def get_contract_fee(
attachments_map.setdefault(key, []) attachments_map.setdefault(key, [])
user = users_map.get(attachment.uploaded_by) user = users_map.get(attachment.uploaded_by)
attachments_map[key].append( attachments_map[key].append(
FeeAttachmentRead( AttachmentRead(
id=attachment.id, id=attachment.id,
entity_type="contract_fee",
entity_id=attachment.entity_id,
file_type=key,
filename=attachment.filename, filename=attachment.filename,
mime_type=attachment.content_type, file_size=attachment.file_size,
size=attachment.file_size, content_type=attachment.content_type,
storage_key=attachment.file_path,
url=None,
uploaded_by_id=attachment.uploaded_by, uploaded_by_id=attachment.uploaded_by,
uploaded_by=UserDisplay.model_validate(user) if user else None, uploaded_by=UserDisplay.model_validate(user) if user else None,
uploaded_at=attachment.uploaded_at, uploaded_at=attachment.uploaded_at,
@@ -244,7 +238,7 @@ async def create_contract_fee(
action="CREATE_CONTRACT_FEE", action="CREATE_CONTRACT_FEE",
detail="合同费用已创建", detail="合同费用已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -274,7 +268,7 @@ async def update_contract_fee(
action="UPDATE_CONTRACT_FEE", action="UPDATE_CONTRACT_FEE",
detail="合同费用已更新", detail="合同费用已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) return FeeApiResponse(data=ContractFeeRead.model_validate(contract))
@@ -303,7 +297,7 @@ async def delete_contract_fee(
action="DELETE_CONTRACT_FEE", action="DELETE_CONTRACT_FEE",
detail="合同费用已删除", detail="合同费用已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -333,7 +327,7 @@ async def create_contract_payment(
action="CREATE_CONTRACT_FEE_PAYMENT", action="CREATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已创建", detail="合同费用分期已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -374,7 +368,7 @@ async def update_contract_payment(
action="UPDATE_CONTRACT_FEE_PAYMENT", action="UPDATE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已更新", detail="合同费用分期已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment))
@@ -406,5 +400,5 @@ async def delete_contract_payment(
action="DELETE_CONTRACT_FEE_PAYMENT", action="DELETE_CONTRACT_FEE_PAYMENT",
detail="合同费用分期已删除", detail="合同费用分期已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+3 -2
View File
@@ -4,7 +4,7 @@ from datetime import date
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member from app.core.deps import get_db_session, require_api_permission
from app.crud import finance as finance_crud from app.crud import finance as finance_crud
from app.crud import study as study_crud from app.crud import study as study_crud
from app.schemas.finance import FinanceSummaryRead from app.schemas.finance import FinanceSummaryRead
@@ -12,11 +12,12 @@ from app.schemas.finance import FinanceSummaryRead
router = APIRouter() router = APIRouter()
@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())]) @router.get("/summary", response_model=FinanceSummaryRead)
async def finance_summary( async def finance_summary(
study_id: uuid.UUID, study_id: uuid.UUID,
date_from: date | None = None, date_from: date | None = None,
date_to: date | None = None, date_to: date | None = None,
_=Depends(require_api_permission("fees_contracts:read")),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
) -> FinanceSummaryRead: ) -> FinanceSummaryRead:
# ensure study exists # ensure study exists
+4 -4
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import material_equipment as equipment_crud from app.crud import material_equipment as equipment_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -49,7 +49,7 @@ async def create_equipment(
action="CREATE_MATERIAL_EQUIPMENT", action="CREATE_MATERIAL_EQUIPMENT",
detail=f"设备 {item.id} 已创建", detail=f"设备 {item.id} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return MaterialEquipmentRead.model_validate(item) return MaterialEquipmentRead.model_validate(item)
@@ -120,7 +120,7 @@ async def update_equipment(
action="UPDATE_MATERIAL_EQUIPMENT", action="UPDATE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已更新", detail=f"设备 {equipment_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return MaterialEquipmentRead.model_validate(item) return MaterialEquipmentRead.model_validate(item)
@@ -149,5 +149,5 @@ async def delete_equipment(
action="DELETE_MATERIAL_EQUIPMENT", action="DELETE_MATERIAL_EQUIPMENT",
detail=f"设备 {equipment_id} 已删除", detail=f"设备 {equipment_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+13 -10
View File
@@ -4,7 +4,7 @@ import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked from app.core.deps import get_current_user, get_db_session, is_system_admin, require_api_permission, require_study_not_locked
from app.core.decorators import register_api_endpoint from app.core.decorators import register_api_endpoint
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import member as member_crud from app.crud import member as member_crud
@@ -32,8 +32,11 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
return study return study
def _role_value(user) -> str: async def _operator_project_role(db: AsyncSession, study_id: uuid.UUID, current_user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role) if is_system_admin(current_user):
return "ADMIN"
member = await member_crud.get_member(db, study_id, current_user.id)
return member.role_in_study if member and member.is_active else ""
def _role_rank(role: str | None) -> int: def _role_rank(role: str | None) -> int:
@@ -48,7 +51,7 @@ async def _ensure_member_mutation_allowed(
target_member=None, target_member=None,
target_role: str | None = None, target_role: str | None = None,
) -> None: ) -> None:
if _role_value(current_user) == "ADMIN": if is_system_admin(current_user):
return return
actor_member = await member_crud.get_member(db, study_id, current_user.id) actor_member = await member_crud.get_member(db, study_id, current_user.id)
@@ -60,7 +63,7 @@ async def _ensure_member_mutation_allowed(
if target_member: if target_member:
target_user = await user_crud.get_by_id(db, target_member.user_id) target_user = await user_crud.get_by_id(db, target_member.user_id)
if _role_value(target_user) == "ADMIN": if is_system_admin(target_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限")
if target_member and _role_rank(target_member.role_in_study) >= actor_rank: if target_member and _role_rank(target_member.role_in_study) >= actor_rank:
@@ -118,7 +121,7 @@ async def add_member(
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study}, "after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
}, ensure_ascii=False), }, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await _operator_project_role(db, study_id, current_user),
) )
return updated return updated
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
@@ -131,7 +134,7 @@ async def add_member(
action="PROJECT_MEMBER_ADDED", action="PROJECT_MEMBER_ADDED",
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False), detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await _operator_project_role(db, study_id, current_user),
) )
return member return member
@@ -246,7 +249,7 @@ async def update_member(
action="PROJECT_MEMBER_UPDATED", action="PROJECT_MEMBER_UPDATED",
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False), detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await _operator_project_role(db, study_id, current_user),
) )
return updated return updated
@@ -274,7 +277,7 @@ async def remove_member(
if not member or member.study_id != study_id: if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
target_user = await user_crud.get_by_id(db, member.user_id) target_user = await user_crud.get_by_id(db, member.user_id)
if target_user and _role_value(target_user) == "ADMIN": if target_user and is_system_admin(target_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统管理员不可从项目中移除")
await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member) await _ensure_member_mutation_allowed(db, study_id, current_user, target_member=member)
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
@@ -287,6 +290,6 @@ async def remove_member(
action="PROJECT_MEMBER_REMOVED", action="PROJECT_MEMBER_REMOVED",
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False), detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await _operator_project_role(db, study_id, current_user),
) )
return removed return removed
@@ -11,7 +11,7 @@ from openpyxl import Workbook, load_workbook
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import monitoring_visit_issue as issue_crud from app.crud import monitoring_visit_issue as issue_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -413,7 +413,7 @@ async def create_monitoring_visit_issue(
action="CREATE_MONITORING_VISIT_ISSUE", action="CREATE_MONITORING_VISIT_ISSUE",
detail=f"监查访视问题 {item.issue_no} 已创建", detail=f"监查访视问题 {item.issue_no} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return _to_read(item) return _to_read(item)
@@ -617,7 +617,7 @@ async def update_monitoring_visit_issue(
action="UPDATE_MONITORING_VISIT_ISSUE", action="UPDATE_MONITORING_VISIT_ISSUE",
detail=json.dumps(detail_payload, ensure_ascii=False), detail=json.dumps(detail_payload, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return _to_read(updated) return _to_read(updated)
@@ -648,7 +648,7 @@ async def delete_monitoring_visit_issue(
action="DELETE_MONITORING_VISIT_ISSUE", action="DELETE_MONITORING_VISIT_ISSUE",
detail=f"监查访视问题 {issue_no} 已删除", detail=f"监查访视问题 {issue_no} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -765,7 +765,7 @@ async def import_monitoring_visit_issues(
action="IMPORT_MONITORING_VISIT_ISSUE", action="IMPORT_MONITORING_VISIT_ISSUE",
detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)}", detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return MonitoringVisitIssueImportSummary( return MonitoringVisitIssueImportSummary(
+2 -8
View File
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, desc from sqlalchemy import func, select, desc
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, list_active_pm_study_ids from app.core.deps import get_current_user, get_db_session, is_system_admin, list_active_pm_study_ids
from app.core.permission_monitor import get_permission_monitor from app.core.permission_monitor import get_permission_monitor
from app.models.permission_access_log import PermissionAccessLog from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot from app.models.permission_metric_snapshot import PermissionMetricSnapshot
@@ -36,14 +36,8 @@ class MonitoringScope:
return study_id is not None and study_id in self.study_ids return study_id is not None and study_id in self.study_ids
def _role_value(user) -> str:
if not hasattr(user, "role"):
return "ADMIN"
return user.role.value if hasattr(user.role, "value") else str(user.role)
async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope: async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope:
if _role_value(current_user) == "ADMIN": if is_system_admin(current_user):
return MonitoringScope(is_admin=True, study_ids=set()) return MonitoringScope(is_admin=True, study_ids=set())
return MonitoringScope( return MonitoringScope(
is_admin=False, is_admin=False,
+3 -4
View File
@@ -11,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_roles from app.core.deps import get_current_user, get_db_session, require_roles, require_study_roles
from app.models.permission_template import TemplateType from app.models.permission_template import TemplateType
from app.models.user import UserRole
from app.schemas.permission_template import ( from app.schemas.permission_template import (
ApplyTemplateRequest, ApplyTemplateRequest,
ApplyTemplateResponse, ApplyTemplateResponse,
@@ -52,7 +51,7 @@ async def list_templates(
async def create_template( async def create_template(
payload: PermissionTemplateCreate, payload: PermissionTemplateCreate,
db: Annotated[AsyncSession, Depends(get_db_session)] = None, db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(require_roles([UserRole.ADMIN.value])), current_user=Depends(require_roles(["ADMIN"])),
) -> PermissionTemplateRead: ) -> PermissionTemplateRead:
"""创建权限模板""" """创建权限模板"""
try: try:
@@ -79,7 +78,7 @@ async def update_template(
template_id: uuid.UUID, template_id: uuid.UUID,
payload: PermissionTemplateUpdate, payload: PermissionTemplateUpdate,
db: Annotated[AsyncSession, Depends(get_db_session)] = None, db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(require_roles([UserRole.ADMIN.value])), current_user=Depends(require_roles(["ADMIN"])),
) -> PermissionTemplateRead: ) -> PermissionTemplateRead:
"""更新权限模板""" """更新权限模板"""
try: try:
@@ -93,7 +92,7 @@ async def update_template(
async def delete_template( async def delete_template(
template_id: uuid.UUID, template_id: uuid.UUID,
db: Annotated[AsyncSession, Depends(get_db_session)] = None, db: Annotated[AsyncSession, Depends(get_db_session)] = None,
current_user=Depends(require_roles([UserRole.ADMIN.value])), current_user=Depends(require_roles(["ADMIN"])),
): ):
"""删除权限模板""" """删除权限模板"""
try: try:
+4 -4
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import precaution as precaution_crud from app.crud import precaution as precaution_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -51,7 +51,7 @@ async def create_precaution(
action="CREATE_PRECAUTION", action="CREATE_PRECAUTION",
detail=f"注意事项 {precaution.title} 已创建", detail=f"注意事项 {precaution.title} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return PrecautionRead.model_validate(precaution) return PrecautionRead.model_validate(precaution)
@@ -117,7 +117,7 @@ async def update_precaution(
action="UPDATE_PRECAUTION", action="UPDATE_PRECAUTION",
detail=f"注意事项 {precaution_id} 已更新", detail=f"注意事项 {precaution_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return PrecautionRead.model_validate(precaution) return PrecautionRead.model_validate(precaution)
@@ -147,5 +147,5 @@ async def delete_precaution(
action="DELETE_PRECAUTION", action="DELETE_PRECAUTION",
detail=f"注意事项 {precaution_id} 已删除", detail=f"注意事项 {precaution_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+2 -2
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import project_milestone as milestone_crud from app.crud import project_milestone as milestone_crud
from app.crud import study as study_crud from app.crud import study as study_crud
@@ -69,6 +69,6 @@ async def update_project_milestone(
action="UPDATE_PROJECT_MILESTONE", action="UPDATE_PROJECT_MILESTONE",
detail=f"项目里程碑 {milestone_id} 已更新", detail=f"项目里程碑 {milestone_id} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return ProjectMilestoneRead.model_validate(item) return ProjectMilestoneRead.model_validate(item)
+4 -4
View File
@@ -4,7 +4,7 @@ import json
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_api_permission, require_study_not_locked
from app.core.decorators import register_api_endpoint from app.core.decorators import register_api_endpoint
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
@@ -64,7 +64,7 @@ async def create_site(
action="SITE_CREATED", action="SITE_CREATED",
detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False), detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return site return site
@@ -152,7 +152,7 @@ async def update_site(
action=action, action=action,
detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False), detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return updated return updated
@@ -196,6 +196,6 @@ async def delete_site(
action="SITE_DELETED", action="SITE_DELETED",
detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False), detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return None return None
+12 -12
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_api_permission from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import startup as startup_crud from app.crud import startup as startup_crud
@@ -75,7 +75,7 @@ async def create_feasibility(
action="CREATE_STARTUP_FEASIBILITY", action="CREATE_STARTUP_FEASIBILITY",
detail="立项记录已创建", detail="立项记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return StartupFeasibilityRead.model_validate(record) return StartupFeasibilityRead.model_validate(record)
@@ -149,7 +149,7 @@ async def update_feasibility(
action="UPDATE_STARTUP_FEASIBILITY", action="UPDATE_STARTUP_FEASIBILITY",
detail="立项记录已更新", detail="立项记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return StartupFeasibilityRead.model_validate(record) return StartupFeasibilityRead.model_validate(record)
@@ -182,7 +182,7 @@ async def delete_feasibility(
action="DELETE_STARTUP_FEASIBILITY", action="DELETE_STARTUP_FEASIBILITY",
detail="立项记录已删除", detail="立项记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -212,7 +212,7 @@ async def create_ethics(
action="CREATE_STARTUP_ETHICS", action="CREATE_STARTUP_ETHICS",
detail="伦理记录已创建", detail="伦理记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return StartupEthicsRead.model_validate(record) return StartupEthicsRead.model_validate(record)
@@ -286,7 +286,7 @@ async def update_ethics(
action="UPDATE_STARTUP_ETHICS", action="UPDATE_STARTUP_ETHICS",
detail="伦理记录已更新", detail="伦理记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return StartupEthicsRead.model_validate(record) return StartupEthicsRead.model_validate(record)
@@ -319,7 +319,7 @@ async def delete_ethics(
action="DELETE_STARTUP_ETHICS", action="DELETE_STARTUP_ETHICS",
detail="伦理记录已删除", detail="伦理记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
@@ -349,7 +349,7 @@ async def create_kickoff(
action="CREATE_KICKOFF_MEETING", action="CREATE_KICKOFF_MEETING",
detail="启动会记录已创建", detail="启动会记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return KickoffMeetingRead.model_validate(meeting) return KickoffMeetingRead.model_validate(meeting)
@@ -423,7 +423,7 @@ async def update_kickoff(
action="UPDATE_KICKOFF_MEETING", action="UPDATE_KICKOFF_MEETING",
detail="启动会记录已更新", detail="启动会记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return KickoffMeetingRead.model_validate(meeting) return KickoffMeetingRead.model_validate(meeting)
@@ -454,7 +454,7 @@ async def create_training_authorization(
action="CREATE_TRAINING_AUTH", action="CREATE_TRAINING_AUTH",
detail="培训授权人员已创建", detail="培训授权人员已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return TrainingAuthorizationRead.model_validate(record) return TrainingAuthorizationRead.model_validate(record)
@@ -528,7 +528,7 @@ async def update_training_authorization(
action="UPDATE_TRAINING_AUTH", action="UPDATE_TRAINING_AUTH",
detail="培训授权人员已更新", detail="培训授权人员已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return TrainingAuthorizationRead.model_validate(record) return TrainingAuthorizationRead.model_validate(record)
@@ -561,5 +561,5 @@ async def delete_training_authorization(
action="DELETE_TRAINING_AUTH", action="DELETE_TRAINING_AUTH",
detail="培训授权人员已删除", detail="培训授权人员已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+16 -17
View File
@@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ( from app.core.deps import (
get_current_user, get_current_user,
get_db_session, get_db_session,
get_operator_role_label,
is_system_admin,
require_api_permission, require_api_permission,
require_roles, require_roles,
require_study_member, require_study_member,
@@ -50,9 +52,6 @@ def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead:
return data return data
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role)
def _raise_validation_error(errors: list[dict[str, str]]) -> None: def _raise_validation_error(errors: list[dict[str, str]]) -> None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -769,7 +768,7 @@ async def create_study(
action="CREATE_SETUP_CONFIG", action="CREATE_SETUP_CONFIG",
detail="初始化立项配置(创建项目时回填基础信息)", detail="初始化立项配置(创建项目时回填基础信息)",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return study return study
@@ -782,7 +781,7 @@ async def list_studies(
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> PaginatedResponse[StudyRead]: ) -> PaginatedResponse[StudyRead]:
if _role_value(current_user) == "ADMIN": if is_system_admin(current_user):
studies = await study_crud.list_studies(db, skip=skip, limit=limit) studies = await study_crud.list_studies(db, skip=skip, limit=limit)
total = await study_crud.list_studies(db, skip=0, limit=10_000_000) total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
items = [_study_read_with_role(study) for study in studies] items = [_study_read_with_role(study) for study in studies]
@@ -805,7 +804,7 @@ async def get_study(
study = await study_crud.get(db, study_id) study = await study_crud.get(db, study_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if _role_value(current_user) == "ADMIN": if is_system_admin(current_user):
return _study_read_with_role(study) return _study_read_with_role(study)
member = await member_crud.get_member(db, study_id, current_user.id) member = await member_crud.get_member(db, study_id, current_user.id)
return _study_read_with_role(study, member.role_in_study if member else None) return _study_read_with_role(study, member.role_in_study if member else None)
@@ -893,7 +892,7 @@ async def lock_study(
action="LOCK_STUDY", action="LOCK_STUDY",
detail=f"项目已锁定:{study.name} ({study.code})", detail=f"项目已锁定:{study.name} ({study.code})",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return locked_study return locked_study
@@ -925,7 +924,7 @@ async def unlock_study(
action="UNLOCK_STUDY", action="UNLOCK_STUDY",
detail=f"项目已解锁:{study.name} ({study.code})", detail=f"项目已解锁:{study.name} ({study.code})",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return unlocked_study return unlocked_study
@@ -964,7 +963,7 @@ async def get_study_setup_config(
action="CREATE_SETUP_CONFIG", action="CREATE_SETUP_CONFIG",
detail="初始化立项配置", detail="初始化立项配置",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
saved_by_name = None saved_by_name = None
@@ -1047,7 +1046,7 @@ async def upsert_study_setup_config(
action="UPDATE_SETUP_CONFIG", action="UPDATE_SETUP_CONFIG",
detail=_top_level_diff_summary(old_config, record.config), detail=_top_level_diff_summary(old_config, record.config),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
saved_by_name = current_user.full_name or current_user.username or current_user.email saved_by_name = current_user.full_name or current_user.username or current_user.email
@@ -1159,7 +1158,7 @@ async def publish_study_setup_config(
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items], skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
), ),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
auto_commit=False, auto_commit=False,
) )
published.config = study_setup_config_crud.empty_draft_payload() published.config = study_setup_config_crud.empty_draft_payload()
@@ -1259,7 +1258,7 @@ async def rollback_study_setup_config(
action="ROLLBACK_SETUP_CONFIG", action="ROLLBACK_SETUP_CONFIG",
detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线", detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
operator_name = current_user.full_name or current_user.username or current_user.email operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None published_by_name = None
@@ -1318,7 +1317,7 @@ async def checkout_study_setup_config_branch_draft(
action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT", action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT",
detail=f"立项配置草稿已切换到 {target_label} 对应分支", detail=f"立项配置草稿已切换到 {target_label} 对应分支",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
operator_name = current_user.full_name or current_user.username or current_user.email operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None published_by_name = None
@@ -1370,7 +1369,7 @@ async def clear_study_setup_config_draft(
action="CLEAR_SETUP_CONFIG_DRAFT", action="CLEAR_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已清空", detail="立项配置草稿已清空",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
operator_name = current_user.full_name or current_user.username or current_user.email operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None published_by_name = None
@@ -1424,7 +1423,7 @@ async def refill_study_setup_config_draft(
action="REFILL_SETUP_CONFIG_DRAFT", action="REFILL_SETUP_CONFIG_DRAFT",
detail="立项配置草稿已从当前发布版本一键回填", detail="立项配置草稿已从当前发布版本一键回填",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
operator_name = current_user.full_name or current_user.username or current_user.email operator_name = current_user.full_name or current_user.username or current_user.email
published_by_name = None published_by_name = None
@@ -1511,7 +1510,7 @@ async def merge_study_setup_config_to_main(
action="MERGE_SETUP_CONFIG_TO_MAIN", action="MERGE_SETUP_CONFIG_TO_MAIN",
detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本", detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
auto_commit=False, auto_commit=False,
) )
merged.config = study_setup_config_crud.empty_draft_payload() merged.config = study_setup_config_crud.empty_draft_payload()
@@ -1578,5 +1577,5 @@ async def delete_study_setup_config_version(
action="DELETE_SETUP_CONFIG_VERSION", action="DELETE_SETUP_CONFIG_VERSION",
detail=f"删除发布版本快照 v{target_version}", detail=f"删除发布版本快照 v{target_version}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+4 -4
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
@@ -58,7 +58,7 @@ async def create_history(
action="CREATE_SUBJECT_HISTORY", action="CREATE_SUBJECT_HISTORY",
detail="病史记录已创建", detail="病史记录已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return SubjectHistoryRead.model_validate(history) return SubjectHistoryRead.model_validate(history)
@@ -133,7 +133,7 @@ async def update_history(
action="UPDATE_SUBJECT_HISTORY", action="UPDATE_SUBJECT_HISTORY",
detail="病史记录已更新", detail="病史记录已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return SubjectHistoryRead.model_validate(history) return SubjectHistoryRead.model_validate(history)
@@ -166,5 +166,5 @@ async def delete_history(
action="DELETE_SUBJECT_HISTORY", action="DELETE_SUBJECT_HISTORY",
detail="病史记录已删除", detail="病史记录已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+4 -3
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import ( from app.core.deps import (
get_current_user, get_current_user,
get_db_session, get_db_session,
get_operator_role_label,
require_study_not_locked, require_study_not_locked,
require_api_permission, require_api_permission,
) )
@@ -120,7 +121,7 @@ async def create_subject_pd(
action="CREATE_SUBJECT_PD", action="CREATE_SUBJECT_PD",
detail=f"PD记录 {item.pd_no} 已创建", detail=f"PD记录 {item.pd_no} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return SubjectPdRead.model_validate(item) return SubjectPdRead.model_validate(item)
@@ -167,7 +168,7 @@ async def update_subject_pd(
action="UPDATE_SUBJECT_PD", action="UPDATE_SUBJECT_PD",
detail=f"PD记录 {updated.pd_no} 已更新", detail=f"PD记录 {updated.pd_no} 已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return SubjectPdRead.model_validate(updated) return SubjectPdRead.model_validate(updated)
@@ -201,5 +202,5 @@ async def delete_subject_pd(
action="DELETE_SUBJECT_PD", action="DELETE_SUBJECT_PD",
detail=f"PD记录 {item.pd_no} 已删除", detail=f"PD记录 {item.pd_no} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+4 -4
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
@@ -58,7 +58,7 @@ async def create_subject(
action="CREATE_SUBJECT", action="CREATE_SUBJECT",
detail=f"参与者 {subject.subject_no} 已创建", detail=f"参与者 {subject.subject_no} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return subject return subject
@@ -188,7 +188,7 @@ async def update_subject(
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT", action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
detail=detail or "参与者已更新", detail=detail or "参与者已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return updated return updated
@@ -220,5 +220,5 @@ async def delete_subject(
action="DELETE_SUBJECT", action="DELETE_SUBJECT",
detail=f"参与者 {subject_id} 已删除", detail=f"参与者 {subject_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
+3 -3
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_roles from app.core.deps import get_db_session, is_system_admin, require_roles
from app.schemas.common import PaginatedResponse from app.schemas.common import PaginatedResponse
from app.crud import user as user_crud from app.crud import user as user_crud
from app.crud import member as member_crud from app.crud import member as member_crud
@@ -59,7 +59,7 @@ async def update_user(
requested_status = "ACTIVE" if user_in.is_active else "DISABLED" requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
if requested_status is not None and requested_status != "ACTIVE": if requested_status is not None and requested_status != "ACTIVE":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员不允许停用")
if db_user.role.value == "ADMIN": if is_system_admin(db_user):
requested_status = user_in.status requested_status = user_in.status
if user_in.is_active is not None: if user_in.is_active is not None:
requested_status = "ACTIVE" if user_in.is_active else "DISABLED" requested_status = "ACTIVE" if user_in.is_active else "DISABLED"
@@ -85,7 +85,7 @@ async def delete_user(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员账号不允许删除") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="系统管理员账号不允许删除")
if db_user.id == current_user.id: if db_user.id == current_user.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不允许删除自己")
if db_user.role.value == "ADMIN" and db_user.status.value == "ACTIVE": if is_system_admin(db_user) and db_user.status.value == "ACTIVE":
active_admins = await user_crud.count_active_admins(db) active_admins = await user_crud.count_active_admins(db)
if active_admins <= 1: if active_admins <= 1:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
+5 -5
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud from app.crud import audit as audit_crud
from app.crud import site as site_crud from app.crud import site as site_crud
from app.crud import subject as subject_crud from app.crud import subject as subject_crud
@@ -107,7 +107,7 @@ async def create_visit(
action="CREATE_VISIT", action="CREATE_VISIT",
detail=f"访视 {visit.visit_code} 已创建", detail=f"访视 {visit.visit_code} 已创建",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return visit return visit
@@ -160,7 +160,7 @@ async def create_early_termination(
action="CREATE_EARLY_TERMINATION", action="CREATE_EARLY_TERMINATION",
detail=f"参与者 {subject.subject_no} 已提前终止", detail=f"参与者 {subject.subject_no} 已提前终止",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return visit return visit
@@ -204,7 +204,7 @@ async def update_visit(
action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT", action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT",
detail=detail or "访视已更新", detail=detail or "访视已更新",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
return updated return updated
@@ -236,5 +236,5 @@ async def delete_visit(
action="DELETE_VISIT", action="DELETE_VISIT",
detail=f"访视 {visit_id} 已删除", detail=f"访视 {visit_id} 已删除",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=current_user.role, operator_role=await get_operator_role_label(db, study_id, current_user),
) )
-13
View File
@@ -269,19 +269,6 @@ API_ENDPOINT_PERMISSIONS = {
"description": "删除立项记录", "description": "删除立项记录",
"default_roles": ["PM"], "default_roles": ["PM"],
}, },
# 项目权限管理
"permissions:read": {
"module": "project_members",
"action": "read",
"description": "查询项目权限矩阵",
"default_roles": ["PM"],
},
"permissions:update": {
"module": "project_members",
"action": "write",
"description": "更新项目权限矩阵",
"default_roles": ["PM"],
},
# 项目概览 # 项目概览
"project_overview:read": { "project_overview:read": {
"module": "project_overview", "module": "project_overview",
+47 -17
View File
@@ -12,6 +12,7 @@ from app.core.exceptions import AppException
from app.core.security import decode_token, oauth2_scheme from app.core.security import decode_token, oauth2_scheme
from app.crud import user as user_crud from app.crud import user as user_crud
from app.crud import member as member_crud from app.crud import member as member_crud
from app.core.api_permissions import SYSTEM_PERMISSIONS
from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
@@ -53,12 +54,15 @@ async def get_current_user(
return user return user
def is_system_admin(user) -> bool:
return bool(getattr(user, "is_admin", False))
def require_roles(roles: Iterable[str]) -> Callable: def require_roles(roles: Iterable[str]) -> Callable:
roles_set = set(roles) roles_set = set(roles)
async def dependency(current_user=Depends(get_current_user)): async def dependency(current_user=Depends(get_current_user)):
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) if "ADMIN" not in roles_set or not is_system_admin(current_user):
if current_role not in roles_set:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足", detail="权限不足",
@@ -68,10 +72,6 @@ def require_roles(roles: Iterable[str]) -> Callable:
return dependency return dependency
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role)
async def list_active_pm_study_ids(db: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]: async def list_active_pm_study_ids(db: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
result = await db.execute( result = await db.execute(
select(StudyMember.study_id).where( select(StudyMember.study_id).where(
@@ -95,12 +95,21 @@ async def is_active_project_pm(db: AsyncSession, user_id: uuid.UUID, study_id: u
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
async def get_operator_role_label(db: AsyncSession, study_id: uuid.UUID | None, current_user) -> str:
if is_system_admin(current_user):
return "ADMIN"
if not study_id:
return ""
membership = await member_crud.get_member(db, study_id, current_user.id)
return membership.role_in_study if membership and membership.is_active else ""
def require_admin_or_any_project_pm() -> Callable: def require_admin_or_any_project_pm() -> Callable:
async def dependency( async def dependency(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
): ):
if _role_value(current_user) == "ADMIN": if is_system_admin(current_user):
return current_user return current_user
if await list_active_pm_study_ids(db, current_user.id): if await list_active_pm_study_ids(db, current_user.id):
return current_user return current_user
@@ -112,13 +121,38 @@ def require_admin_or_any_project_pm() -> Callable:
return dependency return dependency
def require_system_permission(permission_key: str) -> Callable:
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
permission = SYSTEM_PERMISSIONS.get(permission_key)
if not permission:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
)
allowed_roles = set(permission.get("roles", []))
if is_system_admin(current_user) and "ADMIN" in allowed_roles:
return current_user
if "PM" in allowed_roles and await is_active_project_pm(db, current_user.id, study_id):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="权限不足",
)
return dependency
async def get_study_member( async def get_study_member(
study_id: uuid.UUID, study_id: uuid.UUID,
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
): ):
role_value = _role_value(current_user) if is_system_admin(current_user):
if role_value == "ADMIN":
return None return None
return await member_crud.get_member(db, study_id, current_user.id) return await member_crud.get_member(db, study_id, current_user.id)
@@ -129,8 +163,7 @@ def require_study_member():
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
): ):
role_value = _role_value(current_user) if is_system_admin(current_user):
if role_value == "ADMIN":
return current_user return current_user
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
@@ -152,8 +185,7 @@ def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
): ):
role_value = _role_value(current_user) if allow_system_admin and is_system_admin(current_user):
if allow_system_admin and role_value == "ADMIN":
return current_user return current_user
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active or membership.role_in_study not in roles_set: if not membership or not membership.is_active or membership.role_in_study not in roles_set:
@@ -183,8 +215,7 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
db: AsyncSession = Depends(get_db_session), db: AsyncSession = Depends(get_db_session),
): ):
from app.core.permission_monitor import get_permission_monitor from app.core.permission_monitor import get_permission_monitor
role_value = _role_value(current_user) if allow_system_admin and is_system_admin(current_user):
if allow_system_admin and role_value == "ADMIN":
_enqueue_permission_log( _enqueue_permission_log(
study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request
) )
@@ -275,8 +306,7 @@ async def get_cra_site_scope(
) -> tuple[set[uuid.UUID], set[str]] | None: ) -> tuple[set[uuid.UUID], set[str]] | None:
from app.crud import site as site_crud from app.crud import site as site_crud
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if is_system_admin(current_user):
if role_value == "ADMIN":
return None return None
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
+1 -2
View File
@@ -30,8 +30,7 @@ ROLE_ACTIONS: dict[str, set[str]] = {
def is_allowed(action: str, user, membership) -> bool: def is_allowed(action: str, user, membership) -> bool:
role_value = user.role.value if hasattr(user.role, "value") else str(user.role) if getattr(user, "is_admin", False):
if role_value == "ADMIN":
return action in ROLE_ACTIONS["ADMIN"] return action in ROLE_ACTIONS["ADMIN"]
member_role = getattr(membership, "role_in_study", None) member_role = getattr(membership, "role_in_study", None)
if member_role: if member_role:
-2
View File
@@ -17,7 +17,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name=
def create_access_token( def create_access_token(
*, *,
user_id: str, user_id: str,
role: str,
expires_minutes: Optional[int] = None, expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None, session_start: Optional[datetime] = None,
) -> str: ) -> str:
@@ -26,7 +25,6 @@ def create_access_token(
session_start_time = session_start or now session_start_time = session_start or now
to_encode: Dict[str, Any] = { to_encode: Dict[str, Any] = {
"sub": user_id, "sub": user_id,
"role": role,
"exp": expire, "exp": expire,
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()), "orig_iat": int(session_start_time.timestamp()),
+3 -6
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from __future__ import annotations
import uuid import uuid
from typing import Sequence from typing import Sequence
@@ -16,7 +14,7 @@ from app.core.config import (
) )
from app.core.security import hash_password from app.core.security import hash_password
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
from app.models.user import User, UserRole, UserStatus from app.models.user import User, UserStatus
from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate from app.schemas.user import UserCreate, UserRegisterRequest, UserUpdate
@@ -46,7 +44,6 @@ async def create_user(
email=user_in.email, email=user_in.email,
password_hash=hash_password(user_in.password), password_hash=hash_password(user_in.password),
full_name=user_in.full_name, full_name=user_in.full_name,
role=UserRole.PV,
clinical_department=user_in.clinical_department, clinical_department=user_in.clinical_department,
status=status_value, status=status_value,
) )
@@ -114,7 +111,7 @@ async def count_active_admins(db: AsyncSession) -> int:
result = await db.execute( result = await db.execute(
select(func.count()) select(func.count())
.select_from(User) .select_from(User)
.where(User.role == UserRole.ADMIN, User.status == UserStatus.ACTIVE) .where(User.is_admin.is_(True), User.status == UserStatus.ACTIVE)
) )
return int(result.scalar_one() or 0) return int(result.scalar_one() or 0)
@@ -138,7 +135,7 @@ async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTE
email=PROTECTED_ADMIN_EMAIL, email=PROTECTED_ADMIN_EMAIL,
password_hash=hash_password(default_password), password_hash=hash_password(default_password),
full_name=PROTECTED_ADMIN_FULL_NAME, full_name=PROTECTED_ADMIN_FULL_NAME,
role=UserRole.ADMIN, is_admin=True,
clinical_department=PROTECTED_ADMIN_CLINICAL_DEPARTMENT, clinical_department=PROTECTED_ADMIN_CLINICAL_DEPARTMENT,
status=UserStatus.ACTIVE, status=UserStatus.ACTIVE,
) )
+2 -15
View File
@@ -5,22 +5,13 @@ import enum
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, func from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base from app.db.base_class import Base
class UserRole(str, enum.Enum):
ADMIN = "ADMIN"
PM = "PM"
CRA = "CRA"
PV = "PV"
QA = "QA"
CTA = "CTA"
class UserStatus(str, enum.Enum): class UserStatus(str, enum.Enum):
PENDING = "PENDING" PENDING = "PENDING"
ACTIVE = "ACTIVE" ACTIVE = "ACTIVE"
@@ -35,8 +26,8 @@ class User(Base):
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False) full_name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(Enum(UserRole, name="user_role"), nullable=False)
clinical_department: Mapped[str] = mapped_column(String(255), nullable=False) clinical_department: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False)
status: Mapped[UserStatus] = mapped_column( status: Mapped[UserStatus] = mapped_column(
Enum(UserStatus, name="user_status"), Enum(UserStatus, name="user_status"),
nullable=False, nullable=False,
@@ -66,10 +57,6 @@ class User(Base):
def is_active(self) -> bool: # compatibility helper for existing checks def is_active(self) -> bool: # compatibility helper for existing checks
return self.status == UserStatus.ACTIVE return self.status == UserStatus.ACTIVE
@property
def is_admin(self) -> bool:
return self.role == UserRole.ADMIN
@property @property
def username(self) -> str: # backward compatibility for existing UI copy def username(self) -> str: # backward compatibility for existing UI copy
return self.email return self.email
-2
View File
@@ -5,7 +5,6 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["PM", "CRA", "PV", "QA", "CTA", "ADMIN"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"] UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$") PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
@@ -103,5 +102,4 @@ class Token(BaseModel):
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
sub: uuid.UUID sub: uuid.UUID
role: str
exp: Optional[int] = None exp: Optional[int] = None
+13 -17
View File
@@ -14,6 +14,7 @@ from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope from app.core.deps import get_cra_site_scope
from app.core.deps import get_operator_role_label, is_system_admin
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.crud import acknowledgement as acknowledgement_crud from app.crud import acknowledgement as acknowledgement_crud
from app.crud import distribution as distribution_crud from app.crud import distribution as distribution_crud
@@ -43,14 +44,11 @@ DOCUMENT_ACTION_PERMISSIONS = {
"ack": "documents:read", "ack": "documents:read",
"create_document": "documents:create", "create_document": "documents:create",
"create_version": "documents:update", "create_version": "documents:update",
"distribute": "documents:update",
"delete_document": "documents:delete", "delete_document": "documents:delete",
} }
def _role_value(user) -> str:
return user.role.value if hasattr(user.role, "value") else str(user.role)
def _audit_detail(before: dict | None, after: dict | None) -> str: def _audit_detail(before: dict | None, after: dict | None) -> str:
payload = {"before": before, "after": after} payload = {"before": before, "after": after}
return json.dumps(payload, ensure_ascii=True) return json.dumps(payload, ensure_ascii=True)
@@ -82,7 +80,7 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us
study = await study_crud.get(db, trial_id) study = await study_crud.get(db, trial_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if current_user.role == "ADMIN": if is_system_admin(current_user):
return None return None
membership = await member_crud.get_member(db, trial_id, current_user.id) membership = await member_crud.get_member(db, trial_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
@@ -98,7 +96,7 @@ async def _ensure_study_member(db: AsyncSession, trial_id: uuid.UUID, current_us
study = await study_crud.get(db, trial_id) study = await study_crud.get(db, trial_id)
if not study: if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if current_user.role == "ADMIN": if is_system_admin(current_user):
return None return None
membership = await member_crud.get_member(db, trial_id, current_user.id) membership = await member_crud.get_member(db, trial_id, current_user.id)
if not membership or not membership.is_active: if not membership or not membership.is_active:
@@ -154,7 +152,7 @@ async def create_document(
action="DOCUMENT_CREATED", action="DOCUMENT_CREATED",
detail=_audit_detail(None, _doc_snapshot(doc)), detail=_audit_detail(None, _doc_snapshot(doc)),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
) )
) )
await db.commit() await db.commit()
@@ -345,7 +343,7 @@ async def create_version(
action="VERSION_CREATED", action="VERSION_CREATED",
detail=_audit_detail(None, _version_snapshot(version)), detail=_audit_detail(None, _version_snapshot(version)),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
) )
) )
@@ -429,7 +427,7 @@ async def delete_version(
action="VERSION_DELETED", action="VERSION_DELETED",
detail=_audit_detail(before, None), detail=_audit_detail(before, None),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
) )
) )
await db.commit() await db.commit()
@@ -466,7 +464,7 @@ async def delete_document(
action="DOCUMENT_ARCHIVED", action="DOCUMENT_ARCHIVED",
detail=_audit_detail(before, _doc_snapshot(doc)), detail=_audit_detail(before, _doc_snapshot(doc)),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
) )
) )
await db.commit() await db.commit()
@@ -511,7 +509,7 @@ async def create_distributions(
doc = await document_crud.get(db, version.document_id) doc = await document_crud.get(db, version.document_id)
if not doc: if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_member(db, doc.trial_id, current_user) await _ensure_study_access(db, doc.trial_id, current_user, action="distribute")
if version.status != DocumentVersionStatus.EFFECTIVE: if version.status != DocumentVersionStatus.EFFECTIVE:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发")
@@ -536,7 +534,7 @@ async def create_distributions(
action="DISTRIBUTION_CREATED", action="DISTRIBUTION_CREATED",
detail=_audit_detail(None, {"version_id": str(version.id), "target_id": target.target_id}), detail=_audit_detail(None, {"version_id": str(version.id), "target_id": target.target_id}),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
) )
) )
await db.commit() await db.commit()
@@ -599,8 +597,7 @@ async def create_acknowledgement(
if distribution.target_type == DistributionTargetType.USER and distribution.target_id != str(current_user.id): if distribution.target_type == DistributionTargetType.USER and distribution.target_id != str(current_user.id):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
if distribution.target_type == DistributionTargetType.ROLE: if distribution.target_type == DistributionTargetType.ROLE:
role_value = _role_value(current_user) if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
if distribution.target_id not in (role_value, getattr(membership, "role_in_study", "")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
if payload.ack_type != AcknowledgementType.RECEIVED: if payload.ack_type != AcknowledgementType.RECEIVED:
@@ -633,7 +630,7 @@ async def create_acknowledgement(
action="ACK_CREATED", action="ACK_CREATED",
detail=_audit_detail(None, {"distribution_id": str(distribution.id), "ack_type": payload.ack_type}), detail=_audit_detail(None, {"distribution_id": str(distribution.id), "ack_type": payload.ack_type}),
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
) )
) )
await db.commit() await db.commit()
@@ -693,9 +690,8 @@ async def list_distribution_notifications(
skip: int = 0, skip: int = 0,
limit: int = 20, limit: int = 20,
) -> list[NotificationItem]: ) -> list[NotificationItem]:
role_value = _role_value(current_user)
membership = None membership = None
if role_value != "ADMIN": if not is_system_admin(current_user):
membership = await member_crud.get_member(db, study_id, current_user.id) membership = await member_crud.get_member(db, study_id, current_user.id)
role_in_study = membership.role_in_study if membership else None role_in_study = membership.role_in_study if membership else None
+3 -8
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import document as document_crud from app.crud import document as document_crud
from app.crud import etmf as etmf_crud from app.crud import etmf as etmf_crud
from app.core.deps import get_operator_role_label
from app.models.document import Document, DocumentStatus from app.models.document import Document, DocumentStatus
from app.models.etmf import EtmfNode from app.models.etmf import EtmfNode
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -17,12 +18,6 @@ from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate
def _role_value(user) -> str:
if user is None:
return "SYSTEM"
return user.role.value if hasattr(user.role, "value") else str(user.role)
def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus: def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus:
docs = list(documents) docs = list(documents)
if not node.is_active: if not node.is_active:
@@ -109,7 +104,7 @@ async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -
action="ETMF_NODE_CREATED", action="ETMF_NODE_CREATED",
detail=f'{{"code":"{node.code}","name":"{node.name}"}}', detail=f'{{"code":"{node.code}","name":"{node.name}"}}',
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, payload.study_id, current_user),
) )
) )
await db.commit() await db.commit()
@@ -138,7 +133,7 @@ async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpd
action="ETMF_NODE_UPDATED", action="ETMF_NODE_UPDATED",
detail="{}", detail="{}",
operator_id=current_user.id, operator_id=current_user.id,
operator_role=_role_value(current_user), operator_role=await get_operator_role_label(db, node.study_id, current_user),
) )
) )
await db.commit() await db.commit()
+48 -17
View File
@@ -10,22 +10,22 @@ from app.api.v1.api_permissions import get_my_study_api_permissions, update_stud
from app.api.v1.members import update_member from app.api.v1.members import update_member
from app.api.v1.permission_monitoring import resolve_monitoring_scope from app.api.v1.permission_monitoring import resolve_monitoring_scope
from app.api.v1.system_permissions import list_system_permissions from app.api.v1.system_permissions import list_system_permissions
from app.core.deps import require_admin_or_any_project_pm from app.core.deps import require_admin_or_any_project_pm, require_system_permission
from app.schemas.member import StudyMemberUpdate from app.schemas.member import StudyMemberUpdate
@dataclass @dataclass
class UserStub: class UserStub:
id: uuid.UUID id: uuid.UUID
role: str is_admin: bool = False
async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") -> None: async def _seed_user(db: AsyncSession, user_id: uuid.UUID, *, is_admin: bool = False) -> None:
await db.execute( await db.execute(
text( text(
""" """
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
""" """
), ),
{ {
@@ -33,8 +33,8 @@ async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") ->
"email": f"{user_id.hex}@example.com", "email": f"{user_id.hex}@example.com",
"password_hash": "hash", "password_hash": "hash",
"full_name": f"User {user_id.hex[:6]}", "full_name": f"User {user_id.hex[:6]}",
"role": role,
"clinical_department": "Clinical", "clinical_department": "Clinical",
"is_admin": is_admin,
"status": "ACTIVE", "status": "ACTIVE",
}, },
) )
@@ -114,7 +114,7 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy
await db_session.commit() await db_session.commit()
dependency = require_admin_or_any_project_pm() dependency = require_admin_or_any_project_pm()
await dependency(current_user=UserStub(id=pm_id, role="PM"), db=db_session) await dependency(current_user=UserStub(id=pm_id), db=db_session)
data = await list_system_permissions() data = await list_system_permissions()
assert data["permissions"] assert data["permissions"]
@@ -124,14 +124,45 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy
async def test_non_pm_cannot_view_system_permission_definitions(db_session: AsyncSession): async def test_non_pm_cannot_view_system_permission_definitions(db_session: AsyncSession):
cra_id = uuid.uuid4() cra_id = uuid.uuid4()
study_id = uuid.uuid4() study_id = uuid.uuid4()
await _seed_user(db_session, cra_id, role="CRA") await _seed_user(db_session, cra_id)
await _seed_study(db_session, study_id, "CRA-SYSTEM-PERMS") await _seed_study(db_session, study_id, "CRA-SYSTEM-PERMS")
await _seed_member(db_session, study_id, cra_id, "CRA") await _seed_member(db_session, study_id, cra_id, "CRA")
await db_session.commit() await db_session.commit()
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
dependency = require_admin_or_any_project_pm() dependency = require_admin_or_any_project_pm()
await dependency(current_user=UserStub(id=cra_id, role="CRA"), db=db_session) await dependency(current_user=UserStub(id=cra_id), db=db_session)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_project_pm_can_open_project_permission_config_without_global_pm_role(db_session: AsyncSession):
user_id = uuid.uuid4()
study_id = uuid.uuid4()
await _seed_user(db_session, user_id)
await _seed_study(db_session, study_id, "PROJECT-PM-NO-GLOBAL-ROLE")
await _seed_member(db_session, study_id, user_id, "PM")
await db_session.commit()
dependency = require_system_permission("system:permissions:project_config")
result = await dependency(study_id=study_id, current_user=UserStub(id=user_id), db=db_session)
assert result.id == user_id
@pytest.mark.asyncio
async def test_non_project_pm_cannot_open_project_permission_config_even_with_active_membership(db_session: AsyncSession):
user_id = uuid.uuid4()
study_id = uuid.uuid4()
await _seed_user(db_session, user_id)
await _seed_study(db_session, study_id, "PROJECT-NON-PM-DENIED")
await _seed_member(db_session, study_id, user_id, "CRA")
await db_session.commit()
dependency = require_system_permission("system:permissions:project_config")
with pytest.raises(HTTPException) as exc_info:
await dependency(study_id=study_id, current_user=UserStub(id=user_id), db=db_session)
assert exc_info.value.status_code == 403 assert exc_info.value.status_code == 403
@@ -141,7 +172,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
study_id = uuid.uuid4() study_id = uuid.uuid4()
admin_id = uuid.uuid4() admin_id = uuid.uuid4()
await _seed_study(db_session, study_id, "PM-PERM-SKIP") await _seed_study(db_session, study_id, "PM-PERM-SKIP")
await _seed_user(db_session, admin_id, role="ADMIN") await _seed_user(db_session, admin_id, is_admin=True)
await db_session.commit() await db_session.commit()
result = await update_study_api_permissions( result = await update_study_api_permissions(
@@ -151,7 +182,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
"PM": {"subjects:delete": False}, "PM": {"subjects:delete": False},
"CRA": {"subjects:delete": True}, "CRA": {"subjects:delete": True},
}, },
current_user=UserStub(id=admin_id, role="ADMIN"), current_user=UserStub(id=admin_id, is_admin=True),
db=db_session, db=db_session,
) )
@@ -177,14 +208,14 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
async def test_project_member_can_read_own_effective_permissions(db_session: AsyncSession): async def test_project_member_can_read_own_effective_permissions(db_session: AsyncSession):
cra_id = uuid.uuid4() cra_id = uuid.uuid4()
study_id = uuid.uuid4() study_id = uuid.uuid4()
await _seed_user(db_session, cra_id, role="CRA") await _seed_user(db_session, cra_id)
await _seed_study(db_session, study_id, "CRA-MY-PERMS") await _seed_study(db_session, study_id, "CRA-MY-PERMS")
await _seed_member(db_session, study_id, cra_id, "CRA") await _seed_member(db_session, study_id, cra_id, "CRA")
await db_session.commit() await db_session.commit()
result = await get_my_study_api_permissions( result = await get_my_study_api_permissions(
study_id=study_id, study_id=study_id,
current_user=UserStub(id=cra_id, role="CRA"), current_user=UserStub(id=cra_id),
db=db_session, db=db_session,
) )
@@ -204,7 +235,7 @@ async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session
await _seed_member(db_session, own_study_id, pm_id, "PM") await _seed_member(db_session, own_study_id, pm_id, "PM")
await db_session.commit() await db_session.commit()
scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id, role="PM")) scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
assert scope.is_admin is False assert scope.is_admin is False
assert scope.study_ids == {own_study_id} assert scope.study_ids == {own_study_id}
@@ -229,7 +260,7 @@ async def test_project_pm_cannot_update_peer_pm_member(db_session: AsyncSession)
study_id=study_id, study_id=study_id,
member_id=peer_member_id, member_id=peer_member_id,
member_in=StudyMemberUpdate(role_in_study="CRA"), member_in=StudyMemberUpdate(role_in_study="CRA"),
current_user=UserStub(id=actor_id, role="PM"), current_user=UserStub(id=actor_id),
db=db_session, db=db_session,
) )
@@ -243,7 +274,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
cra_id = uuid.uuid4() cra_id = uuid.uuid4()
await _seed_study(db_session, study_id, "PM-GRANT-PM") await _seed_study(db_session, study_id, "PM-GRANT-PM")
await _seed_user(db_session, actor_id) await _seed_user(db_session, actor_id)
await _seed_user(db_session, cra_id, role="CRA") await _seed_user(db_session, cra_id)
await _seed_member(db_session, study_id, actor_id, "PM") await _seed_member(db_session, study_id, actor_id, "PM")
cra_member_id = await _seed_member_return_id(db_session, study_id, cra_id, "CRA") cra_member_id = await _seed_member_return_id(db_session, study_id, cra_id, "CRA")
await db_session.commit() await db_session.commit()
@@ -253,7 +284,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
study_id=study_id, study_id=study_id,
member_id=cra_member_id, member_id=cra_member_id,
member_in=StudyMemberUpdate(role_in_study="PM"), member_in=StudyMemberUpdate(role_in_study="PM"),
current_user=UserStub(id=actor_id, role="PM"), current_user=UserStub(id=actor_id),
db=db_session, db=db_session,
) )
+67 -3
View File
@@ -7,14 +7,13 @@ import ast
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_TO_ENDPOINTS, PROJECT_PERMISSION_ROLES, SYSTEM_PERMISSIONS
from app.core.project_permissions import role_has_api_permission from app.core.project_permissions import role_has_api_permission
from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions from app.core.project_permissions import get_api_endpoint_permissions, replace_api_endpoint_permissions
from app.core.permission_cache import PermissionCache, set_permission_cache from app.core.permission_cache import PermissionCache, set_permission_cache
from app.core.permission_monitor import PermissionMonitor, set_permission_monitor from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
from app.models.api_endpoint_permission import ApiEndpointPermission from app.models.api_endpoint_permission import ApiEndpointPermission
from app.models.study import Study from app.models.study import Study
from app.models.user import UserRole
from app.schemas.member import StudyMemberCreate from app.schemas.member import StudyMemberCreate
@@ -183,6 +182,24 @@ def test_legacy_fee_attachment_router_is_not_registered():
assert "fees_attachments" not in router_source assert "fees_attachments" not in router_source
def test_legacy_fee_attachment_runtime_code_is_removed():
"""旧费用附件运行时代码应删除,避免绕过模块附件权限的死代码被重新注册。"""
backend_root = Path(__file__).resolve().parents[1] / "app"
legacy_tokens = {
"fees_attachments",
"fee_attachment",
"FeeAttachment",
"fee_attachments",
}
offenders: list[str] = []
for path in backend_root.rglob("*.py"):
source = path.read_text(encoding="utf-8")
if any(token in source or token in path.name for token in legacy_tokens):
offenders.append(str(path.relative_to(backend_root)))
assert offenders == []
def test_attachment_permissions_use_module_permissions(): def test_attachment_permissions_use_module_permissions():
"""附件鉴权应使用模块附件权限,不再回退通用 attachments:*。""" """附件鉴权应使用模块附件权限,不再回退通用 attachments:*。"""
attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py" attachments_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "attachments.py"
@@ -388,12 +405,59 @@ def test_document_service_uses_specific_document_permission_keys():
assert '"create_document": "documents:create"' in source assert '"create_document": "documents:create"' in source
assert '"create_version": "documents:update"' in source assert '"create_version": "documents:update"' in source
assert '"distribute": "documents:update"' in source
assert '"delete_document": "documents:delete"' in source assert '"delete_document": "documents:delete"' in source
assert 'else "documents:update"' not in source assert 'else "documents:update"' not in source
assert "await _ensure_study_access(db, doc.trial_id, current_user, action=\"distribute\")" in source
def test_document_routes_do_not_hardcode_admin_for_matrix_controlled_actions():
"""文档删除和版本删除应由项目权限矩阵控制,而不是路由层硬编码 ADMIN。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "documents.py"
source = route_path.read_text()
delete_document_chunk = source[source.index("async def delete_document") : source.index("@router.post", source.index("async def delete_document"))]
delete_version_chunk = source[source.index("async def delete_version") : source.index("@router.post", source.index("async def delete_version"))]
assert "require_roles" not in delete_document_chunk
assert "require_roles" not in delete_version_chunk
assert "仅管理员可删除文档" not in delete_document_chunk
assert "仅管理员可删除版本" not in delete_version_chunk
def test_project_permission_config_is_system_level_permission():
"""项目权限配置应归属系统级权限,不能作为项目矩阵里的自管理权限。"""
assert "permissions:read" not in API_ENDPOINT_PERMISSIONS
assert "permissions:update" not in API_ENDPOINT_PERMISSIONS
assert SYSTEM_PERMISSIONS["system:permissions:project_config"]["description"] == "配置项目接口权限"
assert "PM" in SYSTEM_PERMISSIONS["system:permissions:project_config"]["roles"]
def test_project_api_permission_routes_use_system_project_config_permission():
"""项目权限矩阵 API 应明确依赖 system:permissions:project_config。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "api_permissions.py"
source = route_path.read_text()
get_chunk = source[source.index("async def get_study_api_permissions") : source.index("@study_router.put")]
update_chunk = source[source.index("async def update_study_api_permissions") :]
assert 'require_system_permission("system:permissions:project_config")' in get_chunk
assert 'require_system_permission("system:permissions:project_config")' in update_chunk
assert 'require_study_roles(["PM"])' not in get_chunk
assert 'require_study_roles(["PM"])' not in update_chunk
def test_audit_export_event_uses_export_permission():
"""审计导出事件应使用 audit_logs:export,而不是仅要求项目成员。"""
route_path = Path(__file__).resolve().parents[1] / "app" / "api" / "v1" / "audit_logs.py"
source = route_path.read_text()
event_chunk = source[source.index("async def create_audit_event") :]
assert 'require_api_permission("audit_logs:export")' in event_chunk
assert "require_study_member()" not in event_chunk
def test_user_role_contains_current_project_roles(): def test_user_role_contains_current_project_roles():
assert {"QA", "CTA"} <= {role.value for role in UserRole} assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -49,8 +49,8 @@ async def test_material_equipment_operations_are_grouped_under_materials_module(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_materials_module_only_contains_drug_flow_and_equipment_sections(): async def test_materials_module_only_contains_drug_flow_attachments_and_equipment_sections():
"""测试物资管理模块只包含药品流向管理和设备管理""" """测试物资管理模块只包含药品流向、药品流向附件和设备管理"""
data = await list_api_operations() data = await list_api_operations()
operations = data["operations"] operations = data["operations"]
@@ -60,7 +60,7 @@ async def test_materials_module_only_contains_drug_flow_and_equipment_sections()
if op["module"] == "materials" if op["module"] == "materials"
} }
assert material_prefixes == {"drug_shipments", "material_equipments"} assert material_prefixes == {"drug_shipments", "drug_shipments_attachments", "material_equipments"}
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -90,6 +90,29 @@ def test_dashboard_summary_uses_project_overview_permission():
assert 'require_api_permission("dashboard:read")' not in source assert 'require_api_permission("dashboard:read")' not in source
def test_project_overview_dashboard_endpoints_require_project_overview_permission():
"""项目总览聚合接口应统一使用 project_overview:read,而不是仅要求项目成员。"""
source = Path(__file__).parents[1].joinpath("app/api/v1/dashboard.py").read_text()
progress_chunk = source[source.index("async def get_progress") : source.index("@router.get", source.index("async def get_progress"))]
lost_visits_chunk = source[source.index("async def list_lost_visits") : source.index("@router.get", source.index("async def list_lost_visits"))]
center_summary_chunk = source[source.index("async def get_center_summary") :]
assert 'require_api_permission("project_overview:read")' in progress_chunk
assert 'require_api_permission("project_overview:read")' in lost_visits_chunk
assert 'require_api_permission("project_overview:read")' in center_summary_chunk
assert "require_study_member()" not in progress_chunk
assert "require_study_member()" not in lost_visits_chunk
def test_finance_summary_requires_contract_fee_read_permission():
"""项目首页财务汇总应使用费用读取权限,避免绕过费用模块权限。"""
source = Path(__file__).parents[1].joinpath("app/api/v1/finance_dashboard.py").read_text()
summary_chunk = source[source.index("async def finance_summary") :]
assert 'require_api_permission("fees_contracts:read")' in summary_chunk
assert "require_study_member()" not in summary_chunk
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_operation_prerequisites_endpoint(): async def test_list_operation_prerequisites_endpoint():
"""测试获取所有操作的前置权限依赖""" """测试获取所有操作的前置权限依赖"""
+2 -2
View File
@@ -12,13 +12,13 @@ from app.models.document import Document, DocumentScopeType
from app.models.document_version import DocumentVersion # noqa: F401 from app.models.document_version import DocumentVersion # noqa: F401
from app.models.distribution import Distribution # noqa: F401 from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401 from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.user import UserRole, UserStatus from app.models.user import UserStatus
class UserStub: class UserStub:
def __init__(self) -> None: def __init__(self) -> None:
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.role = UserRole.ADMIN self.is_admin = True
self.status = UserStatus.ACTIVE self.status = UserStatus.ACTIVE
+34 -30
View File
@@ -18,6 +18,10 @@ class FakeIpInfo:
self.location = f"中国 / {province} / {city} / {isp}" self.location = f"中国 / {province} / {city} / {isp}"
class AdminUserStub:
is_admin = True
async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None: async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None:
study_exists = ( study_exists = (
await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)}) await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)})
@@ -48,8 +52,8 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
await db_session.execute( await db_session.execute(
text( text(
""" """
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
""" """
), ),
{ {
@@ -57,8 +61,8 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
"email": f"{user_id.hex}@example.com", "email": f"{user_id.hex}@example.com",
"password_hash": "hash", "password_hash": "hash",
"full_name": "Permission Monitoring User", "full_name": "Permission Monitoring User",
"role": "PM",
"clinical_department": "临床运营", "clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE", "status": "ACTIVE",
}, },
) )
@@ -97,7 +101,7 @@ async def test_get_permission_metrics(db_session):
await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=5) await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=5)
await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=3) await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=3)
data = await permission_monitoring.get_permission_metrics(db=db_session, _=object(), hours=24) data = await permission_monitoring.get_permission_metrics(db=db_session, _=AdminUserStub(), hours=24)
assert "check_metrics" in data assert "check_metrics" in data
assert "cache_metrics" in data assert "cache_metrics" in data
assert data["check_metrics"]["total_checks"] == 2 assert data["check_metrics"]["total_checks"] == 2
@@ -115,7 +119,7 @@ async def test_get_cache_statistics(db_session):
monitor.record_cache_hit() monitor.record_cache_hit()
monitor.record_cache_miss() monitor.record_cache_miss()
data = await permission_monitoring.get_cache_statistics(_=object(), db=db_session) data = await permission_monitoring.get_cache_statistics(_=AdminUserStub(), db=db_session)
assert "cache_metrics" in data assert "cache_metrics" in data
assert data["cache_metrics"]["total_accesses"] == 3 assert data["cache_metrics"]["total_accesses"] == 3
assert data["cache_metrics"]["cache_hits"] == 2 assert data["cache_metrics"]["cache_hits"] == 2
@@ -129,7 +133,7 @@ async def test_get_alerts(db_session):
monitor.record_slow_check_alert(100) monitor.record_slow_check_alert(100)
data = await permission_monitoring.get_alerts(_=object(), db=db_session) data = await permission_monitoring.get_alerts(_=AdminUserStub(), db=db_session)
assert "alerts" in data assert "alerts" in data
assert data["total"] > 0 assert data["total"] > 0
@@ -143,7 +147,7 @@ async def test_get_alerts_with_level_filter(db_session):
monitor.record_slow_check_alert(100) monitor.record_slow_check_alert(100)
monitor.record_error_alert(ValueError("test error")) monitor.record_error_alert(ValueError("test error"))
data = await permission_monitoring.get_alerts(level="warning", _=object(), db=db_session) data = await permission_monitoring.get_alerts(level="warning", _=AdminUserStub(), db=db_session)
assert "alerts" in data assert "alerts" in data
assert all(alert["level"] == "warning" for alert in data["alerts"]) assert all(alert["level"] == "warning" for alert in data["alerts"])
@@ -157,7 +161,7 @@ async def test_get_alerts_with_limit(db_session):
for _ in range(20): for _ in range(20):
monitor.record_slow_check_alert(100) monitor.record_slow_check_alert(100)
data = await permission_monitoring.get_alerts(limit=5, _=object(), db=db_session) data = await permission_monitoring.get_alerts(limit=5, _=AdminUserStub(), db=db_session)
assert len(data["alerts"]) <= 5 assert len(data["alerts"]) <= 5
@@ -170,7 +174,7 @@ async def test_reset_metrics(db_session):
monitor.record_cache_hit() monitor.record_cache_hit()
assert monitor.metrics.cache_metrics.total_accesses == 1 assert monitor.metrics.cache_metrics.total_accesses == 1
result = await permission_monitoring.reset_metrics(_=object(), db=db_session) result = await permission_monitoring.reset_metrics(_=AdminUserStub(), db=db_session)
assert result["message"] == "指标已重置" assert result["message"] == "指标已重置"
assert monitor.metrics.cache_metrics.total_accesses == 0 assert monitor.metrics.cache_metrics.total_accesses == 0
@@ -185,7 +189,7 @@ async def test_clear_alerts(db_session):
monitor.record_slow_check_alert(100) monitor.record_slow_check_alert(100)
assert len(monitor.get_alerts()) > 0 assert len(monitor.get_alerts()) > 0
result = await permission_monitoring.clear_alerts(_=object(), db=db_session) result = await permission_monitoring.clear_alerts(_=AdminUserStub(), db=db_session)
assert result["message"] == "告警已清除" assert result["message"] == "告警已清除"
assert len(monitor.get_alerts()) == 0 assert len(monitor.get_alerts()) == 0
@@ -200,7 +204,7 @@ async def test_permission_system_health_healthy(db_session):
for _ in range(100): for _ in range(100):
monitor.record_cache_hit() monitor.record_cache_hit()
data = await permission_monitoring.permission_system_health(db=db_session, _=object()) data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
assert data["status"] == "healthy" assert data["status"] == "healthy"
assert data["health_score"] >= 80 assert data["health_score"] >= 80
@@ -218,7 +222,7 @@ async def test_permission_system_health_degraded(db_session):
for _ in range(100): for _ in range(100):
monitor.record_cache_miss() monitor.record_cache_miss()
data = await permission_monitoring.permission_system_health(db=db_session, _=object()) data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
assert "status" in data assert "status" in data
assert "health_score" in data assert "health_score" in data
assert "issues" in data assert "issues" in data
@@ -233,7 +237,7 @@ async def test_permission_system_health_includes_metrics(db_session):
monitor = PermissionMonitor() monitor = PermissionMonitor()
set_permission_monitor(monitor) set_permission_monitor(monitor)
data = await permission_monitoring.permission_system_health(db=db_session, _=object()) data = await permission_monitoring.permission_system_health(db=db_session, _=AdminUserStub())
assert "last_hour" in data assert "last_hour" in data
assert "cache_stats" in data assert "cache_stats" in data
assert "total_checks" in data["last_hour"] assert "total_checks" in data["last_hour"]
@@ -271,8 +275,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
await db_session.execute( await db_session.execute(
text( text(
""" """
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
""" """
), ),
{ {
@@ -280,8 +284,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
"email": email, "email": email,
"password_hash": "hash", "password_hash": "hash",
"full_name": email, "full_name": email,
"role": "PM",
"clinical_department": "临床运营", "clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE", "status": "ACTIVE",
}, },
) )
@@ -323,7 +327,7 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
lambda ip: FakeIpInfo("广东省", "深圳市"), lambda ip: FakeIpInfo("广东省", "深圳市"),
) )
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
assert result["items"][0]["province"] == "广东省" assert result["items"][0]["province"] == "广东省"
assert result["items"][0]["total_count"] == 3 assert result["items"][0]["total_count"] == 3
@@ -363,8 +367,8 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo
await db_session.execute( await db_session.execute(
text( text(
""" """
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
""" """
), ),
{ {
@@ -372,8 +376,8 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo
"email": "region-merge@example.com", "email": "region-merge@example.com",
"password_hash": "hash", "password_hash": "hash",
"full_name": "属地合并用户", "full_name": "属地合并用户",
"role": "PM",
"clinical_department": "临床运营", "clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE", "status": "ACTIVE",
}, },
) )
@@ -410,7 +414,7 @@ async def test_ip_locations_merges_same_region_with_different_isp(db_session, mo
lambda ip: ip_info_by_address[ip], lambda ip: ip_info_by_address[ip],
) )
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
assert len(result["items"]) == 1 assert len(result["items"]) == 1
assert result["items"][0]["province"] == "重庆" assert result["items"][0]["province"] == "重庆"
@@ -448,8 +452,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
await db_session.execute( await db_session.execute(
text( text(
""" """
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
""" """
), ),
{ {
@@ -457,8 +461,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
"email": "private-network@example.com", "email": "private-network@example.com",
"password_hash": "hash", "password_hash": "hash",
"full_name": "局域网用户", "full_name": "局域网用户",
"role": "PM",
"clinical_department": "临床运营", "clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE", "status": "ACTIVE",
}, },
) )
@@ -484,7 +488,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
) )
await db_session.commit() await db_session.commit()
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10) result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
assert result["items"][0]["location"] == "局域网" assert result["items"][0]["location"] == "局域网"
assert result["items"][0]["province"] == "" assert result["items"][0]["province"] == ""
@@ -525,8 +529,8 @@ async def test_access_logs_include_user_behavior_summary(db_session):
await db_session.execute( await db_session.execute(
text( text(
""" """
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
""" """
), ),
{ {
@@ -534,8 +538,8 @@ async def test_access_logs_include_user_behavior_summary(db_session):
"email": email, "email": email,
"password_hash": "hash", "password_hash": "hash",
"full_name": name, "full_name": name,
"role": role,
"clinical_department": "临床运营", "clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE", "status": "ACTIVE",
}, },
) )
@@ -573,7 +577,7 @@ async def test_access_logs_include_user_behavior_summary(db_session):
result = await permission_monitoring.get_access_logs( result = await permission_monitoring.get_access_logs(
db=db_session, db=db_session,
_=object(), _=AdminUserStub(),
study_id=None, study_id=None,
user_id=None, user_id=None,
endpoint_key=None, endpoint_key=None,
@@ -621,7 +625,7 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
result = await permission_monitoring.get_security_access_logs( result = await permission_monitoring.get_security_access_logs(
db=db_session, db=db_session,
_=object(), _=AdminUserStub(),
status_min=400, status_min=400,
auth_status=None, auth_status=None,
page=1, page=1,
+2 -2
View File
@@ -1,7 +1,7 @@
import pytest import pytest
from app.crud import user as user_crud from app.crud import user as user_crud
from app.models.user import UserRole, UserStatus from app.models.user import UserStatus
class _ScalarResult: class _ScalarResult:
@@ -37,6 +37,6 @@ async def test_ensure_admin_exists_creates_protected_admin():
assert len(session.added) == 1 assert len(session.added) == 1
admin = session.added[0] admin = session.added[0]
assert admin.email == "admin@huapont.cn" assert admin.email == "admin@huapont.cn"
assert admin.role == UserRole.ADMIN assert admin.is_admin is True
assert admin.status == UserStatus.ACTIVE assert admin.status == UserStatus.ACTIVE
assert session.commit_count == 1 assert session.commit_count == 1
+4 -4
View File
@@ -5,7 +5,7 @@ from app.core import rbac
@dataclass @dataclass
class UserStub: class UserStub:
role: str is_admin: bool = False
@dataclass @dataclass
@@ -14,12 +14,12 @@ class MemberStub:
def test_non_admin_global_role_does_not_grant_project_document_permission(): def test_non_admin_global_role_does_not_grant_project_document_permission():
assert not rbac.is_allowed("delete_document", UserStub(role="PM"), MemberStub(role_in_study="CRA")) assert not rbac.is_allowed("delete_document", UserStub(), MemberStub(role_in_study="CRA"))
def test_project_role_grants_project_document_permission(): def test_project_role_grants_project_document_permission():
assert rbac.is_allowed("delete_document", UserStub(role="CRA"), MemberStub(role_in_study="PM")) assert rbac.is_allowed("delete_document", UserStub(), MemberStub(role_in_study="PM"))
def test_global_admin_keeps_system_document_permission_without_membership(): def test_global_admin_keeps_system_document_permission_without_membership():
assert rbac.is_allowed("delete_document", UserStub(role="ADMIN"), None) assert rbac.is_allowed("delete_document", UserStub(is_admin=True), None)
+3 -3
View File
@@ -19,7 +19,7 @@ from app.crud import user as user_crud
from app.db.base_class import Base from app.db.base_class import Base
from tests.conftest import GUID from tests.conftest import GUID
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
from app.models.user import User, UserRole, UserStatus from app.models.user import User, UserStatus
from app.schemas.user import UserRegisterRequest from app.schemas.user import UserRegisterRequest
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@@ -99,7 +99,7 @@ async def client_and_db():
password_hash=hash_password("admin123"), password_hash=hash_password("admin123"),
full_name="Admin", full_name="Admin",
clinical_department="Admin", clinical_department="Admin",
role=UserRole.ADMIN, is_admin=True,
status=UserStatus.ACTIVE, status=UserStatus.ACTIVE,
) )
session.add(admin) session.add(admin)
@@ -128,7 +128,7 @@ async def test_register_creates_pending_user(client_and_db):
user = await user_crud.get_by_email(session, payload["email"]) user = await user_crud.get_by_email(session, payload["email"])
assert user is not None assert user is not None
assert user.status == UserStatus.PENDING assert user.status == UserStatus.PENDING
assert user.role == UserRole.PV assert user.is_admin is False
member_rows = ( member_rows = (
await session.execute(select(StudyMember).where(StudyMember.user_id == user.id)) await session.execute(select(StudyMember).where(StudyMember.user_id == user.id))
).scalars().all() ).scalars().all()
+5 -5
View File
@@ -6,17 +6,17 @@ import pytest
from app.api.v1 import studies as studies_api from app.api.v1 import studies as studies_api
from app.models.study import Study from app.models.study import Study
from app.models.study_member import StudyMember from app.models.study_member import StudyMember
from app.models.user import User, UserRole, UserStatus from app.models.user import User, UserStatus
def _make_user(role: UserRole = UserRole.CRA) -> User: def _make_user(*, is_admin: bool = False) -> User:
return User( return User(
id=uuid.uuid4(), id=uuid.uuid4(),
email="cra-pm@test.com", email="cra-pm@test.com",
password_hash="hashed", password_hash="hashed",
full_name="CRA PM", full_name="CRA PM",
clinical_department="Clinical", clinical_department="Clinical",
role=role, is_admin=is_admin,
status=UserStatus.ACTIVE, status=UserStatus.ACTIVE,
) )
@@ -36,7 +36,7 @@ def _make_study() -> Study:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_studies_returns_project_role_for_non_admin(monkeypatch): async def test_list_studies_returns_project_role_for_non_admin(monkeypatch):
current_user = _make_user(UserRole.CRA) current_user = _make_user()
study = _make_study() study = _make_study()
member = StudyMember( member = StudyMember(
id=uuid.uuid4(), id=uuid.uuid4(),
@@ -62,7 +62,7 @@ async def test_list_studies_returns_project_role_for_non_admin(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_list_studies_does_not_map_system_admin_to_project_admin(monkeypatch): async def test_list_studies_does_not_map_system_admin_to_project_admin(monkeypatch):
current_user = _make_user(UserRole.ADMIN) current_user = _make_user(is_admin=True)
study = _make_study() study = _make_study()
async def fake_list_studies(_db, skip=0, limit=100): async def fake_list_studies(_db, skip=0, limit=100):
+8 -15
View File
@@ -1,12 +1,5 @@
-- Idempotent schema for CTMS (derived from current SQLAlchemy models) -- Idempotent schema for CTMS (derived from current SQLAlchemy models)
DO $$
BEGIN
CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP', 'MEDICAL_REVIEW');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ DO $$
BEGIN BEGIN
CREATE TYPE public.user_status AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'); CREATE TYPE public.user_status AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'DISABLED');
@@ -19,8 +12,8 @@ CREATE TABLE IF NOT EXISTS public.users (
email character varying(255) NOT NULL, email character varying(255) NOT NULL,
password_hash character varying(255) NOT NULL, password_hash character varying(255) NOT NULL,
full_name character varying(255) NOT NULL, full_name character varying(255) NOT NULL,
role public.user_role NOT NULL,
clinical_department character varying(255) NOT NULL, clinical_department character varying(255) NOT NULL,
is_admin boolean NOT NULL DEFAULT false,
status public.user_status NOT NULL DEFAULT 'PENDING', status public.user_status NOT NULL DEFAULT 'PENDING',
created_at timestamp with time zone NOT NULL DEFAULT now(), created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_at timestamp with time zone NOT NULL DEFAULT now(), updated_at timestamp with time zone NOT NULL DEFAULT now(),
@@ -528,18 +521,18 @@ CREATE INDEX IF NOT EXISTS ix_faq_replies_study_id ON public.faq_replies (study_
-- DEMO SEED DATA (idempotent) -- DEMO SEED DATA (idempotent)
-- ========================================= -- =========================================
INSERT INTO public.users ( INSERT INTO public.users (
id, email, password_hash, full_name, role, clinical_department, status, created_at, updated_at id, email, password_hash, full_name, clinical_department, is_admin, status, created_at, updated_at
) VALUES ) VALUES
('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'), ('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'SYSTEM', true, 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'),
('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'), ('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PMO', false, 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'),
('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', 'CRA', 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00'), ('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', false, 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00'),
('55555555-5555-5555-5555-555555555555', 'pv@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'PV 用户', 'PV', 'PV', 'ACTIVE', '2025-01-05 08:40:00+00', '2025-01-05 08:40:00+00'), ('55555555-5555-5555-5555-555555555555', 'pv@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', 'PV 用户', 'PV', false, 'ACTIVE', '2025-01-05 08:40:00+00', '2025-01-05 08:40:00+00'),
('66666666-6666-6666-6666-666666666666', 'imp@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '药品管理员', 'IMP', 'IMP', 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00') ('66666666-6666-6666-6666-666666666666', 'imp@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '药品管理员', 'IMP', false, 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00')
ON CONFLICT (email) DO UPDATE SET ON CONFLICT (email) DO UPDATE SET
password_hash = EXCLUDED.password_hash, password_hash = EXCLUDED.password_hash,
full_name = EXCLUDED.full_name, full_name = EXCLUDED.full_name,
role = EXCLUDED.role,
clinical_department = EXCLUDED.clinical_department, clinical_department = EXCLUDED.clinical_department,
is_admin = EXCLUDED.is_admin,
status = EXCLUDED.status, status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at; updated_at = EXCLUDED.updated_at;
+13
View File
@@ -26,6 +26,19 @@ describe("admin project route permissions", () => {
expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]"); expect(source).toContain("studyStore.currentPermissions?.[role]?.[operationKey]");
}); });
it("guards project permission configuration with the system-level project config permission", () => {
const source = readRouter();
const projectPermissionRouteStart = source.indexOf('name: "AdminPermissionsProject"');
const projectPermissionRouteEnd = source.indexOf('name: "AdminPermissionsMonitoring"', projectPermissionRouteStart);
const projectPermissionRoute = source.slice(projectPermissionRouteStart, projectPermissionRouteEnd);
expect(source).toContain('const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";');
expect(projectPermissionRoute).toContain("systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG");
expect(projectPermissionRoute).not.toContain("requiresProjectPm: true");
expect(source).toContain("to.meta.systemPermission");
expect(source).toContain("hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin)");
});
it("keeps login landing independent from PM management backend access", () => { it("keeps login landing independent from PM management backend access", () => {
const source = readRouter(); const source = readRouter();
+34 -4
View File
@@ -57,6 +57,10 @@ import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue"; import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
const SYSTEM_PERMISSION_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
const SYSTEM_PERMISSION_MONITORING_METRICS = "system:monitoring:metrics";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: "/login", path: "/login",
@@ -414,19 +418,19 @@ const routes: RouteRecordRaw[] = [
path: "permissions/system", path: "permissions/system",
name: "AdminPermissionsSystem", name: "AdminPermissionsSystem",
component: PermissionManagement, component: PermissionManagement,
meta: { title: "系统级权限", requiresProjectPm: true }, meta: { title: "系统级权限", systemPermission: SYSTEM_PERMISSION_READ },
}, },
{ {
path: "permissions/project", path: "permissions/project",
name: "AdminPermissionsProject", name: "AdminPermissionsProject",
component: PermissionManagement, component: PermissionManagement,
meta: { title: "项目权限配置", requiresProjectPm: true }, meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG },
}, },
{ {
path: "permissions/monitoring", path: "permissions/monitoring",
name: "AdminPermissionsMonitoring", name: "AdminPermissionsMonitoring",
component: PermissionManagement, component: PermissionManagement,
meta: { title: "权限监控", requiresProjectPm: true }, meta: { title: "权限监控", systemPermission: SYSTEM_PERMISSION_MONITORING_METRICS },
}, },
], ],
}, },
@@ -442,11 +446,30 @@ const router = createRouter({
}); });
const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = { const ADMIN_PROJECT_OPERATION_KEYS: Record<string, Record<"read" | "write", string>> = {
audit_export: { read: "audit_logs:read", write: "audit_logs:read" }, audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
project_members: { read: "project_members:list", write: "project_members:update" }, project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" }, sites: { read: "sites:read", write: "sites:update" },
}; };
const SYSTEM_PERMISSION_ROLES: Record<string, string[]> = {
[SYSTEM_PERMISSION_READ]: ["ADMIN", "PM"],
[SYSTEM_PERMISSION_PROJECT_CONFIG]: ["ADMIN", "PM"],
[SYSTEM_PERMISSION_MONITORING_METRICS]: ["ADMIN", "PM"],
};
const hasSystemPermissionAccess = async (permissionKey: string, isAdmin: boolean) => {
const roles = SYSTEM_PERMISSION_ROLES[permissionKey] || [];
if (isAdmin) return roles.includes("ADMIN");
if (!roles.includes("PM")) return false;
const studyStore = useStudyStore();
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasPmProject) {
await studyStore.ensureDefaultPmStudy().catch(() => {});
}
return studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
};
const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => { const ensureAdminProjectAccess = async (to: any, isAdmin: boolean) => {
if (isAdmin) return true; if (isAdmin) return true;
const studyStore = useStudyStore(); const studyStore = useStudyStore();
@@ -548,6 +571,13 @@ router.beforeEach(async (to, _from, next) => {
return; return;
} }
} }
if (to.meta.systemPermission) {
const allowed = await hasSystemPermissionAccess(to.meta.systemPermission as string, isAdmin);
if (!allowed) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
}
if (to.meta.requiresProjectPm && !isAdmin) { if (to.meta.requiresProjectPm && !isAdmin) {
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM"; const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
if (!hasPmProject) { if (!hasPmProject) {
-1
View File
@@ -37,7 +37,6 @@ export interface LoginKeyResponse {
expires_at: string; expires_at: string;
} }
export type UserRole = "PM" | "CRA" | "PV" | "QA" | "CTA" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED"; export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export interface UserInfo { export interface UserInfo {
+6
View File
@@ -18,6 +18,11 @@ describe("permission project role model", () => {
expect(source).toContain('"fees.contract.create": "fees_contracts:create"'); expect(source).toContain('"fees.contract.create": "fees_contracts:create"');
expect(source).toContain('"fees.contract.update": "fees_contracts:update"'); expect(source).toContain('"fees.contract.update": "fees_contracts:update"');
expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"'); expect(source).toContain('"fees.contract.delete": "fees_contracts:delete"');
expect(source).toContain('"project.members.list": "project_members:list"');
expect(source).toContain('"project.members.candidates": "project_members:candidates"');
expect(source).toContain('"project.members.create": "project_members:create"');
expect(source).toContain('"project.members.update": "project_members:update"');
expect(source).toContain('"project.members.delete": "project_members:delete"');
expect(source).toContain('"documents.create": "documents:create"'); expect(source).toContain('"documents.create": "documents:create"');
expect(source).toContain('"documents.delete": "documents:delete"'); expect(source).toContain('"documents.delete": "documents:delete"');
expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain("isApiPermissionAllowed");
@@ -26,5 +31,6 @@ describe("permission project role model", () => {
expect(source).not.toContain('"faq.edit": "etmf'); expect(source).not.toContain('"faq.edit": "etmf');
expect(source).not.toContain('"ADMIN", "PM"'); expect(source).not.toContain('"ADMIN", "PM"');
expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"'); expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"');
expect(source).not.toContain("project.members.manage");
}); });
}); });
+21 -3
View File
@@ -15,13 +15,19 @@ const PERMISSIONS: Record<string, string[]> = {
"faq.edit": ["ADMIN"], "faq.edit": ["ADMIN"],
"faq.create": ["ADMIN"], "faq.create": ["ADMIN"],
"faq.reply": ["ADMIN"], "faq.reply": ["ADMIN"],
"project.members.manage": ["ADMIN"], "project.members.list": ["ADMIN"],
"project.members.candidates": ["ADMIN"],
"project.members.create": ["ADMIN"],
"project.members.update": ["ADMIN"],
"project.members.delete": ["ADMIN"],
"site.manage": ["ADMIN"], "site.manage": ["ADMIN"],
"site.cra.bind": ["ADMIN"], "site.cra.bind": ["ADMIN"],
"fees.contract.create": ["ADMIN"], "fees.contract.create": ["ADMIN"],
"fees.contract.update": ["ADMIN"], "fees.contract.update": ["ADMIN"],
"fees.contract.delete": ["ADMIN"], "fees.contract.delete": ["ADMIN"],
"documents.read": ["ADMIN"],
"documents.create": ["ADMIN"], "documents.create": ["ADMIN"],
"documents.update": ["ADMIN"],
"documents.delete": ["ADMIN"], "documents.delete": ["ADMIN"],
}; };
@@ -33,13 +39,19 @@ const REASONS: Record<string, string> = {
"faq.edit": TEXT.modules.permissions.faqEdit, "faq.edit": TEXT.modules.permissions.faqEdit,
"faq.create": TEXT.modules.permissions.faqCreate, "faq.create": TEXT.modules.permissions.faqCreate,
"faq.reply": TEXT.modules.permissions.faqReply, "faq.reply": TEXT.modules.permissions.faqReply,
"project.members.manage": TEXT.modules.permissions.projectMembersManage, "project.members.list": TEXT.modules.permissions.projectMembersManage,
"project.members.candidates": TEXT.modules.permissions.projectMembersManage,
"project.members.create": TEXT.modules.permissions.projectMembersManage,
"project.members.update": TEXT.modules.permissions.projectMembersManage,
"project.members.delete": TEXT.modules.permissions.projectMembersManage,
"site.manage": TEXT.modules.permissions.siteManage, "site.manage": TEXT.modules.permissions.siteManage,
"site.cra.bind": TEXT.modules.permissions.siteCraBind, "site.cra.bind": TEXT.modules.permissions.siteCraBind,
"fees.contract.create": TEXT.modules.permissions.feeContractsWrite, "fees.contract.create": TEXT.modules.permissions.feeContractsWrite,
"fees.contract.update": TEXT.modules.permissions.feeContractsWrite, "fees.contract.update": TEXT.modules.permissions.feeContractsWrite,
"fees.contract.delete": TEXT.modules.permissions.feeContractsWrite, "fees.contract.delete": TEXT.modules.permissions.feeContractsWrite,
"documents.read": TEXT.modules.permissions.default,
"documents.create": TEXT.modules.permissions.default, "documents.create": TEXT.modules.permissions.default,
"documents.update": TEXT.modules.permissions.default,
"documents.delete": TEXT.modules.permissions.default, "documents.delete": TEXT.modules.permissions.default,
}; };
@@ -67,13 +79,19 @@ export const usePermission = () => {
"faq.reply": "faq_reply:create", "faq.reply": "faq_reply:create",
"precautions.read": "precautions:read", "precautions.read": "precautions:read",
"precautions.write": "precautions:create", "precautions.write": "precautions:create",
"project.members.manage": "project_members:update", "project.members.list": "project_members:list",
"project.members.candidates": "project_members:candidates",
"project.members.create": "project_members:create",
"project.members.update": "project_members:update",
"project.members.delete": "project_members:delete",
"site.manage": "sites:update", "site.manage": "sites:update",
"site.cra.bind": "sites:update", "site.cra.bind": "sites:update",
"fees.contract.create": "fees_contracts:create", "fees.contract.create": "fees_contracts:create",
"fees.contract.update": "fees_contracts:update", "fees.contract.update": "fees_contracts:update",
"fees.contract.delete": "fees_contracts:delete", "fees.contract.delete": "fees_contracts:delete",
"documents.read": "documents:read",
"documents.create": "documents:create", "documents.create": "documents:create",
"documents.update": "documents:update",
"documents.delete": "documents:delete", "documents.delete": "documents:delete",
"audit.export.read": "audit_logs:read", "audit.export.read": "audit_logs:read",
}; };
+4 -2
View File
@@ -15,8 +15,10 @@ describe("audit logs access", () => {
expect(source).toContain('projectRole.value === "PM"'); expect(source).toContain('projectRole.value === "PM"');
expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]'); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
expect(source).not.toContain('const role = auth.user?.role'); expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]');
expect(source).not.toContain('role !== "ADMIN"'); expect(source).toContain('if (scope === "project" && !canProjectExport.value) return;');
expect(source).not.toMatch(/auth\.user\?\.role/);
expect(source).not.toContain('!== "ADMIN"');
}); });
it("does not expose legacy startup ethics business labels", () => { it("does not expose legacy startup ethics business labels", () => {
+2 -1
View File
@@ -328,7 +328,7 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole)); const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const canProjectExport = computed(() => { const canProjectExport = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]); return projectRole.value === "PM" && isApiPermissionAllowed(permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]);
}); });
const canAccessAuditLogs = computed(() => { const canAccessAuditLogs = computed(() => {
if (isAdmin.value) return true; if (isAdmin.value) return true;
@@ -510,6 +510,7 @@ const handleExportCommand = (command: string) => {
const confirmExport = async (scope: "system" | "project") => { const confirmExport = async (scope: "system" | "project") => {
const currentStudy = study.currentStudy; const currentStudy = study.currentStudy;
if (!currentStudy) return; if (!currentStudy) return;
if (scope === "project" && !canProjectExport.value) return;
const ok = await ElMessageBox.confirm( const ok = await ElMessageBox.confirm(
TEXT.modules.adminAuditLogs.exportConfirm, TEXT.modules.adminAuditLogs.exportConfirm,
TEXT.modules.adminAuditLogs.exportConfirmTitle, TEXT.modules.adminAuditLogs.exportConfirmTitle,
@@ -108,6 +108,20 @@ describe("permission management custom roles", () => {
expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);"); expect(source).toContain("await addMember(selectedStudyId.value, addMemberForm);");
}); });
it("uses granular project member permissions for member management actions", () => {
const source = readSource();
expect(source).toContain('selectedProjectPermissionAllowed("project_members:list")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:candidates")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:create")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:update")');
expect(source).toContain('selectedProjectPermissionAllowed("project_members:delete")');
expect(source).toContain('v-if="canAddProjectMember"');
expect(source).toContain(":disabled=\"!canUpdateProjectMember(row)");
expect(source).toContain(":disabled=\"!canDeleteProjectMember(row)\"");
expect(source).not.toContain("project.members.manage");
});
it("uses QA and CTA as preset project permission role keys", () => { it("uses QA and CTA as preset project permission role keys", () => {
const source = readSource(); const source = readSource();
@@ -173,13 +187,17 @@ describe("permission management custom roles", () => {
expect(source).not.toContain('subjects: "参与者基础信息"'); expect(source).not.toContain('subjects: "参与者基础信息"');
}); });
it("shows startup ethics permissions in role editor with business section names", () => { it("shows startup ethics permissions in role editor with two business section names", () => {
const source = readSource(); const source = readSource();
const moduleSource = readProjectPermissionModulesSource(); const moduleSource = readProjectPermissionModulesSource();
expect(moduleSource).toContain('startup_ethics: "立项与伦理"'); expect(moduleSource).toContain('startup_ethics: "立项与伦理"');
expect(source).toContain('startup_initiation: "立项记录"'); expect(source).toContain('startup_initiation: "立项"');
expect(source).toContain('startup_ethics: "伦理记录"'); expect(source).toContain('startup_initiation_attachments: "立项"');
expect(source).toContain('startup_ethics: "伦理"');
expect(source).toContain('startup_ethics_attachments: "伦理"');
expect(source).not.toContain('startup_initiation: "立项记录"');
expect(source).not.toContain('startup_ethics: "伦理记录"');
}); });
it("does not expose the legacy dashboard module after merging into project overview", () => { it("does not expose the legacy dashboard module after merging into project overview", () => {
@@ -73,7 +73,7 @@
<el-tag v-if="memberRows.length" effect="plain" size="small" round>{{ memberRows.length }} 名成员</el-tag> <el-tag v-if="memberRows.length" effect="plain" size="small" round>{{ memberRows.length }} 名成员</el-tag>
</div> </div>
<div class="sub-tab-actions"> <div class="sub-tab-actions">
<el-button type="primary" @click="openAddMember"> <el-button v-if="canAddProjectMember" type="primary" @click="openAddMember">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 14px; height: 14px; margin-right: 4px"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
添加成员 添加成员
</el-button> </el-button>
@@ -96,7 +96,7 @@
size="small" size="small"
style="width: 130px" style="width: 130px"
@change="(val: string) => updateMemberRole(row.id, val)" @change="(val: string) => updateMemberRole(row.id, val)"
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!canUpdateProjectMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
> >
<el-option <el-option
v-for="(label, val) in memberRoleLabels(row)" v-for="(label, val) in memberRoleLabels(row)"
@@ -128,12 +128,12 @@
link link
:type="row.is_active ? 'warning' : 'primary'" :type="row.is_active ? 'warning' : 'primary'"
size="small" size="small"
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'" :disabled="!canUpdateProjectMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
@click="toggleMemberActive(row)" @click="toggleMemberActive(row)"
> >
{{ row.is_active ? "停用" : "启用" }} {{ row.is_active ? "停用" : "启用" }}
</el-button> </el-button>
<el-button link type="danger" size="small" :disabled="!canEditMember(row)" @click="deleteMember(row)"> <el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(row)" @click="deleteMember(row)">
移除 移除
</el-button> </el-button>
</template> </template>
@@ -480,6 +480,18 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || ""); const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || "");
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM"); const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value); const canManageSelectedProject = computed(() => isAdmin.value || isSelectedProjectPm.value);
const selectedProjectPermissionAllowed = (operationKey: string) => {
if (isAdmin.value) return true;
const role = selectedProjectRole.value;
if (!role || !apiMatrix.value) return false;
return isApiPermissionAllowed(apiMatrix.value?.[role]?.[operationKey]);
};
const canListProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:list"));
const canListProjectMemberCandidates = computed(() => selectedProjectPermissionAllowed("project_members:candidates"));
const canCreateProjectMember = computed(() => selectedProjectPermissionAllowed("project_members:create"));
const canUpdateProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:update"));
const canDeleteProjectMembers = computed(() => selectedProjectPermissionAllowed("project_members:delete"));
const canAddProjectMember = computed(() => canCreateProjectMember.value && canListProjectMemberCandidates.value);
const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta(); const { roleLabel, roleDescription, roleIcon, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTemplateMeta();
const ROLE_RANK: Record<string, number> = { const ROLE_RANK: Record<string, number> = {
@@ -495,7 +507,7 @@ const assignableRoleLabels = computed(() => {
return result; return result;
}); });
const memberRoleLabels = (row: MemberRoleRow) => { const memberRoleLabels = (row: MemberRoleRow) => {
if (canEditMember(row)) return assignableRoleLabels.value; if (canUpdateProjectMember(row)) return assignableRoleLabels.value;
return { return {
...assignableRoleLabels.value, ...assignableRoleLabels.value,
[row.role_in_study]: roleLabel(row.role_in_study), [row.role_in_study]: roleLabel(row.role_in_study),
@@ -574,7 +586,8 @@ const onStudyChange = async (id: string) => {
candidates.value = []; candidates.value = [];
activeRolesDraft.value = []; activeRolesDraft.value = [];
router.replace({ query: { ...route.query, projectId: id } }); router.replace({ query: { ...route.query, projectId: id } });
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]); await loadPermissionData();
await Promise.all([loadMembers(), loadActiveRoles()]);
loadCandidates(); loadCandidates();
}; };
@@ -617,13 +630,14 @@ const addMemberRules: FormRules = {
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }], role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
}; };
const canEditMember = (row: MemberRoleRow) => { const canMutateProjectMember = (row: MemberRoleRow) => {
if (!canManageSelectedProject.value) return false;
if (row.user_id === auth.user?.id) return false; if (row.user_id === auth.user?.id) return false;
if (row.user?.is_admin) return false; if (row.user?.is_admin) return false;
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false; if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
return true; return true;
}; };
const canUpdateProjectMember = (row: MemberRoleRow) => canUpdateProjectMembers.value && canMutateProjectMember(row);
const canDeleteProjectMember = (row: MemberRoleRow) => canDeleteProjectMembers.value && canMutateProjectMember(row);
const memberRows = computed(() => const memberRows = computed(() =>
members.value.map((m) => { members.value.map((m) => {
@@ -639,7 +653,7 @@ const availableCandidates = computed(() => {
}); });
const loadMembers = async () => { const loadMembers = async () => {
if (!selectedStudyId.value) return; if (!canListProjectMembers.value || !selectedStudyId.value) return;
membersLoading.value = true; membersLoading.value = true;
try { try {
const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true }); const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true });
@@ -652,7 +666,10 @@ const loadMembers = async () => {
}; };
const loadCandidates = async () => { const loadCandidates = async () => {
if (!selectedStudyId.value) return; if (!canListProjectMemberCandidates.value || !selectedStudyId.value) {
candidates.value = [];
return;
}
try { try {
const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 }); const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 });
candidates.value = data ?? []; candidates.value = data ?? [];
@@ -662,6 +679,7 @@ const loadCandidates = async () => {
}; };
const openAddMember = () => { const openAddMember = () => {
if (!canAddProjectMember.value) return;
addMemberForm.user_id = ""; addMemberForm.user_id = "";
addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || ""; addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
addMemberVisible.value = true; addMemberVisible.value = true;
@@ -670,6 +688,10 @@ const openAddMember = () => {
const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); }; const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); };
const submitAddMember = async () => { const submitAddMember = async () => {
if (!canAddProjectMember.value) {
ElMessage.warning("无权限添加成员");
return;
}
const valid = await addMemberFormRef.value?.validate().catch(() => false); const valid = await addMemberFormRef.value?.validate().catch(() => false);
if (!valid) return; if (!valid) return;
addMemberSaving.value = true; addMemberSaving.value = true;
@@ -686,6 +708,12 @@ const submitAddMember = async () => {
}; };
const updateMemberRole = async (memberId: string, role: string) => { const updateMemberRole = async (memberId: string, role: string) => {
const row = members.value.find((item) => item.id === memberId);
if (!row || !canUpdateProjectMember(row)) {
ElMessage.warning("无权限更新成员");
loadMembers();
return;
}
try { try {
await updateMember(selectedStudyId.value, memberId, { role_in_study: role }); await updateMember(selectedStudyId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新"); ElMessage.success("角色已更新");
@@ -696,6 +724,10 @@ const updateMemberRole = async (memberId: string, role: string) => {
}; };
const toggleMemberActive = async (row: any) => { const toggleMemberActive = async (row: any) => {
if (!canUpdateProjectMember(row)) {
ElMessage.warning("无权限更新成员");
return;
}
if (row.is_active) { if (row.is_active) {
const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null); const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null);
if (!ok) return; if (!ok) return;
@@ -710,6 +742,10 @@ const toggleMemberActive = async (row: any) => {
}; };
const deleteMember = async (row: any) => { const deleteMember = async (row: any) => {
if (!canDeleteProjectMember(row)) {
ElMessage.warning("无权限移除成员");
return;
}
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", { const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", {
type: "warning", type: "warning",
}).catch(() => null); }).catch(() => null);
@@ -973,8 +1009,10 @@ const PERMISSION_SECTION_LABELS: Record<string, string> = {
subject_histories: "病史", subject_histories: "病史",
monitoring_issues: "监查访视问题", monitoring_issues: "监查访视问题",
project_milestones: "项目里程碑", project_milestones: "项目里程碑",
startup_initiation: "立项记录", startup_initiation: "立项",
startup_ethics: "伦理记录", startup_initiation_attachments: "立项",
startup_ethics: "伦理",
startup_ethics_attachments: "伦理",
precautions: "注意事项", precautions: "注意事项",
precautions_attachments: "注意事项", precautions_attachments: "注意事项",
faq: "医学咨询/FAQ", faq: "医学咨询/FAQ",
@@ -1147,7 +1185,8 @@ onMounted(async () => {
if (initialStudyId) { if (initialStudyId) {
selectedStudyId.value = initialStudyId; selectedStudyId.value = initialStudyId;
if (qSub === "members") projectSubTab.value = "members"; if (qSub === "members") projectSubTab.value = "members";
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]); await loadPermissionData();
await Promise.all([loadMembers(), loadActiveRoles()]);
loadCandidates(); loadCandidates();
} }
+27 -1
View File
@@ -88,6 +88,32 @@ describe("project detail management role", () => {
expect(source).toContain("isSystemAdmin(authStore.user)"); expect(source).toContain("isSystemAdmin(authStore.user)");
expect(source).not.toContain('setupRole.value === "ADMIN"'); expect(source).not.toContain('setupRole.value === "ADMIN"');
expect(source).not.toContain("project.value?.role_in_study || authStore.user?.role"); expect(source).not.toMatch(/authStore\.user\?\.role/);
});
it("uses fine-grained setup config permissions for each setup action", () => {
const source = readProjectDetail();
expect(source).toContain("const canReadSetupConfig = computed");
expect(source).toContain("const canUpdateSetupConfig = computed");
expect(source).toContain("const canPublishSetupConfig = computed");
expect(source).toContain("const canRollbackSetupConfig = computed");
expect(source).toContain("const canDeleteSetupVersion = computed");
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:read")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:update")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:publish")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:rollback")');
expect(source).toContain('selectedProjectPermissionAllowed("setup_config:delete_version")');
expect(source).toContain('v-if="canUpdateSetupConfig" type="success"');
expect(source).toContain('v-if="canPublishSetupConfig" type="warning"');
expect(source).toContain('v-if="canRollbackSetupConfig"');
expect(source).toContain('v-if="canDeleteSetupVersion"');
expect(source).toContain("if (!canReadSetupConfig.value)");
expect(source).toContain("if (!canUpdateSetupConfig.value)");
expect(source).toContain("if (!canPublishAction.value) return");
expect(source).toContain("if (!canRollbackSetupConfig.value) return");
expect(source).toContain("if (!canDeleteSetupVersion.value) return");
expect(source).not.toContain("const hasSetupReadPermission = computed");
expect(source).not.toContain("const canManageSetup = computed");
}); });
}); });
+34 -30
View File
@@ -56,10 +56,10 @@
</div> </div>
<div class="setup-toolbar-actions"> <div class="setup-toolbar-actions">
<el-button v-if="canManageSetup" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button> <el-button v-if="canUpdateSetupConfig" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
<el-button v-if="canManageSetup" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button> <el-button v-if="canUpdateSetupConfig" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
<el-button v-if="canManageSetup" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button> <el-button v-if="canUpdateSetupConfig" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
<el-button v-if="canManageSetup" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button> <el-button v-if="canPublishSetupConfig" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
<el-button <el-button
type="info" type="info"
plain plain
@@ -1498,18 +1498,18 @@
</div> </div>
<div class="rollback-axis-actions"> <div class="rollback-axis-actions">
<el-tooltip content="回滚到此版本" placement="top" :show-after="300"> <el-tooltip content="回滚到此版本" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-danger" size="small" @click="selectRollbackVersion(row.version)"> <el-button v-if="canRollbackSetupConfig" class="vm-action-btn vm-action-danger" size="small" @click="selectRollbackVersion(row.version)">
<el-icon :size="13"><RefreshLeft /></el-icon> <el-icon :size="13"><RefreshLeft /></el-icon>
回滚 回滚
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip v-if="(row.branch_name || 'main') === 'main'" content="基于此版本新建分支草稿" placement="top" :show-after="300"> <el-tooltip v-if="canUpdateSetupConfig && (row.branch_name || 'main') === 'main'" content="基于此版本新建分支草稿" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-primary" size="small" @click="checkoutBranchDraftFromVersion(row.version)"> <el-button class="vm-action-btn vm-action-primary" size="small" @click="checkoutBranchDraftFromVersion(row.version)">
<el-icon :size="13"><DocumentCopy /></el-icon> <el-icon :size="13"><DocumentCopy /></el-icon>
新建分支草稿 新建分支草稿
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip v-if="(row.branch_name || 'main') !== 'main'" content="合并至主线创建新版本" placement="top" :show-after="300"> <el-tooltip v-if="canPublishSetupConfig && (row.branch_name || 'main') !== 'main'" content="合并至主线创建新版本" placement="top" :show-after="300">
<el-button class="vm-action-btn vm-action-primary" size="small" @click="mergeVersionToMain(row.version)"> <el-button class="vm-action-btn vm-action-primary" size="small" @click="mergeVersionToMain(row.version)">
<el-icon :size="13"><Connection /></el-icon> <el-icon :size="13"><Connection /></el-icon>
合并主线新版本 合并主线新版本
@@ -1521,7 +1521,7 @@
下载 下载
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip content="删除此版本" placement="top" :show-after="300"> <el-tooltip v-if="canDeleteSetupVersion" content="删除此版本" placement="top" :show-after="300">
<el-button <el-button
class="vm-action-btn vm-action-danger" class="vm-action-btn vm-action-danger"
size="small" size="small"
@@ -2405,20 +2405,18 @@ const setupDraft = reactive<SetupConfigDraft>({
const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`); const storageKey = computed(() => `ctms_setup_config_draft_${String(route.params.projectId || "")}`);
const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`); const projectDraftStorageKey = computed(() => `ctms_setup_project_draft_${String(route.params.projectId || "")}`);
const projectPermissions = ref<Record<string, Record<string, any>> | null>(null); const projectPermissions = ref<Record<string, Record<string, any>> | null>(null);
const hasSetupReadPermission = computed(() => { const selectedProjectPermissionAllowed = (operationKey: string) => {
if (isSystemAdmin(authStore.user)) return true; if (isSystemAdmin(authStore.user)) return true;
if (!projectPermissions.value) return false; if (!projectPermissions.value) return false;
const rolePerms = Object.values(projectPermissions.value)[0]; const rolePerms = Object.values(projectPermissions.value)[0];
if (!rolePerms) return false; if (!rolePerms) return false;
return isApiPermissionAllowed(rolePerms["setup_config:update"]); return isApiPermissionAllowed(rolePerms[operationKey]);
}); };
const canManageSetup = computed(() => { const canReadSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:read"));
if (isSystemAdmin(authStore.user)) return true; const canUpdateSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:update"));
if (!projectPermissions.value) return false; const canPublishSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:publish"));
const rolePerms = Object.values(projectPermissions.value)[0]; const canRollbackSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:rollback"));
if (!rolePerms) return false; const canDeleteSetupVersion = computed(() => selectedProjectPermissionAllowed("setup_config:delete_version"));
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
});
const isPreviewView = computed(() => setupViewMode.value === "preview"); const isPreviewView = computed(() => setupViewMode.value === "preview");
const isPublishedVersionView = computed(() => setupViewMode.value === "published"); const isPublishedVersionView = computed(() => setupViewMode.value === "published");
const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value); const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value);
@@ -2429,7 +2427,7 @@ const currentSetupDraft = computed<SetupConfigDraft>(() => {
return setupDraft; return setupDraft;
}); });
const canEditSetup = computed( const canEditSetup = computed(
() => Boolean(project.value && !project.value?.is_locked && canManageSetup.value && !isPublishedView.value) () => Boolean(project.value && !project.value?.is_locked && canUpdateSetupConfig.value && !isPublishedView.value)
); );
const canMutateDraft = () => canEditSetup.value && !isPublishedView.value; const canMutateDraft = () => canEditSetup.value && !isPublishedView.value;
const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) => const canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
@@ -2482,8 +2480,8 @@ const closeIndexedEditor = (controller: IndexedEditorController, successMessage:
controller.index.value = -1; controller.index.value = -1;
ElMessage.success(successMessage); ElMessage.success(successMessage);
}; };
const canSaveDraftAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked && !isPublishedView.value)); const canSaveDraftAction = computed(() => Boolean(project.value && canUpdateSetupConfig.value && !project.value?.is_locked && !isPublishedView.value));
const canPublishAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked)); const canPublishAction = computed(() => Boolean(project.value && canPublishSetupConfig.value && !project.value?.is_locked));
const currentStepTitle = computed(() => `${activeStep.value + 1}步:${steps[activeStep.value]?.label || ""}`); const currentStepTitle = computed(() => `${activeStep.value + 1}步:${steps[activeStep.value]?.label || ""}`);
const setupPublishedVersionText = computed(() => { const setupPublishedVersionText = computed(() => {
return setupPublishedVersion.value || "v0"; return setupPublishedVersion.value || "v0";
@@ -4397,7 +4395,7 @@ const loadProject = async () => {
project.value = data as Study; project.value = data as Study;
projectPermissions.value = permRes.data as any; projectPermissions.value = permRes.data as any;
if (!hasSetupReadPermission.value) { if (!canReadSetupConfig.value) {
ElMessage.warning("当前角色无权访问立项配置"); ElMessage.warning("当前角色无权访问立项配置");
router.replace("/admin/projects"); router.replace("/admin/projects");
return; return;
@@ -4450,7 +4448,7 @@ const startEdit = () => {
ElMessage.info("当前为只读视图,请先切换到草稿后再编辑"); ElMessage.info("当前为只读视图,请先切换到草稿后再编辑");
return; return;
} }
if (!canManageSetup.value) { if (!canUpdateSetupConfig.value) {
ElMessage.warning("当前角色无权编辑立项配置"); ElMessage.warning("当前角色无权编辑立项配置");
return; return;
} }
@@ -4634,7 +4632,8 @@ const saveConfigNow = async (): Promise<boolean> => {
}; };
const doPublishConfigNow = async (): Promise<boolean> => { const doPublishConfigNow = async (): Promise<boolean> => {
if (!project.value || !canPublishAction.value) return false; if (!project.value) return false;
if (!canPublishAction.value) return false;
try { try {
if (hasFormUnsavedChanges.value) { if (hasFormUnsavedChanges.value) {
syncProjectInfoIntoSetupDraft(); syncProjectInfoIntoSetupDraft();
@@ -4695,7 +4694,8 @@ const doPublishConfigNow = async (): Promise<boolean> => {
}; };
const publishConfigNow = async () => { const publishConfigNow = async () => {
if (!project.value || !canManageSetup.value) return; if (!project.value) return;
if (!canPublishAction.value) return;
reconcileDraftSyncStateBeforePublish(); reconcileDraftSyncStateBeforePublish();
if (hasSetupDraftUnsavedChanges.value) { if (hasSetupDraftUnsavedChanges.value) {
ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”"); ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”");
@@ -4960,13 +4960,14 @@ const isLatestPublishedVersion = (rawVersion: number): boolean => {
}; };
const openRollbackDialog = async () => { const openRollbackDialog = async () => {
if (!project.value || !canManageSetup.value) return; if (!project.value || !canReadSetupConfig.value) return;
await loadSetupVersionHistory(); await loadSetupVersionHistory();
rollbackDialogVisible.value = true; rollbackDialogVisible.value = true;
}; };
const selectRollbackVersion = async (targetVersion: number) => { const selectRollbackVersion = async (targetVersion: number) => {
if (!project.value) return; if (!project.value) return;
if (!canRollbackSetupConfig.value) return;
const targetDisplayVersion = getDisplayVersionLabel(targetVersion); const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -5018,6 +5019,7 @@ const selectRollbackVersion = async (targetVersion: number) => {
const checkoutBranchDraftFromVersion = async (targetVersion: number) => { const checkoutBranchDraftFromVersion = async (targetVersion: number) => {
if (!project.value) return; if (!project.value) return;
if (!canUpdateSetupConfig.value) return;
const targetDisplayVersion = getDisplayVersionLabel(targetVersion); const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -5344,6 +5346,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const removeVersion = async (targetVersion: number) => { const removeVersion = async (targetVersion: number) => {
if (!project.value) return; if (!project.value) return;
if (!canDeleteSetupVersion.value) return;
if (isLatestPublishedVersion(targetVersion)) { if (isLatestPublishedVersion(targetVersion)) {
ElMessage.warning("当前发布版本不能删除"); ElMessage.warning("当前发布版本不能删除");
return; return;
@@ -5372,6 +5375,7 @@ const removeVersion = async (targetVersion: number) => {
const mergeVersionToMain = async (sourceVersion: number) => { const mergeVersionToMain = async (sourceVersion: number) => {
if (!project.value) return; if (!project.value) return;
if (!canPublishSetupConfig.value) return;
const sourceVersionLabel = getDisplayVersionLabel(sourceVersion); const sourceVersionLabel = getDisplayVersionLabel(sourceVersion);
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
@@ -5420,7 +5424,7 @@ const mergeVersionToMain = async (sourceVersion: number) => {
}; };
const handleRefillDraft = async () => { const handleRefillDraft = async () => {
if (!project.value || !canManageSetup.value) return; if (!project.value || !canUpdateSetupConfig.value) return;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`确认将草稿一键回填为当前发布版本 ${setupPublishedVersionText.value} 的内容?`, `确认将草稿一键回填为当前发布版本 ${setupPublishedVersionText.value} 的内容?`,
@@ -5468,7 +5472,7 @@ const handleRefillDraft = async () => {
}; };
const handleClearDraft = async () => { const handleClearDraft = async () => {
if (!project.value || !canManageSetup.value) return; if (!project.value || !canUpdateSetupConfig.value) return;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。", "确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。",
@@ -5521,7 +5525,7 @@ const ensureCanSaveDraftAction = (): boolean => {
ElMessage.warning("项目信息尚未加载完成,请稍后重试"); ElMessage.warning("项目信息尚未加载完成,请稍后重试");
return false; return false;
} }
if (!canManageSetup.value) { if (!canUpdateSetupConfig.value) {
ElMessage.warning("当前角色无权保存立项配置"); ElMessage.warning("当前角色无权保存立项配置");
return false; return false;
} }
@@ -5641,7 +5645,7 @@ const ensureStep2Editable = (): boolean => {
ElMessage.info("当前为只读视图,请先切换到草稿后再操作"); ElMessage.info("当前为只读视图,请先切换到草稿后再操作");
return false; return false;
} }
if (!canManageSetup.value) { if (!canUpdateSetupConfig.value) {
ElMessage.warning("当前角色无权编辑立项配置"); ElMessage.warning("当前角色无权编辑立项配置");
return false; return false;
} }
@@ -10,11 +10,17 @@ describe("ProjectMembers user directory access", () => {
expect(source).not.toContain("fetchUsers"); expect(source).not.toContain("fetchUsers");
expect(source).not.toContain("../../api/users"); expect(source).not.toContain("../../api/users");
expect(source).toContain('const canManageMembers = computed(() => permission.can("project.members.manage"));'); expect(source).toContain('const canListMembers = computed(() => permission.can("project.members.list"));');
expect(source).toContain("if (!canManageMembers.value"); expect(source).toContain('const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));');
expect(source).toContain('const canCreateMember = computed(() => permission.can("project.members.create"));');
expect(source).toContain('const canUpdateMember = computed(() => permission.can("project.members.update"));');
expect(source).toContain('const canDeleteMember = computed(() => permission.can("project.members.delete"));');
expect(source).toContain("if (!canListMembers.value");
expect(source).toContain("if (!canListMemberCandidates.value");
expect(source).toContain("return;"); expect(source).toContain("return;");
expect(source).toContain('v-if="canManageMembers"'); expect(source).toContain('v-if="canAddMember"');
expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })"); expect(source).toContain("listMemberCandidates(projectId.value, { limit: 500 })");
expect(source).not.toContain("project.members.manage");
}); });
it("uses the member API embedded user data when the global directory is unavailable", () => { it("uses the member API embedded user data when the global directory is unavailable", () => {
@@ -34,6 +40,7 @@ describe("ProjectMembers user directory access", () => {
expect(source).toContain("const canAssignRole = (role: string)"); expect(source).toContain("const canAssignRole = (role: string)");
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\""); expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
expect(source).toContain(':disabled="!canEditMember(scope.row)'); expect(source).toContain(':disabled="!canEditMember(scope.row)');
expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"');
}); });
it("uses permission template names for project role display options", () => { it("uses permission template names for project role display options", () => {
+31 -19
View File
@@ -3,7 +3,7 @@
<div class="main-content-flat unified-shell"> <div class="main-content-flat unified-shell">
<div class="unified-action-bar actions-only-bar"> <div class="unified-action-bar actions-only-bar">
<div class="filter-spacer"></div> <div class="filter-spacer"></div>
<el-button v-if="canManageMembers" type="primary" @click="openAdd"> <el-button v-if="canAddMember" type="primary" @click="openAdd">
{{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }} {{ TEXT.common.actions.add }}{{ TEXT.modules.adminProjectMembers.memberLabel }}
</el-button> </el-button>
</div> </div>
@@ -50,7 +50,7 @@
/> />
</el-select> </el-select>
<span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span> <span v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'" class="hint">{{ TEXT.modules.adminProjectMembers.disabledGlobalDesc }}</span>
<el-button link type="danger" size="small" :disabled="!canEditMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button> <el-button link type="danger" size="small" :disabled="!canDeleteProjectMember(scope.row)" @click="onDelete(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
<el-button <el-button
link link
:type="scope.row.is_active ? 'danger' : 'primary'" :type="scope.row.is_active ? 'danger' : 'primary'"
@@ -130,7 +130,12 @@ const newMember = reactive({
user_id: "", user_id: "",
role_in_study: "PM", role_in_study: "PM",
}); });
const canManageMembers = computed(() => permission.can("project.members.manage")); const canListMembers = computed(() => permission.can("project.members.list"));
const canListMemberCandidates = computed(() => permission.can("project.members.candidates"));
const canCreateMember = computed(() => permission.can("project.members.create"));
const canUpdateMember = computed(() => permission.can("project.members.update"));
const canDeleteMember = computed(() => permission.can("project.members.delete"));
const canAddMember = computed(() => canCreateMember.value && canListMemberCandidates.value);
const projectRole = computed(() => project.value?.role_in_study || ""); const projectRole = computed(() => project.value?.role_in_study || "");
const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"]; const ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS)); const roleOptions = computed(() => roleOptionsFor(ROLE_KEYS));
@@ -144,13 +149,14 @@ const roleRank: Record<string, number> = {
}; };
const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0); const currentRoleRank = computed(() => auth.user?.is_admin ? Number.POSITIVE_INFINITY : roleRank[projectRole.value] || 0);
const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value); const canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
const canEditMember = (row: StudyMember) => { const canMutateProjectMember = (row: StudyMember) => {
if (!canManageMembers.value) return false;
if (row.user?.is_admin) return false; if (row.user?.is_admin) return false;
if (auth.user?.is_admin) return true; if (auth.user?.is_admin) return true;
if (row.user_id === auth.user?.id) return false; if (row.user_id === auth.user?.id) return false;
return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value; return (roleRank[row.role_in_study] || 0) <= currentRoleRank.value;
}; };
const canEditMember = (row: StudyMember) => canUpdateMember.value && canMutateProjectMember(row);
const canDeleteProjectMember = (row: StudyMember) => canDeleteMember.value && canMutateProjectMember(row);
const addRules = reactive<FormRules>({ const addRules = reactive<FormRules>({
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }], user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
@@ -168,16 +174,12 @@ const loadProject = async () => {
}; };
const loadMembers = async () => { const loadMembers = async () => {
if (!projectId.value) return; if (!canListMembers.value || !projectId.value) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true }); const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
members.value = Array.isArray(data) ? data : data.items || []; members.value = Array.isArray(data) ? data : data.items || [];
if (!canManageMembers.value) { if (!canListMemberCandidates.value) syncUsersFromMembers();
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
}
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed); ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
} finally { } finally {
@@ -185,9 +187,15 @@ const loadMembers = async () => {
} }
}; };
const syncUsersFromMembers = () => {
users.value = members.value
.map((member) => member.user)
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
};
const loadUsers = async () => { const loadUsers = async () => {
if (!canManageMembers.value || !projectId.value) { if (!canListMemberCandidates.value || !projectId.value) {
users.value = []; syncUsersFromMembers();
return; return;
} }
try { try {
@@ -211,7 +219,7 @@ const memberRows = computed(() =>
); );
const openAdd = () => { const openAdd = () => {
if (!canManageMembers.value) return; if (!canAddMember.value) return;
newMember.user_id = ""; newMember.user_id = "";
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA"; newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
addVisible.value = true; addVisible.value = true;
@@ -219,9 +227,13 @@ const openAdd = () => {
const submitAdd = async () => { const submitAdd = async () => {
if (!projectId.value) return; if (!projectId.value) return;
if (!canAddMember.value) {
ElMessage.warning(TEXT.common.messages.noPermission);
return;
}
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null, actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.create",
target: { projectId: projectId.value }, target: { projectId: projectId.value },
}); });
if (!decision.allowed) { if (!decision.allowed) {
@@ -256,7 +268,7 @@ const updateRole = async (memberId: string, role: string) => {
} }
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null, actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.update",
target: { projectId: projectId.value, memberId }, target: { projectId: projectId.value, memberId },
}); });
if (!decision.allowed) { if (!decision.allowed) {
@@ -281,7 +293,7 @@ const toggleActive = async (row: StudyMember) => {
} }
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null, actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.update",
target: { projectId: projectId.value, memberId: row.id }, target: { projectId: projectId.value, memberId: row.id },
}); });
if (!decision.allowed) { if (!decision.allowed) {
@@ -314,13 +326,13 @@ const toggleActive = async (row: StudyMember) => {
const onDelete = async (row: StudyMember) => { const onDelete = async (row: StudyMember) => {
if (!projectId.value) return; if (!projectId.value) return;
if (!canEditMember(row)) { if (!canDeleteProjectMember(row)) {
ElMessage.warning(TEXT.common.messages.noPermission); ElMessage.warning(TEXT.common.messages.noPermission);
return; return;
} }
const decision = evaluateAction({ const decision = evaluateAction({
actorRole: auth.user?.is_admin ? "ADMIN" : null, actorRole: auth.user?.is_admin ? "ADMIN" : null,
requiredPermission: "project.members.manage", requiredPermission: "project.members.delete",
target: { projectId: projectId.value, memberId: row.id }, target: { projectId: projectId.value, memberId: row.id },
}); });
if (!decision.allowed) { if (!decision.allowed) {
+13 -3
View File
@@ -52,12 +52,22 @@ describe("project management access", () => {
expect(source).toContain("canProject(scope.row, 'audit_export', 'read')"); expect(source).toContain("canProject(scope.row, 'audit_export', 'read')");
}); });
it("opens project names in management detail when the user has setup config write permission", () => { it("opens project names in management detail when the user has setup config read permission", () => {
const source = readProjects(); const source = readProjects();
expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'write')\""); expect(source).toContain("v-if=\"canProject(scope.row, 'setup_config', 'read')\"");
expect(source).toContain('@click="goProject(scope.row)" class="project-name"'); expect(source).toContain('@click="goProject(scope.row)" class="project-name"');
expect(source).toContain('@click="enterStudy(scope.row)"'); expect(source).toContain('@click="enterStudy(scope.row)"');
expect(source).toContain('if (!canProject(row, "setup_config", "write"))'); expect(source).toContain('if (!canProject(row, "setup_config", "read"))');
});
it("keeps audit export write mappings on the export operation", () => {
const projects = readProjects();
const router = readRouter();
expect(projects).toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:export" }');
expect(router).toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:export" }');
expect(projects).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
expect(router).not.toContain('audit_export: { read: "audit_logs:read", write: "audit_logs:read" }');
}); });
}); });
+3 -3
View File
@@ -74,7 +74,7 @@
</svg> </svg>
</div> </div>
<div class="project-info"> <div class="project-info">
<el-link v-if="canProject(scope.row, 'setup_config', 'write')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link> <el-link v-if="canProject(scope.row, 'setup_config', 'read')" type="primary" @click="goProject(scope.row)" class="project-name">{{ scope.row.name }}</el-link>
<span v-else class="project-name project-name--disabled">{{ scope.row.name }}</span> <span v-else class="project-name project-name--disabled">{{ scope.row.name }}</span>
<span class="project-code">{{ scope.row.code || '-' }}</span> <span class="project-code">{{ scope.row.code || '-' }}</span>
</div> </div>
@@ -210,7 +210,7 @@ const openCreate = () => {
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = { const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
project_members: { read: "project_members:list", write: "project_members:update" }, project_members: { read: "project_members:list", write: "project_members:update" },
sites: { read: "sites:read", write: "sites:update" }, sites: { read: "sites:read", write: "sites:update" },
audit_export: { read: "audit_logs:read", write: "audit_logs:read" }, audit_export: { read: "audit_logs:read", write: "audit_logs:export" },
setup_config: { read: "setup_config:read", write: "setup_config:update" }, setup_config: { read: "setup_config:read", write: "setup_config:update" },
}; };
@@ -335,7 +335,7 @@ const goDetail = (row: Study) => {
}; };
const goProject = (row: Study) => { const goProject = (row: Study) => {
if (!canProject(row, "setup_config", "write")) { if (!canProject(row, "setup_config", "read")) {
ElMessage.warning("当前角色无权访问该项目的立项配置"); ElMessage.warning("当前角色无权访问该项目的立项配置");
return; return;
} }