立项配置版本管理优化(回滚逻辑、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
@@ -0,0 +1,44 @@
"""add published project snapshot to study_setup_config_versions
Revision ID: 20260228_02
Revises: 20260228_01
Create Date: 2026-02-28 18:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260228_02"
down_revision: Union[str, None] = "20260228_01"
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)
tables = set(inspector.get_table_names())
if "study_setup_config_versions" not in tables:
return
columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")}
if "published_project_snapshot" not in columns:
op.add_column(
"study_setup_config_versions",
sa.Column("published_project_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "study_setup_config_versions" not in tables:
return
columns = {col["name"] for col in inspector.get_columns("study_setup_config_versions")}
if "published_project_snapshot" in columns:
op.drop_column("study_setup_config_versions", "published_project_snapshot")
@@ -0,0 +1,235 @@
"""add setup config version branching metadata
Revision ID: 20260302_01
Revises: 20260228_02
Create Date: 2026-03-02 16:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "20260302_01"
down_revision: Union[str, None] = "20260228_02"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
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 column_name in {col["name"] for col in inspector.get_columns(table_name)}
def _has_unique_constraint(inspector: sa.Inspector, table_name: str, constraint_name: str) -> bool:
return constraint_name in {item["name"] for item in inspector.get_unique_constraints(table_name)}
def _has_foreign_key(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
return fk_name in {item["name"] for item in inspector.get_foreign_keys(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _has_table(inspector, "study_setup_config_versions"):
if not _has_column(inspector, "study_setup_config_versions", "branch_name"):
op.add_column("study_setup_config_versions", sa.Column("branch_name", sa.String(), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "branch_seq"):
op.add_column("study_setup_config_versions", sa.Column("branch_seq", sa.Integer(), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "version_label"):
op.add_column("study_setup_config_versions", sa.Column("version_label", sa.String(), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "parent_version_id"):
op.add_column("study_setup_config_versions", sa.Column("parent_version_id", postgresql.UUID(as_uuid=True), nullable=True))
if not _has_column(inspector, "study_setup_config_versions", "merged_from_version_id"):
op.add_column(
"study_setup_config_versions",
sa.Column("merged_from_version_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.execute("UPDATE study_setup_config_versions SET branch_name = 'main' WHERE branch_name IS NULL")
op.execute(
"UPDATE study_setup_config_versions SET branch_seq = COALESCE(display_version, version, 1) WHERE branch_seq IS NULL"
)
op.execute(
"UPDATE study_setup_config_versions SET version_label = ('v' || COALESCE(display_version, version, 1)::text) WHERE version_label IS NULL"
)
op.alter_column("study_setup_config_versions", "branch_name", nullable=False)
op.alter_column("study_setup_config_versions", "branch_seq", nullable=False)
op.alter_column("study_setup_config_versions", "version_label", nullable=False)
inspector = sa.inspect(bind)
if not _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_parent_version"):
op.create_foreign_key(
"fk_setup_config_versions_parent_version",
"study_setup_config_versions",
"study_setup_config_versions",
["parent_version_id"],
["id"],
ondelete="SET NULL",
)
if not _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_merged_from_version"):
op.create_foreign_key(
"fk_setup_config_versions_merged_from_version",
"study_setup_config_versions",
"study_setup_config_versions",
["merged_from_version_id"],
["id"],
ondelete="SET NULL",
)
if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_display_version"):
op.drop_constraint(
"uq_setup_config_versions_study_display_version",
"study_setup_config_versions",
type_="unique",
)
inspector = sa.inspect(bind)
if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_branch_seq"):
op.create_unique_constraint(
"uq_setup_config_versions_study_branch_seq",
"study_setup_config_versions",
["study_id", "branch_name", "branch_seq"],
)
if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_version_label"):
op.create_unique_constraint(
"uq_setup_config_versions_study_version_label",
"study_setup_config_versions",
["study_id", "version_label"],
)
if _has_table(inspector, "study_setup_configs"):
if not _has_column(inspector, "study_setup_configs", "current_branch_name"):
op.add_column("study_setup_configs", sa.Column("current_branch_name", sa.String(), nullable=True))
if not _has_column(inspector, "study_setup_configs", "active_branch_base_version_id"):
op.add_column(
"study_setup_configs",
sa.Column("active_branch_base_version_id", postgresql.UUID(as_uuid=True), nullable=True),
)
if not _has_column(inspector, "study_setup_configs", "current_published_version_id"):
op.add_column(
"study_setup_configs",
sa.Column("current_published_version_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.execute("UPDATE study_setup_configs SET current_branch_name = 'main' WHERE current_branch_name IS NULL")
op.alter_column("study_setup_configs", "current_branch_name", nullable=False)
if _has_table(inspector, "study_setup_config_versions"):
op.execute(
"""
UPDATE study_setup_configs
SET current_published_version_id = (
SELECT v.id
FROM study_setup_config_versions AS v
WHERE v.study_id = study_setup_configs.study_id
AND study_setup_configs.published_config IS NOT NULL
AND v.config = study_setup_configs.published_config
ORDER BY
CASE
WHEN study_setup_configs.published_at IS NOT NULL
AND v.published_at = study_setup_configs.published_at THEN 0
ELSE 1
END,
v.version DESC
LIMIT 1
)
WHERE current_published_version_id IS NULL
"""
)
inspector = sa.inspect(bind)
if not _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_active_branch_base_version"):
op.create_foreign_key(
"fk_setup_configs_active_branch_base_version",
"study_setup_configs",
"study_setup_config_versions",
["active_branch_base_version_id"],
["id"],
ondelete="SET NULL",
)
if not _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_current_published_version"):
op.create_foreign_key(
"fk_setup_configs_current_published_version",
"study_setup_configs",
"study_setup_config_versions",
["current_published_version_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _has_table(inspector, "study_setup_configs"):
if _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_current_published_version"):
op.drop_constraint(
"fk_setup_configs_current_published_version",
"study_setup_configs",
type_="foreignkey",
)
if _has_foreign_key(inspector, "study_setup_configs", "fk_setup_configs_active_branch_base_version"):
op.drop_constraint(
"fk_setup_configs_active_branch_base_version",
"study_setup_configs",
type_="foreignkey",
)
if _has_column(inspector, "study_setup_configs", "current_published_version_id"):
op.drop_column("study_setup_configs", "current_published_version_id")
if _has_column(inspector, "study_setup_configs", "active_branch_base_version_id"):
op.drop_column("study_setup_configs", "active_branch_base_version_id")
if _has_column(inspector, "study_setup_configs", "current_branch_name"):
op.drop_column("study_setup_configs", "current_branch_name")
inspector = sa.inspect(bind)
if _has_table(inspector, "study_setup_config_versions"):
if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_version_label"):
op.drop_constraint(
"uq_setup_config_versions_study_version_label",
"study_setup_config_versions",
type_="unique",
)
if _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_branch_seq"):
op.drop_constraint(
"uq_setup_config_versions_study_branch_seq",
"study_setup_config_versions",
type_="unique",
)
if not _has_unique_constraint(inspector, "study_setup_config_versions", "uq_setup_config_versions_study_display_version"):
op.create_unique_constraint(
"uq_setup_config_versions_study_display_version",
"study_setup_config_versions",
["study_id", "display_version"],
)
if _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_merged_from_version"):
op.drop_constraint(
"fk_setup_config_versions_merged_from_version",
"study_setup_config_versions",
type_="foreignkey",
)
if _has_foreign_key(inspector, "study_setup_config_versions", "fk_setup_config_versions_parent_version"):
op.drop_constraint(
"fk_setup_config_versions_parent_version",
"study_setup_config_versions",
type_="foreignkey",
)
if _has_column(inspector, "study_setup_config_versions", "merged_from_version_id"):
op.drop_column("study_setup_config_versions", "merged_from_version_id")
if _has_column(inspector, "study_setup_config_versions", "parent_version_id"):
op.drop_column("study_setup_config_versions", "parent_version_id")
if _has_column(inspector, "study_setup_config_versions", "version_label"):
op.drop_column("study_setup_config_versions", "version_label")
if _has_column(inspector, "study_setup_config_versions", "branch_seq"):
op.drop_column("study_setup_config_versions", "branch_seq")
if _has_column(inspector, "study_setup_config_versions", "branch_name"):
op.drop_column("study_setup_config_versions", "branch_name")
@@ -0,0 +1,71 @@
"""drop unique constraint on setup config version label
Revision ID: 20260302_02
Revises: 20260302_01
Create Date: 2026-03-02 20:20:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "20260302_02"
down_revision: Union[str, None] = "20260302_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in set(inspector.get_table_names())
def _has_unique_constraint(inspector: sa.Inspector, table_name: str, constraint_name: str) -> bool:
return constraint_name in {item["name"] for item in inspector.get_unique_constraints(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
table_name = "study_setup_config_versions"
constraint_name = "uq_setup_config_versions_study_version_label"
if _has_table(inspector, table_name) and _has_unique_constraint(inspector, table_name, constraint_name):
op.drop_constraint(constraint_name, table_name, type_="unique")
if _has_table(inspector, table_name):
op.execute(
"""
WITH ranked AS (
SELECT
id,
display_version,
ROW_NUMBER() OVER (
PARTITION BY study_id, branch_name, display_version
ORDER BY published_at ASC, version ASC, created_at ASC
) AS rev_seq
FROM study_setup_config_versions
WHERE branch_name = 'main'
)
UPDATE study_setup_config_versions AS v
SET version_label = CASE
WHEN ranked.rev_seq <= 1 THEN ('v' || ranked.display_version::text)
ELSE ('v' || ranked.display_version::text || '-r' || ranked.rev_seq::text)
END
FROM ranked
WHERE v.id = ranked.id
"""
)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
table_name = "study_setup_config_versions"
constraint_name = "uq_setup_config_versions_study_version_label"
if _has_table(inspector, table_name) and not _has_unique_constraint(inspector, table_name, constraint_name):
op.create_unique_constraint(
constraint_name,
table_name,
["study_id", "version_label"],
)
+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,
+390 -31
View File
@@ -2,7 +2,7 @@ import uuid
from copy import deepcopy
from datetime import datetime
from sqlalchemy import delete as sa_delete, select
from sqlalchemy import delete as sa_delete, select, update as sa_update
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +10,8 @@ from app.models.study_setup_config import StudySetupConfig
from app.models.study_setup_config_version import StudySetupConfigVersion
from app.schemas.study_setup_config import StudySetupConfigData
MAIN_BRANCH = "main"
def _to_storage(data: StudySetupConfigData | dict) -> dict:
if isinstance(data, StudySetupConfigData):
@@ -17,6 +19,130 @@ def _to_storage(data: StudySetupConfigData | dict) -> dict:
return deepcopy(data)
def empty_draft_payload() -> dict:
return StudySetupConfigData().model_dump(mode="json")
def _build_release_branch_name(base_display_version: int) -> str:
return f"release/v{base_display_version}"
def _parse_release_branch_base_display_version(branch_name: str) -> int | None:
prefix = "release/v"
if not branch_name.startswith(prefix):
return None
suffix = branch_name[len(prefix) :]
if not suffix.isdigit():
return None
return int(suffix)
def _build_version_label(branch_name: str, *, display_version: int, branch_seq: int) -> str:
if branch_name == MAIN_BRANCH:
return f"v{display_version}"
return f"v{display_version}.{branch_seq}"
async def _get_snapshot_by_version(
db: AsyncSession,
*,
study_id: uuid.UUID,
version: int,
) -> StudySetupConfigVersion | None:
result = await db.execute(
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == version,
)
)
return result.scalar_one_or_none()
async def _get_snapshot_by_id(
db: AsyncSession,
*,
snapshot_id: uuid.UUID,
) -> StudySetupConfigVersion | None:
result = await db.execute(select(StudySetupConfigVersion).where(StudySetupConfigVersion.id == snapshot_id))
return result.scalar_one_or_none()
async def _get_latest_snapshot_in_branch(
db: AsyncSession,
*,
study_id: uuid.UUID,
branch_name: str,
) -> StudySetupConfigVersion | None:
result = await db.execute(
select(StudySetupConfigVersion)
.where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.branch_name == branch_name,
)
.order_by(
StudySetupConfigVersion.branch_seq.desc(),
StudySetupConfigVersion.version.desc(),
StudySetupConfigVersion.published_at.desc(),
)
.limit(1)
)
return result.scalar_one_or_none()
async def _get_main_next_display_version(db: AsyncSession, *, study_id: uuid.UUID) -> int:
result = await db.execute(
select(func.max(StudySetupConfigVersion.display_version)).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.branch_name == MAIN_BRANCH,
)
)
current_max = result.scalar_one_or_none() or 0
return current_max + 1
async def _get_branch_next_seq(
db: AsyncSession,
*,
study_id: uuid.UUID,
branch_name: str,
) -> int:
result = await db.execute(
select(func.max(StudySetupConfigVersion.branch_seq)).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.branch_name == branch_name,
)
)
current_max = result.scalar_one_or_none() or 0
return current_max + 1
async def _resolve_current_published_snapshot_id(
db: AsyncSession,
*,
study_id: uuid.UUID,
published_config: dict | None,
published_by: uuid.UUID | None,
published_at: datetime | None,
) -> uuid.UUID | None:
if not published_config:
return None
rows = await list_versions(db, study_id)
best_score = -1
best_id: uuid.UUID | None = None
for item in rows:
if (item.config or {}) != (published_config or {}):
continue
score = 1
if published_at and item.published_at == published_at:
score += 2
if published_by and item.published_by == published_by:
score += 1
if score > best_score:
best_score = score
best_id = item.id
return best_id
async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None:
result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
return result.scalar_one_or_none()
@@ -56,6 +182,7 @@ async def upsert(
version=1,
config=payload,
publish_status="DRAFT",
current_branch_name=MAIN_BRANCH,
saved_by=saved_by,
)
db.add(record)
@@ -70,6 +197,7 @@ async def publish(
*,
expected_version: int | None,
published_by: uuid.UUID | None,
force_create_snapshot: bool = False,
auto_commit: bool = True,
) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id)
@@ -79,15 +207,15 @@ async def publish(
return None, True
publish_payload = _to_storage(existing.config or {})
next_version = existing.version + 1
latest_snapshot_stmt = (
select(StudySetupConfigVersion)
.where(StudySetupConfigVersion.study_id == study_id)
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc())
.limit(1)
)
latest_snapshot_result = await db.execute(latest_snapshot_stmt)
latest_snapshot = latest_snapshot_result.scalar_one_or_none()
should_create_snapshot = not latest_snapshot or (latest_snapshot.config or {}) != publish_payload
branch_name = (existing.current_branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
current_published_snapshot: StudySetupConfigVersion | None = None
current_published_branch = ""
if existing.current_published_version_id:
current_published_snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id)
if current_published_snapshot:
current_published_branch = (current_published_snapshot.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
branch_changed = bool(current_published_branch and current_published_branch != branch_name)
should_create_snapshot = force_create_snapshot or (existing.published_config or {}) != publish_payload or branch_changed
existing.version = next_version
existing.publish_status = "PUBLISHED"
@@ -98,23 +226,58 @@ async def publish(
existing.updated_at = datetime.utcnow()
if should_create_snapshot:
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
StudySetupConfigVersion.study_id == study_id
)
display_version_result = await db.execute(next_display_version_stmt)
current_max_display_version = display_version_result.scalar_one_or_none() or 0
next_display_version = current_max_display_version + 1
parent_snapshot_id = existing.active_branch_base_version_id
if parent_snapshot_id is None:
latest_branch_snapshot = await _get_latest_snapshot_in_branch(db, study_id=study_id, branch_name=branch_name)
parent_snapshot_id = latest_branch_snapshot.id if latest_branch_snapshot else None
if branch_name == MAIN_BRANCH:
next_display_version = await _get_main_next_display_version(db, study_id=study_id)
next_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=branch_name)
else:
next_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=branch_name)
branch_base = _parse_release_branch_base_display_version(branch_name)
if branch_base is None:
# Fall back to parent/main latest when branch name is customized.
if parent_snapshot_id:
parent_result = await db.execute(
select(StudySetupConfigVersion.display_version).where(StudySetupConfigVersion.id == parent_snapshot_id)
)
branch_base = parent_result.scalar_one_or_none()
if branch_base is None:
branch_base = await _get_main_next_display_version(db, study_id=study_id) - 1
next_display_version = branch_base
snapshot = StudySetupConfigVersion(
study_setup_config_id=existing.id,
study_id=study_id,
version=next_version,
display_version=next_display_version,
branch_name=branch_name,
branch_seq=next_branch_seq,
version_label=_build_version_label(
branch_name,
display_version=next_display_version,
branch_seq=next_branch_seq,
),
source_version=next_version - 1,
parent_version_id=parent_snapshot_id,
config=_to_storage(publish_payload),
published_by=published_by,
published_at=existing.published_at,
)
db.add(snapshot)
existing.current_published_version_id = snapshot.id
existing.active_branch_base_version_id = snapshot.id
else:
if not existing.current_published_version_id:
existing.current_published_version_id = await _resolve_current_published_snapshot_id(
db,
study_id=study_id,
published_config=existing.published_config,
published_by=existing.published_by,
published_at=existing.published_at,
)
if auto_commit:
await db.commit()
@@ -133,6 +296,21 @@ async def list_versions(db: AsyncSession, study_id: uuid.UUID) -> list[StudySetu
return list(result.scalars().all())
async def set_version_project_snapshot(
db: AsyncSession,
study_id: uuid.UUID,
*,
version: int,
project_snapshot: dict | None,
) -> bool:
target = await _get_snapshot_by_version(db, study_id=study_id, version=version)
if not target:
return False
target.published_project_snapshot = _to_storage(project_snapshot) if project_snapshot else None
await db.flush()
return True
async def rollback_to_version(
db: AsyncSession,
study_id: uuid.UUID,
@@ -147,23 +325,200 @@ async def rollback_to_version(
if expected_version is not None and expected_version != existing.version:
return None, True, False
result = await db.execute(
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == target_version,
)
)
target = result.scalar_one_or_none()
target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
if not target:
return None, False, True
target_display_version = target.display_version or 0
target_branch = (target.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
if target_branch == MAIN_BRANCH:
branch_name = _build_release_branch_name(target_display_version)
else:
branch_name = target_branch
now = datetime.utcnow()
existing.version = existing.version + 1
target_config = _to_storage(target.config or {})
existing.config = target_config
existing.published_config = _to_storage(target.config or {})
existing.published_project_snapshot = (
_to_storage(target.published_project_snapshot) if target.published_project_snapshot else None
)
existing.current_branch_name = branch_name
existing.active_branch_base_version_id = target.id
existing.current_published_version_id = target.id
existing.saved_by = saved_by
existing.published_by = saved_by
existing.published_at = now
existing.publish_status = "PUBLISHED"
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return existing, False, False
async def checkout_branch_draft_from_version(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
target_version: int,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
if not target:
return None, False, True
target_display_version = target.display_version or 0
target_branch = (target.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
if target_branch == MAIN_BRANCH:
checkout_branch = _build_release_branch_name(target_display_version)
else:
checkout_branch = target_branch
now = datetime.utcnow()
existing.version = existing.version + 1
existing.config = _to_storage(target.config or {})
existing.current_branch_name = checkout_branch
existing.active_branch_base_version_id = target.id
existing.saved_by = saved_by
existing.publish_status = "PUBLISHED" if (existing.published_config or {}) == (existing.config or {}) else "DRAFT"
existing.updated_at = now
await db.commit()
await db.refresh(existing)
return existing, False, False
async def clear_draft(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False
if expected_version is not None and expected_version != existing.version:
return None, True
existing.version = existing.version + 1
existing.config = empty_draft_payload()
existing.saved_by = saved_by
existing.active_branch_base_version_id = None
existing.publish_status = "DRAFT"
existing.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
return existing, False
async def refill_draft_from_published(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
if not existing.published_config:
return None, False, True
existing.version = existing.version + 1
existing.config = _to_storage(existing.published_config or {})
existing.saved_by = saved_by
if existing.current_published_version_id:
snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id)
if snapshot:
if not (existing.current_branch_name or "").strip():
existing.current_branch_name = snapshot.branch_name or MAIN_BRANCH
if existing.active_branch_base_version_id is None:
existing.active_branch_base_version_id = snapshot.id
existing.publish_status = "PUBLISHED" if (existing.published_config or {}) == (existing.config or {}) else "DRAFT"
existing.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
return existing, False, False
async def merge_to_main(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
source_version: int,
merged_by: uuid.UUID | None,
auto_commit: bool = True,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
source = await _get_snapshot_by_version(db, study_id=study_id, version=source_version)
if not source:
return None, False, True
payload = _to_storage(source.config or {})
next_version = existing.version + 1
now = datetime.utcnow()
latest_main_snapshot = await _get_latest_snapshot_in_branch(db, study_id=study_id, branch_name=MAIN_BRANCH)
parent_snapshot_id = latest_main_snapshot.id if latest_main_snapshot else None
next_main_display_version = await _get_main_next_display_version(db, study_id=study_id)
next_main_branch_seq = await _get_branch_next_seq(db, study_id=study_id, branch_name=MAIN_BRANCH)
snapshot = StudySetupConfigVersion(
study_setup_config_id=existing.id,
study_id=study_id,
version=next_version,
display_version=next_main_display_version,
branch_name=MAIN_BRANCH,
branch_seq=next_main_branch_seq,
version_label=_build_version_label(
MAIN_BRANCH,
display_version=next_main_display_version,
branch_seq=next_main_branch_seq,
),
source_version=source.version,
parent_version_id=parent_snapshot_id,
merged_from_version_id=source.id,
config=payload,
published_project_snapshot=_to_storage(source.published_project_snapshot) if source.published_project_snapshot else None,
published_by=merged_by,
published_at=now,
)
db.add(snapshot)
existing.version = next_version
existing.config = payload
existing.publish_status = "PUBLISHED"
existing.published_config = _to_storage(payload)
existing.published_project_snapshot = (
_to_storage(source.published_project_snapshot) if source.published_project_snapshot else None
)
existing.published_by = merged_by
existing.published_at = now
existing.saved_by = merged_by
existing.current_branch_name = MAIN_BRANCH
existing.active_branch_base_version_id = snapshot.id
existing.current_published_version_id = snapshot.id
existing.updated_at = now
if auto_commit:
await db.commit()
await db.refresh(existing)
else:
await db.flush()
return existing, False, False
@@ -173,21 +528,25 @@ async def delete_version(
*,
target_version: int,
) -> bool:
result = await db.execute(
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == target_version,
)
)
target = result.scalar_one_or_none()
target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
if not target:
return False
await db.execute(
sa_update(StudySetupConfig)
.where(StudySetupConfig.study_id == study_id, StudySetupConfig.active_branch_base_version_id == target.id)
.values(active_branch_base_version_id=None)
)
await db.execute(
sa_update(StudySetupConfig)
.where(StudySetupConfig.study_id == study_id, StudySetupConfig.current_published_version_id == target.id)
.values(current_published_version_id=None)
)
await db.delete(target)
await db.commit()
return True
async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None:
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
await db.commit()
+7
View File
@@ -19,6 +19,13 @@ class StudySetupConfig(Base):
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
active_branch_base_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
current_published_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
@@ -12,7 +12,7 @@ class StudySetupConfigVersion(Base):
__tablename__ = "study_setup_config_versions"
__table_args__ = (
UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"),
UniqueConstraint("study_id", "display_version", name="uq_setup_config_versions_study_display_version"),
UniqueConstraint("study_id", "branch_name", "branch_seq", name="uq_setup_config_versions_study_branch_seq"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -22,8 +22,18 @@ class StudySetupConfigVersion(Base):
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True)
version: Mapped[int] = mapped_column(Integer, nullable=False)
display_version: Mapped[int] = mapped_column(Integer, nullable=False)
branch_name: Mapped[str] = mapped_column(nullable=False, default="main")
branch_seq: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
version_label: Mapped[str] = mapped_column(nullable=False, default="")
source_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
parent_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
merged_from_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True
)
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+27
View File
@@ -92,14 +92,36 @@ class StudySetupConfigRollback(BaseModel):
target_version: int
class StudySetupConfigCheckoutBranch(BaseModel):
expected_version: int | None = None
target_version: int
class StudySetupConfigDraftAction(BaseModel):
expected_version: int | None = None
class StudySetupConfigMergeToMain(BaseModel):
expected_version: int | None = None
source_version: int
class StudySetupConfigVersionRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
study_setup_config_id: uuid.UUID
version: int
display_version: int
branch_name: str = "main"
branch_seq: int = 1
version_label: str = ""
source_version: int | None = None
parent_version_id: uuid.UUID | None = None
merged_from_version_id: uuid.UUID | None = None
config: StudySetupConfigData
published_project_snapshot: "ProjectPublishSnapshot | None" = None
is_current_published: bool = False
is_active_draft_base: bool = False
published_by: uuid.UUID | None = None
published_by_name: str | None = None
published_at: datetime
@@ -153,6 +175,11 @@ class StudySetupConfigRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
version: int
current_branch_name: str = "main"
current_published_version_id: uuid.UUID | None = None
current_published_version_label: str | None = None
active_branch_base_version_id: uuid.UUID | None = None
active_branch_base_version_label: str | None = None
data: StudySetupConfigData
publish_status: str
published_data: StudySetupConfigData | None = None
+1 -1
View File
@@ -370,7 +370,7 @@ def main() -> int:
)
assert_or_exit(status == 200, f"回滚失败 status={status} body={rolled}")
assert_or_exit(
rolled.get("publish_status") == "DRAFT"
rolled.get("publish_status") == "PUBLISHED"
and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"],
f"回滚结果异常 body={rolled}",
)