939fc70532
新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。 后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。 前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。 补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
1582 lines
66 KiB
Python
1582 lines
66 KiB
Python
import uuid
|
||
import re
|
||
from datetime import date, datetime
|
||
from collections import Counter
|
||
from typing import Any
|
||
|
||
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_study_not_locked,
|
||
require_study_roles,
|
||
)
|
||
from app.crud import study as study_crud
|
||
from app.crud import member as member_crud
|
||
from app.crud import audit as audit_crud
|
||
from app.crud import site as site_crud
|
||
from app.crud import study_setup_config as study_setup_config_crud
|
||
from app.crud import user as user_crud
|
||
from app.schemas.common import PaginatedResponse
|
||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||
from app.schemas.member import StudyMemberCreate
|
||
from app.schemas.study_setup_config import (
|
||
ProjectPublishSnapshot,
|
||
SetupProjectionSummary,
|
||
StudySetupConfigCheckoutBranch,
|
||
StudySetupConfigData,
|
||
StudySetupConfigDraftAction,
|
||
StudySetupConfigMergeToMain,
|
||
StudySetupConfigPublish,
|
||
StudySetupConfigRead,
|
||
StudySetupConfigRollback,
|
||
StudySetupConfigUpsert,
|
||
StudySetupConfigVersionRead,
|
||
)
|
||
from app.services.setup_config_projection import apply_setup_projection_on_publish
|
||
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,
|
||
detail={
|
||
"code": "VALIDATION_ERROR",
|
||
"message": "校验失败",
|
||
"errors": errors,
|
||
},
|
||
)
|
||
|
||
|
||
def _raise_conflict_error() -> None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_409_CONFLICT,
|
||
detail={
|
||
"code": "SETUP_CONFIG_VERSION_CONFLICT",
|
||
"message": "配置版本冲突,请刷新后重试",
|
||
},
|
||
)
|
||
|
||
|
||
def _parse_date_str(value: str, field: str, errors: list[dict[str, str]]) -> date | None:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return date.fromisoformat(value)
|
||
except ValueError:
|
||
errors.append({"field": field, "message": "日期格式应为 YYYY-MM-DD"})
|
||
return None
|
||
|
||
|
||
def _is_empty_row(values: list[str | None]) -> bool:
|
||
return all(not (item or "").strip() for item in values)
|
||
|
||
|
||
def _validate_setup_data(
|
||
payload: StudySetupConfigData,
|
||
study_sites: dict[str, str],
|
||
*,
|
||
project_plan_start: date | None = None,
|
||
project_plan_end: date | None = None,
|
||
strict_required: bool = True,
|
||
) -> None:
|
||
errors: list[dict[str, str]] = []
|
||
allowed_milestone_status = {"未开始", "进行中", "已完成", "延期"}
|
||
allowed_strategy_types = {
|
||
"启动访视",
|
||
"筛选访视",
|
||
"监查访视",
|
||
"协同访视",
|
||
"风险访视",
|
||
"末次访视",
|
||
}
|
||
allowed_confirm_status = {"待确认", "已确认", "退回"}
|
||
|
||
for index, row in enumerate(payload.projectMilestones):
|
||
row_prefix = f"projectMilestones[{index}]"
|
||
start_text = (row.startDate or row.planDate or "").strip()
|
||
end_text = (row.endDate or row.planDate or "").strip()
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.name, row.owner, row.remark, start_text, end_text]):
|
||
continue
|
||
if not row.name.strip():
|
||
errors.append({"field": f"{row_prefix}.name", "message": "里程碑名称不能为空"})
|
||
start = _parse_date_str(start_text, f"{row_prefix}.startDate", errors)
|
||
end = _parse_date_str(end_text, f"{row_prefix}.endDate", errors)
|
||
if not start_text:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写开始日期"})
|
||
if not end_text:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写结束日期"})
|
||
if start and end and start > end:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "结束日期不能早于开始日期"})
|
||
if row.durationDays < 1:
|
||
errors.append({"field": f"{row_prefix}.durationDays", "message": "耗时天数不能小于1"})
|
||
if row.status not in allowed_milestone_status:
|
||
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
|
||
|
||
plan = payload.enrollmentPlan
|
||
if plan.totalTarget < 0:
|
||
errors.append({"field": "enrollmentPlan.totalTarget", "message": "计划总入组例数不能小于0"})
|
||
enrollment_plan_start = _parse_date_str(plan.startDate, "enrollmentPlan.startDate", errors)
|
||
enrollment_plan_end = _parse_date_str(plan.endDate, "enrollmentPlan.endDate", errors)
|
||
if strict_required and not plan.startDate:
|
||
errors.append({"field": "enrollmentPlan.startDate", "message": "请填写计划开始日期"})
|
||
if strict_required and not plan.endDate:
|
||
errors.append({"field": "enrollmentPlan.endDate", "message": "请填写计划结束日期"})
|
||
if enrollment_plan_start and enrollment_plan_end and enrollment_plan_start > enrollment_plan_end:
|
||
errors.append({"field": "enrollmentPlan.endDate", "message": "结束日期不能早于开始日期"})
|
||
if project_plan_start and enrollment_plan_start and enrollment_plan_start < project_plan_start:
|
||
errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能早于项目计划开始日期"})
|
||
if project_plan_end and enrollment_plan_start and enrollment_plan_start > project_plan_end:
|
||
errors.append({"field": "enrollmentPlan.startDate", "message": "项目入组计划开始日期不能晚于项目计划结束日期"})
|
||
if project_plan_start and enrollment_plan_end and enrollment_plan_end < project_plan_start:
|
||
errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能早于项目计划开始日期"})
|
||
if project_plan_end and enrollment_plan_end and enrollment_plan_end > project_plan_end:
|
||
errors.append({"field": "enrollmentPlan.endDate", "message": "项目入组计划结束日期不能晚于项目计划结束日期"})
|
||
|
||
total_site_target = 0
|
||
for index, row in enumerate(payload.siteMilestones):
|
||
row_prefix = f"siteMilestones[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.milestone, row.owner, row.remark, row.planDate]):
|
||
continue
|
||
if not row.milestone.strip():
|
||
errors.append({"field": f"{row_prefix}.milestone", "message": "中心里程碑不能为空"})
|
||
if row.status not in allowed_milestone_status:
|
||
errors.append({"field": f"{row_prefix}.status", "message": "里程碑状态不合法"})
|
||
plan_date = _parse_date_str(row.planDate, f"{row_prefix}.planDate", errors)
|
||
if project_plan_start and plan_date and plan_date < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能早于项目计划开始日期"})
|
||
if project_plan_end and plan_date and plan_date > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.planDate", "message": "中心里程碑日期不能晚于项目计划结束日期"})
|
||
|
||
seen_site_enrollment_site_ids: set[str] = set()
|
||
for index, row in enumerate(payload.siteEnrollmentPlans):
|
||
row_prefix = f"siteEnrollmentPlans[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.siteId, row.note, row.startDate, row.endDate]):
|
||
continue
|
||
site_id = (row.siteId or "").strip()
|
||
if not site_id:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"})
|
||
elif site_id not in study_sites:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"})
|
||
elif site_id in seen_site_enrollment_site_ids:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心入组计划不允许重复配置同一中心"})
|
||
else:
|
||
seen_site_enrollment_site_ids.add(site_id)
|
||
if row.target < 0:
|
||
errors.append({"field": f"{row_prefix}.target", "message": "目标入组人数不能小于0"})
|
||
total_site_target += max(row.target, 0)
|
||
start = _parse_date_str(row.startDate, f"{row_prefix}.startDate", errors)
|
||
end = _parse_date_str(row.endDate, f"{row_prefix}.endDate", errors)
|
||
if strict_required and not row.startDate:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "请填写启动日期"})
|
||
if strict_required and not row.endDate:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "请填写完成日期"})
|
||
if start and end and start > end:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "完成日期不能早于启动日期"})
|
||
if project_plan_start and start and start < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能早于项目计划开始日期"})
|
||
if project_plan_end and start and start > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.startDate", "message": "中心启动日期不能晚于项目计划结束日期"})
|
||
if project_plan_start and end and end < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能早于项目计划开始日期"})
|
||
if project_plan_end and end and end > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.endDate", "message": "中心完成日期不能晚于项目计划结束日期"})
|
||
|
||
if total_site_target > plan.totalTarget:
|
||
errors.append({"field": "siteEnrollmentPlans", "message": "中心计划总例数不能超过项目总入组例数"})
|
||
|
||
for index, row in enumerate(payload.centerConfirm):
|
||
row_prefix = f"centerConfirm[{index}]"
|
||
if not row.id:
|
||
errors.append({"field": f"{row_prefix}.id", "message": "ID不能为空"})
|
||
if _is_empty_row([row.siteId, row.confirmer, row.note, row.confirmDate]):
|
||
continue
|
||
if not row.siteId:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "请选择中心"})
|
||
elif row.siteId not in study_sites:
|
||
errors.append({"field": f"{row_prefix}.siteId", "message": "中心不属于当前项目"})
|
||
if row.confirmStatus not in allowed_confirm_status:
|
||
errors.append({"field": f"{row_prefix}.confirmStatus", "message": "确认状态不合法"})
|
||
parsed_confirm_date = _parse_date_str(row.confirmDate, f"{row_prefix}.confirmDate", errors)
|
||
if row.confirmStatus == "已确认" and not parsed_confirm_date:
|
||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "已确认时必须填写确认日期"})
|
||
if row.confirmStatus == "退回" and not row.note.strip():
|
||
errors.append({"field": f"{row_prefix}.note", "message": "退回时必须填写备注"})
|
||
if project_plan_start and parsed_confirm_date and parsed_confirm_date < project_plan_start:
|
||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能早于项目计划开始日期"})
|
||
if project_plan_end and parsed_confirm_date and parsed_confirm_date > project_plan_end:
|
||
errors.append({"field": f"{row_prefix}.confirmDate", "message": "中心确认日期不能晚于项目计划结束日期"})
|
||
|
||
if errors:
|
||
_raise_validation_error(errors)
|
||
|
||
|
||
def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConfigData:
|
||
plan_start = study.plan_start_date.isoformat() if getattr(study, "plan_start_date", None) else ""
|
||
plan_end = study.plan_end_date.isoformat() if getattr(study, "plan_end_date", None) else ""
|
||
total_target = getattr(study, "planned_enrollment_count", None)
|
||
if total_target is None:
|
||
total_target = 0
|
||
return StudySetupConfigData(
|
||
projectInfo=_build_project_publish_snapshot(study),
|
||
projectMilestones=[],
|
||
enrollmentPlan={
|
||
"totalTarget": total_target,
|
||
"startDate": plan_start,
|
||
"endDate": plan_end,
|
||
"monthlyGoalNote": getattr(study, "enrollment_monthly_goal_note", None) or "",
|
||
"stageBreakdown": getattr(study, "enrollment_stage_breakdown", None) or "",
|
||
},
|
||
siteMilestones=[],
|
||
siteEnrollmentPlans=[],
|
||
centerConfirm=[],
|
||
)
|
||
|
||
|
||
def _to_date_text(value: date | None) -> str:
|
||
if not value:
|
||
return ""
|
||
return value.isoformat()
|
||
|
||
|
||
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||
return ProjectPublishSnapshot(
|
||
code=getattr(study, "code", None) or "",
|
||
name=getattr(study, "name", None) or "",
|
||
project_full_name=getattr(study, "project_full_name", None) or "",
|
||
sponsor=getattr(study, "sponsor", None) or "",
|
||
protocol_no=getattr(study, "protocol_no", None) or "",
|
||
lead_unit=getattr(study, "lead_unit", None) or "",
|
||
principal_investigator=getattr(study, "principal_investigator", None) or "",
|
||
main_pm=getattr(study, "main_pm", None) or "",
|
||
research_analysis=getattr(study, "research_analysis", None) or "",
|
||
research_product=getattr(study, "research_product", None) or "",
|
||
control_product=getattr(study, "control_product", None) or "",
|
||
indication=getattr(study, "indication", None) or "",
|
||
research_population=getattr(study, "research_population", None) or "",
|
||
research_design=getattr(study, "research_design", None) or "",
|
||
plan_start_date=_to_date_text(getattr(study, "plan_start_date", None)),
|
||
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
|
||
planned_site_count=getattr(study, "planned_site_count", None),
|
||
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
|
||
status=getattr(study, "status", None) or "",
|
||
visit_schedule=getattr(study, "visit_schedule", None) or [],
|
||
)
|
||
|
||
|
||
def _resolve_setup_project_snapshot(setup_data: StudySetupConfigData, study) -> ProjectPublishSnapshot:
|
||
return setup_data.projectInfo
|
||
|
||
|
||
def _parse_optional_snapshot_date(value: str) -> date | None:
|
||
text = (value or "").strip()
|
||
if not text:
|
||
return None
|
||
return date.fromisoformat(text)
|
||
|
||
|
||
def _normalize_optional_snapshot_text(value: str | None) -> str | None:
|
||
if value is None:
|
||
return None
|
||
text = value.strip()
|
||
return text or None
|
||
|
||
|
||
def _snapshot_visit_schedule(snapshot: ProjectPublishSnapshot) -> list[dict[str, int | str]]:
|
||
return [item.model_dump(mode="json") for item in snapshot.visit_schedule]
|
||
|
||
|
||
def _apply_project_publish_snapshot_to_study(study, snapshot: ProjectPublishSnapshot) -> bool:
|
||
next_values = {
|
||
"code": snapshot.code.strip(),
|
||
"name": snapshot.name.strip(),
|
||
"project_full_name": _normalize_optional_snapshot_text(snapshot.project_full_name),
|
||
"sponsor": _normalize_optional_snapshot_text(snapshot.sponsor),
|
||
"protocol_no": _normalize_optional_snapshot_text(snapshot.protocol_no),
|
||
"lead_unit": _normalize_optional_snapshot_text(snapshot.lead_unit),
|
||
"principal_investigator": _normalize_optional_snapshot_text(snapshot.principal_investigator),
|
||
"main_pm": _normalize_optional_snapshot_text(snapshot.main_pm),
|
||
"research_analysis": _normalize_optional_snapshot_text(snapshot.research_analysis),
|
||
"research_product": _normalize_optional_snapshot_text(snapshot.research_product),
|
||
"control_product": _normalize_optional_snapshot_text(snapshot.control_product),
|
||
"indication": _normalize_optional_snapshot_text(snapshot.indication),
|
||
"research_population": _normalize_optional_snapshot_text(snapshot.research_population),
|
||
"research_design": _normalize_optional_snapshot_text(snapshot.research_design),
|
||
"plan_start_date": _parse_optional_snapshot_date(snapshot.plan_start_date),
|
||
"plan_end_date": _parse_optional_snapshot_date(snapshot.plan_end_date),
|
||
"planned_site_count": snapshot.planned_site_count,
|
||
"planned_enrollment_count": snapshot.planned_enrollment_count,
|
||
"status": snapshot.status or "DRAFT",
|
||
"visit_schedule": _snapshot_visit_schedule(snapshot),
|
||
}
|
||
changed = False
|
||
for field, value in next_values.items():
|
||
if getattr(study, field, None) != value:
|
||
setattr(study, field, value)
|
||
changed = True
|
||
return changed
|
||
|
||
|
||
def _to_setup_config_read(
|
||
record,
|
||
*,
|
||
saved_by_name: str | None = None,
|
||
published_by_name: str | None = None,
|
||
current_published_version_label: str | None = None,
|
||
active_branch_base_version_label: str | None = None,
|
||
projection_status: str | None = None,
|
||
projection_summary: SetupProjectionSummary | None = None,
|
||
) -> StudySetupConfigRead:
|
||
return StudySetupConfigRead(
|
||
id=record.id,
|
||
study_id=record.study_id,
|
||
version=record.version,
|
||
current_branch_name=(getattr(record, "current_branch_name", None) or "main"),
|
||
current_published_version_id=getattr(record, "current_published_version_id", None),
|
||
current_published_version_label=current_published_version_label,
|
||
active_branch_base_version_id=getattr(record, "active_branch_base_version_id", None),
|
||
active_branch_base_version_label=active_branch_base_version_label,
|
||
data=StudySetupConfigData.model_validate(record.config or {}),
|
||
publish_status=record.publish_status or "DRAFT",
|
||
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
||
published_project_snapshot=(
|
||
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
|
||
if record.published_project_snapshot
|
||
else None
|
||
),
|
||
published_by=record.published_by,
|
||
published_by_name=published_by_name,
|
||
published_at=record.published_at,
|
||
saved_by=record.saved_by,
|
||
saved_by_name=saved_by_name,
|
||
projection_status=projection_status,
|
||
projection_summary=projection_summary,
|
||
created_at=record.created_at,
|
||
updated_at=record.updated_at,
|
||
)
|
||
|
||
|
||
def _build_projection_audit_detail(
|
||
*,
|
||
projection_status: str,
|
||
study_updated: bool,
|
||
site_updated_count: int,
|
||
site_skipped_count: int,
|
||
warnings: list[str],
|
||
skipped_items: list[dict[str, str]],
|
||
) -> str:
|
||
warning_part = ";".join(warnings[:5]) if warnings else "无"
|
||
reason_counter = Counter(item.get("reason") for item in skipped_items if item.get("reason"))
|
||
reason_summary = ";".join(f"{reason} {count}条" for reason, count in sorted(reason_counter.items())) if reason_counter else "无"
|
||
projection_status_label = "成功" if projection_status == "success" else "失败" if projection_status == "failed" else projection_status
|
||
return (
|
||
"立项配置已发布"
|
||
f"; 发布同步结果:{projection_status_label}"
|
||
f"; 已同步项目主信息:{'是' if study_updated else '否'}"
|
||
f"; 已同步中心数:{site_updated_count}"
|
||
f"; 未同步中心数:{site_skipped_count}"
|
||
f"; 同步告警:{warning_part}"
|
||
f"; 未同步原因统计:{reason_summary}"
|
||
)
|
||
|
||
|
||
def _ensure_study_timeline_valid(
|
||
*,
|
||
plan_start_date: date | None,
|
||
plan_end_date: date | None,
|
||
) -> None:
|
||
if plan_start_date and plan_end_date and plan_start_date > plan_end_date:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期")
|
||
|
||
|
||
async def _validate_setup_project_snapshot(
|
||
db: AsyncSession,
|
||
*,
|
||
study_id: uuid.UUID,
|
||
snapshot: ProjectPublishSnapshot,
|
||
strict_required: bool = True,
|
||
) -> None:
|
||
errors: list[dict[str, str]] = []
|
||
code = snapshot.code.strip()
|
||
name = snapshot.name.strip()
|
||
if strict_required and not code:
|
||
errors.append({"field": "projectInfo.code", "message": "项目编号不能为空"})
|
||
if strict_required and not name:
|
||
errors.append({"field": "projectInfo.name", "message": "项目名称不能为空"})
|
||
try:
|
||
plan_start = _parse_optional_snapshot_date(snapshot.plan_start_date)
|
||
plan_end = _parse_optional_snapshot_date(snapshot.plan_end_date)
|
||
except ValueError:
|
||
errors.append({"field": "projectInfo.plan_start_date", "message": "项目计划日期格式应为 YYYY-MM-DD"})
|
||
plan_start = None
|
||
plan_end = None
|
||
if plan_start and plan_end and plan_start > plan_end:
|
||
errors.append({"field": "projectInfo.plan_end_date", "message": "项目计划结束日期不能早于开始日期"})
|
||
|
||
seen_visit_codes: set[str] = set()
|
||
for index, item in enumerate(snapshot.visit_schedule):
|
||
row_prefix = f"projectInfo.visit_schedule[{index}]"
|
||
visit_code = item.visit_code.strip()
|
||
has_visit_values = bool(visit_code) or any(
|
||
value != 0
|
||
for value in (item.baseline_offset_days, item.window_before_days, item.window_after_days)
|
||
)
|
||
if strict_required and not visit_code:
|
||
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能为空"})
|
||
if not has_visit_values:
|
||
continue
|
||
if visit_code:
|
||
if len(visit_code) > 50:
|
||
errors.append({"field": f"{row_prefix}.visit_code", "message": "访视编号不能超过50个字符"})
|
||
if visit_code in seen_visit_codes:
|
||
errors.append({"field": f"{row_prefix}.visit_code", "message": f"访视编号重复:{visit_code}"})
|
||
seen_visit_codes.add(visit_code)
|
||
if item.baseline_offset_days < 0 or item.baseline_offset_days > 3650:
|
||
errors.append({"field": f"{row_prefix}.baseline_offset_days", "message": "基线偏移天数应在0到3650之间"})
|
||
if item.window_before_days < 0 or item.window_before_days > 365:
|
||
errors.append({"field": f"{row_prefix}.window_before_days", "message": "窗口前天数应在0到365之间"})
|
||
if item.window_after_days < 0 or item.window_after_days > 365:
|
||
errors.append({"field": f"{row_prefix}.window_after_days", "message": "窗口后天数应在0到365之间"})
|
||
|
||
if errors:
|
||
_raise_validation_error(errors)
|
||
if code:
|
||
existing = await study_crud.get_by_code(db, code)
|
||
if existing and existing.id != study_id:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||
|
||
|
||
def _resolve_version_label(record: Any) -> str:
|
||
branch_name = (getattr(record, "branch_name", None) or "main").strip() or "main"
|
||
display_version = int(getattr(record, "display_version", 0) or 0)
|
||
branch_seq = int(getattr(record, "branch_seq", 1) or 1)
|
||
if branch_name == "main":
|
||
return getattr(record, "version_label", None) or f"v{display_version}"
|
||
return getattr(record, "version_label", None) or f"v{display_version}.{branch_seq}"
|
||
|
||
|
||
def _to_setup_config_version_read(
|
||
record,
|
||
*,
|
||
published_by_name: str | None = None,
|
||
is_current_published: bool = False,
|
||
is_active_draft_base: bool = False,
|
||
) -> StudySetupConfigVersionRead:
|
||
return StudySetupConfigVersionRead(
|
||
id=record.id,
|
||
study_id=record.study_id,
|
||
study_setup_config_id=record.study_setup_config_id,
|
||
version=record.version,
|
||
display_version=record.display_version,
|
||
branch_name=(record.branch_name or "main"),
|
||
branch_seq=record.branch_seq or 1,
|
||
version_label=_resolve_version_label(record),
|
||
source_version=record.source_version,
|
||
parent_version_id=record.parent_version_id,
|
||
merged_from_version_id=record.merged_from_version_id,
|
||
config=StudySetupConfigData.model_validate(record.config or {}),
|
||
published_project_snapshot=(
|
||
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
|
||
if record.published_project_snapshot
|
||
else None
|
||
),
|
||
is_current_published=is_current_published,
|
||
is_active_draft_base=is_active_draft_base,
|
||
published_by=record.published_by,
|
||
published_by_name=published_by_name,
|
||
published_at=record.published_at,
|
||
created_at=record.created_at,
|
||
)
|
||
|
||
|
||
def _resolve_current_published_snapshot_version(
|
||
versions: list[Any],
|
||
setup_record: Any | None,
|
||
) -> int | None:
|
||
if not setup_record:
|
||
return None
|
||
current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None)
|
||
if current_published_snapshot_id:
|
||
matched = next((item for item in versions if item.id == current_published_snapshot_id), None)
|
||
if matched:
|
||
return matched.version
|
||
|
||
published_config = setup_record.published_config or {}
|
||
if not published_config:
|
||
return None
|
||
|
||
best_score = -1
|
||
best_version: int | None = None
|
||
for item in versions:
|
||
if (item.config or {}) != published_config:
|
||
continue
|
||
score = 1
|
||
if setup_record.published_at and item.published_at == setup_record.published_at:
|
||
score += 2
|
||
if setup_record.published_by and item.published_by == setup_record.published_by:
|
||
score += 1
|
||
if score > best_score or (score == best_score and (best_version is None or item.version > best_version)):
|
||
best_score = score
|
||
best_version = item.version
|
||
return best_version
|
||
|
||
|
||
def _resolve_current_published_snapshot_label(
|
||
versions: list[Any],
|
||
setup_record: Any | None,
|
||
) -> str | None:
|
||
if not setup_record:
|
||
return None
|
||
current_published_snapshot_id = getattr(setup_record, "current_published_version_id", None)
|
||
if current_published_snapshot_id:
|
||
matched = next((item for item in versions if item.id == current_published_snapshot_id), None)
|
||
if matched:
|
||
return _resolve_version_label(matched)
|
||
|
||
version = _resolve_current_published_snapshot_version(versions, setup_record)
|
||
if version is None:
|
||
return None
|
||
matched = next((item for item in versions if item.version == version), None)
|
||
if not matched:
|
||
return None
|
||
return _resolve_version_label(matched)
|
||
|
||
|
||
def _resolve_active_branch_base_snapshot_version(
|
||
versions: list[Any],
|
||
setup_record: Any | None,
|
||
) -> int | None:
|
||
if not setup_record:
|
||
return None
|
||
base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None)
|
||
if not base_snapshot_id:
|
||
return None
|
||
matched = next((item for item in versions if item.id == base_snapshot_id), None)
|
||
if not matched:
|
||
return None
|
||
return matched.version
|
||
|
||
|
||
def _resolve_active_branch_base_snapshot_label(
|
||
versions: list[Any],
|
||
setup_record: Any | None,
|
||
) -> str | None:
|
||
if not setup_record:
|
||
return None
|
||
base_snapshot_id = getattr(setup_record, "active_branch_base_version_id", None)
|
||
if not base_snapshot_id:
|
||
return None
|
||
matched = next((item for item in versions if item.id == base_snapshot_id), None)
|
||
if not matched:
|
||
return None
|
||
return _resolve_version_label(matched)
|
||
|
||
|
||
_SETUP_MODULE_KEYS = (
|
||
"projectMilestones",
|
||
"enrollmentPlan",
|
||
"siteMilestones",
|
||
"siteEnrollmentPlans",
|
||
"centerConfirm",
|
||
)
|
||
|
||
_SETUP_MODULE_LABELS = {
|
||
"projectMilestones": "项目里程碑",
|
||
"enrollmentPlan": "项目入组计划",
|
||
"siteMilestones": "中心里程碑",
|
||
"siteEnrollmentPlans": "中心入组计划",
|
||
"centerConfirm": "中心确认",
|
||
}
|
||
|
||
_SETUP_FIELD_LABELS = {
|
||
"id": "ID",
|
||
"name": "名称",
|
||
"milestone": "里程碑",
|
||
"planDate": "计划日期",
|
||
"startDate": "开始日期",
|
||
"endDate": "结束日期",
|
||
"durationDays": "耗时(天)",
|
||
"owner": "负责人",
|
||
"remark": "备注",
|
||
"status": "状态",
|
||
"totalTarget": "计划总入组例数",
|
||
"startDate": "开始日期",
|
||
"endDate": "结束日期",
|
||
"monthlyGoalNote": "月度目标说明",
|
||
"stageBreakdown": "分阶段计划",
|
||
"siteId": "中心ID",
|
||
"siteName": "中心名称",
|
||
"target": "计划例数",
|
||
"note": "备注",
|
||
"confirmer": "确认人",
|
||
"confirmStatus": "确认状态",
|
||
"confirmDate": "确认日期",
|
||
}
|
||
|
||
|
||
def _setup_value_text(value: Any) -> str:
|
||
if value is None:
|
||
return "未填写"
|
||
if isinstance(value, bool):
|
||
return "是" if value else "否"
|
||
if isinstance(value, (int, float)):
|
||
return str(value)
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
if not text:
|
||
return "未填写"
|
||
if len(text) > 80:
|
||
return f"{text[:80]}..."
|
||
return text
|
||
if isinstance(value, list):
|
||
return f"已配置列表({len(value)}项)"
|
||
if isinstance(value, dict):
|
||
return "已配置内容"
|
||
return str(value)
|
||
|
||
|
||
def _setup_row_identity(module_key: str, row: Any, index: int) -> str:
|
||
if not isinstance(row, dict):
|
||
return f"第{index + 1}行"
|
||
if module_key == "projectMilestones":
|
||
return str(row.get("name") or "").strip() or f"第{index + 1}行"
|
||
if module_key == "siteMilestones":
|
||
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}行"
|
||
return f"第{index + 1}行"
|
||
|
||
|
||
def _setup_collect_diff_lines(
|
||
module_key: str,
|
||
old_value: Any,
|
||
new_value: Any,
|
||
path: list[str],
|
||
lines: list[str],
|
||
) -> None:
|
||
if isinstance(old_value, list) or isinstance(new_value, list):
|
||
old_list = old_value if isinstance(old_value, list) else []
|
||
new_list = new_value if isinstance(new_value, list) else []
|
||
for index in range(max(len(old_list), len(new_list))):
|
||
old_item = old_list[index] if index < len(old_list) else None
|
||
new_item = new_list[index] if index < len(new_list) else None
|
||
identity = _setup_row_identity(module_key, new_item if new_item is not None else old_item, index)
|
||
_setup_collect_diff_lines(module_key, old_item, new_item, [*path, identity], lines)
|
||
return
|
||
|
||
if isinstance(old_value, dict) or isinstance(new_value, dict):
|
||
old_dict = old_value if isinstance(old_value, dict) else {}
|
||
new_dict = new_value if isinstance(new_value, dict) else {}
|
||
keys = sorted(set(old_dict.keys()) | set(new_dict.keys()))
|
||
for key in keys:
|
||
_setup_collect_diff_lines(module_key, old_dict.get(key), new_dict.get(key), [*path, key], lines)
|
||
return
|
||
|
||
if old_value == new_value:
|
||
return
|
||
|
||
module_label = _SETUP_MODULE_LABELS.get(module_key, module_key)
|
||
readable_path = " / ".join(_SETUP_FIELD_LABELS.get(part, part) for part in path if part)
|
||
field_text = f"{module_label} / {readable_path}" if readable_path else module_label
|
||
lines.append(f"{field_text}:{_setup_value_text(old_value)} -> {_setup_value_text(new_value)}")
|
||
|
||
|
||
def _top_level_diff_summary(old: dict | None, new: dict | None) -> str:
|
||
old = old or {}
|
||
new = new or {}
|
||
changed_modules: list[str] = []
|
||
detail_lines: list[str] = []
|
||
|
||
for module_key in _SETUP_MODULE_KEYS:
|
||
if old.get(module_key) == new.get(module_key):
|
||
continue
|
||
changed_modules.append(_SETUP_MODULE_LABELS.get(module_key, module_key))
|
||
_setup_collect_diff_lines(module_key, old.get(module_key), new.get(module_key), [], detail_lines)
|
||
|
||
module_summary = "变更模块:" + ("、".join(changed_modules) if changed_modules else "无")
|
||
if not detail_lines:
|
||
return module_summary
|
||
|
||
max_lines = 20
|
||
visible_lines = detail_lines[:max_lines]
|
||
if len(detail_lines) > max_lines:
|
||
visible_lines.append(f"其余 {len(detail_lines) - max_lines} 项变更已省略")
|
||
return f"{module_summary}; 变更明细:" + ";".join(visible_lines)
|
||
|
||
|
||
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
|
||
async def create_study(
|
||
study_in: StudyCreate,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudyRead:
|
||
_ensure_study_timeline_valid(
|
||
plan_start_date=study_in.plan_start_date,
|
||
plan_end_date=study_in.plan_end_date,
|
||
)
|
||
study_in.code = study_in.code.strip()
|
||
if not study_in.code:
|
||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="项目编号不能为空")
|
||
existing = await study_crud.get_by_code(db, study_in.code)
|
||
if existing:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||
study = await study_crud.create(db, study_in, created_by=current_user.id)
|
||
|
||
# 自动将创建者添加为项目成员,角色为PM
|
||
member_in = StudyMemberCreate(
|
||
user_id=current_user.id,
|
||
role_in_study="PM",
|
||
is_active=True,
|
||
)
|
||
await member_crud.add_member(db, study.id, member_in)
|
||
|
||
# 初始化立项配置草稿,并回填可映射的项目信息字段
|
||
default_setup_data = _build_default_setup_config_from_study(study, [])
|
||
setup_record, _ = await study_setup_config_crud.upsert(
|
||
db,
|
||
study.id,
|
||
expected_version=None,
|
||
data=default_setup_data,
|
||
saved_by=current_user.id,
|
||
)
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study.id,
|
||
entity_type="study_setup_config",
|
||
entity_id=setup_record.id,
|
||
action="CREATE_SETUP_CONFIG",
|
||
detail="初始化立项配置(创建项目时回填基础信息)",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
return study
|
||
|
||
|
||
@router.get("/", response_model=PaginatedResponse[StudyRead])
|
||
async def list_studies(
|
||
skip: int = 0,
|
||
limit: int = 100,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> PaginatedResponse[StudyRead]:
|
||
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)
|
||
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="项目不存在")
|
||
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_roles(["ADMIN"]))],
|
||
)
|
||
async def update_study(
|
||
study_id: uuid.UUID,
|
||
study_in: StudyUpdate,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> StudyRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
# 检查项目是否已锁定(除非是解锁操作)
|
||
if study.is_locked and not (study_in.is_locked is False):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="项目已锁定,无法编辑。请先解锁项目。"
|
||
)
|
||
|
||
if study_in.code and study_in.code != study.code:
|
||
existing = await study_crud.get_by_code(db, study_in.code)
|
||
if existing and existing.id != study.id:
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目编号已存在")
|
||
|
||
next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date
|
||
next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date
|
||
_ensure_study_timeline_valid(
|
||
plan_start_date=next_plan_start,
|
||
plan_end_date=next_plan_end,
|
||
)
|
||
|
||
updated = await study_crud.update(db, study, study_in)
|
||
return updated
|
||
|
||
|
||
@router.delete(
|
||
"/{study_id}",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||
)
|
||
async def delete_study(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> None:
|
||
"""硬删除项目及其所有关联数据(不可逆操作)"""
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
# 删除项目及其所有关联数据(包括审计日志)
|
||
await study_crud.delete(db, study_id)
|
||
|
||
|
||
@router.patch(
|
||
"/{study_id}/lock",
|
||
response_model=StudyRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||
)
|
||
async def lock_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="项目不存在")
|
||
|
||
locked_study = await study_crud.lock(db, study_id)
|
||
|
||
# 记录审计日志
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study",
|
||
entity_id=study_id,
|
||
action="LOCK_STUDY",
|
||
detail=f"项目已锁定:{study.name} ({study.code})",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
return locked_study
|
||
|
||
|
||
@router.patch(
|
||
"/{study_id}/unlock",
|
||
response_model=StudyRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"]))],
|
||
)
|
||
async def unlock_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="项目不存在")
|
||
|
||
unlocked_study = await study_crud.unlock(db, study_id)
|
||
|
||
# 记录审计日志
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study",
|
||
entity_id=study_id,
|
||
action="UNLOCK_STUDY",
|
||
detail=f"项目已解锁:{study.name} ({study.code})",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
return unlocked_study
|
||
|
||
|
||
@router.get(
|
||
"/{study_id}/setup-config",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_study_member())],
|
||
)
|
||
async def get_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||
if not record:
|
||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=500, include_inactive=True)
|
||
default_data = _build_default_setup_config_from_study(study, list(sites))
|
||
record, _ = await study_setup_config_crud.upsert(
|
||
db,
|
||
study_id,
|
||
expected_version=None,
|
||
data=default_data,
|
||
saved_by=current_user.id,
|
||
)
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="CREATE_SETUP_CONFIG",
|
||
detail="初始化立项配置",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
saved_by_name = None
|
||
published_by_name = None
|
||
if record.saved_by:
|
||
user = await user_crud.get_by_id(db, record.saved_by)
|
||
if user:
|
||
saved_by_name = user.full_name or user.username or user.email
|
||
if record.published_by:
|
||
user = await user_crud.get_by_id(db, record.published_by)
|
||
if user:
|
||
published_by_name = user.full_name or user.username or user.email
|
||
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
record,
|
||
saved_by_name=saved_by_name,
|
||
published_by_name=published_by_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
|
||
)
|
||
|
||
|
||
@router.put(
|
||
"/{study_id}/setup-config",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def upsert_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigUpsert,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||
setup_project_snapshot = _resolve_setup_project_snapshot(payload.data, study)
|
||
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=setup_project_snapshot, strict_required=False)
|
||
setup_plan_start = _parse_optional_snapshot_date(setup_project_snapshot.plan_start_date)
|
||
setup_plan_end = _parse_optional_snapshot_date(setup_project_snapshot.plan_end_date)
|
||
_validate_setup_data(
|
||
payload.data,
|
||
site_lookup,
|
||
project_plan_start=setup_plan_start,
|
||
project_plan_end=setup_plan_end,
|
||
strict_required=False,
|
||
)
|
||
|
||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||
old_config = dict(existing.config or {}) if existing else {}
|
||
force_draft = bool(payload.force_draft)
|
||
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
|
||
current_project_snapshot = setup_project_snapshot.model_dump(mode="json")
|
||
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
|
||
force_draft = True
|
||
record, conflict = await study_setup_config_crud.upsert(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
data=payload.data,
|
||
saved_by=current_user.id,
|
||
force_draft=force_draft,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置保存失败")
|
||
|
||
has_setup_diff = old_config != (record.config or {})
|
||
if has_setup_diff:
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="UPDATE_SETUP_CONFIG",
|
||
detail=_top_level_diff_summary(old_config, record.config),
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
|
||
saved_by_name = current_user.full_name or current_user.username or current_user.email
|
||
published_by_name = None
|
||
if record.published_by:
|
||
user = await user_crud.get_by_id(db, record.published_by)
|
||
if user:
|
||
published_by_name = user.full_name or user.username or user.email
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
record,
|
||
saved_by_name=saved_by_name,
|
||
published_by_name=published_by_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/publish",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def publish_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigPublish,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
|
||
setup_data = StudySetupConfigData.model_validate(record.config or {})
|
||
project_snapshot_for_publish = _resolve_setup_project_snapshot(setup_data, study)
|
||
await _validate_setup_project_snapshot(db, study_id=study_id, snapshot=project_snapshot_for_publish)
|
||
setup_plan_start = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_start_date)
|
||
setup_plan_end = _parse_optional_snapshot_date(project_snapshot_for_publish.plan_end_date)
|
||
current_project_snapshot_for_publish = project_snapshot_for_publish.model_dump(mode="json")
|
||
force_create_snapshot = bool(
|
||
(record.published_project_snapshot or {}) != current_project_snapshot_for_publish
|
||
)
|
||
|
||
sites = await site_crud.list_by_study(db, study_id, skip=0, limit=1000, include_inactive=True)
|
||
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||
_validate_setup_data(
|
||
setup_data,
|
||
site_lookup,
|
||
project_plan_start=setup_plan_start,
|
||
project_plan_end=setup_plan_end,
|
||
)
|
||
|
||
published, conflict = await study_setup_config_crud.publish(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
published_by=current_user.id,
|
||
force_create_snapshot=force_create_snapshot,
|
||
auto_commit=False,
|
||
)
|
||
if conflict:
|
||
await db.rollback()
|
||
_raise_conflict_error()
|
||
if not published:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="配置发布失败")
|
||
|
||
try:
|
||
_apply_project_publish_snapshot_to_study(study, project_snapshot_for_publish)
|
||
projection = await apply_setup_projection_on_publish(
|
||
db,
|
||
study_id=study_id,
|
||
setup_data=StudySetupConfigData.model_validate(published.published_config or {}),
|
||
operator_id=current_user.id,
|
||
)
|
||
projection_summary = SetupProjectionSummary(
|
||
study_updated=projection.study_updated,
|
||
site_updated_count=projection.site_updated_count,
|
||
site_skipped_count=projection.site_skipped_count,
|
||
warnings=projection.warnings,
|
||
skipped_items=[
|
||
{"site_id": item.site_id, "reason": item.reason}
|
||
for item in projection.skipped_items
|
||
],
|
||
)
|
||
project_snapshot = project_snapshot_for_publish.model_dump(mode="json")
|
||
published.published_project_snapshot = project_snapshot
|
||
await study_setup_config_crud.set_version_project_snapshot(
|
||
db,
|
||
study_id,
|
||
version=published.version,
|
||
project_snapshot=project_snapshot,
|
||
)
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=published.id,
|
||
action="PUBLISH_SETUP_CONFIG",
|
||
detail=_build_projection_audit_detail(
|
||
projection_status=projection.status,
|
||
study_updated=projection.study_updated,
|
||
site_updated_count=projection.site_updated_count,
|
||
site_skipped_count=projection.site_skipped_count,
|
||
warnings=projection.warnings,
|
||
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,
|
||
auto_commit=False,
|
||
)
|
||
published.config = study_setup_config_crud.empty_draft_payload()
|
||
published.active_branch_base_version_id = None
|
||
published.saved_by = current_user.id
|
||
published.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
except Exception:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="发布失败:联动同步异常")
|
||
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
await db.refresh(published)
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
published,
|
||
saved_by_name=operator_name,
|
||
published_by_name=operator_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, published),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, published),
|
||
projection_status=projection.status,
|
||
projection_summary=projection_summary,
|
||
)
|
||
|
||
|
||
@router.get(
|
||
"/{study_id}/setup-config/versions",
|
||
response_model=list[StudySetupConfigVersionRead],
|
||
dependencies=[Depends(require_study_member())],
|
||
)
|
||
async def list_study_setup_config_versions(
|
||
study_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> list[StudySetupConfigVersionRead]:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
current_setup = await study_setup_config_crud.get_by_study(db, study_id)
|
||
current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
|
||
active_draft_base_version = _resolve_active_branch_base_snapshot_version(versions, current_setup)
|
||
user_ids = {item.published_by for item in versions if item.published_by}
|
||
name_map: dict[uuid.UUID, str] = {}
|
||
for user_id in user_ids:
|
||
user = await user_crud.get_by_id(db, user_id)
|
||
if user:
|
||
name_map[user_id] = user.full_name or user.username or user.email
|
||
return [
|
||
_to_setup_config_version_read(
|
||
item,
|
||
published_by_name=name_map.get(item.published_by) if item.published_by else None,
|
||
is_current_published=item.version == current_published_version,
|
||
is_active_draft_base=item.version == active_draft_base_version,
|
||
)
|
||
for item in versions
|
||
]
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/rollback",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def rollback_study_setup_config(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigRollback,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
versions_before = await study_setup_config_crud.list_versions(db, study_id)
|
||
target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
|
||
target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}")
|
||
|
||
record, conflict, target_missing = await study_setup_config_crud.rollback_to_version(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
target_version=payload.target_version,
|
||
saved_by=current_user.id,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if target_missing:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="版本回滚失败")
|
||
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="ROLLBACK_SETUP_CONFIG",
|
||
detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
published_by_name = None
|
||
if record.published_by:
|
||
user = await user_crud.get_by_id(db, record.published_by)
|
||
if user:
|
||
published_by_name = user.full_name or user.username or user.email
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
record,
|
||
saved_by_name=operator_name,
|
||
published_by_name=published_by_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/draft/checkout-branch",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def checkout_study_setup_config_branch_draft(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigCheckoutBranch,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
versions_before = await study_setup_config_crud.list_versions(db, study_id)
|
||
target_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
|
||
target_label = target_label_map.get(payload.target_version, f"v{payload.target_version}")
|
||
|
||
record, conflict, target_missing = await study_setup_config_crud.checkout_branch_draft_from_version(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
target_version=payload.target_version,
|
||
saved_by=current_user.id,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if target_missing:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="切换分支草稿失败")
|
||
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="CHECKOUT_SETUP_CONFIG_BRANCH_DRAFT",
|
||
detail=f"立项配置草稿已切换到 {target_label} 对应分支",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
published_by_name = None
|
||
if record.published_by:
|
||
user = await user_crud.get_by_id(db, record.published_by)
|
||
if user:
|
||
published_by_name = user.full_name or user.username or user.email
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
record,
|
||
saved_by_name=operator_name,
|
||
published_by_name=published_by_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/draft/clear",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def clear_study_setup_config_draft(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigDraftAction,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record, conflict = await study_setup_config_crud.clear_draft(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
saved_by=current_user.id,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="清空草稿失败")
|
||
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="CLEAR_SETUP_CONFIG_DRAFT",
|
||
detail="立项配置草稿已清空",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
published_by_name = None
|
||
if record.published_by:
|
||
user = await user_crud.get_by_id(db, record.published_by)
|
||
if user:
|
||
published_by_name = user.full_name or user.username or user.email
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
record,
|
||
saved_by_name=operator_name,
|
||
published_by_name=published_by_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/draft/refill",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def refill_study_setup_config_draft(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigDraftAction,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
record, conflict, no_published = await study_setup_config_crud.refill_draft_from_published(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
saved_by=current_user.id,
|
||
)
|
||
if conflict:
|
||
_raise_conflict_error()
|
||
if no_published:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前无已发布版本,无法回填草稿")
|
||
if not record:
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="回填草稿失败")
|
||
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=record.id,
|
||
action="REFILL_SETUP_CONFIG_DRAFT",
|
||
detail="立项配置草稿已从当前发布版本一键回填",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
published_by_name = None
|
||
if record.published_by:
|
||
user = await user_crud.get_by_id(db, record.published_by)
|
||
if user:
|
||
published_by_name = user.full_name or user.username or user.email
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
record,
|
||
saved_by_name=operator_name,
|
||
published_by_name=published_by_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, record),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, record),
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{study_id}/setup-config/merge-main",
|
||
response_model=StudySetupConfigRead,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def merge_study_setup_config_to_main(
|
||
study_id: uuid.UUID,
|
||
payload: StudySetupConfigMergeToMain,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> StudySetupConfigRead:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
versions_before = await study_setup_config_crud.list_versions(db, study_id)
|
||
source_label_map = {item.version: (item.version_label or f"v{item.display_version}") for item in versions_before}
|
||
source_label = source_label_map.get(payload.source_version, f"v{payload.source_version}")
|
||
|
||
merged, conflict, source_missing = await study_setup_config_crud.merge_to_main(
|
||
db,
|
||
study_id,
|
||
expected_version=payload.expected_version,
|
||
source_version=payload.source_version,
|
||
merged_by=current_user.id,
|
||
auto_commit=False,
|
||
)
|
||
if conflict:
|
||
await db.rollback()
|
||
_raise_conflict_error()
|
||
if source_missing:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
if not merged:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败")
|
||
|
||
try:
|
||
merged_setup_data = StudySetupConfigData.model_validate(merged.published_config or {})
|
||
merged_project_snapshot = _resolve_setup_project_snapshot(merged_setup_data, study)
|
||
_apply_project_publish_snapshot_to_study(study, merged_project_snapshot)
|
||
projection = await apply_setup_projection_on_publish(
|
||
db,
|
||
study_id=study_id,
|
||
setup_data=merged_setup_data,
|
||
operator_id=current_user.id,
|
||
)
|
||
projection_summary = SetupProjectionSummary(
|
||
study_updated=projection.study_updated,
|
||
site_updated_count=projection.site_updated_count,
|
||
site_skipped_count=projection.site_skipped_count,
|
||
warnings=projection.warnings,
|
||
skipped_items=[{"site_id": item.site_id, "reason": item.reason} for item in projection.skipped_items],
|
||
)
|
||
project_snapshot = merged_project_snapshot.model_dump(mode="json")
|
||
merged.published_project_snapshot = project_snapshot
|
||
await study_setup_config_crud.set_version_project_snapshot(
|
||
db,
|
||
study_id,
|
||
version=merged.version,
|
||
project_snapshot=project_snapshot,
|
||
)
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=merged.id,
|
||
action="MERGE_SETUP_CONFIG_TO_MAIN",
|
||
detail=f"已将发布版本 {source_label} 合并到主分支并生成新主版本",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
auto_commit=False,
|
||
)
|
||
merged.config = study_setup_config_crud.empty_draft_payload()
|
||
merged.active_branch_base_version_id = None
|
||
merged.saved_by = current_user.id
|
||
merged.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
except Exception:
|
||
await db.rollback()
|
||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="合并主分支失败:联动同步异常")
|
||
|
||
operator_name = current_user.full_name or current_user.username or current_user.email
|
||
await db.refresh(merged)
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
return _to_setup_config_read(
|
||
merged,
|
||
saved_by_name=operator_name,
|
||
published_by_name=operator_name,
|
||
current_published_version_label=_resolve_current_published_snapshot_label(versions, merged),
|
||
active_branch_base_version_label=_resolve_active_branch_base_snapshot_label(versions, merged),
|
||
projection_status=projection.status,
|
||
projection_summary=projection_summary,
|
||
)
|
||
|
||
|
||
@router.delete(
|
||
"/{study_id}/setup-config/versions/{target_version}",
|
||
status_code=status.HTTP_204_NO_CONTENT,
|
||
dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())],
|
||
)
|
||
async def delete_study_setup_config_version(
|
||
study_id: uuid.UUID,
|
||
target_version: int,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
current_user=Depends(get_current_user),
|
||
) -> None:
|
||
study = await study_crud.get(db, study_id)
|
||
if not study:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||
|
||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||
if not versions:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
current_setup = await study_setup_config_crud.get_by_study(db, study_id)
|
||
current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
|
||
if current_published_version is None:
|
||
current_published_version = max(item.version for item in versions)
|
||
if target_version == current_published_version:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前发布版本不能删除")
|
||
|
||
deleted = await study_setup_config_crud.delete_version(
|
||
db,
|
||
study_id,
|
||
target_version=target_version,
|
||
)
|
||
if not deleted:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||
|
||
await audit_crud.log_action(
|
||
db,
|
||
study_id=study_id,
|
||
entity_type="study_setup_config",
|
||
entity_id=study_id,
|
||
action="DELETE_SETUP_CONFIG_VERSION",
|
||
detail=f"删除发布版本快照 v{target_version}",
|
||
operator_id=current_user.id,
|
||
operator_role=current_user.role,
|
||
)
|