清理全局角色残留并修复项目权限判断
This commit is contained in:
@@ -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")
|
||||
@@ -3,9 +3,9 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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.models.user import User, UserRole, UserStatus
|
||||
from app.models.user import User, UserStatus
|
||||
from app.schemas.user import AdminUserListResponse, UserAdminReviewRequest, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/users")
|
||||
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/users")
|
||||
async def list_users_for_review(
|
||||
status_filter: UserStatus = Query(default=UserStatus.PENDING, alias="status"),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> AdminUserListResponse:
|
||||
users = await user_crud.list_users_by_status(db, status=status_filter)
|
||||
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)
|
||||
if not user:
|
||||
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="管理员账号不允许审核")
|
||||
return user
|
||||
|
||||
@@ -35,7 +35,7 @@ async def approve_user(
|
||||
user_id: uuid.UUID,
|
||||
review: UserAdminReviewRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> UserResponse:
|
||||
if review.action != "approve":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
||||
@@ -51,7 +51,7 @@ async def reject_user(
|
||||
user_id: uuid.UUID,
|
||||
review: UserAdminReviewRequest,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> UserResponse:
|
||||
if review.action not in ("reject", "approve"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非法操作")
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
@@ -79,7 +79,7 @@ async def create_ae(
|
||||
action="CREATE_AE",
|
||||
detail=f"AE {ae.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.is_overdue = _is_overdue(data)
|
||||
@@ -249,7 +249,7 @@ async def update_ae(
|
||||
action=action,
|
||||
detail=json.dumps({"before": detail_before, "after": detail_after}, ensure_ascii=False, default=str),
|
||||
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.is_overdue = _is_overdue(data)
|
||||
@@ -281,5 +281,5 @@ async def delete_ae(
|
||||
action="DELETE_AE",
|
||||
detail=f"AE {ae_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
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 (
|
||||
get_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,
|
||||
):
|
||||
"""获取当前用户当前项目角色的有效权限。"""
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
|
||||
if role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return {
|
||||
"ADMIN": {
|
||||
endpoint_key: {"allowed": True}
|
||||
@@ -192,7 +191,7 @@ async def get_my_study_api_permissions(
|
||||
)
|
||||
async def get_study_api_permissions(
|
||||
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,
|
||||
):
|
||||
"""获取项目的接口级权限矩阵
|
||||
@@ -233,7 +232,7 @@ async def get_study_api_permissions(
|
||||
async def update_study_api_permissions(
|
||||
study_id: uuid.UUID,
|
||||
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),
|
||||
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))
|
||||
current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
|
||||
for role in payload.keys():
|
||||
if role == "ADMIN":
|
||||
continue
|
||||
if role not in configurable_roles:
|
||||
raise ValueError(f"无效的角色: {role}")
|
||||
if role == "PM" and current_role != "ADMIN":
|
||||
if role == "PM" and not is_system_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅系统管理员可修改项目负责人权限",
|
||||
|
||||
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status,
|
||||
from fastapi.responses import FileResponse
|
||||
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.crud import attachment as attachment_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
|
||||
|
||||
|
||||
def _role_value(user) -> str:
|
||||
return user.role.value if hasattr(user.role, "value") else user.role
|
||||
|
||||
|
||||
def _attachment_operation(action: str) -> str:
|
||||
if action in {"create", "read", "delete"}:
|
||||
return action
|
||||
@@ -154,8 +150,7 @@ async def _ensure_attachment_permission(
|
||||
membership=None,
|
||||
) -> None:
|
||||
parent_permission = await _resolve_attachment_parent_permission(db, study_id, entity_type, entity_id, action)
|
||||
role_value = _role_value(current_user)
|
||||
if role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return
|
||||
|
||||
if membership is None:
|
||||
@@ -238,7 +233,7 @@ async def upload_attachment(
|
||||
action="UPLOAD_FILE",
|
||||
detail=f"文件已上传:{file.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return AttachmentRead(
|
||||
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"))))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
|
||||
if _role_value(user) == "ADMIN":
|
||||
if is_system_admin(user):
|
||||
return user, None
|
||||
membership = await member_crud.get_member(db, study_id, user.id)
|
||||
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
|
||||
)
|
||||
can_delete = (
|
||||
_role_value(user) == "ADMIN"
|
||||
is_system_admin(user)
|
||||
or attachment.uploaded_by == user.id
|
||||
or membership is not None
|
||||
)
|
||||
@@ -444,7 +439,7 @@ async def global_delete_attachment(
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
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="附件不存在")
|
||||
|
||||
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)
|
||||
|
||||
can_delete = (
|
||||
_role_value(current_user) == "ADMIN"
|
||||
is_system_admin(current_user)
|
||||
or attachment.uploaded_by == current_user.id
|
||||
or membership is not None
|
||||
)
|
||||
@@ -496,5 +491,5 @@ async def delete_attachment(
|
||||
action="DELETE_ATTACHMENT",
|
||||
detail=f"文件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.schemas.audit import AuditEventCreate, AuditLogRead
|
||||
|
||||
@@ -57,11 +57,11 @@ async def delete_audit_log(
|
||||
@router.post(
|
||||
"/events",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def create_audit_event(
|
||||
study_id: uuid.UUID,
|
||||
payload: AuditEventCreate,
|
||||
_export_permission=Depends(require_api_permission("audit_logs:export")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
@@ -76,6 +76,6 @@ async def create_audit_event(
|
||||
action=payload.action,
|
||||
detail=payload.detail,
|
||||
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}
|
||||
|
||||
@@ -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.deps import get_current_user, get_db_session
|
||||
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 fastapi.responses import FileResponse
|
||||
|
||||
@@ -57,7 +57,6 @@ def issue_user_token(db_user) -> Token:
|
||||
session_start = datetime.now(timezone.utc)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
)
|
||||
@@ -191,7 +190,6 @@ async def extend_access_token(
|
||||
session_start = datetime.now(timezone.utc)
|
||||
new_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
)
|
||||
@@ -210,7 +208,6 @@ async def unlock_session(
|
||||
session_start = datetime.now(timezone.utc)
|
||||
access_token = create_access_token(
|
||||
user_id=str(db_user.id),
|
||||
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
|
||||
expires_minutes=None,
|
||||
session_start=session_start,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
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.crud import member as member_crud
|
||||
from app.models.milestone import Milestone
|
||||
@@ -16,13 +16,10 @@ from app.crud import overview as overview_crud
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _role_value(user) -> str:
|
||||
return user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
|
||||
|
||||
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
|
||||
@router.get("/progress", response_model=StudyProgressRead)
|
||||
async def get_progress(
|
||||
study_id: uuid.UUID,
|
||||
_=Depends(require_api_permission("project_overview:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> StudyProgressRead:
|
||||
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(
|
||||
study_id: uuid.UUID,
|
||||
limit: int = 20,
|
||||
_=Depends(require_api_permission("project_overview:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[VisitLostItem]:
|
||||
@@ -68,18 +66,15 @@ async def list_lost_visits(
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/center-summary", response_model=list[CenterSummaryItem], dependencies=[
|
||||
Depends(require_study_member()),
|
||||
Depends(require_api_permission("project_overview:read"))
|
||||
])
|
||||
@router.get("/center-summary", response_model=list[CenterSummaryItem])
|
||||
async def get_center_summary(
|
||||
study_id: uuid.UUID,
|
||||
_=Depends(require_api_permission("project_overview:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> list[CenterSummaryItem]:
|
||||
role_value = _role_value(current_user)
|
||||
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)
|
||||
if not membership or not membership.is_active:
|
||||
return []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
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 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.document import DocumentCreate, DocumentDetail, DocumentSummary
|
||||
from app.schemas.document_version import DocumentVersionRead
|
||||
from app.models.user import UserRole
|
||||
from app.services import document_service
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
@@ -73,9 +72,6 @@ async def delete_document(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> 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)
|
||||
return DocumentSummary.model_validate(doc)
|
||||
|
||||
@@ -123,9 +119,6 @@ async def delete_version(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> 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)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 drug_shipment as shipment_crud
|
||||
from app.crud import site as site_crud
|
||||
@@ -56,7 +56,7 @@ async def create_shipment(
|
||||
action="CREATE_DRUG_SHIPMENT",
|
||||
detail=f"药品运输 {shipment.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)
|
||||
|
||||
@@ -152,7 +152,7 @@ async def update_shipment(
|
||||
action="UPDATE_DRUG_SHIPMENT",
|
||||
detail=f"药品运输 {shipment_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)
|
||||
|
||||
@@ -185,5 +185,5 @@ async def delete_shipment(
|
||||
action="DELETE_DRUG_SHIPMENT",
|
||||
detail=f"药品运输 {shipment_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.crud import audit as audit_crud
|
||||
from app.crud import faq_category as category_crud
|
||||
@@ -17,8 +17,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _is_system_admin(current_user) -> bool:
|
||||
role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
return role == "ADMIN"
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -51,7 +50,7 @@ async def create_category(
|
||||
action="CREATE_FAQ_CATEGORY",
|
||||
detail=f"FAQ 分类 {category.name} 已创建",
|
||||
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)
|
||||
|
||||
@@ -113,7 +112,7 @@ async def update_category(
|
||||
action="UPDATE_FAQ_CATEGORY",
|
||||
detail=f"FAQ 分类 {category_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)
|
||||
|
||||
@@ -151,5 +150,5 @@ async def delete_category(
|
||||
action="DELETE_FAQ_CATEGORY",
|
||||
detail=f"FAQ 分类 {category_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.crud import audit as audit_crud
|
||||
from app.crud import faq_category as category_crud
|
||||
@@ -27,8 +27,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _is_system_admin(current_user) -> bool:
|
||||
role = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
return role == "ADMIN"
|
||||
return is_system_admin(current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -75,7 +74,7 @@ async def create_faq(
|
||||
action="CREATE_FAQ_ITEM",
|
||||
detail="FAQ 已创建",
|
||||
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)
|
||||
|
||||
@@ -108,8 +107,7 @@ async def list_faqs(
|
||||
if not study_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 role_value != "ADMIN":
|
||||
if not is_system_admin(current_user):
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
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 不存在")
|
||||
if not item.study_id:
|
||||
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 role_value != "ADMIN":
|
||||
if not item.is_active and not is_system_admin(current_user):
|
||||
member = await member_crud.get_member(db, item.study_id, current_user.id)
|
||||
if not member or not member.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
@@ -203,7 +200,7 @@ async def update_faq(
|
||||
action=action,
|
||||
detail=detail,
|
||||
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)
|
||||
|
||||
@@ -359,7 +356,7 @@ async def create_reply(
|
||||
action="CREATE_FAQ_REPLY",
|
||||
detail="FAQ 已回复",
|
||||
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)
|
||||
if quote:
|
||||
@@ -404,7 +401,7 @@ async def delete_faq(
|
||||
action="DELETE_FAQ_ITEM",
|
||||
detail="FAQ 已删除",
|
||||
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",
|
||||
detail="FAQ 回复已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.crud import audit as audit_crud
|
||||
from app.crud import contract_fee as contract_fee_crud
|
||||
@@ -22,7 +22,7 @@ from app.schemas.contract_fee_payment import (
|
||||
ContractFeePaymentUpdate,
|
||||
)
|
||||
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.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)
|
||||
if not study:
|
||||
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 role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return None
|
||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||
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}
|
||||
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": [],
|
||||
"voucher": [],
|
||||
"invoice": [],
|
||||
@@ -173,16 +172,11 @@ async def get_contract_fee(
|
||||
attachments_map.setdefault(key, [])
|
||||
user = users_map.get(attachment.uploaded_by)
|
||||
attachments_map[key].append(
|
||||
FeeAttachmentRead(
|
||||
AttachmentRead(
|
||||
id=attachment.id,
|
||||
entity_type="contract_fee",
|
||||
entity_id=attachment.entity_id,
|
||||
file_type=key,
|
||||
filename=attachment.filename,
|
||||
mime_type=attachment.content_type,
|
||||
size=attachment.file_size,
|
||||
storage_key=attachment.file_path,
|
||||
url=None,
|
||||
file_size=attachment.file_size,
|
||||
content_type=attachment.content_type,
|
||||
uploaded_by_id=attachment.uploaded_by,
|
||||
uploaded_by=UserDisplay.model_validate(user) if user else None,
|
||||
uploaded_at=attachment.uploaded_at,
|
||||
@@ -244,7 +238,7 @@ async def create_contract_fee(
|
||||
action="CREATE_CONTRACT_FEE",
|
||||
detail="合同费用已创建",
|
||||
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))
|
||||
|
||||
@@ -274,7 +268,7 @@ async def update_contract_fee(
|
||||
action="UPDATE_CONTRACT_FEE",
|
||||
detail="合同费用已更新",
|
||||
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))
|
||||
|
||||
@@ -303,7 +297,7 @@ async def delete_contract_fee(
|
||||
action="DELETE_CONTRACT_FEE",
|
||||
detail="合同费用已删除",
|
||||
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",
|
||||
detail="合同费用分期已创建",
|
||||
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))
|
||||
|
||||
@@ -374,7 +368,7 @@ async def update_contract_payment(
|
||||
action="UPDATE_CONTRACT_FEE_PAYMENT",
|
||||
detail="合同费用分期已更新",
|
||||
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))
|
||||
|
||||
@@ -406,5 +400,5 @@ async def delete_contract_payment(
|
||||
action="DELETE_CONTRACT_FEE_PAYMENT",
|
||||
detail="合同费用分期已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import date
|
||||
from fastapi import APIRouter, Depends
|
||||
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 study as study_crud
|
||||
from app.schemas.finance import FinanceSummaryRead
|
||||
@@ -12,11 +12,12 @@ from app.schemas.finance import FinanceSummaryRead
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())])
|
||||
@router.get("/summary", response_model=FinanceSummaryRead)
|
||||
async def finance_summary(
|
||||
study_id: uuid.UUID,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
_=Depends(require_api_permission("fees_contracts:read")),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FinanceSummaryRead:
|
||||
# ensure study exists
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 material_equipment as equipment_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -49,7 +49,7 @@ async def create_equipment(
|
||||
action="CREATE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {item.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)
|
||||
|
||||
@@ -120,7 +120,7 @@ async def update_equipment(
|
||||
action="UPDATE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {equipment_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)
|
||||
|
||||
@@ -149,5 +149,5 @@ async def delete_equipment(
|
||||
action="DELETE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {equipment_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.crud import audit as audit_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
|
||||
|
||||
|
||||
def _role_value(user) -> str:
|
||||
return user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
async def _operator_project_role(db: AsyncSession, study_id: uuid.UUID, current_user) -> str:
|
||||
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:
|
||||
@@ -48,7 +51,7 @@ async def _ensure_member_mutation_allowed(
|
||||
target_member=None,
|
||||
target_role: str | None = None,
|
||||
) -> None:
|
||||
if _role_value(current_user) == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return
|
||||
|
||||
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:
|
||||
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="不能修改系统管理员账号的项目权限")
|
||||
|
||||
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},
|
||||
}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
||||
@@ -131,7 +134,7 @@ async def add_member(
|
||||
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),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return member
|
||||
|
||||
@@ -246,7 +249,7 @@ async def update_member(
|
||||
action="PROJECT_MEMBER_UPDATED",
|
||||
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -274,7 +277,7 @@ async def remove_member(
|
||||
if not member or member.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||
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="系统管理员不可从项目中移除")
|
||||
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}
|
||||
@@ -287,6 +290,6 @@ async def remove_member(
|
||||
action="PROJECT_MEMBER_REMOVED",
|
||||
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_role=current_user.role,
|
||||
operator_role=await _operator_project_role(db, study_id, current_user),
|
||||
)
|
||||
return removed
|
||||
|
||||
@@ -11,7 +11,7 @@ from openpyxl import Workbook, load_workbook
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
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 monitoring_visit_issue as issue_crud
|
||||
from app.crud import site as site_crud
|
||||
@@ -413,7 +413,7 @@ async def create_monitoring_visit_issue(
|
||||
action="CREATE_MONITORING_VISIT_ISSUE",
|
||||
detail=f"监查访视问题 {item.issue_no} 已创建",
|
||||
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)
|
||||
|
||||
@@ -617,7 +617,7 @@ async def update_monitoring_visit_issue(
|
||||
action="UPDATE_MONITORING_VISIT_ISSUE",
|
||||
detail=json.dumps(detail_payload, ensure_ascii=False),
|
||||
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)
|
||||
|
||||
@@ -648,7 +648,7 @@ async def delete_monitoring_visit_issue(
|
||||
action="DELETE_MONITORING_VISIT_ISSUE",
|
||||
detail=f"监查访视问题 {issue_no} 已删除",
|
||||
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",
|
||||
detail=f"导入监查访视问题:新增 {created_count} 条,更新 {updated_count} 条,跳过 {len(skipped_rows)} 条",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
return MonitoringVisitIssueImportSummary(
|
||||
|
||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select, desc
|
||||
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.models.permission_access_log import PermissionAccessLog
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if _role_value(current_user) == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return MonitoringScope(is_admin=True, study_ids=set())
|
||||
return MonitoringScope(
|
||||
is_admin=False,
|
||||
|
||||
@@ -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.models.permission_template import TemplateType
|
||||
from app.models.user import UserRole
|
||||
from app.schemas.permission_template import (
|
||||
ApplyTemplateRequest,
|
||||
ApplyTemplateResponse,
|
||||
@@ -52,7 +51,7 @@ async def list_templates(
|
||||
async def create_template(
|
||||
payload: PermissionTemplateCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> PermissionTemplateRead:
|
||||
"""创建权限模板"""
|
||||
try:
|
||||
@@ -79,7 +78,7 @@ async def update_template(
|
||||
template_id: uuid.UUID,
|
||||
payload: PermissionTemplateUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
) -> PermissionTemplateRead:
|
||||
"""更新权限模板"""
|
||||
try:
|
||||
@@ -93,7 +92,7 @@ async def update_template(
|
||||
async def delete_template(
|
||||
template_id: uuid.UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
||||
current_user=Depends(require_roles([UserRole.ADMIN.value])),
|
||||
current_user=Depends(require_roles(["ADMIN"])),
|
||||
):
|
||||
"""删除权限模板"""
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 precaution as precaution_crud
|
||||
from app.crud import site as site_crud
|
||||
@@ -51,7 +51,7 @@ async def create_precaution(
|
||||
action="CREATE_PRECAUTION",
|
||||
detail=f"注意事项 {precaution.title} 已创建",
|
||||
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)
|
||||
|
||||
@@ -117,7 +117,7 @@ async def update_precaution(
|
||||
action="UPDATE_PRECAUTION",
|
||||
detail=f"注意事项 {precaution_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)
|
||||
|
||||
@@ -147,5 +147,5 @@ async def delete_precaution(
|
||||
action="DELETE_PRECAUTION",
|
||||
detail=f"注意事项 {precaution_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 project_milestone as milestone_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -69,6 +69,6 @@ async def update_project_milestone(
|
||||
action="UPDATE_PROJECT_MILESTONE",
|
||||
detail=f"项目里程碑 {milestone_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)
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.crud import audit as audit_crud
|
||||
from app.crud import site as site_crud
|
||||
@@ -64,7 +64,7 @@ async def create_site(
|
||||
action="SITE_CREATED",
|
||||
detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return site
|
||||
|
||||
@@ -152,7 +152,7 @@ async def update_site(
|
||||
action=action,
|
||||
detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -196,6 +196,6 @@ async def delete_site(
|
||||
action="SITE_DELETED",
|
||||
detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 site as site_crud
|
||||
from app.crud import startup as startup_crud
|
||||
@@ -75,7 +75,7 @@ async def create_feasibility(
|
||||
action="CREATE_STARTUP_FEASIBILITY",
|
||||
detail="立项记录已创建",
|
||||
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)
|
||||
|
||||
@@ -149,7 +149,7 @@ async def update_feasibility(
|
||||
action="UPDATE_STARTUP_FEASIBILITY",
|
||||
detail="立项记录已更新",
|
||||
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)
|
||||
|
||||
@@ -182,7 +182,7 @@ async def delete_feasibility(
|
||||
action="DELETE_STARTUP_FEASIBILITY",
|
||||
detail="立项记录已删除",
|
||||
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",
|
||||
detail="伦理记录已创建",
|
||||
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)
|
||||
|
||||
@@ -286,7 +286,7 @@ async def update_ethics(
|
||||
action="UPDATE_STARTUP_ETHICS",
|
||||
detail="伦理记录已更新",
|
||||
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)
|
||||
|
||||
@@ -319,7 +319,7 @@ async def delete_ethics(
|
||||
action="DELETE_STARTUP_ETHICS",
|
||||
detail="伦理记录已删除",
|
||||
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",
|
||||
detail="启动会记录已创建",
|
||||
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)
|
||||
|
||||
@@ -423,7 +423,7 @@ async def update_kickoff(
|
||||
action="UPDATE_KICKOFF_MEETING",
|
||||
detail="启动会记录已更新",
|
||||
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)
|
||||
|
||||
@@ -454,7 +454,7 @@ async def create_training_authorization(
|
||||
action="CREATE_TRAINING_AUTH",
|
||||
detail="培训授权人员已创建",
|
||||
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)
|
||||
|
||||
@@ -528,7 +528,7 @@ async def update_training_authorization(
|
||||
action="UPDATE_TRAINING_AUTH",
|
||||
detail="培训授权人员已更新",
|
||||
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)
|
||||
|
||||
@@ -561,5 +561,5 @@ async def delete_training_authorization(
|
||||
action="DELETE_TRAINING_AUTH",
|
||||
detail="培训授权人员已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.deps import (
|
||||
get_current_user,
|
||||
get_db_session,
|
||||
get_operator_role_label,
|
||||
is_system_admin,
|
||||
require_api_permission,
|
||||
require_roles,
|
||||
require_study_member,
|
||||
@@ -50,9 +52,6 @@ def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead:
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -769,7 +768,7 @@ async def create_study(
|
||||
action="CREATE_SETUP_CONFIG",
|
||||
detail="初始化立项配置(创建项目时回填基础信息)",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
return study
|
||||
@@ -782,7 +781,7 @@ async def list_studies(
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> 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)
|
||||
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
|
||||
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)
|
||||
if not study:
|
||||
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)
|
||||
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)
|
||||
@@ -893,7 +892,7 @@ async def lock_study(
|
||||
action="LOCK_STUDY",
|
||||
detail=f"项目已锁定:{study.name} ({study.code})",
|
||||
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
|
||||
@@ -925,7 +924,7 @@ async def unlock_study(
|
||||
action="UNLOCK_STUDY",
|
||||
detail=f"项目已解锁:{study.name} ({study.code})",
|
||||
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
|
||||
@@ -964,7 +963,7 @@ async def get_study_setup_config(
|
||||
action="CREATE_SETUP_CONFIG",
|
||||
detail="初始化立项配置",
|
||||
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
|
||||
@@ -1047,7 +1046,7 @@ async def upsert_study_setup_config(
|
||||
action="UPDATE_SETUP_CONFIG",
|
||||
detail=_top_level_diff_summary(old_config, record.config),
|
||||
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
|
||||
@@ -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],
|
||||
),
|
||||
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,
|
||||
)
|
||||
published.config = study_setup_config_crud.empty_draft_payload()
|
||||
@@ -1259,7 +1258,7 @@ async def rollback_study_setup_config(
|
||||
action="ROLLBACK_SETUP_CONFIG",
|
||||
detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
|
||||
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
|
||||
published_by_name = None
|
||||
@@ -1318,7 +1317,7 @@ async def checkout_study_setup_config_branch_draft(
|
||||
action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT",
|
||||
detail=f"立项配置草稿已切换到 {target_label} 对应分支",
|
||||
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
|
||||
published_by_name = None
|
||||
@@ -1370,7 +1369,7 @@ async def clear_study_setup_config_draft(
|
||||
action="CLEAR_SETUP_CONFIG_DRAFT",
|
||||
detail="立项配置草稿已清空",
|
||||
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
|
||||
published_by_name = None
|
||||
@@ -1424,7 +1423,7 @@ async def refill_study_setup_config_draft(
|
||||
action="REFILL_SETUP_CONFIG_DRAFT",
|
||||
detail="立项配置草稿已从当前发布版本一键回填",
|
||||
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
|
||||
published_by_name = None
|
||||
@@ -1511,7 +1510,7 @@ async def merge_study_setup_config_to_main(
|
||||
action="MERGE_SETUP_CONFIG_TO_MAIN",
|
||||
detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本",
|
||||
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,
|
||||
)
|
||||
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",
|
||||
detail=f"删除发布版本快照 v{target_version}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 site as site_crud
|
||||
from app.crud import subject as subject_crud
|
||||
@@ -58,7 +58,7 @@ async def create_history(
|
||||
action="CREATE_SUBJECT_HISTORY",
|
||||
detail="病史记录已创建",
|
||||
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)
|
||||
|
||||
@@ -133,7 +133,7 @@ async def update_history(
|
||||
action="UPDATE_SUBJECT_HISTORY",
|
||||
detail="病史记录已更新",
|
||||
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)
|
||||
|
||||
@@ -166,5 +166,5 @@ async def delete_history(
|
||||
action="DELETE_SUBJECT_HISTORY",
|
||||
detail="病史记录已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.deps import (
|
||||
get_current_user,
|
||||
get_db_session,
|
||||
get_operator_role_label,
|
||||
require_study_not_locked,
|
||||
require_api_permission,
|
||||
)
|
||||
@@ -120,7 +121,7 @@ async def create_subject_pd(
|
||||
action="CREATE_SUBJECT_PD",
|
||||
detail=f"PD记录 {item.pd_no} 已创建",
|
||||
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)
|
||||
|
||||
@@ -167,7 +168,7 @@ async def update_subject_pd(
|
||||
action="UPDATE_SUBJECT_PD",
|
||||
detail=f"PD记录 {updated.pd_no} 已更新",
|
||||
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)
|
||||
|
||||
@@ -201,5 +202,5 @@ async def delete_subject_pd(
|
||||
action="DELETE_SUBJECT_PD",
|
||||
detail=f"PD记录 {item.pd_no} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
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 site as site_crud
|
||||
from app.crud import subject as subject_crud
|
||||
@@ -58,7 +58,7 @@ async def create_subject(
|
||||
action="CREATE_SUBJECT",
|
||||
detail=f"参与者 {subject.subject_no} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return subject
|
||||
|
||||
@@ -188,7 +188,7 @@ async def update_subject(
|
||||
action="SUBJECT_STATUS_CHANGE" if detail else "UPDATE_SUBJECT",
|
||||
detail=detail or "参与者已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -220,5 +220,5 @@ async def delete_subject(
|
||||
action="DELETE_SUBJECT",
|
||||
detail=f"参与者 {subject_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.crud import user as user_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"
|
||||
if requested_status is not None and requested_status != "ACTIVE":
|
||||
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
|
||||
if user_in.is_active is not None:
|
||||
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="系统管理员账号不允许删除")
|
||||
if db_user.id == current_user.id:
|
||||
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)
|
||||
if active_admins <= 1:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少保留一个管理员账号")
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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 site as site_crud
|
||||
from app.crud import subject as subject_crud
|
||||
@@ -107,7 +107,7 @@ async def create_visit(
|
||||
action="CREATE_VISIT",
|
||||
detail=f"访视 {visit.visit_code} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return visit
|
||||
|
||||
@@ -160,7 +160,7 @@ async def create_early_termination(
|
||||
action="CREATE_EARLY_TERMINATION",
|
||||
detail=f"参与者 {subject.subject_no} 已提前终止",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return visit
|
||||
|
||||
@@ -204,7 +204,7 @@ async def update_visit(
|
||||
action="VISIT_STATUS_CHANGE" if detail else "UPDATE_VISIT",
|
||||
detail=detail or "访视已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
return updated
|
||||
|
||||
@@ -236,5 +236,5 @@ async def delete_visit(
|
||||
action="DELETE_VISIT",
|
||||
detail=f"访视 {visit_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
operator_role=await get_operator_role_label(db, study_id, current_user),
|
||||
)
|
||||
|
||||
@@ -269,19 +269,6 @@ API_ENDPOINT_PERMISSIONS = {
|
||||
"description": "删除立项记录",
|
||||
"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": {
|
||||
"module": "project_overview",
|
||||
|
||||
+47
-17
@@ -12,6 +12,7 @@ from app.core.exceptions import AppException
|
||||
from app.core.security import decode_token, oauth2_scheme
|
||||
from app.crud import user as user_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.db.session import SessionLocal
|
||||
from app.models.study_member import StudyMember
|
||||
@@ -53,12 +54,15 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def is_system_admin(user) -> bool:
|
||||
return bool(getattr(user, "is_admin", False))
|
||||
|
||||
|
||||
def require_roles(roles: Iterable[str]) -> Callable:
|
||||
roles_set = set(roles)
|
||||
|
||||
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 current_role not in roles_set:
|
||||
if "ADMIN" not in roles_set or not is_system_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足",
|
||||
@@ -68,10 +72,6 @@ def require_roles(roles: Iterable[str]) -> Callable:
|
||||
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]:
|
||||
result = await db.execute(
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
async def dependency(
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if _role_value(current_user) == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return current_user
|
||||
if await list_active_pm_study_ids(db, current_user.id):
|
||||
return current_user
|
||||
@@ -112,13 +121,38 @@ def require_admin_or_any_project_pm() -> Callable:
|
||||
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(
|
||||
study_id: uuid.UUID,
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
role_value = _role_value(current_user)
|
||||
if role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return None
|
||||
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),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
role_value = _role_value(current_user)
|
||||
if role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return current_user
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
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),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
role_value = _role_value(current_user)
|
||||
if allow_system_admin and role_value == "ADMIN":
|
||||
if allow_system_admin and is_system_admin(current_user):
|
||||
return current_user
|
||||
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:
|
||||
@@ -183,8 +215,7 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
from app.core.permission_monitor import get_permission_monitor
|
||||
role_value = _role_value(current_user)
|
||||
if allow_system_admin and role_value == "ADMIN":
|
||||
if allow_system_admin and is_system_admin(current_user):
|
||||
_enqueue_permission_log(
|
||||
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:
|
||||
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 role_value == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return None
|
||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
|
||||
@@ -30,8 +30,7 @@ ROLE_ACTIONS: dict[str, set[str]] = {
|
||||
|
||||
|
||||
def is_allowed(action: str, user, membership) -> bool:
|
||||
role_value = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
if role_value == "ADMIN":
|
||||
if getattr(user, "is_admin", False):
|
||||
return action in ROLE_ACTIONS["ADMIN"]
|
||||
member_role = getattr(membership, "role_in_study", None)
|
||||
if member_role:
|
||||
|
||||
@@ -17,7 +17,6 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name=
|
||||
def create_access_token(
|
||||
*,
|
||||
user_id: str,
|
||||
role: str,
|
||||
expires_minutes: Optional[int] = None,
|
||||
session_start: Optional[datetime] = None,
|
||||
) -> str:
|
||||
@@ -26,7 +25,6 @@ def create_access_token(
|
||||
session_start_time = session_start or now
|
||||
to_encode: Dict[str, Any] = {
|
||||
"sub": user_id,
|
||||
"role": role,
|
||||
"exp": expire,
|
||||
"iat": int(now.timestamp()),
|
||||
"orig_iat": int(session_start_time.timestamp()),
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
@@ -16,7 +14,7 @@ from app.core.config import (
|
||||
)
|
||||
from app.core.security import hash_password
|
||||
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
|
||||
|
||||
|
||||
@@ -46,7 +44,6 @@ async def create_user(
|
||||
email=user_in.email,
|
||||
password_hash=hash_password(user_in.password),
|
||||
full_name=user_in.full_name,
|
||||
role=UserRole.PV,
|
||||
clinical_department=user_in.clinical_department,
|
||||
status=status_value,
|
||||
)
|
||||
@@ -114,7 +111,7 @@ async def count_active_admins(db: AsyncSession) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.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)
|
||||
|
||||
@@ -138,7 +135,7 @@ async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTE
|
||||
email=PROTECTED_ADMIN_EMAIL,
|
||||
password_hash=hash_password(default_password),
|
||||
full_name=PROTECTED_ADMIN_FULL_NAME,
|
||||
role=UserRole.ADMIN,
|
||||
is_admin=True,
|
||||
clinical_department=PROTECTED_ADMIN_CLINICAL_DEPARTMENT,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
|
||||
@@ -5,22 +5,13 @@ import enum
|
||||
import uuid
|
||||
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.orm import Mapped, mapped_column
|
||||
|
||||
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):
|
||||
PENDING = "PENDING"
|
||||
ACTIVE = "ACTIVE"
|
||||
@@ -35,8 +26,8 @@ class User(Base):
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
password_hash: 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)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", default=False)
|
||||
status: Mapped[UserStatus] = mapped_column(
|
||||
Enum(UserStatus, name="user_status"),
|
||||
nullable=False,
|
||||
@@ -66,10 +57,6 @@ class User(Base):
|
||||
def is_active(self) -> bool: # compatibility helper for existing checks
|
||||
return self.status == UserStatus.ACTIVE
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == UserRole.ADMIN
|
||||
|
||||
@property
|
||||
def username(self) -> str: # backward compatibility for existing UI copy
|
||||
return self.email
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
|
||||
|
||||
UserRole = Literal["PM", "CRA", "PV", "QA", "CTA", "ADMIN"]
|
||||
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
|
||||
|
||||
PASSWORD_REGEX = re.compile(r"^(?=.*[A-Za-z])(?=.*\d).{8,}$")
|
||||
@@ -103,5 +102,4 @@ class Token(BaseModel):
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: uuid.UUID
|
||||
role: str
|
||||
exp: Optional[int] = None
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.crud import acknowledgement as acknowledgement_crud
|
||||
from app.crud import distribution as distribution_crud
|
||||
@@ -43,14 +44,11 @@ DOCUMENT_ACTION_PERMISSIONS = {
|
||||
"ack": "documents:read",
|
||||
"create_document": "documents:create",
|
||||
"create_version": "documents:update",
|
||||
"distribute": "documents:update",
|
||||
"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:
|
||||
payload = {"before": before, "after": after}
|
||||
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)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
if current_user.role == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return None
|
||||
membership = await member_crud.get_member(db, trial_id, current_user.id)
|
||||
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)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
if current_user.role == "ADMIN":
|
||||
if is_system_admin(current_user):
|
||||
return None
|
||||
membership = await member_crud.get_member(db, trial_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
@@ -154,7 +152,7 @@ async def create_document(
|
||||
action="DOCUMENT_CREATED",
|
||||
detail=_audit_detail(None, _doc_snapshot(doc)),
|
||||
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()
|
||||
@@ -345,7 +343,7 @@ async def create_version(
|
||||
action="VERSION_CREATED",
|
||||
detail=_audit_detail(None, _version_snapshot(version)),
|
||||
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",
|
||||
detail=_audit_detail(before, None),
|
||||
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()
|
||||
@@ -466,7 +464,7 @@ async def delete_document(
|
||||
action="DOCUMENT_ARCHIVED",
|
||||
detail=_audit_detail(before, _doc_snapshot(doc)),
|
||||
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()
|
||||
@@ -511,7 +509,7 @@ async def create_distributions(
|
||||
doc = await document_crud.get(db, version.document_id)
|
||||
if not doc:
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发")
|
||||
@@ -536,7 +534,7 @@ async def create_distributions(
|
||||
action="DISTRIBUTION_CREATED",
|
||||
detail=_audit_detail(None, {"version_id": str(version.id), "target_id": target.target_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()
|
||||
@@ -599,8 +597,7 @@ async def create_acknowledgement(
|
||||
if distribution.target_type == DistributionTargetType.USER and distribution.target_id != str(current_user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
|
||||
if distribution.target_type == DistributionTargetType.ROLE:
|
||||
role_value = _role_value(current_user)
|
||||
if distribution.target_id not in (role_value, getattr(membership, "role_in_study", "")):
|
||||
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
|
||||
|
||||
if payload.ack_type != AcknowledgementType.RECEIVED:
|
||||
@@ -633,7 +630,7 @@ async def create_acknowledgement(
|
||||
action="ACK_CREATED",
|
||||
detail=_audit_detail(None, {"distribution_id": str(distribution.id), "ack_type": payload.ack_type}),
|
||||
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()
|
||||
@@ -693,9 +690,8 @@ async def list_distribution_notifications(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
) -> list[NotificationItem]:
|
||||
role_value = _role_value(current_user)
|
||||
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)
|
||||
role_in_study = membership.role_in_study if membership else None
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import document as document_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.etmf import EtmfNode
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
docs = list(documents)
|
||||
if not node.is_active:
|
||||
@@ -109,7 +104,7 @@ async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -
|
||||
action="ETMF_NODE_CREATED",
|
||||
detail=f'{{"code":"{node.code}","name":"{node.name}"}}',
|
||||
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()
|
||||
@@ -138,7 +133,7 @@ async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpd
|
||||
action="ETMF_NODE_UPDATED",
|
||||
detail="{}",
|
||||
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()
|
||||
|
||||
@@ -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.permission_monitoring import resolve_monitoring_scope
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserStub:
|
||||
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(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (: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, :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",
|
||||
"password_hash": "hash",
|
||||
"full_name": f"User {user_id.hex[:6]}",
|
||||
"role": role,
|
||||
"clinical_department": "Clinical",
|
||||
"is_admin": is_admin,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -114,7 +114,7 @@ async def test_project_pm_can_view_system_permission_definitions(db_session: Asy
|
||||
await db_session.commit()
|
||||
|
||||
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()
|
||||
|
||||
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):
|
||||
cra_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_member(db_session, study_id, cra_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
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
|
||||
|
||||
@@ -141,7 +172,7 @@ async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_se
|
||||
study_id = uuid.uuid4()
|
||||
admin_id = uuid.uuid4()
|
||||
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()
|
||||
|
||||
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},
|
||||
"CRA": {"subjects:delete": True},
|
||||
},
|
||||
current_user=UserStub(id=admin_id, role="ADMIN"),
|
||||
current_user=UserStub(id=admin_id, is_admin=True),
|
||||
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):
|
||||
cra_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_member(db_session, study_id, cra_id, "CRA")
|
||||
await db_session.commit()
|
||||
|
||||
result = await get_my_study_api_permissions(
|
||||
study_id=study_id,
|
||||
current_user=UserStub(id=cra_id, role="CRA"),
|
||||
current_user=UserStub(id=cra_id),
|
||||
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 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.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,
|
||||
member_id=peer_member_id,
|
||||
member_in=StudyMemberUpdate(role_in_study="CRA"),
|
||||
current_user=UserStub(id=actor_id, role="PM"),
|
||||
current_user=UserStub(id=actor_id),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
@@ -243,7 +274,7 @@ async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession):
|
||||
cra_id = uuid.uuid4()
|
||||
await _seed_study(db_session, study_id, "PM-GRANT-PM")
|
||||
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")
|
||||
cra_member_id = await _seed_member_return_id(db_session, study_id, cra_id, "CRA")
|
||||
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,
|
||||
member_id=cra_member_id,
|
||||
member_in=StudyMemberUpdate(role_in_study="PM"),
|
||||
current_user=UserStub(id=actor_id, role="PM"),
|
||||
current_user=UserStub(id=actor_id),
|
||||
db=db_session,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ import ast
|
||||
from sqlalchemy import delete
|
||||
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 get_api_endpoint_permissions, replace_api_endpoint_permissions
|
||||
from app.core.permission_cache import PermissionCache, set_permission_cache
|
||||
from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
|
||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||
from app.models.study import Study
|
||||
from app.models.user import UserRole
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
"""附件鉴权应使用模块附件权限,不再回退通用 attachments:*。"""
|
||||
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_version": "documents:update"' in source
|
||||
assert '"distribute": "documents:update"' in source
|
||||
assert '"delete_document": "documents:delete"' 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():
|
||||
assert {"QA", "CTA"} <= {role.value for role in UserRole}
|
||||
assert {"QA", "CTA"} <= set(PROJECT_PERMISSION_ROLES)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -49,8 +49,8 @@ async def test_material_equipment_operations_are_grouped_under_materials_module(
|
||||
|
||||
|
||||
@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()
|
||||
operations = data["operations"]
|
||||
|
||||
@@ -60,7 +60,7 @@ async def test_materials_module_only_contains_drug_flow_and_equipment_sections()
|
||||
if op["module"] == "materials"
|
||||
}
|
||||
|
||||
assert material_prefixes == {"drug_shipments", "material_equipments"}
|
||||
assert material_prefixes == {"drug_shipments", "drug_shipments_attachments", "material_equipments"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -90,6 +90,29 @@ def test_dashboard_summary_uses_project_overview_permission():
|
||||
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
|
||||
async def test_list_operation_prerequisites_endpoint():
|
||||
"""测试获取所有操作的前置权限依赖"""
|
||||
|
||||
@@ -12,13 +12,13 @@ from app.models.document import Document, DocumentScopeType
|
||||
from app.models.document_version import DocumentVersion # noqa: F401
|
||||
from app.models.distribution import Distribution # 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:
|
||||
def __init__(self) -> None:
|
||||
self.id = uuid.uuid4()
|
||||
self.role = UserRole.ADMIN
|
||||
self.is_admin = True
|
||||
self.status = UserStatus.ACTIVE
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ class FakeIpInfo:
|
||||
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:
|
||||
study_exists = (
|
||||
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(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (: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, :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",
|
||||
"password_hash": "hash",
|
||||
"full_name": "Permission Monitoring User",
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"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=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 "cache_metrics" in data
|
||||
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_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 data["cache_metrics"]["total_accesses"] == 3
|
||||
assert data["cache_metrics"]["cache_hits"] == 2
|
||||
@@ -129,7 +133,7 @@ async def test_get_alerts(db_session):
|
||||
|
||||
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 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_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 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):
|
||||
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
|
||||
|
||||
|
||||
@@ -170,7 +174,7 @@ async def test_reset_metrics(db_session):
|
||||
monitor.record_cache_hit()
|
||||
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 monitor.metrics.cache_metrics.total_accesses == 0
|
||||
@@ -185,7 +189,7 @@ async def test_clear_alerts(db_session):
|
||||
monitor.record_slow_check_alert(100)
|
||||
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 len(monitor.get_alerts()) == 0
|
||||
@@ -200,7 +204,7 @@ async def test_permission_system_health_healthy(db_session):
|
||||
for _ in range(100):
|
||||
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["health_score"] >= 80
|
||||
|
||||
@@ -218,7 +222,7 @@ async def test_permission_system_health_degraded(db_session):
|
||||
for _ in range(100):
|
||||
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 "health_score" in data
|
||||
assert "issues" in data
|
||||
@@ -233,7 +237,7 @@ async def test_permission_system_health_includes_metrics(db_session):
|
||||
monitor = PermissionMonitor()
|
||||
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 "cache_stats" in data
|
||||
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(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (: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, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -280,8 +284,8 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": email,
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -323,7 +327,7 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
|
||||
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]["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(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (: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, :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",
|
||||
"password_hash": "hash",
|
||||
"full_name": "属地合并用户",
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"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],
|
||||
)
|
||||
|
||||
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 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(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (: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, :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",
|
||||
"password_hash": "hash",
|
||||
"full_name": "局域网用户",
|
||||
"role": "PM",
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -484,7 +488,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
|
||||
)
|
||||
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]["province"] == ""
|
||||
@@ -525,8 +529,8 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
await db_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status)
|
||||
VALUES (: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, :clinical_department, :is_admin, :status)
|
||||
"""
|
||||
),
|
||||
{
|
||||
@@ -534,8 +538,8 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
"email": email,
|
||||
"password_hash": "hash",
|
||||
"full_name": name,
|
||||
"role": role,
|
||||
"clinical_department": "临床运营",
|
||||
"is_admin": False,
|
||||
"status": "ACTIVE",
|
||||
},
|
||||
)
|
||||
@@ -573,7 +577,7 @@ async def test_access_logs_include_user_behavior_summary(db_session):
|
||||
|
||||
result = await permission_monitoring.get_access_logs(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
_=AdminUserStub(),
|
||||
study_id=None,
|
||||
user_id=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(
|
||||
db=db_session,
|
||||
_=object(),
|
||||
_=AdminUserStub(),
|
||||
status_min=400,
|
||||
auth_status=None,
|
||||
page=1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from app.crud import user as user_crud
|
||||
from app.models.user import UserRole, UserStatus
|
||||
from app.models.user import UserStatus
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
@@ -37,6 +37,6 @@ async def test_ensure_admin_exists_creates_protected_admin():
|
||||
assert len(session.added) == 1
|
||||
admin = session.added[0]
|
||||
assert admin.email == "admin@huapont.cn"
|
||||
assert admin.role == UserRole.ADMIN
|
||||
assert admin.is_admin is True
|
||||
assert admin.status == UserStatus.ACTIVE
|
||||
assert session.commit_count == 1
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.core import rbac
|
||||
|
||||
@dataclass
|
||||
class UserStub:
|
||||
role: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -14,12 +14,12 @@ class MemberStub:
|
||||
|
||||
|
||||
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():
|
||||
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():
|
||||
assert rbac.is_allowed("delete_document", UserStub(role="ADMIN"), None)
|
||||
assert rbac.is_allowed("delete_document", UserStub(is_admin=True), None)
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.crud import user as user_crud
|
||||
from app.db.base_class import Base
|
||||
from tests.conftest import GUID
|
||||
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
|
||||
|
||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||
@@ -99,7 +99,7 @@ async def client_and_db():
|
||||
password_hash=hash_password("admin123"),
|
||||
full_name="Admin",
|
||||
clinical_department="Admin",
|
||||
role=UserRole.ADMIN,
|
||||
is_admin=True,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
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"])
|
||||
assert user is not None
|
||||
assert user.status == UserStatus.PENDING
|
||||
assert user.role == UserRole.PV
|
||||
assert user.is_admin is False
|
||||
member_rows = (
|
||||
await session.execute(select(StudyMember).where(StudyMember.user_id == user.id))
|
||||
).scalars().all()
|
||||
|
||||
@@ -6,17 +6,17 @@ import pytest
|
||||
from app.api.v1 import studies as studies_api
|
||||
from app.models.study import Study
|
||||
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(
|
||||
id=uuid.uuid4(),
|
||||
email="cra-pm@test.com",
|
||||
password_hash="hashed",
|
||||
full_name="CRA PM",
|
||||
clinical_department="Clinical",
|
||||
role=role,
|
||||
is_admin=is_admin,
|
||||
status=UserStatus.ACTIVE,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ def _make_study() -> Study:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
member = StudyMember(
|
||||
id=uuid.uuid4(),
|
||||
@@ -62,7 +62,7 @@ async def test_list_studies_returns_project_role_for_non_admin(monkeypatch):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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()
|
||||
|
||||
async def fake_list_studies(_db, skip=0, limit=100):
|
||||
|
||||
+8
-15
@@ -1,12 +1,5 @@
|
||||
-- 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 $$
|
||||
BEGIN
|
||||
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,
|
||||
password_hash 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,
|
||||
is_admin boolean NOT NULL DEFAULT false,
|
||||
status public.user_status NOT NULL DEFAULT 'PENDING',
|
||||
created_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)
|
||||
-- =========================================
|
||||
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
|
||||
('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'),
|
||||
('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'),
|
||||
('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'),
|
||||
('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'),
|
||||
('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')
|
||||
('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', '项目经理', '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', 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', 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', false, 'ACTIVE', '2025-01-05 08:50:00+00', '2025-01-05 08:50:00+00')
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
clinical_department = EXCLUDED.clinical_department,
|
||||
is_admin = EXCLUDED.is_admin,
|
||||
status = EXCLUDED.status,
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
|
||||
|
||||
@@ -26,6 +26,19 @@ describe("admin project route permissions", () => {
|
||||
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", () => {
|
||||
const source = readRouter();
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ import SubjectForm from "../views/subjects/SubjectForm.vue";
|
||||
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
|
||||
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[] = [
|
||||
{
|
||||
path: "/login",
|
||||
@@ -414,19 +418,19 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "permissions/system",
|
||||
name: "AdminPermissionsSystem",
|
||||
component: PermissionManagement,
|
||||
meta: { title: "系统级权限", requiresProjectPm: true },
|
||||
meta: { title: "系统级权限", systemPermission: SYSTEM_PERMISSION_READ },
|
||||
},
|
||||
{
|
||||
path: "permissions/project",
|
||||
name: "AdminPermissionsProject",
|
||||
component: PermissionManagement,
|
||||
meta: { title: "项目权限配置", requiresProjectPm: true },
|
||||
meta: { title: "项目权限配置", systemPermission: SYSTEM_PERMISSION_PROJECT_CONFIG },
|
||||
},
|
||||
{
|
||||
path: "permissions/monitoring",
|
||||
name: "AdminPermissionsMonitoring",
|
||||
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>> = {
|
||||
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" },
|
||||
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) => {
|
||||
if (isAdmin) return true;
|
||||
const studyStore = useStudyStore();
|
||||
@@ -548,6 +571,13 @@ router.beforeEach(async (to, _from, next) => {
|
||||
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) {
|
||||
const hasPmProject = studyStore.currentStudyRole === "PM" || (studyStore.currentStudy as any)?.role_in_study === "PM";
|
||||
if (!hasPmProject) {
|
||||
|
||||
@@ -37,7 +37,6 @@ export interface LoginKeyResponse {
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export type UserRole = "PM" | "CRA" | "PV" | "QA" | "CTA" | "ADMIN";
|
||||
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
|
||||
|
||||
export interface UserInfo {
|
||||
|
||||
@@ -18,6 +18,11 @@ describe("permission project role model", () => {
|
||||
expect(source).toContain('"fees.contract.create": "fees_contracts:create"');
|
||||
expect(source).toContain('"fees.contract.update": "fees_contracts:update"');
|
||||
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.delete": "documents:delete"');
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
@@ -26,5 +31,6 @@ describe("permission project role model", () => {
|
||||
expect(source).not.toContain('"faq.edit": "etmf');
|
||||
expect(source).not.toContain('"ADMIN", "PM"');
|
||||
expect(source).not.toContain('"fees.contract.write": "fees_contracts:create"');
|
||||
expect(source).not.toContain("project.members.manage");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,19 @@ const PERMISSIONS: Record<string, string[]> = {
|
||||
"faq.edit": ["ADMIN"],
|
||||
"faq.create": ["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.cra.bind": ["ADMIN"],
|
||||
"fees.contract.create": ["ADMIN"],
|
||||
"fees.contract.update": ["ADMIN"],
|
||||
"fees.contract.delete": ["ADMIN"],
|
||||
"documents.read": ["ADMIN"],
|
||||
"documents.create": ["ADMIN"],
|
||||
"documents.update": ["ADMIN"],
|
||||
"documents.delete": ["ADMIN"],
|
||||
};
|
||||
|
||||
@@ -33,13 +39,19 @@ const REASONS: Record<string, string> = {
|
||||
"faq.edit": TEXT.modules.permissions.faqEdit,
|
||||
"faq.create": TEXT.modules.permissions.faqCreate,
|
||||
"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.cra.bind": TEXT.modules.permissions.siteCraBind,
|
||||
"fees.contract.create": TEXT.modules.permissions.feeContractsWrite,
|
||||
"fees.contract.update": TEXT.modules.permissions.feeContractsWrite,
|
||||
"fees.contract.delete": TEXT.modules.permissions.feeContractsWrite,
|
||||
"documents.read": TEXT.modules.permissions.default,
|
||||
"documents.create": TEXT.modules.permissions.default,
|
||||
"documents.update": TEXT.modules.permissions.default,
|
||||
"documents.delete": TEXT.modules.permissions.default,
|
||||
};
|
||||
|
||||
@@ -67,13 +79,19 @@ export const usePermission = () => {
|
||||
"faq.reply": "faq_reply:create",
|
||||
"precautions.read": "precautions:read",
|
||||
"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.cra.bind": "sites:update",
|
||||
"fees.contract.create": "fees_contracts:create",
|
||||
"fees.contract.update": "fees_contracts:update",
|
||||
"fees.contract.delete": "fees_contracts:delete",
|
||||
"documents.read": "documents:read",
|
||||
"documents.create": "documents:create",
|
||||
"documents.update": "documents:update",
|
||||
"documents.delete": "documents:delete",
|
||||
"audit.export.read": "audit_logs:read",
|
||||
};
|
||||
|
||||
@@ -15,8 +15,10 @@ describe("audit logs access", () => {
|
||||
expect(source).toContain('projectRole.value === "PM"');
|
||||
expect(source).toContain("isApiPermissionAllowed");
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:read"]');
|
||||
expect(source).not.toContain('const role = auth.user?.role');
|
||||
expect(source).not.toContain('role !== "ADMIN"');
|
||||
expect(source).toContain('permissionMatrix.value?.[projectRole.value]?.["audit_logs:export"]');
|
||||
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", () => {
|
||||
|
||||
@@ -328,7 +328,7 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
|
||||
const canProjectExport = computed(() => {
|
||||
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(() => {
|
||||
if (isAdmin.value) return true;
|
||||
@@ -510,6 +510,7 @@ const handleExportCommand = (command: string) => {
|
||||
const confirmExport = async (scope: "system" | "project") => {
|
||||
const currentStudy = study.currentStudy;
|
||||
if (!currentStudy) return;
|
||||
if (scope === "project" && !canProjectExport.value) return;
|
||||
const ok = await ElMessageBox.confirm(
|
||||
TEXT.modules.adminAuditLogs.exportConfirm,
|
||||
TEXT.modules.adminAuditLogs.exportConfirmTitle,
|
||||
|
||||
@@ -108,6 +108,20 @@ describe("permission management custom roles", () => {
|
||||
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", () => {
|
||||
const source = readSource();
|
||||
|
||||
@@ -173,13 +187,17 @@ describe("permission management custom roles", () => {
|
||||
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 moduleSource = readProjectPermissionModulesSource();
|
||||
|
||||
expect(moduleSource).toContain('startup_ethics: "立项与伦理"');
|
||||
expect(source).toContain('startup_initiation: "立项记录"');
|
||||
expect(source).toContain('startup_ethics: "伦理记录"');
|
||||
expect(source).toContain('startup_initiation: "立项"');
|
||||
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", () => {
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<el-tag v-if="memberRows.length" effect="plain" size="small" round>{{ memberRows.length }} 名成员</el-tag>
|
||||
</div>
|
||||
<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>
|
||||
添加成员
|
||||
</el-button>
|
||||
@@ -96,7 +96,7 @@
|
||||
size="small"
|
||||
style="width: 130px"
|
||||
@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
|
||||
v-for="(label, val) in memberRoleLabels(row)"
|
||||
@@ -128,12 +128,12 @@
|
||||
link
|
||||
:type="row.is_active ? 'warning' : 'primary'"
|
||||
size="small"
|
||||
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
:disabled="!canUpdateProjectMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
@click="toggleMemberActive(row)"
|
||||
>
|
||||
{{ row.is_active ? "停用" : "启用" }}
|
||||
</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>
|
||||
</template>
|
||||
@@ -480,6 +480,18 @@ const isAdmin = computed(() => isSystemAdmin(auth.user));
|
||||
const selectedProjectRole = computed(() => selectedStudy.value?.role_in_study || "");
|
||||
const isSelectedProjectPm = computed(() => selectedProjectRole.value === "PM");
|
||||
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 ROLE_RANK: Record<string, number> = {
|
||||
@@ -495,7 +507,7 @@ const assignableRoleLabels = computed(() => {
|
||||
return result;
|
||||
});
|
||||
const memberRoleLabels = (row: MemberRoleRow) => {
|
||||
if (canEditMember(row)) return assignableRoleLabels.value;
|
||||
if (canUpdateProjectMember(row)) return assignableRoleLabels.value;
|
||||
return {
|
||||
...assignableRoleLabels.value,
|
||||
[row.role_in_study]: roleLabel(row.role_in_study),
|
||||
@@ -574,7 +586,8 @@ const onStudyChange = async (id: string) => {
|
||||
candidates.value = [];
|
||||
activeRolesDraft.value = [];
|
||||
router.replace({ query: { ...route.query, projectId: id } });
|
||||
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
||||
await loadPermissionData();
|
||||
await Promise.all([loadMembers(), loadActiveRoles()]);
|
||||
loadCandidates();
|
||||
};
|
||||
|
||||
@@ -617,13 +630,14 @@ const addMemberRules: FormRules = {
|
||||
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
|
||||
};
|
||||
|
||||
const canEditMember = (row: MemberRoleRow) => {
|
||||
if (!canManageSelectedProject.value) return false;
|
||||
const canMutateProjectMember = (row: MemberRoleRow) => {
|
||||
if (row.user_id === auth.user?.id) return false;
|
||||
if (row.user?.is_admin) return false;
|
||||
if (!isAdmin.value && (ROLE_RANK[row.role_in_study] ?? 0) >= ROLE_RANK.PM) return false;
|
||||
return true;
|
||||
};
|
||||
const canUpdateProjectMember = (row: MemberRoleRow) => canUpdateProjectMembers.value && canMutateProjectMember(row);
|
||||
const canDeleteProjectMember = (row: MemberRoleRow) => canDeleteProjectMembers.value && canMutateProjectMember(row);
|
||||
|
||||
const memberRows = computed(() =>
|
||||
members.value.map((m) => {
|
||||
@@ -639,7 +653,7 @@ const availableCandidates = computed(() => {
|
||||
});
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!selectedStudyId.value) return;
|
||||
if (!canListProjectMembers.value || !selectedStudyId.value) return;
|
||||
membersLoading.value = true;
|
||||
try {
|
||||
const { data } = await listMembers(selectedStudyId.value, { limit: 500, include_inactive: true });
|
||||
@@ -652,7 +666,10 @@ const loadMembers = async () => {
|
||||
};
|
||||
|
||||
const loadCandidates = async () => {
|
||||
if (!selectedStudyId.value) return;
|
||||
if (!canListProjectMemberCandidates.value || !selectedStudyId.value) {
|
||||
candidates.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await listMemberCandidates(selectedStudyId.value, { limit: 500 });
|
||||
candidates.value = data ?? [];
|
||||
@@ -662,6 +679,7 @@ const loadCandidates = async () => {
|
||||
};
|
||||
|
||||
const openAddMember = () => {
|
||||
if (!canAddProjectMember.value) return;
|
||||
addMemberForm.user_id = "";
|
||||
addMemberForm.role_in_study = Object.keys(assignableRoleLabels.value)[0] || "";
|
||||
addMemberVisible.value = true;
|
||||
@@ -670,6 +688,10 @@ const openAddMember = () => {
|
||||
const resetAddMemberForm = () => { addMemberFormRef.value?.clearValidate(); };
|
||||
|
||||
const submitAddMember = async () => {
|
||||
if (!canAddProjectMember.value) {
|
||||
ElMessage.warning("无权限添加成员");
|
||||
return;
|
||||
}
|
||||
const valid = await addMemberFormRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
addMemberSaving.value = true;
|
||||
@@ -686,6 +708,12 @@ const submitAddMember = async () => {
|
||||
};
|
||||
|
||||
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 {
|
||||
await updateMember(selectedStudyId.value, memberId, { role_in_study: role });
|
||||
ElMessage.success("角色已更新");
|
||||
@@ -696,6 +724,10 @@ const updateMemberRole = async (memberId: string, role: string) => {
|
||||
};
|
||||
|
||||
const toggleMemberActive = async (row: any) => {
|
||||
if (!canUpdateProjectMember(row)) {
|
||||
ElMessage.warning("无权限更新成员");
|
||||
return;
|
||||
}
|
||||
if (row.is_active) {
|
||||
const ok = await ElMessageBox.confirm("确认停用该成员?", "停用成员", { type: "warning" }).catch(() => null);
|
||||
if (!ok) return;
|
||||
@@ -710,6 +742,10 @@ const toggleMemberActive = async (row: any) => {
|
||||
};
|
||||
|
||||
const deleteMember = async (row: any) => {
|
||||
if (!canDeleteProjectMember(row)) {
|
||||
ElMessage.warning("无权限移除成员");
|
||||
return;
|
||||
}
|
||||
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "移除成员", {
|
||||
type: "warning",
|
||||
}).catch(() => null);
|
||||
@@ -973,8 +1009,10 @@ const PERMISSION_SECTION_LABELS: Record<string, string> = {
|
||||
subject_histories: "病史",
|
||||
monitoring_issues: "监查访视问题",
|
||||
project_milestones: "项目里程碑",
|
||||
startup_initiation: "立项记录",
|
||||
startup_ethics: "伦理记录",
|
||||
startup_initiation: "立项",
|
||||
startup_initiation_attachments: "立项",
|
||||
startup_ethics: "伦理",
|
||||
startup_ethics_attachments: "伦理",
|
||||
precautions: "注意事项",
|
||||
precautions_attachments: "注意事项",
|
||||
faq: "医学咨询/FAQ",
|
||||
@@ -1147,7 +1185,8 @@ onMounted(async () => {
|
||||
if (initialStudyId) {
|
||||
selectedStudyId.value = initialStudyId;
|
||||
if (qSub === "members") projectSubTab.value = "members";
|
||||
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
||||
await loadPermissionData();
|
||||
await Promise.all([loadMembers(), loadActiveRoles()]);
|
||||
loadCandidates();
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,32 @@ describe("project detail management role", () => {
|
||||
|
||||
expect(source).toContain("isSystemAdmin(authStore.user)");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,10 +56,10 @@
|
||||
</div>
|
||||
|
||||
<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="canManageSetup" 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="canManageSetup" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
|
||||
<el-button v-if="canUpdateSetupConfig" type="success" plain :loading="primarySaveLoading" :disabled="isPublishedView" @click="handlePrimarySaveConfig">保存配置</el-button>
|
||||
<el-button v-if="canUpdateSetupConfig" type="info" plain :disabled="isPublishedView || !publishedSetupDraft" @click="handleRefillDraft">一键回填</el-button>
|
||||
<el-button v-if="canUpdateSetupConfig" type="danger" plain :disabled="isPublishedView" @click="handleClearDraft">清空草稿</el-button>
|
||||
<el-button v-if="canPublishSetupConfig" type="warning" plain :loading="publishConfirmLoading" @click="publishConfigNow">发布版本</el-button>
|
||||
<el-button
|
||||
type="info"
|
||||
plain
|
||||
@@ -1498,18 +1498,18 @@
|
||||
</div>
|
||||
<div class="rollback-axis-actions">
|
||||
<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-button>
|
||||
</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-icon :size="13"><DocumentCopy /></el-icon>
|
||||
新建分支草稿
|
||||
</el-button>
|
||||
</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-icon :size="13"><Connection /></el-icon>
|
||||
合并主线(新版本)
|
||||
@@ -1521,7 +1521,7 @@
|
||||
下载
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除此版本" placement="top" :show-after="300">
|
||||
<el-tooltip v-if="canDeleteSetupVersion" content="删除此版本" placement="top" :show-after="300">
|
||||
<el-button
|
||||
class="vm-action-btn vm-action-danger"
|
||||
size="small"
|
||||
@@ -2405,20 +2405,18 @@ const setupDraft = reactive<SetupConfigDraft>({
|
||||
const storageKey = computed(() => `ctms_setup_config_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 hasSetupReadPermission = computed(() => {
|
||||
const selectedProjectPermissionAllowed = (operationKey: string) => {
|
||||
if (isSystemAdmin(authStore.user)) return true;
|
||||
if (!projectPermissions.value) return false;
|
||||
const rolePerms = Object.values(projectPermissions.value)[0];
|
||||
if (!rolePerms) return false;
|
||||
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
|
||||
});
|
||||
const canManageSetup = computed(() => {
|
||||
if (isSystemAdmin(authStore.user)) return true;
|
||||
if (!projectPermissions.value) return false;
|
||||
const rolePerms = Object.values(projectPermissions.value)[0];
|
||||
if (!rolePerms) return false;
|
||||
return isApiPermissionAllowed(rolePerms["setup_config:update"]);
|
||||
});
|
||||
return isApiPermissionAllowed(rolePerms[operationKey]);
|
||||
};
|
||||
const canReadSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:read"));
|
||||
const canUpdateSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:update"));
|
||||
const canPublishSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:publish"));
|
||||
const canRollbackSetupConfig = computed(() => selectedProjectPermissionAllowed("setup_config:rollback"));
|
||||
const canDeleteSetupVersion = computed(() => selectedProjectPermissionAllowed("setup_config:delete_version"));
|
||||
const isPreviewView = computed(() => setupViewMode.value === "preview");
|
||||
const isPublishedVersionView = computed(() => setupViewMode.value === "published");
|
||||
const isPublishedView = computed(() => isPreviewView.value || isPublishedVersionView.value);
|
||||
@@ -2429,7 +2427,7 @@ const currentSetupDraft = computed<SetupConfigDraft>(() => {
|
||||
return setupDraft;
|
||||
});
|
||||
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 canEditStep1BasicGroup = (group: Exclude<Step1EditSection, "all" | "summary">) =>
|
||||
@@ -2482,8 +2480,8 @@ const closeIndexedEditor = (controller: IndexedEditorController, successMessage:
|
||||
controller.index.value = -1;
|
||||
ElMessage.success(successMessage);
|
||||
};
|
||||
const canSaveDraftAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked && !isPublishedView.value));
|
||||
const canPublishAction = computed(() => Boolean(project.value && canManageSetup.value && !project.value?.is_locked));
|
||||
const canSaveDraftAction = computed(() => Boolean(project.value && canUpdateSetupConfig.value && !project.value?.is_locked && !isPublishedView.value));
|
||||
const canPublishAction = computed(() => Boolean(project.value && canPublishSetupConfig.value && !project.value?.is_locked));
|
||||
const currentStepTitle = computed(() => `第${activeStep.value + 1}步:${steps[activeStep.value]?.label || ""}`);
|
||||
const setupPublishedVersionText = computed(() => {
|
||||
return setupPublishedVersion.value || "v0";
|
||||
@@ -4397,7 +4395,7 @@ const loadProject = async () => {
|
||||
project.value = data as Study;
|
||||
projectPermissions.value = permRes.data as any;
|
||||
|
||||
if (!hasSetupReadPermission.value) {
|
||||
if (!canReadSetupConfig.value) {
|
||||
ElMessage.warning("当前角色无权访问立项配置");
|
||||
router.replace("/admin/projects");
|
||||
return;
|
||||
@@ -4450,7 +4448,7 @@ const startEdit = () => {
|
||||
ElMessage.info("当前为只读视图,请先切换到草稿后再编辑");
|
||||
return;
|
||||
}
|
||||
if (!canManageSetup.value) {
|
||||
if (!canUpdateSetupConfig.value) {
|
||||
ElMessage.warning("当前角色无权编辑立项配置");
|
||||
return;
|
||||
}
|
||||
@@ -4634,7 +4632,8 @@ const saveConfigNow = 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 {
|
||||
if (hasFormUnsavedChanges.value) {
|
||||
syncProjectInfoIntoSetupDraft();
|
||||
@@ -4695,7 +4694,8 @@ const doPublishConfigNow = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
const publishConfigNow = async () => {
|
||||
if (!project.value || !canManageSetup.value) return;
|
||||
if (!project.value) return;
|
||||
if (!canPublishAction.value) return;
|
||||
reconcileDraftSyncStateBeforePublish();
|
||||
if (hasSetupDraftUnsavedChanges.value) {
|
||||
ElMessage.warning("当前存在未上传的本地草稿,请先点击“保存配置”");
|
||||
@@ -4960,13 +4960,14 @@ const isLatestPublishedVersion = (rawVersion: number): boolean => {
|
||||
};
|
||||
|
||||
const openRollbackDialog = async () => {
|
||||
if (!project.value || !canManageSetup.value) return;
|
||||
if (!project.value || !canReadSetupConfig.value) return;
|
||||
await loadSetupVersionHistory();
|
||||
rollbackDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const selectRollbackVersion = async (targetVersion: number) => {
|
||||
if (!project.value) return;
|
||||
if (!canRollbackSetupConfig.value) return;
|
||||
const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -5018,6 +5019,7 @@ const selectRollbackVersion = async (targetVersion: number) => {
|
||||
|
||||
const checkoutBranchDraftFromVersion = async (targetVersion: number) => {
|
||||
if (!project.value) return;
|
||||
if (!canUpdateSetupConfig.value) return;
|
||||
const targetDisplayVersion = getDisplayVersionLabel(targetVersion);
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -5344,6 +5346,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
|
||||
|
||||
const removeVersion = async (targetVersion: number) => {
|
||||
if (!project.value) return;
|
||||
if (!canDeleteSetupVersion.value) return;
|
||||
if (isLatestPublishedVersion(targetVersion)) {
|
||||
ElMessage.warning("当前发布版本不能删除");
|
||||
return;
|
||||
@@ -5372,6 +5375,7 @@ const removeVersion = async (targetVersion: number) => {
|
||||
|
||||
const mergeVersionToMain = async (sourceVersion: number) => {
|
||||
if (!project.value) return;
|
||||
if (!canPublishSetupConfig.value) return;
|
||||
const sourceVersionLabel = getDisplayVersionLabel(sourceVersion);
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
@@ -5420,7 +5424,7 @@ const mergeVersionToMain = async (sourceVersion: number) => {
|
||||
};
|
||||
|
||||
const handleRefillDraft = async () => {
|
||||
if (!project.value || !canManageSetup.value) return;
|
||||
if (!project.value || !canUpdateSetupConfig.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认将草稿一键回填为当前发布版本 ${setupPublishedVersionText.value} 的内容?`,
|
||||
@@ -5468,7 +5472,7 @@ const handleRefillDraft = async () => {
|
||||
};
|
||||
|
||||
const handleClearDraft = async () => {
|
||||
if (!project.value || !canManageSetup.value) return;
|
||||
if (!project.value || !canUpdateSetupConfig.value) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"确认清空当前立项配置草稿的所有数据?该操作不会影响已发布版本。",
|
||||
@@ -5521,7 +5525,7 @@ const ensureCanSaveDraftAction = (): boolean => {
|
||||
ElMessage.warning("项目信息尚未加载完成,请稍后重试");
|
||||
return false;
|
||||
}
|
||||
if (!canManageSetup.value) {
|
||||
if (!canUpdateSetupConfig.value) {
|
||||
ElMessage.warning("当前角色无权保存立项配置");
|
||||
return false;
|
||||
}
|
||||
@@ -5641,7 +5645,7 @@ const ensureStep2Editable = (): boolean => {
|
||||
ElMessage.info("当前为只读视图,请先切换到草稿后再操作");
|
||||
return false;
|
||||
}
|
||||
if (!canManageSetup.value) {
|
||||
if (!canUpdateSetupConfig.value) {
|
||||
ElMessage.warning("当前角色无权编辑立项配置");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,17 @@ describe("ProjectMembers user directory access", () => {
|
||||
|
||||
expect(source).not.toContain("fetchUsers");
|
||||
expect(source).not.toContain("../../api/users");
|
||||
expect(source).toContain('const canManageMembers = computed(() => permission.can("project.members.manage"));');
|
||||
expect(source).toContain("if (!canManageMembers.value");
|
||||
expect(source).toContain('const canListMembers = computed(() => permission.can("project.members.list"));');
|
||||
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('v-if="canManageMembers"');
|
||||
expect(source).toContain('v-if="canAddMember"');
|
||||
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", () => {
|
||||
@@ -34,6 +40,7 @@ describe("ProjectMembers user directory access", () => {
|
||||
expect(source).toContain("const canAssignRole = (role: string)");
|
||||
expect(source).toContain(":disabled=\"!canAssignRole(role.value)\"");
|
||||
expect(source).toContain(':disabled="!canEditMember(scope.row)');
|
||||
expect(source).toContain(':disabled="!canDeleteProjectMember(scope.row)"');
|
||||
});
|
||||
|
||||
it("uses permission template names for project role display options", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="main-content-flat unified-shell">
|
||||
<div class="unified-action-bar actions-only-bar">
|
||||
<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 }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
<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
|
||||
link
|
||||
:type="scope.row.is_active ? 'danger' : 'primary'"
|
||||
@@ -130,7 +130,12 @@ const newMember = reactive({
|
||||
user_id: "",
|
||||
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 ROLE_KEYS = ["PM", "CRA", "PV", "QA", "CTA", "ADMIN"];
|
||||
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 canAssignRole = (role: string) => (auth.user?.is_admin ? true : (roleRank[role] || 0) <= currentRoleRank.value);
|
||||
const canEditMember = (row: StudyMember) => {
|
||||
if (!canManageMembers.value) return false;
|
||||
const canMutateProjectMember = (row: StudyMember) => {
|
||||
if (row.user?.is_admin) return false;
|
||||
if (auth.user?.is_admin) return true;
|
||||
if (row.user_id === auth.user?.id) return false;
|
||||
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>({
|
||||
user_id: [{ required: true, message: requiredMessage(TEXT.modules.adminProjectMembers.user), trigger: "change" }],
|
||||
@@ -168,16 +174,12 @@ const loadProject = async () => {
|
||||
};
|
||||
|
||||
const loadMembers = async () => {
|
||||
if (!projectId.value) return;
|
||||
if (!canListMembers.value || !projectId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listMembers(projectId.value, { limit: 500, include_inactive: true });
|
||||
members.value = Array.isArray(data) ? data : data.items || [];
|
||||
if (!canManageMembers.value) {
|
||||
users.value = members.value
|
||||
.map((member) => member.user)
|
||||
.filter((user): user is UserInfo => Boolean(user?.id)) as UserInfo[];
|
||||
}
|
||||
if (!canListMemberCandidates.value) syncUsersFromMembers();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminProjectMembers.loadFailed);
|
||||
} 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 () => {
|
||||
if (!canManageMembers.value || !projectId.value) {
|
||||
users.value = [];
|
||||
if (!canListMemberCandidates.value || !projectId.value) {
|
||||
syncUsersFromMembers();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -211,7 +219,7 @@ const memberRows = computed(() =>
|
||||
);
|
||||
|
||||
const openAdd = () => {
|
||||
if (!canManageMembers.value) return;
|
||||
if (!canAddMember.value) return;
|
||||
newMember.user_id = "";
|
||||
newMember.role_in_study = canAssignRole("PM") ? "PM" : "CRA";
|
||||
addVisible.value = true;
|
||||
@@ -219,9 +227,13 @@ const openAdd = () => {
|
||||
|
||||
const submitAdd = async () => {
|
||||
if (!projectId.value) return;
|
||||
if (!canAddMember.value) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
requiredPermission: "project.members.create",
|
||||
target: { projectId: projectId.value },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
@@ -256,7 +268,7 @@ const updateRole = async (memberId: string, role: string) => {
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
requiredPermission: "project.members.update",
|
||||
target: { projectId: projectId.value, memberId },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
@@ -281,7 +293,7 @@ const toggleActive = async (row: StudyMember) => {
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
requiredPermission: "project.members.update",
|
||||
target: { projectId: projectId.value, memberId: row.id },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
@@ -314,13 +326,13 @@ const toggleActive = async (row: StudyMember) => {
|
||||
|
||||
const onDelete = async (row: StudyMember) => {
|
||||
if (!projectId.value) return;
|
||||
if (!canEditMember(row)) {
|
||||
if (!canDeleteProjectMember(row)) {
|
||||
ElMessage.warning(TEXT.common.messages.noPermission);
|
||||
return;
|
||||
}
|
||||
const decision = evaluateAction({
|
||||
actorRole: auth.user?.is_admin ? "ADMIN" : null,
|
||||
requiredPermission: "project.members.manage",
|
||||
requiredPermission: "project.members.delete",
|
||||
target: { projectId: projectId.value, memberId: row.id },
|
||||
});
|
||||
if (!decision.allowed) {
|
||||
|
||||
@@ -52,12 +52,22 @@ describe("project management access", () => {
|
||||
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();
|
||||
|
||||
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="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" }');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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 class="project-code">{{ scope.row.code || '-' }}</span>
|
||||
</div>
|
||||
@@ -210,7 +210,7 @@ const openCreate = () => {
|
||||
const MODULE_TO_OPERATION: Record<string, Record<string, string>> = {
|
||||
project_members: { read: "project_members:list", write: "project_members: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" },
|
||||
};
|
||||
|
||||
@@ -335,7 +335,7 @@ const goDetail = (row: Study) => {
|
||||
};
|
||||
|
||||
const goProject = (row: Study) => {
|
||||
if (!canProject(row, "setup_config", "write")) {
|
||||
if (!canProject(row, "setup_config", "read")) {
|
||||
ElMessage.warning("当前角色无权访问该项目的立项配置");
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user