立项配置版本管理优化(回滚逻辑、UI、下载功能)

This commit is contained in:
Cheng Zhou
2026-03-04 14:49:59 +08:00
parent 6c5e5770c6
commit 0cd03eefa8
16 changed files with 3528 additions and 418 deletions
+445 -12
View File
@@ -1,6 +1,6 @@
import uuid
import re
from datetime import date
from datetime import date, datetime
from collections import Counter
from typing import Any
@@ -27,7 +27,10 @@ from app.schemas.member import StudyMemberCreate
from app.schemas.study_setup_config import (
ProjectPublishSnapshot,
SetupProjectionSummary,
StudySetupConfigCheckoutBranch,
StudySetupConfigData,
StudySetupConfigDraftAction,
StudySetupConfigMergeToMain,
StudySetupConfigPublish,
StudySetupConfigRead,
StudySetupConfigRollback,
@@ -295,6 +298,8 @@ def _to_setup_config_read(
*,
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:
@@ -302,6 +307,11 @@ def _to_setup_config_read(
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,
@@ -363,15 +373,42 @@ def _ensure_study_timeline_valid(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移")
def _to_setup_config_version_read(record, *, published_by_name: str | None = None) -> StudySetupConfigVersionRead:
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,
@@ -379,6 +416,89 @@ def _to_setup_config_version_read(record, *, published_by_name: str | None = Non
)
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",
@@ -777,7 +897,14 @@ async def get_study_setup_config(
if user:
published_by_name = user.full_name or user.username or user.email
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
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(
@@ -843,7 +970,14 @@ async def upsert_study_setup_config(
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
return _to_setup_config_read(record, saved_by_name=saved_by_name, published_by_name=published_by_name)
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(
@@ -864,6 +998,10 @@ async def publish_study_setup_config(
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="请先保存立项配置草稿")
current_project_snapshot_for_publish = _build_project_publish_snapshot(study).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}
@@ -879,6 +1017,7 @@ async def publish_study_setup_config(
study_id,
expected_version=payload.expected_version,
published_by=current_user.id,
force_create_snapshot=force_create_snapshot,
auto_commit=False,
)
if conflict:
@@ -892,7 +1031,7 @@ async def publish_study_setup_config(
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
setup_data=StudySetupConfigData.model_validate(published.config or {}),
setup_data=StudySetupConfigData.model_validate(published.published_config or {}),
operator_id=current_user.id,
)
projection_summary = SetupProjectionSummary(
@@ -907,7 +1046,14 @@ async def publish_study_setup_config(
)
study_after_publish = await study_crud.get(db, study_id)
if study_after_publish:
published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
project_snapshot = _build_project_publish_snapshot(study_after_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,
@@ -926,6 +1072,10 @@ async def publish_study_setup_config(
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()
@@ -933,10 +1083,13 @@ async def publish_study_setup_config(
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,
)
@@ -956,6 +1109,9 @@ async def list_study_setup_config_versions(
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:
@@ -963,7 +1119,12 @@ async def list_study_setup_config_versions(
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)
_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
]
@@ -982,6 +1143,9 @@ async def rollback_study_setup_config(
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,
@@ -1003,7 +1167,7 @@ async def rollback_study_setup_config(
entity_type="study_setup_config",
entity_id=record.id,
action="ROLLBACK_SETUP_CONFIG",
detail=f"立项配置已回滚到发布版本 v{payload.target_version}",
detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
operator_id=current_user.id,
operator_role=current_user.role,
)
@@ -1013,7 +1177,273 @@ async def rollback_study_setup_config(
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
return _to_setup_config_read(record, saved_by_name=operator_name, published_by_name=published_by_name)
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_study_roles(["PM"])), 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_study_roles(["PM"])), 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_study_roles(["PM"])), 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_study_roles(["PM"])), 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:
projection = await apply_setup_projection_on_publish(
db,
study_id=study_id,
setup_data=StudySetupConfigData.model_validate(merged.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],
)
study_after_publish = await study_crud.get(db, study_id)
if study_after_publish:
project_snapshot = _build_project_publish_snapshot(study_after_publish).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(
@@ -1034,9 +1464,12 @@ async def delete_study_setup_config_version(
versions = await study_setup_config_crud.list_versions(db, study_id)
if not versions:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
latest_version = max(item.version for item in versions)
if target_version == latest_version:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, 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,