中文:收口权限与中心/立项配置改造
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user