立项配置版本管理优化(回滚逻辑、UI、下载功能)
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: ctms_frontend
|
||||
restart: always
|
||||
# 开启 Host 模式以支持 Vite 热更新
|
||||
command: npm run dev -- --host
|
||||
volumes:
|
||||
@@ -63,6 +64,7 @@ services:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
VITE_API_BASE_URL: /
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios";
|
||||
import type { ApiListResponse, Study } from "../types/api";
|
||||
import type {
|
||||
StudySetupConfigCheckoutBranchPayload,
|
||||
StudySetupConfigDraftActionPayload,
|
||||
StudySetupConfigPublishPayload,
|
||||
StudySetupConfigMergeMainPayload,
|
||||
StudySetupConfigResponse,
|
||||
StudySetupConfigRollbackPayload,
|
||||
StudySetupConfigUpsertPayload,
|
||||
@@ -42,3 +45,17 @@ export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: nu
|
||||
|
||||
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const checkoutStudySetupConfigBranchDraft = (studyId: string, payload: StudySetupConfigCheckoutBranchPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/draft/checkout-branch`, payload, {
|
||||
suppressErrorMessage: true,
|
||||
});
|
||||
|
||||
export const clearStudySetupConfigDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/draft/clear`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const refillStudySetupConfigDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/draft/refill`, payload, { suppressErrorMessage: true });
|
||||
|
||||
export const mergeStudySetupConfigToMain = (studyId: string, payload: StudySetupConfigMergeMainPayload) =>
|
||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/merge-main`, payload, { suppressErrorMessage: true });
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import {
|
||||
checkoutStudySetupConfigBranchDraft,
|
||||
clearStudySetupConfigDraft,
|
||||
deleteStudySetupConfigVersion,
|
||||
fetchStudySetupConfig,
|
||||
fetchStudySetupConfigVersions,
|
||||
mergeStudySetupConfigToMain,
|
||||
publishStudySetupConfig,
|
||||
refillStudySetupConfigDraft,
|
||||
rollbackStudySetupConfig,
|
||||
saveStudySetupConfig,
|
||||
} from "../api/studies";
|
||||
import type {
|
||||
StudySetupConfigCheckoutBranchPayload,
|
||||
StudySetupConfigDraftActionPayload,
|
||||
StudySetupConfigMergeMainPayload,
|
||||
StudySetupConfigPublishPayload,
|
||||
StudySetupConfigRollbackPayload,
|
||||
StudySetupConfigUpsertPayload,
|
||||
@@ -16,16 +23,25 @@ export const useSetupConfig = () => {
|
||||
const getConfig = (studyId: string) => fetchStudySetupConfig(studyId);
|
||||
const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload);
|
||||
const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(studyId, payload);
|
||||
const mergeToMain = (studyId: string, payload: StudySetupConfigMergeMainPayload) => mergeStudySetupConfigToMain(studyId, payload);
|
||||
const listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId);
|
||||
const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload);
|
||||
const checkoutBranchDraft = (studyId: string, payload: StudySetupConfigCheckoutBranchPayload) =>
|
||||
checkoutStudySetupConfigBranchDraft(studyId, payload);
|
||||
const clearDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => clearStudySetupConfigDraft(studyId, payload);
|
||||
const refillDraft = (studyId: string, payload: StudySetupConfigDraftActionPayload) => refillStudySetupConfigDraft(studyId, payload);
|
||||
const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion);
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
saveConfig,
|
||||
publishConfig,
|
||||
mergeToMain,
|
||||
listVersions,
|
||||
rollbackConfig,
|
||||
checkoutBranchDraft,
|
||||
clearDraft,
|
||||
refillDraft,
|
||||
deleteVersion,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -98,6 +98,11 @@ export interface StudySetupConfigResponse {
|
||||
id: string;
|
||||
study_id: string;
|
||||
version: number;
|
||||
current_branch_name?: string;
|
||||
current_published_version_id?: string | null;
|
||||
current_published_version_label?: string | null;
|
||||
active_branch_base_version_id?: string | null;
|
||||
active_branch_base_version_label?: string | null;
|
||||
data: SetupConfigDraft;
|
||||
publish_status: "DRAFT" | "PUBLISHED" | string;
|
||||
published_data?: SetupConfigDraft | null;
|
||||
@@ -134,14 +139,36 @@ export interface StudySetupConfigRollbackPayload {
|
||||
target_version: number;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigCheckoutBranchPayload {
|
||||
expected_version?: number | null;
|
||||
target_version: number;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigMergeMainPayload {
|
||||
expected_version?: number | null;
|
||||
source_version: number;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigDraftActionPayload {
|
||||
expected_version?: number | null;
|
||||
}
|
||||
|
||||
export interface StudySetupConfigVersionItem {
|
||||
id: string;
|
||||
study_id: string;
|
||||
study_setup_config_id: string;
|
||||
version: number;
|
||||
display_version: number;
|
||||
branch_name?: string;
|
||||
branch_seq?: number;
|
||||
version_label?: string;
|
||||
source_version?: number | null;
|
||||
parent_version_id?: string | null;
|
||||
merged_from_version_id?: string | null;
|
||||
config: SetupConfigDraft;
|
||||
published_project_snapshot?: ProjectPublishSnapshot | null;
|
||||
is_current_published?: boolean;
|
||||
is_active_draft_base?: boolean;
|
||||
published_by?: string | null;
|
||||
published_by_name?: string | null;
|
||||
published_at: string;
|
||||
|
||||
@@ -107,6 +107,38 @@ const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown):
|
||||
return "";
|
||||
};
|
||||
|
||||
type ArrayRowEntry = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
rowIdentity: string;
|
||||
};
|
||||
|
||||
const buildArrayRowEntry = (moduleKey: keyof SetupConfigDraft, value: unknown, index: number): ArrayRowEntry => {
|
||||
const rowIdentity = getArrayRowIdentity(moduleKey, value);
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const id = String((value as Record<string, unknown>).id || "").trim();
|
||||
if (id) {
|
||||
return {
|
||||
key: `id:${id}`,
|
||||
value,
|
||||
rowIdentity,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (rowIdentity) {
|
||||
return {
|
||||
key: `identity:${rowIdentity}`,
|
||||
value,
|
||||
rowIdentity,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: `index:${index}`,
|
||||
value,
|
||||
rowIdentity: "",
|
||||
};
|
||||
};
|
||||
|
||||
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
|
||||
let normalized = path;
|
||||
if (path === moduleKey) {
|
||||
@@ -247,11 +279,47 @@ const collectDiffRows = (
|
||||
if (Array.isArray(localValue) || Array.isArray(serverValue)) {
|
||||
const localArr = Array.isArray(localValue) ? localValue : [];
|
||||
const serverArr = Array.isArray(serverValue) ? serverValue : [];
|
||||
const maxLen = Math.max(localArr.length, serverArr.length);
|
||||
for (let i = 0; i < maxLen; i += 1) {
|
||||
const rowIdentity = getArrayRowIdentity(moduleKey, localArr[i] ?? serverArr[i]);
|
||||
const indexLabel = rowIdentity ? `${i}|${rowIdentity}` : `${i}`;
|
||||
collectDiffRows(moduleKey, moduleLabel, localArr[i], serverArr[i], `${path}[${indexLabel}]`, rows);
|
||||
|
||||
const localEntryMap = new Map<string, ArrayRowEntry[]>();
|
||||
localArr.forEach((item, index) => {
|
||||
const entry = buildArrayRowEntry(moduleKey, item, index);
|
||||
const list = localEntryMap.get(entry.key);
|
||||
if (list) {
|
||||
list.push(entry);
|
||||
} else {
|
||||
localEntryMap.set(entry.key, [entry]);
|
||||
}
|
||||
});
|
||||
|
||||
const consumeLocalEntry = (key: string): ArrayRowEntry | null => {
|
||||
const list = localEntryMap.get(key);
|
||||
if (!list || list.length === 0) return null;
|
||||
const entry = list.shift() || null;
|
||||
if (list.length === 0) localEntryMap.delete(key);
|
||||
return entry;
|
||||
};
|
||||
|
||||
let pairIndex = 0;
|
||||
serverArr.forEach((item, index) => {
|
||||
const serverEntry = buildArrayRowEntry(moduleKey, item, index);
|
||||
const localEntry = consumeLocalEntry(serverEntry.key);
|
||||
const rowIdentity = (localEntry?.rowIdentity || serverEntry.rowIdentity || "").trim();
|
||||
const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`;
|
||||
collectDiffRows(moduleKey, moduleLabel, localEntry?.value, serverEntry.value, `${path}[${indexLabel}]`, rows);
|
||||
pairIndex += 1;
|
||||
});
|
||||
|
||||
localEntryMap.forEach((list) => {
|
||||
list.forEach((localEntry) => {
|
||||
const rowIdentity = localEntry.rowIdentity.trim();
|
||||
const indexLabel = rowIdentity ? `${pairIndex}|${rowIdentity}` : `${pairIndex}`;
|
||||
collectDiffRows(moduleKey, moduleLabel, localEntry.value, undefined, `${path}[${indexLabel}]`, rows);
|
||||
pairIndex += 1;
|
||||
});
|
||||
});
|
||||
if (localArr.length === 0 && serverArr.length === 0) {
|
||||
// Keep behavior explicit for empty array pair to avoid extra recursion.
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ export type SetupWorkflowStatus = "UNSAVED_DRAFT" | "SAVED_DRAFT" | "PUBLISHED";
|
||||
export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" };
|
||||
|
||||
export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = {
|
||||
UNSAVED_DRAFT: { label: "草稿未保存", type: "warning" },
|
||||
SAVED_DRAFT: { label: "草稿已保存(未发布)", type: "info" },
|
||||
UNSAVED_DRAFT: { label: "未保存", type: "warning" },
|
||||
SAVED_DRAFT: { label: "已保存", type: "info" },
|
||||
PUBLISHED: { label: "已发布", type: "success" },
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user