立项配置版本管理优化(回滚逻辑、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 uuid
|
||||||
import re
|
import re
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -27,7 +27,10 @@ from app.schemas.member import StudyMemberCreate
|
|||||||
from app.schemas.study_setup_config import (
|
from app.schemas.study_setup_config import (
|
||||||
ProjectPublishSnapshot,
|
ProjectPublishSnapshot,
|
||||||
SetupProjectionSummary,
|
SetupProjectionSummary,
|
||||||
|
StudySetupConfigCheckoutBranch,
|
||||||
StudySetupConfigData,
|
StudySetupConfigData,
|
||||||
|
StudySetupConfigDraftAction,
|
||||||
|
StudySetupConfigMergeToMain,
|
||||||
StudySetupConfigPublish,
|
StudySetupConfigPublish,
|
||||||
StudySetupConfigRead,
|
StudySetupConfigRead,
|
||||||
StudySetupConfigRollback,
|
StudySetupConfigRollback,
|
||||||
@@ -295,6 +298,8 @@ def _to_setup_config_read(
|
|||||||
*,
|
*,
|
||||||
saved_by_name: str | None = None,
|
saved_by_name: str | None = None,
|
||||||
published_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_status: str | None = None,
|
||||||
projection_summary: SetupProjectionSummary | None = None,
|
projection_summary: SetupProjectionSummary | None = None,
|
||||||
) -> StudySetupConfigRead:
|
) -> StudySetupConfigRead:
|
||||||
@@ -302,6 +307,11 @@ def _to_setup_config_read(
|
|||||||
id=record.id,
|
id=record.id,
|
||||||
study_id=record.study_id,
|
study_id=record.study_id,
|
||||||
version=record.version,
|
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 {}),
|
data=StudySetupConfigData.model_validate(record.config or {}),
|
||||||
publish_status=record.publish_status or "DRAFT",
|
publish_status=record.publish_status or "DRAFT",
|
||||||
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
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="访视窗口开始偏移不能晚于结束偏移")
|
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(
|
return StudySetupConfigVersionRead(
|
||||||
id=record.id,
|
id=record.id,
|
||||||
study_id=record.study_id,
|
study_id=record.study_id,
|
||||||
study_setup_config_id=record.study_setup_config_id,
|
study_setup_config_id=record.study_setup_config_id,
|
||||||
version=record.version,
|
version=record.version,
|
||||||
display_version=record.display_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,
|
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 {}),
|
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=record.published_by,
|
||||||
published_by_name=published_by_name,
|
published_by_name=published_by_name,
|
||||||
published_at=record.published_at,
|
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 = (
|
_SETUP_MODULE_KEYS = (
|
||||||
"projectMilestones",
|
"projectMilestones",
|
||||||
"enrollmentPlan",
|
"enrollmentPlan",
|
||||||
@@ -777,7 +897,14 @@ async def get_study_setup_config(
|
|||||||
if user:
|
if user:
|
||||||
published_by_name = user.full_name or user.username or user.email
|
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(
|
@router.put(
|
||||||
@@ -843,7 +970,14 @@ async def upsert_study_setup_config(
|
|||||||
user = await user_crud.get_by_id(db, record.published_by)
|
user = await user_crud.get_by_id(db, record.published_by)
|
||||||
if user:
|
if user:
|
||||||
published_by_name = user.full_name or user.username or user.email
|
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(
|
@router.post(
|
||||||
@@ -864,6 +998,10 @@ async def publish_study_setup_config(
|
|||||||
record = await study_setup_config_crud.get_by_study(db, study_id)
|
record = await study_setup_config_crud.get_by_study(db, study_id)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="请先保存立项配置草稿")
|
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)
|
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}
|
site_lookup = {str(site.id): site.name or "" for site in sites}
|
||||||
@@ -879,6 +1017,7 @@ async def publish_study_setup_config(
|
|||||||
study_id,
|
study_id,
|
||||||
expected_version=payload.expected_version,
|
expected_version=payload.expected_version,
|
||||||
published_by=current_user.id,
|
published_by=current_user.id,
|
||||||
|
force_create_snapshot=force_create_snapshot,
|
||||||
auto_commit=False,
|
auto_commit=False,
|
||||||
)
|
)
|
||||||
if conflict:
|
if conflict:
|
||||||
@@ -892,7 +1031,7 @@ async def publish_study_setup_config(
|
|||||||
projection = await apply_setup_projection_on_publish(
|
projection = await apply_setup_projection_on_publish(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
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,
|
operator_id=current_user.id,
|
||||||
)
|
)
|
||||||
projection_summary = SetupProjectionSummary(
|
projection_summary = SetupProjectionSummary(
|
||||||
@@ -907,7 +1046,14 @@ async def publish_study_setup_config(
|
|||||||
)
|
)
|
||||||
study_after_publish = await study_crud.get(db, study_id)
|
study_after_publish = await study_crud.get(db, study_id)
|
||||||
if study_after_publish:
|
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(
|
await audit_crud.log_action(
|
||||||
db,
|
db,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -926,6 +1072,10 @@ async def publish_study_setup_config(
|
|||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
auto_commit=False,
|
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()
|
await db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
await db.rollback()
|
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
|
operator_name = current_user.full_name or current_user.username or current_user.email
|
||||||
await db.refresh(published)
|
await db.refresh(published)
|
||||||
|
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||||||
return _to_setup_config_read(
|
return _to_setup_config_read(
|
||||||
published,
|
published,
|
||||||
saved_by_name=operator_name,
|
saved_by_name=operator_name,
|
||||||
published_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_status=projection.status,
|
||||||
projection_summary=projection_summary,
|
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="项目不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||||
|
|
||||||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
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}
|
user_ids = {item.published_by for item in versions if item.published_by}
|
||||||
name_map: dict[uuid.UUID, str] = {}
|
name_map: dict[uuid.UUID, str] = {}
|
||||||
for user_id in user_ids:
|
for user_id in user_ids:
|
||||||
@@ -963,7 +1119,12 @@ async def list_study_setup_config_versions(
|
|||||||
if user:
|
if user:
|
||||||
name_map[user_id] = user.full_name or user.username or user.email
|
name_map[user_id] = user.full_name or user.username or user.email
|
||||||
return [
|
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
|
for item in versions
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -982,6 +1143,9 @@ async def rollback_study_setup_config(
|
|||||||
study = await study_crud.get(db, study_id)
|
study = await study_crud.get(db, study_id)
|
||||||
if not study:
|
if not study:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
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(
|
record, conflict, target_missing = await study_setup_config_crud.rollback_to_version(
|
||||||
db,
|
db,
|
||||||
@@ -1003,7 +1167,7 @@ async def rollback_study_setup_config(
|
|||||||
entity_type="study_setup_config",
|
entity_type="study_setup_config",
|
||||||
entity_id=record.id,
|
entity_id=record.id,
|
||||||
action="ROLLBACK_SETUP_CONFIG",
|
action="ROLLBACK_SETUP_CONFIG",
|
||||||
detail=f"立项配置已回滚到发布版本 v{payload.target_version}",
|
detail=f"立项配置已回滚并替换当前发布为 {target_label},草稿分支已切换到对应分支基线",
|
||||||
operator_id=current_user.id,
|
operator_id=current_user.id,
|
||||||
operator_role=current_user.role,
|
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)
|
user = await user_crud.get_by_id(db, record.published_by)
|
||||||
if user:
|
if user:
|
||||||
published_by_name = user.full_name or user.username or user.email
|
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(
|
@router.delete(
|
||||||
@@ -1034,9 +1464,12 @@ async def delete_study_setup_config_version(
|
|||||||
versions = await study_setup_config_crud.list_versions(db, study_id)
|
versions = await study_setup_config_crud.list_versions(db, study_id)
|
||||||
if not versions:
|
if not versions:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标版本不存在")
|
||||||
latest_version = max(item.version for item in versions)
|
current_setup = await study_setup_config_crud.get_by_study(db, study_id)
|
||||||
if target_version == latest_version:
|
current_published_version = _resolve_current_published_snapshot_version(versions, current_setup)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最新发布版本不能删除")
|
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(
|
deleted = await study_setup_config_crud.delete_version(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import datetime
|
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 import func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models.study_setup_config_version import StudySetupConfigVersion
|
||||||
from app.schemas.study_setup_config import StudySetupConfigData
|
from app.schemas.study_setup_config import StudySetupConfigData
|
||||||
|
|
||||||
|
MAIN_BRANCH = "main"
|
||||||
|
|
||||||
|
|
||||||
def _to_storage(data: StudySetupConfigData | dict) -> dict:
|
def _to_storage(data: StudySetupConfigData | dict) -> dict:
|
||||||
if isinstance(data, StudySetupConfigData):
|
if isinstance(data, StudySetupConfigData):
|
||||||
@@ -17,6 +19,130 @@ def _to_storage(data: StudySetupConfigData | dict) -> dict:
|
|||||||
return deepcopy(data)
|
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:
|
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))
|
result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
@@ -56,6 +182,7 @@ async def upsert(
|
|||||||
version=1,
|
version=1,
|
||||||
config=payload,
|
config=payload,
|
||||||
publish_status="DRAFT",
|
publish_status="DRAFT",
|
||||||
|
current_branch_name=MAIN_BRANCH,
|
||||||
saved_by=saved_by,
|
saved_by=saved_by,
|
||||||
)
|
)
|
||||||
db.add(record)
|
db.add(record)
|
||||||
@@ -70,6 +197,7 @@ async def publish(
|
|||||||
*,
|
*,
|
||||||
expected_version: int | None,
|
expected_version: int | None,
|
||||||
published_by: uuid.UUID | None,
|
published_by: uuid.UUID | None,
|
||||||
|
force_create_snapshot: bool = False,
|
||||||
auto_commit: bool = True,
|
auto_commit: bool = True,
|
||||||
) -> tuple[StudySetupConfig | None, bool]:
|
) -> tuple[StudySetupConfig | None, bool]:
|
||||||
existing = await get_by_study(db, study_id)
|
existing = await get_by_study(db, study_id)
|
||||||
@@ -79,15 +207,15 @@ async def publish(
|
|||||||
return None, True
|
return None, True
|
||||||
publish_payload = _to_storage(existing.config or {})
|
publish_payload = _to_storage(existing.config or {})
|
||||||
next_version = existing.version + 1
|
next_version = existing.version + 1
|
||||||
latest_snapshot_stmt = (
|
branch_name = (existing.current_branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
|
||||||
select(StudySetupConfigVersion)
|
current_published_snapshot: StudySetupConfigVersion | None = None
|
||||||
.where(StudySetupConfigVersion.study_id == study_id)
|
current_published_branch = ""
|
||||||
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc())
|
if existing.current_published_version_id:
|
||||||
.limit(1)
|
current_published_snapshot = await _get_snapshot_by_id(db, snapshot_id=existing.current_published_version_id)
|
||||||
)
|
if current_published_snapshot:
|
||||||
latest_snapshot_result = await db.execute(latest_snapshot_stmt)
|
current_published_branch = (current_published_snapshot.branch_name or MAIN_BRANCH).strip() or MAIN_BRANCH
|
||||||
latest_snapshot = latest_snapshot_result.scalar_one_or_none()
|
branch_changed = bool(current_published_branch and current_published_branch != branch_name)
|
||||||
should_create_snapshot = not latest_snapshot or (latest_snapshot.config or {}) != publish_payload
|
should_create_snapshot = force_create_snapshot or (existing.published_config or {}) != publish_payload or branch_changed
|
||||||
|
|
||||||
existing.version = next_version
|
existing.version = next_version
|
||||||
existing.publish_status = "PUBLISHED"
|
existing.publish_status = "PUBLISHED"
|
||||||
@@ -98,23 +226,58 @@ async def publish(
|
|||||||
existing.updated_at = datetime.utcnow()
|
existing.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
if should_create_snapshot:
|
if should_create_snapshot:
|
||||||
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
|
parent_snapshot_id = existing.active_branch_base_version_id
|
||||||
StudySetupConfigVersion.study_id == study_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)
|
||||||
)
|
)
|
||||||
display_version_result = await db.execute(next_display_version_stmt)
|
branch_base = parent_result.scalar_one_or_none()
|
||||||
current_max_display_version = display_version_result.scalar_one_or_none() or 0
|
if branch_base is None:
|
||||||
next_display_version = current_max_display_version + 1
|
branch_base = await _get_main_next_display_version(db, study_id=study_id) - 1
|
||||||
|
next_display_version = branch_base
|
||||||
|
|
||||||
snapshot = StudySetupConfigVersion(
|
snapshot = StudySetupConfigVersion(
|
||||||
study_setup_config_id=existing.id,
|
study_setup_config_id=existing.id,
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
version=next_version,
|
version=next_version,
|
||||||
display_version=next_display_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,
|
source_version=next_version - 1,
|
||||||
|
parent_version_id=parent_snapshot_id,
|
||||||
config=_to_storage(publish_payload),
|
config=_to_storage(publish_payload),
|
||||||
published_by=published_by,
|
published_by=published_by,
|
||||||
published_at=existing.published_at,
|
published_at=existing.published_at,
|
||||||
)
|
)
|
||||||
db.add(snapshot)
|
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:
|
if auto_commit:
|
||||||
await db.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())
|
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(
|
async def rollback_to_version(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -147,23 +325,200 @@ async def rollback_to_version(
|
|||||||
if expected_version is not None and expected_version != existing.version:
|
if expected_version is not None and expected_version != existing.version:
|
||||||
return None, True, False
|
return None, True, False
|
||||||
|
|
||||||
result = await db.execute(
|
target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
|
||||||
select(StudySetupConfigVersion).where(
|
|
||||||
StudySetupConfigVersion.study_id == study_id,
|
|
||||||
StudySetupConfigVersion.version == target_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
target = result.scalar_one_or_none()
|
|
||||||
if not target:
|
if not target:
|
||||||
return None, False, True
|
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.version = existing.version + 1
|
||||||
existing.config = _to_storage(target.config or {})
|
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.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.publish_status = "DRAFT"
|
||||||
existing.updated_at = datetime.utcnow()
|
existing.updated_at = datetime.utcnow()
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(existing)
|
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
|
return existing, False, False
|
||||||
|
|
||||||
|
|
||||||
@@ -173,21 +528,25 @@ async def delete_version(
|
|||||||
*,
|
*,
|
||||||
target_version: int,
|
target_version: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
result = await db.execute(
|
target = await _get_snapshot_by_version(db, study_id=study_id, version=target_version)
|
||||||
select(StudySetupConfigVersion).where(
|
|
||||||
StudySetupConfigVersion.study_id == study_id,
|
|
||||||
StudySetupConfigVersion.version == target_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
target = result.scalar_one_or_none()
|
|
||||||
if not target:
|
if not target:
|
||||||
return False
|
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.delete(target)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None:
|
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(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
|
||||||
|
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ class StudySetupConfig(Base):
|
|||||||
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
|
||||||
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
published_project_snapshot: 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_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)
|
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)
|
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"
|
__tablename__ = "study_setup_config_versions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("study_id", "version", name="uq_setup_config_versions_study_version"),
|
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)
|
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)
|
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)
|
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
display_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)
|
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)
|
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_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())
|
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())
|
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
|
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):
|
class StudySetupConfigVersionRead(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
study_id: uuid.UUID
|
study_id: uuid.UUID
|
||||||
study_setup_config_id: uuid.UUID
|
study_setup_config_id: uuid.UUID
|
||||||
version: int
|
version: int
|
||||||
display_version: int
|
display_version: int
|
||||||
|
branch_name: str = "main"
|
||||||
|
branch_seq: int = 1
|
||||||
|
version_label: str = ""
|
||||||
source_version: int | None = None
|
source_version: int | None = None
|
||||||
|
parent_version_id: uuid.UUID | None = None
|
||||||
|
merged_from_version_id: uuid.UUID | None = None
|
||||||
config: StudySetupConfigData
|
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: uuid.UUID | None = None
|
||||||
published_by_name: str | None = None
|
published_by_name: str | None = None
|
||||||
published_at: datetime
|
published_at: datetime
|
||||||
@@ -153,6 +175,11 @@ class StudySetupConfigRead(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
study_id: uuid.UUID
|
study_id: uuid.UUID
|
||||||
version: int
|
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
|
data: StudySetupConfigData
|
||||||
publish_status: str
|
publish_status: str
|
||||||
published_data: StudySetupConfigData | None = None
|
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(status == 200, f"回滚失败 status={status} body={rolled}")
|
||||||
assert_or_exit(
|
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"],
|
and (rolled.get("data") or {}).get("enrollmentPlan", {}).get("totalTarget") == payload["data"]["enrollmentPlan"]["totalTarget"],
|
||||||
f"回滚结果异常 body={rolled}",
|
f"回滚结果异常 body={rolled}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: ctms_frontend
|
container_name: ctms_frontend
|
||||||
|
restart: always
|
||||||
# 开启 Host 模式以支持 Vite 热更新
|
# 开启 Host 模式以支持 Vite 热更新
|
||||||
command: npm run dev -- --host
|
command: npm run dev -- --host
|
||||||
volumes:
|
volumes:
|
||||||
@@ -63,6 +64,7 @@ services:
|
|||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: /
|
VITE_API_BASE_URL: /
|
||||||
|
NODE_OPTIONS: --max-old-space-size=4096
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios";
|
import { apiGet, apiPatch, apiPost, apiDelete, apiPut } from "./axios";
|
||||||
import type { ApiListResponse, Study } from "../types/api";
|
import type { ApiListResponse, Study } from "../types/api";
|
||||||
import type {
|
import type {
|
||||||
|
StudySetupConfigCheckoutBranchPayload,
|
||||||
|
StudySetupConfigDraftActionPayload,
|
||||||
StudySetupConfigPublishPayload,
|
StudySetupConfigPublishPayload,
|
||||||
|
StudySetupConfigMergeMainPayload,
|
||||||
StudySetupConfigResponse,
|
StudySetupConfigResponse,
|
||||||
StudySetupConfigRollbackPayload,
|
StudySetupConfigRollbackPayload,
|
||||||
StudySetupConfigUpsertPayload,
|
StudySetupConfigUpsertPayload,
|
||||||
@@ -42,3 +45,17 @@ export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: nu
|
|||||||
|
|
||||||
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
|
export const rollbackStudySetupConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) =>
|
||||||
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/rollback`, payload, { suppressErrorMessage: true });
|
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 {
|
import {
|
||||||
|
checkoutStudySetupConfigBranchDraft,
|
||||||
|
clearStudySetupConfigDraft,
|
||||||
deleteStudySetupConfigVersion,
|
deleteStudySetupConfigVersion,
|
||||||
fetchStudySetupConfig,
|
fetchStudySetupConfig,
|
||||||
fetchStudySetupConfigVersions,
|
fetchStudySetupConfigVersions,
|
||||||
|
mergeStudySetupConfigToMain,
|
||||||
publishStudySetupConfig,
|
publishStudySetupConfig,
|
||||||
|
refillStudySetupConfigDraft,
|
||||||
rollbackStudySetupConfig,
|
rollbackStudySetupConfig,
|
||||||
saveStudySetupConfig,
|
saveStudySetupConfig,
|
||||||
} from "../api/studies";
|
} from "../api/studies";
|
||||||
import type {
|
import type {
|
||||||
|
StudySetupConfigCheckoutBranchPayload,
|
||||||
|
StudySetupConfigDraftActionPayload,
|
||||||
|
StudySetupConfigMergeMainPayload,
|
||||||
StudySetupConfigPublishPayload,
|
StudySetupConfigPublishPayload,
|
||||||
StudySetupConfigRollbackPayload,
|
StudySetupConfigRollbackPayload,
|
||||||
StudySetupConfigUpsertPayload,
|
StudySetupConfigUpsertPayload,
|
||||||
@@ -16,16 +23,25 @@ export const useSetupConfig = () => {
|
|||||||
const getConfig = (studyId: string) => fetchStudySetupConfig(studyId);
|
const getConfig = (studyId: string) => fetchStudySetupConfig(studyId);
|
||||||
const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload);
|
const saveConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) => saveStudySetupConfig(studyId, payload);
|
||||||
const publishConfig = (studyId: string, payload: StudySetupConfigPublishPayload) => publishStudySetupConfig(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 listVersions = (studyId: string) => fetchStudySetupConfigVersions(studyId);
|
||||||
const rollbackConfig = (studyId: string, payload: StudySetupConfigRollbackPayload) => rollbackStudySetupConfig(studyId, payload);
|
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);
|
const deleteVersion = (studyId: string, targetVersion: number) => deleteStudySetupConfigVersion(studyId, targetVersion);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getConfig,
|
getConfig,
|
||||||
saveConfig,
|
saveConfig,
|
||||||
publishConfig,
|
publishConfig,
|
||||||
|
mergeToMain,
|
||||||
listVersions,
|
listVersions,
|
||||||
rollbackConfig,
|
rollbackConfig,
|
||||||
|
checkoutBranchDraft,
|
||||||
|
clearDraft,
|
||||||
|
refillDraft,
|
||||||
deleteVersion,
|
deleteVersion,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export interface StudySetupConfigResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
study_id: string;
|
study_id: string;
|
||||||
version: number;
|
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;
|
data: SetupConfigDraft;
|
||||||
publish_status: "DRAFT" | "PUBLISHED" | string;
|
publish_status: "DRAFT" | "PUBLISHED" | string;
|
||||||
published_data?: SetupConfigDraft | null;
|
published_data?: SetupConfigDraft | null;
|
||||||
@@ -134,14 +139,36 @@ export interface StudySetupConfigRollbackPayload {
|
|||||||
target_version: number;
|
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 {
|
export interface StudySetupConfigVersionItem {
|
||||||
id: string;
|
id: string;
|
||||||
study_id: string;
|
study_id: string;
|
||||||
study_setup_config_id: string;
|
study_setup_config_id: string;
|
||||||
version: number;
|
version: number;
|
||||||
display_version: number;
|
display_version: number;
|
||||||
|
branch_name?: string;
|
||||||
|
branch_seq?: number;
|
||||||
|
version_label?: string;
|
||||||
source_version?: number | null;
|
source_version?: number | null;
|
||||||
|
parent_version_id?: string | null;
|
||||||
|
merged_from_version_id?: string | null;
|
||||||
config: SetupConfigDraft;
|
config: SetupConfigDraft;
|
||||||
|
published_project_snapshot?: ProjectPublishSnapshot | null;
|
||||||
|
is_current_published?: boolean;
|
||||||
|
is_active_draft_base?: boolean;
|
||||||
published_by?: string | null;
|
published_by?: string | null;
|
||||||
published_by_name?: string | null;
|
published_by_name?: string | null;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
|
|||||||
@@ -107,6 +107,38 @@ const getArrayRowIdentity = (moduleKey: keyof SetupConfigDraft, value: unknown):
|
|||||||
return "";
|
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 => {
|
const formatReadablePath = (moduleKey: keyof SetupConfigDraft, path: string): string => {
|
||||||
let normalized = path;
|
let normalized = path;
|
||||||
if (path === moduleKey) {
|
if (path === moduleKey) {
|
||||||
@@ -247,11 +279,47 @@ const collectDiffRows = (
|
|||||||
if (Array.isArray(localValue) || Array.isArray(serverValue)) {
|
if (Array.isArray(localValue) || Array.isArray(serverValue)) {
|
||||||
const localArr = Array.isArray(localValue) ? localValue : [];
|
const localArr = Array.isArray(localValue) ? localValue : [];
|
||||||
const serverArr = Array.isArray(serverValue) ? serverValue : [];
|
const serverArr = Array.isArray(serverValue) ? serverValue : [];
|
||||||
const maxLen = Math.max(localArr.length, serverArr.length);
|
|
||||||
for (let i = 0; i < maxLen; i += 1) {
|
const localEntryMap = new Map<string, ArrayRowEntry[]>();
|
||||||
const rowIdentity = getArrayRowIdentity(moduleKey, localArr[i] ?? serverArr[i]);
|
localArr.forEach((item, index) => {
|
||||||
const indexLabel = rowIdentity ? `${i}|${rowIdentity}` : `${i}`;
|
const entry = buildArrayRowEntry(moduleKey, item, index);
|
||||||
collectDiffRows(moduleKey, moduleLabel, localArr[i], serverArr[i], `${path}[${indexLabel}]`, rows);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export type SetupWorkflowStatus = "UNSAVED_DRAFT" | "SAVED_DRAFT" | "PUBLISHED";
|
|||||||
export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" };
|
export type SetupWorkflowTagMeta = { label: string; type: "success" | "warning" | "info" };
|
||||||
|
|
||||||
export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = {
|
export const SETUP_WORKFLOW_TAG_META: Record<SetupWorkflowStatus, SetupWorkflowTagMeta> = {
|
||||||
UNSAVED_DRAFT: { label: "草稿未保存", type: "warning" },
|
UNSAVED_DRAFT: { label: "未保存", type: "warning" },
|
||||||
SAVED_DRAFT: { label: "草稿已保存(未发布)", type: "info" },
|
SAVED_DRAFT: { label: "已保存", type: "info" },
|
||||||
PUBLISHED: { label: "已发布", type: "success" },
|
PUBLISHED: { label: "已发布", type: "success" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user