diff --git a/backend/alembic/versions/20260511_02_rename_user_department_to_clinical_department.py b/backend/alembic/versions/20260511_02_rename_user_department_to_clinical_department.py new file mode 100644 index 00000000..5a491588 --- /dev/null +++ b/backend/alembic/versions/20260511_02_rename_user_department_to_clinical_department.py @@ -0,0 +1,49 @@ +"""rename user department to clinical_department + +Revision ID: 20260511_02 +Revises: 20260511_01 +Create Date: 2026-05-11 17:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260511_02" +down_revision: Union[str, None] = "20260511_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +TABLE_NAME = "users" +OLD_COLUMN = "department" +NEW_COLUMN = "clinical_department" + + +def _has_table(inspector: sa.Inspector, table_name: str) -> bool: + return table_name in set(inspector.get_table_names()) + + +def _has_column(inspector: sa.Inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not _has_table(inspector, TABLE_NAME): + return + if _has_column(inspector, TABLE_NAME, OLD_COLUMN) and not _has_column(inspector, TABLE_NAME, NEW_COLUMN): + op.alter_column(TABLE_NAME, OLD_COLUMN, new_column_name=NEW_COLUMN) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not _has_table(inspector, TABLE_NAME): + return + if _has_column(inspector, TABLE_NAME, NEW_COLUMN) and not _has_column(inspector, TABLE_NAME, OLD_COLUMN): + op.alter_column(TABLE_NAME, NEW_COLUMN, new_column_name=OLD_COLUMN) diff --git a/backend/alembic/versions/20260512_01_add_phone_to_sites.py b/backend/alembic/versions/20260512_01_add_phone_to_sites.py new file mode 100644 index 00000000..9bcbdf73 --- /dev/null +++ b/backend/alembic/versions/20260512_01_add_phone_to_sites.py @@ -0,0 +1,36 @@ +"""add phone to sites + +Revision ID: 20260512_01 +Revises: 20260511_02 +Create Date: 2026-05-12 09:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260512_01" +down_revision: Union[str, None] = "20260511_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("sites")} + + if "phone" not in columns: + op.add_column("sites", sa.Column("phone", sa.String(length=100), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("sites")} + + if "phone" in columns: + op.drop_column("sites", "phone") diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 14ee5ff5..ba1eed2a 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -196,7 +196,9 @@ async def update_me( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码不正确") update_data = { "full_name": payload.full_name if payload.full_name is not None else current_user.full_name, - "department": payload.department if payload.department is not None else current_user.department, + "clinical_department": ( + payload.clinical_department if payload.clinical_department is not None else current_user.clinical_department + ), "password": payload.password if payload.password else None, "avatar_url": payload.avatar_url if payload.avatar_url is not None else current_user.avatar_url, } diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index b093bfab..32ab8916 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -4,6 +4,7 @@ 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 +from app.crud import member as member_crud from app.models.milestone import Milestone from app.schemas.progress import StudyProgressRead from app.schemas.visit import VisitLostItem @@ -14,6 +15,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())]) async def get_progress( study_id: uuid.UUID, @@ -68,8 +73,12 @@ async def get_center_summary( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> list[CenterSummaryItem]: - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value not in {"ADMIN", "PM", "CRA"}: + role_value = _role_value(current_user) + member_role = None + if role_value != "ADMIN": + membership = await member_crud.get_member(db, study_id, current_user.id) + member_role = membership.role_in_study if membership and membership.is_active else None + if role_value != "ADMIN" and member_role not in {"PM", "CRA"}: return [] cra_scope = await get_cra_site_scope(db, study_id, current_user) scope_ids = cra_scope[0] if cra_scope else None diff --git a/backend/app/api/v1/fees_attachments.py b/backend/app/api/v1/fees_attachments.py index a9bf6e0b..32928ccb 100644 --- a/backend/app/api/v1/fees_attachments.py +++ b/backend/app/api/v1/fees_attachments.py @@ -54,7 +54,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren membership = await member_crud.get_member(db, project_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - if write and membership.role_in_study != "PM": + if write and membership.role_in_study not in {"ADMIN", "PM"}: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") return membership diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py index d224f988..39efd221 100644 --- a/backend/app/api/v1/fees_contracts.py +++ b/backend/app/api/v1/fees_contracts.py @@ -38,7 +38,7 @@ async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, curren membership = await member_crud.get_member(db, project_id, current_user.id) if not membership or not membership.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员") - if write and membership.role_in_study != "PM": + if write and membership.role_in_study not in {"ADMIN", "PM"}: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足") return membership diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index c2245bc8..27a259b2 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): "/", response_model=StudyMemberRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def add_member( study_id: uuid.UUID, @@ -115,7 +115,7 @@ async def list_members( @router.patch( "/{member_id}", response_model=StudyMemberRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def update_member( study_id: uuid.UUID, @@ -147,7 +147,7 @@ async def update_member( @router.delete( "/{member_id}", response_model=StudyMemberRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def remove_member( study_id: uuid.UUID, diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index 807b10a6..08af9013 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -26,7 +26,7 @@ async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): "/", response_model=SiteRead, status_code=status.HTTP_201_CREATED, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def create_site( study_id: uuid.UUID, @@ -91,7 +91,7 @@ async def list_sites( @router.patch( "/{site_id}", response_model=SiteRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def update_site( study_id: uuid.UUID, @@ -108,6 +108,7 @@ async def update_site( "name": site.name, "city": site.city, "pi_name": site.pi_name, + "phone": site.phone, "contact": site.contact, "is_active": site.is_active, } @@ -116,6 +117,7 @@ async def update_site( "name": updated.name, "city": updated.city, "pi_name": updated.pi_name, + "phone": updated.phone, "contact": updated.contact, "is_active": updated.is_active, } @@ -155,6 +157,7 @@ async def delete_site( "name": site.name, "city": site.city, "pi_name": site.pi_name, + "phone": site.phone, "contact": site.contact, "is_active": site.is_active, } diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 879b732e..bde5ac0d 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -42,6 +42,16 @@ from app.utils.pagination import paginate router = APIRouter() + +def _study_read_with_role(study, role_in_study: str | None = None) -> StudyRead: + data = StudyRead.model_validate(study) + data.role_in_study = role_in_study + 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, @@ -196,19 +206,6 @@ def _validate_setup_data( if total_site_target > plan.totalTarget: errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"}) - for index, row in enumerate(payload.monitoringStrategies): - row_prefix = f"monitoringStrategies[{index}]" - if not row.id: - errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"}) - if _is_empty_row([row.strategyType, row.detail, row.frequency]): - continue - if row.strategyType not in allowed_strategy_types: - errors.append({"field": f"{row_prefix}.strategyType", "message": "监查类型不合法"}) - if not row.detail.strip(): - errors.append({"field": f"{row_prefix}.detail", "message": "策略详情不能为空"}) - if row.frequency not in {"不限", "按触发", "每月1次"} and re.fullmatch(r"\d+次", row.frequency) is None: - errors.append({"field": f"{row_prefix}.frequency", "message": "监查次数格式应为“不限”/“按触发”/“每月1次”或“N次”"}) - for index, row in enumerate(payload.centerConfirm): row_prefix = f"centerConfirm[{index}]" if not row.id: @@ -253,7 +250,6 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf }, siteMilestones=[], siteEnrollmentPlans=[], - monitoringStrategies=[], centerConfirm=[], ) @@ -602,7 +598,6 @@ _SETUP_MODULE_KEYS = ( "enrollmentPlan", "siteMilestones", "siteEnrollmentPlans", - "monitoringStrategies", "centerConfirm", ) @@ -611,7 +606,6 @@ _SETUP_MODULE_LABELS = { "enrollmentPlan": "项目入组计划", "siteMilestones": "中心里程碑", "siteEnrollmentPlans": "中心入组计划", - "monitoringStrategies": "监查策略", "centerConfirm": "中心确认", } @@ -635,11 +629,6 @@ _SETUP_FIELD_LABELS = { "siteName": "中心名称", "target": "计划例数", "note": "备注", - "strategyType": "监查类型", - "detail": "策略详情", - "frequency": "监查频次", - "updatedAt": "更新时间", - "enabled": "启用状态", "confirmer": "确认人", "confirmStatus": "确认状态", "confirmDate": "确认日期", @@ -676,8 +665,6 @@ def _setup_row_identity(module_key: str, row: Any, index: int) -> str: return str(row.get("milestone") or "").strip() or f"第{index + 1}行" if module_key in {"siteEnrollmentPlans", "centerConfirm"}: return str(row.get("siteName") or row.get("siteId") or "").strip() or f"第{index + 1}行" - if module_key == "monitoringStrategies": - return str(row.get("strategyType") or "").strip() or f"第{index + 1}行" return f"第{index + 1}行" @@ -794,30 +781,39 @@ async def list_studies( db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> PaginatedResponse[StudyRead]: - if current_user.role == "ADMIN": + if _role_value(current_user) == "ADMIN": 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] else: studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit) total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000) - return paginate(list(studies), total=len(total)) + items = [] + for study in studies: + member = await member_crud.get_member(db, study.id, current_user.id) + items.append(_study_read_with_role(study, member.role_in_study if member else None)) + return paginate(items, total=len(total)) @router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())]) async def get_study( study_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> StudyRead: study = await study_crud.get(db, study_id) if not study: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") - return study + if _role_value(current_user) == "ADMIN": + 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) @router.patch( "/{study_id}", response_model=StudyRead, - dependencies=[Depends(require_study_roles(["PM"]))], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"]))], ) async def update_study( study_id: uuid.UUID, @@ -994,7 +990,7 @@ async def get_study_setup_config( @router.put( "/{study_id}/setup-config", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def upsert_study_setup_config( study_id: uuid.UUID, @@ -1072,7 +1068,7 @@ async def upsert_study_setup_config( @router.post( "/{study_id}/setup-config/publish", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def publish_study_setup_config( study_id: uuid.UUID, @@ -1225,7 +1221,7 @@ async def list_study_setup_config_versions( @router.post( "/{study_id}/setup-config/rollback", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def rollback_study_setup_config( study_id: uuid.UUID, @@ -1283,7 +1279,7 @@ async def rollback_study_setup_config( @router.post( "/{study_id}/setup-config/draft/checkout-branch", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def checkout_study_setup_config_branch_draft( study_id: uuid.UUID, @@ -1342,7 +1338,7 @@ async def checkout_study_setup_config_branch_draft( @router.post( "/{study_id}/setup-config/draft/clear", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def clear_study_setup_config_draft( study_id: uuid.UUID, @@ -1394,7 +1390,7 @@ async def clear_study_setup_config_draft( @router.post( "/{study_id}/setup-config/draft/refill", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def refill_study_setup_config_draft( study_id: uuid.UUID, @@ -1448,7 +1444,7 @@ async def refill_study_setup_config_draft( @router.post( "/{study_id}/setup-config/merge-main", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def merge_study_setup_config_to_main( study_id: uuid.UUID, @@ -1543,7 +1539,7 @@ async def merge_study_setup_config_to_main( @router.delete( "/{study_id}/setup-config/versions/{target_version}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_study_roles(["PM"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_study_roles(["ADMIN", "PM"])), Depends(require_study_not_locked())], ) async def delete_study_setup_config_version( study_id: uuid.UUID, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ad8ee134..354df982 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict PROTECTED_ADMIN_EMAIL = "admin@huapont.cn" PROTECTED_ADMIN_DEFAULT_PASSWORD = "admin123" PROTECTED_ADMIN_FULL_NAME = "System Admin" -PROTECTED_ADMIN_DEPARTMENT = "SYSTEM" +PROTECTED_ADMIN_CLINICAL_DEPARTMENT = "SYSTEM" class Settings(BaseSettings): diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 0505948a..b18dd050 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -95,7 +95,7 @@ def require_study_member(): return dependency -def require_study_roles(roles: Iterable[str]): +def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True): roles_set = set(roles) async def dependency( @@ -104,7 +104,7 @@ def require_study_roles(roles: Iterable[str]): db: AsyncSession = Depends(get_db_session), ): role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role - if role_value == "ADMIN": + if allow_system_admin and role_value == "ADMIN": 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: diff --git a/backend/app/core/rbac.py b/backend/app/core/rbac.py index 2d51d6e7..bfceaac4 100644 --- a/backend/app/core/rbac.py +++ b/backend/app/core/rbac.py @@ -32,9 +32,8 @@ 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) - allowed = ROLE_ACTIONS.get(role_value, set()) - if action in allowed: - return True + if role_value == "ADMIN": + return action in ROLE_ACTIONS["ADMIN"] member_role = getattr(membership, "role_in_study", None) if member_role: return action in ROLE_ACTIONS.get(member_role, set()) diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py index f64bc54e..f0013b22 100644 --- a/backend/app/crud/site.py +++ b/backend/app/crud/site.py @@ -42,6 +42,7 @@ async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate name=site_in.name, city=site_in.city, pi_name=site_in.pi_name, + phone=site_in.phone, contact=site_in.contact, is_active=site_in.is_active, enrollment_plan_start_date=site_in.enrollment_plan_start_date, diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index a503bf64..dd7b6283 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -5,8 +5,8 @@ from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import ( + PROTECTED_ADMIN_CLINICAL_DEPARTMENT, PROTECTED_ADMIN_DEFAULT_PASSWORD, - PROTECTED_ADMIN_DEPARTMENT, PROTECTED_ADMIN_EMAIL, PROTECTED_ADMIN_FULL_NAME, ) @@ -43,7 +43,7 @@ async def create_user( password_hash=hash_password(user_in.password), full_name=user_in.full_name, role=UserRole(user_in.role), - department=user_in.department, + clinical_department=user_in.clinical_department, status=status_value, ) db.add(user) @@ -70,8 +70,8 @@ async def update_user(db: AsyncSession, user: User, user_in: UserUpdate) -> User update_data["password_hash"] = hash_password(user_in.password) if user_in.full_name is not None: update_data["full_name"] = user_in.full_name - if user_in.department is not None: - update_data["department"] = user_in.department + if user_in.clinical_department is not None: + update_data["clinical_department"] = user_in.clinical_department if update_data: await db.execute(update(User).where(User.id == user.id).values(**update_data)) @@ -114,7 +114,7 @@ async def ensure_admin_exists(db: AsyncSession, *, default_password: str = PROTE password_hash=hash_password(default_password), full_name=PROTECTED_ADMIN_FULL_NAME, role=UserRole.ADMIN, - department=PROTECTED_ADMIN_DEPARTMENT, + clinical_department=PROTECTED_ADMIN_CLINICAL_DEPARTMENT, status=UserStatus.ACTIVE, ) db.add(new_admin) diff --git a/backend/app/models/site.py b/backend/app/models/site.py index 0744be43..f8da570a 100644 --- a/backend/app/models/site.py +++ b/backend/app/models/site.py @@ -16,6 +16,7 @@ class Site(Base): name: Mapped[str] = mapped_column(String(200), nullable=False) city: Mapped[str | None] = mapped_column(String(100), nullable=True) pi_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + phone: Mapped[str | None] = mapped_column(String(100), nullable=True) contact: Mapped[str | None] = mapped_column(String(100), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f45f61bb..dd0a5570 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -33,7 +33,7 @@ class User(Base): 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) - department: Mapped[str] = mapped_column(String(255), nullable=False) + clinical_department: Mapped[str] = mapped_column(String(255), nullable=False) status: Mapped[UserStatus] = mapped_column( Enum(UserStatus, name="user_status"), nullable=False, diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py index 25e3b52b..fc9b73db 100644 --- a/backend/app/schemas/site.py +++ b/backend/app/schemas/site.py @@ -9,6 +9,7 @@ class SiteCreate(BaseModel): name: str = Field(min_length=1) city: Optional[str] = None pi_name: Optional[str] = None + phone: Optional[str] = None contact: Optional[str] = None is_active: bool = True enrollment_plan_start_date: Optional[date] = None @@ -20,6 +21,7 @@ class SiteUpdate(BaseModel): name: Optional[str] = None city: Optional[str] = None pi_name: Optional[str] = None + phone: Optional[str] = None contact: Optional[str] = None is_active: Optional[bool] = None enrollment_plan_start_date: Optional[date] = None @@ -33,6 +35,7 @@ class SiteRead(BaseModel): name: str city: Optional[str] pi_name: Optional[str] + phone: Optional[str] contact: Optional[str] is_active: bool enrollment_target: Optional[int] diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py index 59a032d6..fc77c9d5 100644 --- a/backend/app/schemas/study.py +++ b/backend/app/schemas/study.py @@ -126,5 +126,6 @@ class StudyRead(BaseModel): visit_schedule: list[VisitScheduleItem] created_by: Optional[uuid.UUID] created_at: datetime + role_in_study: Optional[str] = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index de538093..94d809d9 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -44,15 +44,6 @@ class SiteEnrollmentPlanItem(BaseModel): stageBreakdown: str = "" -class MonitoringStrategyItem(BaseModel): - id: str - strategyType: str = "" - detail: str = "" - frequency: str = "" - updatedAt: str = "" - enabled: bool = True - - class CenterConfirmItem(BaseModel): id: str siteId: str = "" @@ -105,7 +96,6 @@ class StudySetupConfigData(BaseModel): enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem) siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list) siteEnrollmentPlans: list[SiteEnrollmentPlanItem] = Field(default_factory=list) - monitoringStrategies: list[MonitoringStrategyItem] = Field(default_factory=list) centerConfirm: list[CenterConfirmItem] = Field(default_factory=list) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index c343e08e..8b0393ac 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -40,7 +40,7 @@ class UserRegisterRequest(_PasswordValidator): email: EmailStr full_name: str = Field(min_length=1) role: RegisterRole - department: str = Field(min_length=1) + clinical_department: str = Field(min_length=1) class UserCreate(_PasswordValidator): @@ -48,7 +48,7 @@ class UserCreate(_PasswordValidator): email: EmailStr full_name: str = Field(min_length=1) role: UserRole - department: str = Field(min_length=1) + clinical_department: str = Field(min_length=1) status: UserStatus = "ACTIVE" @@ -58,7 +58,7 @@ class UserRead(BaseModel): username: str full_name: str role: UserRole - department: str + clinical_department: str status: UserStatus is_active: bool created_at: datetime @@ -73,7 +73,7 @@ class UserUpdate(_PasswordValidator): email: Optional[EmailStr] = None full_name: Optional[str] = None role: Optional[UserRole] = None - department: Optional[str] = None + clinical_department: Optional[str] = None status: Optional[UserStatus] = None password: Optional[str] = None is_active: Optional[bool] = None @@ -95,7 +95,7 @@ class AdminUserListResponse(BaseModel): class UserSelfUpdate(_PasswordValidator): full_name: Optional[str] = None - department: Optional[str] = None + clinical_department: Optional[str] = None password: Optional[str] = None current_password: Optional[str] = None avatar_url: Optional[str] = None diff --git a/backend/app/services/setup_config_projection.py b/backend/app/services/setup_config_projection.py index 8e0218cf..b2972b7d 100644 --- a/backend/app/services/setup_config_projection.py +++ b/backend/app/services/setup_config_projection.py @@ -10,7 +10,6 @@ from app.models.milestone import Milestone from app.models.site import Site from app.models.study import Study from app.models.study_center_confirm import StudyCenterConfirm -from app.models.study_monitoring_strategy import StudyMonitoringStrategy from app.models.user import User from app.schemas.study_setup_config import StudySetupConfigData @@ -207,34 +206,6 @@ async def _replace_site_milestones( ) -async def _replace_monitoring_strategies( - db: AsyncSession, - *, - study_id: uuid.UUID, - setup_data: StudySetupConfigData, - result: SetupProjectionResult, -) -> None: - await db.execute(delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id)) - for row in setup_data.monitoringStrategies: - strategy_type = _normalize_text(row.strategyType) - detail = _normalize_text(row.detail) - frequency = _normalize_text(row.frequency) - if not any([strategy_type, detail, frequency]): - continue - if not strategy_type or not detail or not frequency: - result.warnings.append(f"monitoring_strategy_incomplete:{row.id}") - continue - db.add( - StudyMonitoringStrategy( - study_id=study_id, - source_setup_item_id=_normalize_text(row.id), - strategy_type=strategy_type, - detail=detail, - frequency=frequency, - enabled=bool(row.enabled), - ) - ) - async def _replace_center_confirms( db: AsyncSession, @@ -384,7 +355,6 @@ async def apply_setup_projection_on_publish( owner_lookup=owner_lookup, result=result, ) - await _replace_monitoring_strategies(db, study_id=study_id, setup_data=setup_data, result=result) await _replace_center_confirms(db, study_id=study_id, setup_data=setup_data, site_map=site_map, result=result) result.site_skipped_count = len(result.skipped_items) diff --git a/backend/tests/test_project_management_permissions_static.py b/backend/tests/test_project_management_permissions_static.py new file mode 100644 index 00000000..c0d241e1 --- /dev/null +++ b/backend/tests/test_project_management_permissions_static.py @@ -0,0 +1,16 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_project_management_write_routes_allow_project_admin_and_pm(): + for relative_path in ( + "app/api/v1/studies.py", + "app/api/v1/sites.py", + "app/api/v1/members.py", + ): + source = (ROOT / relative_path).read_text(encoding="utf-8") + + assert 'require_study_roles(["PM"])' not in source + assert 'require_study_roles(["ADMIN", "PM"])' in source diff --git a/backend/tests/test_protected_admin_api.py b/backend/tests/test_protected_admin_api.py deleted file mode 100644 index ca94ac4d..00000000 --- a/backend/tests/test_protected_admin_api.py +++ /dev/null @@ -1,107 +0,0 @@ -import uuid - -import pytest -from fastapi import HTTPException - -from app.api.v1 import users as users_api -from app.models.user import User, UserRole, UserStatus -from app.schemas.user import UserRead, UserUpdate - - -def _make_user( - *, - email: str, - role: UserRole = UserRole.ADMIN, - status: UserStatus = UserStatus.ACTIVE, -) -> User: - return User( - id=uuid.uuid4(), - email=email, - password_hash="hashed", - full_name="User", - department="SYSTEM", - role=role, - status=status, - ) - - -@pytest.mark.asyncio -async def test_delete_user_blocks_protected_admin(monkeypatch): - current_user = _make_user(email="other-admin@huapont.cn") - protected_admin = _make_user(email="admin@huapont.cn") - - async def fake_get_by_id(_db, _user_id): - return protected_admin - - async def fake_count_active_admins(_db): - return 2 - - async def fake_user_has_memberships(_db, _user_id): - return False - - async def fake_delete_user(_db, _user): - raise AssertionError("protected admin should not be deleted") - - monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id) - monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins) - monkeypatch.setattr(users_api.member_crud, "user_has_memberships", fake_user_has_memberships) - monkeypatch.setattr(users_api.user_crud, "delete_user", fake_delete_user) - - with pytest.raises(HTTPException) as exc_info: - await users_api.delete_user(protected_admin.id, db=object(), current_user=current_user) - - assert exc_info.value.status_code == 400 - - -@pytest.mark.asyncio -async def test_update_user_blocks_protected_admin_email_change(monkeypatch): - protected_admin = _make_user(email="admin@huapont.cn") - - async def fake_get_by_id(_db, _user_id): - return protected_admin - - async def fake_count_active_admins(_db): - return 2 - - async def fake_update_user(_db, _user, _payload): - raise AssertionError("protected admin email should not be changed") - - monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id) - monkeypatch.setattr(users_api.user_crud, "count_active_admins", fake_count_active_admins) - monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user) - - with pytest.raises(HTTPException) as exc_info: - await users_api.update_user( - protected_admin.id, - UserUpdate(email="renamed@huapont.cn"), - db=object(), - current_user=protected_admin, - ) - - assert exc_info.value.status_code == 400 - - -@pytest.mark.asyncio -async def test_update_user_allows_protected_admin_password_change(monkeypatch): - protected_admin = _make_user(email="admin@huapont.cn") - captured_payload = {} - - async def fake_get_by_id(_db, _user_id): - return protected_admin - - async def fake_update_user(_db, _user, payload): - captured_payload["password"] = payload.password - return _user - - monkeypatch.setattr(users_api.user_crud, "get_by_id", fake_get_by_id) - monkeypatch.setattr(users_api.user_crud, "update_user", fake_update_user) - - result = await users_api.update_user( - protected_admin.id, - UserUpdate(password="Password123"), - db=object(), - current_user=protected_admin, - ) - - assert isinstance(result, UserRead | User) - assert captured_payload["password"] == "Password123" diff --git a/backend/tests/test_rbac_project_role.py b/backend/tests/test_rbac_project_role.py new file mode 100644 index 00000000..90ce3a47 --- /dev/null +++ b/backend/tests/test_rbac_project_role.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +from app.core import rbac + + +@dataclass +class UserStub: + role: str + + +@dataclass +class MemberStub: + role_in_study: str + + +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")) + + +def test_project_role_grants_project_document_permission(): + assert rbac.is_allowed("delete_document", UserStub(role="CRA"), 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) diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index f8fc2cb1..9d1b864e 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -88,7 +88,7 @@ async def client_and_db(): email="admin@test.com", password_hash=hash_password("admin123"), full_name="Admin", - department="Admin", + clinical_department="Admin", role=UserRole.ADMIN, status=UserStatus.ACTIVE, ) @@ -109,7 +109,7 @@ async def test_register_creates_pending_user(client_and_db): "password": "Password123", "full_name": "New User", "role": "CRA", - "department": "Clinical", + "clinical_department": "Clinical", } resp = await client.post("/api/v1/auth/register", json=payload) assert resp.status_code == 201 @@ -141,7 +141,7 @@ async def test_login_blocked_before_approval(client_and_db): "password": "Password123", "full_name": "Pending User", "role": "PV", - "department": "Safety", + "clinical_department": "Safety", } await client.post("/api/v1/auth/register", json=payload) resp = await encrypted_login(client, payload["email"], payload["password"]) @@ -157,7 +157,7 @@ async def test_admin_can_approve_user(client_and_db): "password": "Password123", "full_name": "Approve Target", "role": "IMP", - "department": "Supply", + "clinical_department": "Supply", } await client.post("/api/v1/auth/register", json=payload) async with SessionLocal() as session: @@ -184,7 +184,7 @@ async def test_admin_role_cannot_register(client_and_db): "password": "Password123", "full_name": "Bad Admin", "role": "ADMIN", - "department": "IT", + "clinical_department": "IT", } resp = await client.post("/api/v1/auth/register", json=payload) assert resp.status_code in (400, 422) diff --git a/backend/tests/test_study_role_context.py b/backend/tests/test_study_role_context.py new file mode 100644 index 00000000..08563401 --- /dev/null +++ b/backend/tests/test_study_role_context.py @@ -0,0 +1,72 @@ +import uuid + +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 + + +def _make_user(role: UserRole = UserRole.CRA) -> User: + return User( + id=uuid.uuid4(), + email="cra-pm@test.com", + password_hash="hashed", + full_name="CRA PM", + clinical_department="Clinical", + role=role, + status=UserStatus.ACTIVE, + ) + + +def _make_study() -> Study: + return Study( + id=uuid.uuid4(), + code="STUDY-001", + name="测试项目", + status="ACTIVE", + is_locked=False, + visit_schedule=[], + ) + + +@pytest.mark.asyncio +async def test_list_studies_returns_project_role_for_non_admin(monkeypatch): + current_user = _make_user(UserRole.CRA) + study = _make_study() + member = StudyMember( + id=uuid.uuid4(), + study_id=study.id, + user_id=current_user.id, + role_in_study="PM", + is_active=True, + ) + + async def fake_list_studies_for_user(_db, _user_id, skip=0, limit=100): + return [study] + + async def fake_get_member(_db, _study_id, _user_id): + return member + + monkeypatch.setattr(studies_api.study_crud, "list_studies_for_user", fake_list_studies_for_user) + monkeypatch.setattr(studies_api.member_crud, "get_member", fake_get_member) + + result = await studies_api.list_studies(db=object(), current_user=current_user) + + assert result["items"][0].role_in_study == "PM" + + +@pytest.mark.asyncio +async def test_list_studies_does_not_map_system_admin_to_project_admin(monkeypatch): + current_user = _make_user(UserRole.ADMIN) + study = _make_study() + + async def fake_list_studies(_db, skip=0, limit=100): + return [study] + + monkeypatch.setattr(studies_api.study_crud, "list_studies", fake_list_studies) + + result = await studies_api.list_studies(db=object(), current_user=current_user) + + assert result["items"][0].role_in_study is None diff --git a/database/init.sql b/database/init.sql index fd5ce1fe..8dfd74c3 100644 --- a/database/init.sql +++ b/database/init.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS public.users ( password_hash character varying(255) NOT NULL, full_name character varying(255) NOT NULL, role public.user_role NOT NULL, - department character varying(255) NOT NULL, + clinical_department character varying(255) NOT NULL, 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(), @@ -535,7 +535,7 @@ 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, department, status, created_at, updated_at + id, email, password_hash, full_name, role, clinical_department, 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'), @@ -547,7 +547,7 @@ ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, full_name = EXCLUDED.full_name, role = EXCLUDED.role, - department = EXCLUDED.department, + clinical_department = EXCLUDED.clinical_department, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 01970c38..b82ee2f2 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -15,7 +15,7 @@ export const register = (payload: RegisterRequest): Promise apiPatch("/api/v1/auth/me", payload); diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 7b22b862..2934bfe8 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -10,13 +10,13 @@ export const createUser = (payload: { password: string; full_name: string; role: string; - department: string; + clinical_department: string; }) => apiPost("/api/v1/users/", payload); export const updateUser = ( userId: string, - payload: Partial<{ role: string; status: string; password: string; full_name: string; department: string; is_active: boolean }> + payload: Partial<{ role: string; status: string; password: string; full_name: string; clinical_department: string; is_active: boolean }> ) => apiPatch(`/api/v1/users/${userId}`, payload); diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index e5520cc3..4df8f946 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -53,7 +53,6 @@ const setupModuleLabelMap: Record = { enrollmentPlan: "项目入组计划", siteMilestones: "中心里程碑", siteEnrollmentPlans: "中心入组计划", - monitoringStrategies: "监查策略", centerConfirm: "中心确认", }; @@ -113,7 +112,6 @@ const normalizeLegacyDetailLine = (line: string): string => { } if (text === "立项配置已发布") return "立项配置已发布"; if (text.includes("siteEnrollmentPlans")) return text.replaceAll("siteEnrollmentPlans", "中心入组计划"); - if (text.includes("monitoringStrategies")) return text.replaceAll("monitoringStrategies", "监查策略"); return text; }; @@ -150,7 +148,7 @@ const auditFieldLabelMap: Record = { role_in_study: "项目角色", full_name: "姓名", username: "账号", - department: "部门", + clinical_department: "科室", email: "邮箱", name: "名称", code: "编号", diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 9b90473c..b09ca542 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -47,13 +47,10 @@ {{ TEXT.menu.projectMilestones }} - - - {{ TEXT.menu.feeContracts }} - + + + {{ TEXT.menu.feeContracts }} +