From fcfd67b0b57a8ccf6f9d6dfda6c152ab9a6901df Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 4 Aug 2026 01:46:36 +0200 Subject: [PATCH] Add governed organization template upgrades --- docs/ORGANIZATION_MODEL.md | 16 +- .../backend/api/v1/routes.py | 250 ++++ .../backend/api/v1/schemas.py | 96 ++ .../backend/db/models.py | 72 ++ .../backend/manifest.py | 60 +- ...4d9c72b8_organization_template_upgrades.py | 10 + ...4d9c72b8_organization_template_upgrades.py | 102 ++ .../backend/upgrades.py | 1054 +++++++++++++++++ .../test_interface_documentation_contract.py | 5 +- tests/test_model_templates.py | 251 ++++ webui/package.json | 3 +- webui/scripts/test-template-upgrades.mjs | 35 + webui/src/api/organizations.ts | 110 ++ .../OrganizationTemplateUpgradePanel.tsx | 252 ++++ .../organizations/OrganizationsAdminPanel.tsx | 2 + webui/src/module.ts | 8 + webui/src/styles/organizations.css | 37 + 17 files changed, 2357 insertions(+), 6 deletions(-) create mode 100644 src/govoplan_organizations/backend/migrations/dev_versions/a61e4d9c72b8_organization_template_upgrades.py create mode 100644 src/govoplan_organizations/backend/migrations/versions/a61e4d9c72b8_organization_template_upgrades.py create mode 100644 src/govoplan_organizations/backend/upgrades.py create mode 100644 webui/scripts/test-template-upgrades.mjs create mode 100644 webui/src/features/organizations/OrganizationTemplateUpgradePanel.tsx diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md index ff531f5..3a1985d 100644 --- a/docs/ORGANIZATION_MODEL.md +++ b/docs/ORGANIZATION_MODEL.md @@ -44,9 +44,19 @@ model: policy. This avoids ambiguous inheritance when institutions model responsibility -differently. Template catalogue, instantiation, and upgrade orchestration are a -separate implementation slice; the existing organization tables remain the -canonical tenant-local state. +differently. Template catalogue, instantiation, and explicit upgrades are +available through the Organizations API and administration surface. The +existing organization tables remain the canonical tenant-local state. + +An upgrade starts by persisting a three-way comparison of the source template, +the current tenant-owned model, and a newer published template version. The +preview distinguishes compatible additions and changes from local divergence, +destructive remapping, and invalid references. Local-only changes are retained; +conflicts require an explicit keep, replace, or bounded mapping decision. The +apply operation rejects stale source, target, or local state and records a new +instantiation plus platform event. Cancelling a preview records the outcome but +does not change tenant data. Consumers of organization references must respond +to the applied event; Organizations does not rewrite another module's records. ## Boundary With Identity And IDM diff --git a/src/govoplan_organizations/backend/api/v1/routes.py b/src/govoplan_organizations/backend/api/v1/routes.py index ded65dc..f4b5dcd 100644 --- a/src/govoplan_organizations/backend/api/v1/routes.py +++ b/src/govoplan_organizations/backend/api/v1/routes.py @@ -22,6 +22,7 @@ from govoplan_core.core.organizations import ( organization_lifecycle_event_type, ) from govoplan_core.core.events import ( + EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, @@ -34,6 +35,7 @@ from govoplan_organizations.backend.db.models import ( OrganizationModelInstantiation, OrganizationModelTemplate, OrganizationModelTemplateVersion, + OrganizationModelUpgrade, OrganizationRelation, OrganizationRelationType, OrganizationTenantSettings, @@ -48,6 +50,12 @@ from .schemas import ( FunctionTypeUpdateRequest, FunctionUpdateRequest, OrganizationModelInstantiationItem, + OrganizationModelUpgradeApplyRequest, + OrganizationModelUpgradeApplyResponse, + OrganizationModelUpgradeCancelRequest, + OrganizationModelUpgradeItem, + OrganizationModelUpgradeListResponse, + OrganizationModelUpgradePreviewRequest, OrganizationFunctionItem, OrganizationFunctionTypeItem, OrganizationModelResponse, @@ -80,6 +88,14 @@ from govoplan_organizations.backend.templates import ( canonical_template_definition, instantiate_template_version, ) +from govoplan_organizations.backend.upgrades import ( + OrganizationUpgradeError, + apply_model_upgrade, + cancel_model_upgrade, + create_model_upgrade_preview, + current_model_instantiation, + list_model_upgrades, +) router = APIRouter(prefix="/organizations", tags=["organizations"]) @@ -475,6 +491,74 @@ def _instantiation_item( ) +def _upgrade_item(item: OrganizationModelUpgrade) -> OrganizationModelUpgradeItem: + return OrganizationModelUpgradeItem( + id=item.id, + tenant_id=item.tenant_id, + template_id=item.template_id, + source_instantiation_id=item.source_instantiation_id, + source_template_version_id=item.source_template_version_id, + target_template_version_id=item.target_template_version_id, + status=item.status, + revision=item.revision, + base_definition_sha256=item.base_definition_sha256, + local_definition_sha256=item.local_definition_sha256, + target_definition_sha256=item.target_definition_sha256, + preview=item.preview, + decisions=item.decisions, + requested_by_account_id=item.requested_by_account_id, + applied_by_account_id=item.applied_by_account_id, + cancelled_by_account_id=item.cancelled_by_account_id, + applied_at=item.applied_at, + cancelled_at=item.cancelled_at, + provenance=dict(item.provenance or {}), + created_at=item.created_at, + updated_at=item.updated_at, + ) + + +def _emit_model_upgrade_event( + session: Session, + principal: ApiPrincipal, + item: OrganizationModelUpgrade, + action: str, +) -> None: + emit_platform_event( + session, + PlatformEvent( + type=f"organizations.model_upgrade.{action}.v1", + module_id="organizations", + tenant=EventTenantRef(id=item.tenant_id), + actor=EventActorRef(type="account", id=principal.account_id), + subject=EventObjectRef(type="organization_model_upgrade", id=item.id), + resource=EventObjectRef( + type="organization_model_template_version", + id=item.target_template_version_id, + ), + payload={ + "schema_version": "1", + "tenant_id": item.tenant_id, + "upgrade_id": item.id, + "status": item.status, + "revision": item.revision, + "source_template_version_id": item.source_template_version_id, + "target_template_version_id": item.target_template_version_id, + "requires_decisions": int( + dict(item.preview or {}).get("requires_decisions", 0) + ), + "blocking_invalid_references": int( + dict(item.preview or {}).get( + "blocking_invalid_references", 0 + ) + ), + "decision_count": len(dict(item.decisions or {})), + "silent_mutation": False, + }, + classification="internal", + ), + ) + + def _template_version( session: Session, template_id: str, @@ -742,6 +826,172 @@ def instantiate_organization_model_template( return _instantiation_item(instantiation) +@router.get( + "/model-upgrades", + response_model=OrganizationModelUpgradeListResponse, +) +def list_organization_model_upgrades( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)), +) -> OrganizationModelUpgradeListResponse: + current = current_model_instantiation( + session, + tenant_id=principal.tenant_id, + ) + return OrganizationModelUpgradeListResponse( + current_instantiation=( + _instantiation_item(current) if current is not None else None + ), + upgrades=[ + _upgrade_item(item) + for item in list_model_upgrades( + session, + tenant_id=principal.tenant_id, + ) + ], + ) + + +@router.post( + "/model-upgrades/preview", + response_model=OrganizationModelUpgradeItem, + status_code=status.HTTP_201_CREATED, +) +def preview_organization_model_upgrade( + payload: OrganizationModelUpgradePreviewRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_any_scope(*ORG_MODEL_WRITE_SCOPES) + ), +) -> OrganizationModelUpgradeItem: + target = session.get( + OrganizationModelTemplateVersion, + payload.target_template_version_id, + ) + if target is None: + raise _not_found("Organization model template version") + try: + item = create_model_upgrade_preview( + session, + tenant_id=principal.tenant_id, + target_version=target, + actor_account_id=principal.account_id, + idempotency_key=payload.idempotency_key, + ) + _emit_model_upgrade_event(session, principal, item, "previewed") + session.commit() + session.refresh(item) + except OrganizationUpgradeError as exc: + session.rollback() + raise _conflict(str(exc)) from exc + return _upgrade_item(item) + + +@router.post( + "/model-upgrades/{upgrade_id}/cancel", + response_model=OrganizationModelUpgradeItem, +) +def cancel_organization_model_upgrade( + upgrade_id: str, + payload: OrganizationModelUpgradeCancelRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_any_scope(*ORG_MODEL_WRITE_SCOPES) + ), +) -> OrganizationModelUpgradeItem: + try: + item = cancel_model_upgrade( + session, + tenant_id=principal.tenant_id, + upgrade_id=upgrade_id, + expected_revision=payload.expected_revision, + actor_account_id=principal.account_id, + ) + _emit_model_upgrade_event(session, principal, item, "cancelled") + session.commit() + session.refresh(item) + except OrganizationUpgradeError as exc: + session.rollback() + raise _conflict(str(exc)) from exc + return _upgrade_item(item) + + +@router.post( + "/model-upgrades/{upgrade_id}/apply", + response_model=OrganizationModelUpgradeApplyResponse, +) +def apply_organization_model_upgrade( + upgrade_id: str, + payload: OrganizationModelUpgradeApplyRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_any_scope(*ORG_MODEL_WRITE_SCOPES) + ), +) -> OrganizationModelUpgradeApplyResponse: + approval, control_target, _value = _ensure_organization_change_allowed( + session, + principal, + tenant_id=principal.tenant_id, + resource_type="model_upgrade", + operation="applied", + payload=payload, + resource_id=upgrade_id, + ) + before = _upgrade_item( + _get_tenant_row( + session, + OrganizationModelUpgrade, + upgrade_id, + principal.tenant_id, + "Organization model upgrade", + ) + ).model_dump(mode="json") + try: + item, instantiation = apply_model_upgrade( + session, + tenant_id=principal.tenant_id, + upgrade_id=upgrade_id, + expected_revision=payload.expected_revision, + decisions={ + key: value.model_dump(exclude_none=True) + for key, value in payload.decisions.items() + }, + actor_account_id=principal.account_id, + ) + invalidate_auth_principals( + session, + tenant_id=principal.tenant_id, + source_module="organizations", + resource_type="organization_model_upgrade", + resource_id=item.id, + ) + _emit_model_upgrade_event(session, principal, item, "applied") + response = OrganizationModelUpgradeApplyResponse( + upgrade=_upgrade_item(item), + instantiation=_instantiation_item(instantiation), + ) + if approval is None: + session.commit() + else: + _record_organization_change_applied( + session, + principal, + approval=approval, + target=control_target, + before=before, + after=response.model_dump(mode="json"), + ) + session.refresh(item) + session.refresh(instantiation) + except OrganizationUpgradeError as exc: + session.rollback() + raise _conflict(str(exc)) from exc + return OrganizationModelUpgradeApplyResponse( + upgrade=_upgrade_item(item), + instantiation=_instantiation_item(instantiation), + ) + + @router.get("/model", response_model=OrganizationModelResponse) def get_organization_model( session: Session = Depends(get_session), diff --git a/src/govoplan_organizations/backend/api/v1/schemas.py b/src/govoplan_organizations/backend/api/v1/schemas.py index 79661f7..7a9b2aa 100644 --- a/src/govoplan_organizations/backend/api/v1/schemas.py +++ b/src/govoplan_organizations/backend/api/v1/schemas.py @@ -180,6 +180,102 @@ class OrganizationModelInstantiationItem(BaseModel): updated_at: datetime +UpgradeClassification = Literal[ + "unchanged", + "compatible_addition", + "compatible_change", + "destructive_remapping", + "local_divergence", + "invalid_reference", +] +UpgradeDecisionAction = Literal["keep_local", "use_target", "map_to"] + + +class OrganizationModelUpgradePreviewRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_template_version_id: str = Field(min_length=1, max_length=36) + idempotency_key: str = Field(min_length=1, max_length=255) + + +class OrganizationModelUpgradeDecision(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: UpgradeDecisionAction + target_key: str | None = Field(default=None, max_length=500) + + +class OrganizationModelUpgradeApplyRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + decisions: dict[str, OrganizationModelUpgradeDecision] = Field( + default_factory=dict, + max_length=5000, + ) + change_request_id: str | None = Field(default=None, max_length=255) + + +class OrganizationModelUpgradeCancelRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + + +class OrganizationModelUpgradeDiffEntry(BaseModel): + id: str + collection: str + key: str + classification: UpgradeClassification + requires_decision: bool + base: dict[str, Any] | None = None + local: dict[str, Any] | None = None + target: dict[str, Any] | None = None + allowed_actions: list[UpgradeDecisionAction] = Field(default_factory=list) + + +class OrganizationModelUpgradePreview(BaseModel): + entries: list[OrganizationModelUpgradeDiffEntry] = Field(default_factory=list) + counts: dict[str, int] = Field(default_factory=dict) + requires_decisions: int = 0 + blocking_invalid_references: int = 0 + silent_mutation: bool = False + + +class OrganizationModelUpgradeItem(BaseModel): + id: str + tenant_id: str + template_id: str + source_instantiation_id: str + source_template_version_id: str + target_template_version_id: str + status: str + revision: int + base_definition_sha256: str + local_definition_sha256: str + target_definition_sha256: str + preview: OrganizationModelUpgradePreview + decisions: dict[str, OrganizationModelUpgradeDecision] = Field(default_factory=dict) + requested_by_account_id: str | None = None + applied_by_account_id: str | None = None + cancelled_by_account_id: str | None = None + applied_at: datetime | None = None + cancelled_at: datetime | None = None + provenance: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + updated_at: datetime + + +class OrganizationModelUpgradeListResponse(BaseModel): + current_instantiation: OrganizationModelInstantiationItem | None = None + upgrades: list[OrganizationModelUpgradeItem] = Field(default_factory=list) + + +class OrganizationModelUpgradeApplyResponse(BaseModel): + upgrade: OrganizationModelUpgradeItem + instantiation: OrganizationModelInstantiationItem + + class OrganizationSettingsItem(BaseModel): id: str | None = None tenant_id: str diff --git a/src/govoplan_organizations/backend/db/models.py b/src/govoplan_organizations/backend/db/models.py index 904f55a..7b9858c 100644 --- a/src/govoplan_organizations/backend/db/models.py +++ b/src/govoplan_organizations/backend/db/models.py @@ -156,6 +156,77 @@ class OrganizationModelInstantiation(Base, TimestampMixin): ) +class OrganizationModelUpgrade(Base, TimestampMixin): + __tablename__ = "organizations_model_upgrades" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_organizations_model_upgrade_idempotency", + ), + Index( + "ix_organizations_model_upgrades_tenant_status", + "tenant_id", + "status", + "updated_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + template_id: Mapped[str] = mapped_column( + ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + source_instantiation_id: Mapped[str] = mapped_column( + ForeignKey("organizations_model_instantiations.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + source_template_version_id: Mapped[str] = mapped_column( + ForeignKey( + "organizations_model_template_versions.id", + ondelete="RESTRICT", + ), + nullable=False, + index=True, + ) + target_template_version_id: Mapped[str] = mapped_column( + ForeignKey( + "organizations_model_template_versions.id", + ondelete="RESTRICT", + ), + nullable=False, + index=True, + ) + status: Mapped[str] = mapped_column( + String(30), default="previewed", nullable=False, index=True + ) + revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + base_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + local_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + target_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + preview: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + decisions: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_digest: Mapped[str] = mapped_column(String(64), nullable=False) + requested_by_account_id: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True + ) + applied_by_account_id: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True + ) + cancelled_by_account_id: Mapped[str | None] = mapped_column( + String(36), nullable=True, index=True + ) + applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + provenance: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) + + class OrganizationStructure(Base, TimestampMixin): __tablename__ = "organizations_structures" __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),) @@ -257,6 +328,7 @@ __all__ = [ "OrganizationModelInstantiation", "OrganizationModelTemplate", "OrganizationModelTemplateVersion", + "OrganizationModelUpgrade", "OrganizationRelation", "OrganizationRelationType", "OrganizationStructure", diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py index e38bf90..c33e3fb 100644 --- a/src/govoplan_organizations/backend/manifest.py +++ b/src/govoplan_organizations/backend/manifest.py @@ -189,6 +189,14 @@ manifest = ModuleManifest( label="Organizations administration", order=85, ), + ViewSurface( + id="organizations.admin.template-upgrades", + module_id="organizations", + kind="section", + label="Organization template upgrades", + parent_id="organizations.admin.tenant", + order=86, + ), ), ), migration_spec=MigrationSpec( @@ -203,6 +211,7 @@ manifest = ModuleManifest( organization_models.OrganizationModelTemplate, organization_models.OrganizationModelTemplateVersion, organization_models.OrganizationModelInstantiation, + organization_models.OrganizationModelUpgrade, organization_models.OrganizationStructure, organization_models.OrganizationRelationType, organization_models.OrganizationRelation, @@ -217,6 +226,53 @@ manifest = ModuleManifest( CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory), }, documentation=( + DocumentationTopic( + id="organizations.template-upgrades", + title="Upgrade a tenant organization model", + summary=( + "Compare an immutable system template version with the current " + "tenant-owned model before explicitly applying an upgrade." + ), + body=( + "Open Organizations administration and create a preview for a newer " + "published version of the template used by the tenant. The persisted " + "three-way comparison separates compatible additions, compatible " + "changes, tenant-only divergence, destructive remapping, and invalid " + "references. Tenant-only changes are preserved. Conflicting or " + "destructive entries require an explicit keep, replace, or bounded " + "mapping decision. Applying the reviewed preview creates a new " + "tenant-owned instantiation and supersedes the previous provenance; " + "it never creates live inheritance. A stale preview is rejected if the " + "tenant model or either template version changed. Cancelling retains " + "the review record without modifying organization data." + ), + layer="configured", + documentation_types=("admin",), + audience=("tenant_admin", "operator"), + related_modules=("policy", "audit", "idm", "access"), + links=( + DocumentationLink( + label="Organizations administration", + href="/admin?section=tenant-organization-settings", + kind="runtime", + ), + DocumentationLink( + label="Organization upgrade API", + href="/api/v1/organizations/model-upgrades", + kind="api", + ), + ), + metadata={ + "kind": "guide", + "help_contexts": [ + "organizations.admin.template-upgrades", + "organizations.template-upgrade.preview", + "organizations.template-upgrade.decision", + "organizations.template-upgrade.apply", + ], + }, + order=27, + ), DocumentationTopic( id="organizations.model", title="Organization model", @@ -320,7 +376,9 @@ manifest = ModuleManifest( maturity="vertical_slice", documentation_ref="docs/ORGANIZATION_MODEL.md", test_ref="tests/test_model_templates.py", - known_limits=("Cross-tenant template lifecycle and target upgrade evidence are not reference-ready.",), + known_limits=( + "Downstream modules must reconcile organization-reference changes from the emitted upgrade event; cross-module records are not rewritten directly.", + ), owned_concepts=("organization unit", "organization structure", "organization relation", "organization function"), non_owned_concepts=("function incumbency", "identity", "application role", "mandate"), recovery_docs=("docs/ORGANIZATION_MODEL.md",), diff --git a/src/govoplan_organizations/backend/migrations/dev_versions/a61e4d9c72b8_organization_template_upgrades.py b/src/govoplan_organizations/backend/migrations/dev_versions/a61e4d9c72b8_organization_template_upgrades.py new file mode 100644 index 0000000..627e8ed --- /dev/null +++ b/src/govoplan_organizations/backend/migrations/dev_versions/a61e4d9c72b8_organization_template_upgrades.py @@ -0,0 +1,10 @@ +"""Development mirror for the organization template upgrade ledger.""" + +from govoplan_organizations.backend.migrations.versions.a61e4d9c72b8_organization_template_upgrades import ( # noqa: F401 + branch_labels, + depends_on, + downgrade, + down_revision, + revision, + upgrade, +) diff --git a/src/govoplan_organizations/backend/migrations/versions/a61e4d9c72b8_organization_template_upgrades.py b/src/govoplan_organizations/backend/migrations/versions/a61e4d9c72b8_organization_template_upgrades.py new file mode 100644 index 0000000..158fc75 --- /dev/null +++ b/src/govoplan_organizations/backend/migrations/versions/a61e4d9c72b8_organization_template_upgrades.py @@ -0,0 +1,102 @@ +"""organization template upgrade ledger + +Revision ID: a61e4d9c72b8 +Revises: 7e8f9a0b1c2d +Create Date: 2026-08-04 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "a61e4d9c72b8" +down_revision = "7e8f9a0b1c2d" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "organizations_model_upgrades", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("template_id", sa.String(length=36), nullable=False), + sa.Column("source_instantiation_id", sa.String(length=36), nullable=False), + sa.Column("source_template_version_id", sa.String(length=36), nullable=False), + sa.Column("target_template_version_id", sa.String(length=36), nullable=False), + sa.Column("status", sa.String(length=30), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("base_definition_sha256", sa.String(length=64), nullable=False), + sa.Column("local_definition_sha256", sa.String(length=64), nullable=False), + sa.Column("target_definition_sha256", sa.String(length=64), nullable=False), + sa.Column("preview", sa.JSON(), nullable=False), + sa.Column("decisions", sa.JSON(), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_digest", sa.String(length=64), nullable=False), + sa.Column("requested_by_account_id", sa.String(length=36), nullable=True), + sa.Column("applied_by_account_id", sa.String(length=36), nullable=True), + sa.Column("cancelled_by_account_id", sa.String(length=36), nullable=True), + sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("provenance", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["template_id"], + ["organizations_model_templates.id"], + name=op.f("fk_organizations_model_upgrades_template_id_organizations_model_templates"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["source_instantiation_id"], + ["organizations_model_instantiations.id"], + name=op.f("fk_organizations_model_upgrades_source_instantiation_id_organizations_model_instantiations"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["source_template_version_id"], + ["organizations_model_template_versions.id"], + name=op.f("fk_organizations_model_upgrades_source_template_version_id_organizations_model_template_versions"), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["target_template_version_id"], + ["organizations_model_template_versions.id"], + name=op.f("fk_organizations_model_upgrades_target_template_version_id_organizations_model_template_versions"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_model_upgrades")), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_organizations_model_upgrade_idempotency", + ), + ) + for column in ( + "tenant_id", + "template_id", + "source_instantiation_id", + "source_template_version_id", + "target_template_version_id", + "status", + "requested_by_account_id", + "applied_by_account_id", + "cancelled_by_account_id", + ): + op.create_index( + op.f(f"ix_organizations_model_upgrades_{column}"), + "organizations_model_upgrades", + [column], + unique=False, + ) + op.create_index( + "ix_organizations_model_upgrades_tenant_status", + "organizations_model_upgrades", + ["tenant_id", "status", "updated_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("organizations_model_upgrades") diff --git a/src/govoplan_organizations/backend/upgrades.py b/src/govoplan_organizations/backend/upgrades.py new file mode 100644 index 0000000..eac2792 --- /dev/null +++ b/src/govoplan_organizations/backend/upgrades.py @@ -0,0 +1,1054 @@ +from __future__ import annotations + +from collections import Counter +from datetime import UTC, datetime +import hashlib +import json +from typing import Any, Mapping + +from sqlalchemy.orm import Session + +from govoplan_organizations.backend.api.v1.schemas import ( + OrganizationModelTemplateDefinition, +) +from govoplan_organizations.backend.db.models import ( + OrganizationFunction, + OrganizationFunctionType, + OrganizationModelInstantiation, + OrganizationModelTemplateVersion, + OrganizationModelUpgrade, + OrganizationRelation, + OrganizationRelationType, + OrganizationStructure, + OrganizationUnit, + OrganizationUnitType, +) +from govoplan_organizations.backend.templates import ( + OrganizationTemplateError, + canonical_template_definition, +) + + +COLLECTIONS = ( + "unit_types", + "structures", + "relation_types", + "units", + "relations", + "function_types", + "functions", +) +REFERENCE_FIELDS = { + "relation_types": { + "structure_slug", + "source_unit_type_slug", + "target_unit_type_slug", + }, + "units": {"unit_type_slug", "parent_slug"}, + "relations": { + "structure_slug", + "relation_type_slug", + "source_unit_slug", + "target_unit_slug", + }, + "function_types": {"organization_unit_type_slug"}, + "functions": {"function_type_slug", "organization_unit_slug"}, +} +MAPPABLE_COLLECTIONS = { + "unit_types", + "structures", + "relation_types", + "units", + "function_types", + "functions", +} + + +class OrganizationUpgradeError(ValueError): + pass + + +def current_model_instantiation( + session: Session, + *, + tenant_id: str, +) -> OrganizationModelInstantiation | None: + return ( + session.query(OrganizationModelInstantiation) + .filter( + OrganizationModelInstantiation.tenant_id == tenant_id, + OrganizationModelInstantiation.status == "applied", + ) + .order_by( + OrganizationModelInstantiation.updated_at.desc(), + OrganizationModelInstantiation.id.desc(), + ) + .first() + ) + + +def list_model_upgrades( + session: Session, + *, + tenant_id: str, + limit: int = 100, +) -> tuple[OrganizationModelUpgrade, ...]: + return tuple( + session.query(OrganizationModelUpgrade) + .filter(OrganizationModelUpgrade.tenant_id == tenant_id) + .order_by( + OrganizationModelUpgrade.updated_at.desc(), + OrganizationModelUpgrade.id.desc(), + ) + .limit(max(1, min(limit, 500))) + .all() + ) + + +def create_model_upgrade_preview( + session: Session, + *, + tenant_id: str, + target_version: OrganizationModelTemplateVersion, + actor_account_id: str | None, + idempotency_key: str, +) -> OrganizationModelUpgrade: + source = current_model_instantiation(session, tenant_id=tenant_id) + if source is None: + raise OrganizationUpgradeError( + "The tenant has no applied organization template version to upgrade." + ) + if target_version.status != "published": + raise OrganizationUpgradeError("Only a published target version can be used.") + if target_version.template_id != source.template_id: + raise OrganizationUpgradeError( + "The target version belongs to another organization template." + ) + if target_version.id == source.template_version_id: + raise OrganizationUpgradeError("The target version is already applied.") + source_version = session.get( + OrganizationModelTemplateVersion, source.template_version_id + ) + if source_version is None: + raise OrganizationUpgradeError("The source template version is unavailable.") + request_payload = { + "tenant_id": tenant_id, + "source_instantiation_id": source.id, + "target_template_version_id": target_version.id, + } + request_digest = _json_hash(request_payload) + replay = ( + session.query(OrganizationModelUpgrade) + .filter( + OrganizationModelUpgrade.tenant_id == tenant_id, + OrganizationModelUpgrade.idempotency_key == idempotency_key, + ) + .one_or_none() + ) + if replay is not None: + if replay.request_digest != request_digest: + raise OrganizationUpgradeError( + "The upgrade idempotency key was reused for another request." + ) + return replay + + local_definition, local_hash, invalid_references = tenant_model_definition( + session, tenant_id=tenant_id + ) + preview = compare_model_definitions( + OrganizationModelTemplateDefinition.model_validate(source_version.definition), + local_definition, + OrganizationModelTemplateDefinition.model_validate(target_version.definition), + invalid_references=invalid_references, + ) + item = OrganizationModelUpgrade( + tenant_id=tenant_id, + template_id=source.template_id, + source_instantiation_id=source.id, + source_template_version_id=source.template_version_id, + target_template_version_id=target_version.id, + status="previewed", + revision=1, + base_definition_sha256=source_version.definition_sha256, + local_definition_sha256=local_hash, + target_definition_sha256=target_version.definition_sha256, + preview=preview, + decisions={}, + idempotency_key=idempotency_key, + request_digest=request_digest, + requested_by_account_id=actor_account_id, + provenance={ + "comparison": "three_way", + "copy_semantics": "tenant_owned_no_live_inheritance", + "silent_mutation": False, + }, + ) + session.add(item) + session.flush() + return item + + +def cancel_model_upgrade( + session: Session, + *, + tenant_id: str, + upgrade_id: str, + expected_revision: int, + actor_account_id: str | None, +) -> OrganizationModelUpgrade: + item = _locked_upgrade(session, tenant_id=tenant_id, upgrade_id=upgrade_id) + if item.status == "cancelled": + return item + if item.status != "previewed": + raise OrganizationUpgradeError("Only a previewed upgrade can be cancelled.") + _expected_revision(item, expected_revision) + item.status = "cancelled" + item.revision += 1 + item.cancelled_by_account_id = actor_account_id + item.cancelled_at = datetime.now(UTC) + item.provenance = { + **dict(item.provenance), + "cancelled_explicitly": True, + } + session.flush() + return item + + +def apply_model_upgrade( + session: Session, + *, + tenant_id: str, + upgrade_id: str, + expected_revision: int, + decisions: Mapping[str, Mapping[str, str | None]], + actor_account_id: str | None, +) -> tuple[OrganizationModelUpgrade, OrganizationModelInstantiation]: + item = _locked_upgrade(session, tenant_id=tenant_id, upgrade_id=upgrade_id) + if item.status != "previewed": + raise OrganizationUpgradeError("Only a previewed upgrade can be applied.") + _expected_revision(item, expected_revision) + source = current_model_instantiation(session, tenant_id=tenant_id) + if source is None or source.id != item.source_instantiation_id: + raise OrganizationUpgradeError( + "The tenant's applied template version changed after this preview. Create a new preview." + ) + source_version = session.get( + OrganizationModelTemplateVersion, item.source_template_version_id + ) + target_version = session.get( + OrganizationModelTemplateVersion, item.target_template_version_id + ) + if source_version is None or target_version is None: + raise OrganizationUpgradeError("A referenced template version is unavailable.") + if target_version.status != "published": + raise OrganizationUpgradeError("The target version is no longer published.") + local_definition, local_hash, invalid_references = tenant_model_definition( + session, tenant_id=tenant_id + ) + if ( + source_version.definition_sha256 != item.base_definition_sha256 + or target_version.definition_sha256 != item.target_definition_sha256 + or local_hash != item.local_definition_sha256 + ): + raise OrganizationUpgradeError( + "The source, target, or tenant model changed after this preview. Create a new preview." + ) + preview = compare_model_definitions( + OrganizationModelTemplateDefinition.model_validate(source_version.definition), + local_definition, + OrganizationModelTemplateDefinition.model_validate(target_version.definition), + invalid_references=invalid_references, + ) + if preview != item.preview: + raise OrganizationUpgradeError( + "The upgrade comparison changed after this preview. Create a new preview." + ) + merged = resolve_model_upgrade( + OrganizationModelTemplateDefinition.model_validate(source_version.definition), + local_definition, + OrganizationModelTemplateDefinition.model_validate(target_version.definition), + preview=preview, + decisions=decisions, + ) + merged_payload, merged_hash = canonical_template_definition(merged) + counts = _apply_definition( + session, + tenant_id=tenant_id, + definition=OrganizationModelTemplateDefinition.model_validate(merged_payload), + target_version=target_version, + upgrade_id=item.id, + ) + source.status = "superseded" + instantiation = OrganizationModelInstantiation( + tenant_id=tenant_id, + template_id=item.template_id, + template_version_id=target_version.id, + source_definition_sha256=target_version.definition_sha256, + status="applied", + instantiated_by_account_id=actor_account_id, + object_counts=counts, + provenance={ + "template_id": item.template_id, + "template_version_id": target_version.id, + "template_version": target_version.version, + "definition_sha256": target_version.definition_sha256, + "merged_definition_sha256": merged_hash, + "copy_semantics": "tenant_owned_no_live_inheritance", + "upgrade_id": item.id, + "previous_instantiation_id": source.id, + }, + ) + session.add(instantiation) + session.flush() + item.status = "applied" + item.revision += 1 + item.decisions = {key: dict(value) for key, value in decisions.items()} + item.applied_by_account_id = actor_account_id + item.applied_at = datetime.now(UTC) + item.provenance = { + **dict(item.provenance), + "result_instantiation_id": instantiation.id, + "merged_definition_sha256": merged_hash, + "conflict_decision_count": len(decisions), + } + session.flush() + return item, instantiation + + +def tenant_model_definition( + session: Session, + *, + tenant_id: str, +) -> tuple[OrganizationModelTemplateDefinition, str, tuple[str, ...]]: + unit_types = _active_template_rows( + session.query(OrganizationUnitType) + .filter(OrganizationUnitType.tenant_id == tenant_id) + .order_by(OrganizationUnitType.slug) + .all() + ) + structures = _active_template_rows( + session.query(OrganizationStructure) + .filter(OrganizationStructure.tenant_id == tenant_id) + .order_by(OrganizationStructure.slug) + .all() + ) + relation_types = _active_template_rows( + session.query(OrganizationRelationType) + .filter(OrganizationRelationType.tenant_id == tenant_id) + .order_by(OrganizationRelationType.slug) + .all() + ) + units = _active_template_rows( + session.query(OrganizationUnit) + .filter(OrganizationUnit.tenant_id == tenant_id) + .order_by(OrganizationUnit.slug) + .all() + ) + function_types = _active_template_rows( + session.query(OrganizationFunctionType) + .filter(OrganizationFunctionType.tenant_id == tenant_id) + .order_by(OrganizationFunctionType.slug) + .all() + ) + functions = _active_template_rows( + session.query(OrganizationFunction) + .filter(OrganizationFunction.tenant_id == tenant_id) + .order_by(OrganizationFunction.slug) + .all() + ) + relations = _active_template_rows( + session.query(OrganizationRelation) + .filter(OrganizationRelation.tenant_id == tenant_id) + .order_by(OrganizationRelation.id) + .all() + ) + unit_type_slugs = {item.id: item.slug for item in unit_types} + structure_slugs = {item.id: item.slug for item in structures} + relation_type_slugs = {item.id: item.slug for item in relation_types} + unit_slugs = {item.id: item.slug for item in units} + function_type_slugs = {item.id: item.slug for item in function_types} + invalid: list[str] = [] + + def ref(values: Mapping[str, str], value: str | None, label: str) -> str | None: + if value is None: + return None + resolved = values.get(value) + if resolved is None: + invalid.append(f"{label} references missing object {value}") + return f"missing-{value}" + return resolved + + payload = { + "unit_types": [_slugged_payload(item) for item in unit_types], + "structures": [ + {**_slugged_payload(item), "structure_kind": item.structure_kind} + for item in structures + ], + "relation_types": [ + { + **_slugged_payload(item), + "structure_slug": ref( + structure_slugs, item.structure_id, "Relation type structure" + ), + "source_unit_type_slug": ref( + unit_type_slugs, + item.source_unit_type_id, + "Relation type source", + ), + "target_unit_type_slug": ref( + unit_type_slugs, + item.target_unit_type_id, + "Relation type target", + ), + "is_hierarchical": item.is_hierarchical, + "allow_cycles": item.allow_cycles, + } + for item in relation_types + ], + "units": [ + { + **_slugged_payload(item), + "unit_type_slug": ref( + unit_type_slugs, item.unit_type_id, "Unit type" + ), + "parent_slug": ref(unit_slugs, item.parent_id, "Unit parent"), + } + for item in units + ], + "relations": [ + { + "structure_slug": ref( + structure_slugs, item.structure_id, "Relation structure" + ), + "relation_type_slug": ref( + relation_type_slugs, + item.relation_type_id, + "Relation type", + ), + "source_unit_slug": ref( + unit_slugs, item.source_unit_id, "Relation source" + ), + "target_unit_slug": ref( + unit_slugs, item.target_unit_id, "Relation target" + ), + "valid_from": item.valid_from, + "valid_until": item.valid_until, + "is_active": item.is_active, + "settings": _public_settings(item.settings), + } + for item in relations + ], + "function_types": [ + { + **_slugged_payload(item), + "organization_unit_type_slug": ref( + unit_type_slugs, + item.organization_unit_type_id, + "Function type unit type", + ), + "delegable": item.delegable, + "act_in_place_allowed": item.act_in_place_allowed, + } + for item in function_types + ], + "functions": [ + { + **_slugged_payload(item), + "function_type_slug": ref( + function_type_slugs, + item.function_type_id, + "Function type", + ), + "organization_unit_slug": ref( + unit_slugs, + item.organization_unit_id, + "Function organization unit", + ), + "delegable": item.delegable, + "act_in_place_allowed": item.act_in_place_allowed, + } + for item in functions + ], + } + definition = OrganizationModelTemplateDefinition.model_validate(payload) + try: + normalized, fingerprint = canonical_template_definition(definition) + definition = OrganizationModelTemplateDefinition.model_validate(normalized) + except OrganizationTemplateError as exc: + invalid.append(str(exc)) + fingerprint = _json_hash(definition.model_dump(mode="json")) + return definition, fingerprint, tuple(sorted(set(invalid))) + + +def compare_model_definitions( + base: OrganizationModelTemplateDefinition, + local: OrganizationModelTemplateDefinition, + target: OrganizationModelTemplateDefinition, + *, + invalid_references: tuple[str, ...] = (), +) -> dict[str, Any]: + entries: list[dict[str, Any]] = [] + for collection in COLLECTIONS: + base_items = _collection_map(base, collection) + local_items = _collection_map(local, collection) + target_items = _collection_map(target, collection) + for key in sorted(set(base_items) | set(local_items) | set(target_items)): + base_value = base_items.get(key) + local_value = local_items.get(key) + target_value = target_items.get(key) + classification, requires_decision = _classify_change( + collection, + base_value, + local_value, + target_value, + ) + if classification == "unchanged": + continue + entries.append( + { + "id": f"{collection}:{key}", + "collection": collection, + "key": key, + "classification": classification, + "requires_decision": requires_decision, + "base": base_value, + "local": local_value, + "target": target_value, + "allowed_actions": ( + ["keep_local", "use_target", "map_to"] + if requires_decision and collection in MAPPABLE_COLLECTIONS + else ["keep_local", "use_target"] + if requires_decision + else [] + ), + } + ) + entries.extend( + { + "id": f"invalid_reference:{index}", + "collection": "model", + "key": str(index + 1), + "classification": "invalid_reference", + "requires_decision": False, + "base": None, + "local": {"diagnostic": diagnostic}, + "target": None, + "allowed_actions": [], + } + for index, diagnostic in enumerate(invalid_references) + ) + counts = Counter(entry["classification"] for entry in entries) + return { + "entries": entries, + "counts": dict(sorted(counts.items())), + "requires_decisions": sum( + 1 for entry in entries if entry["requires_decision"] + ), + "blocking_invalid_references": len(invalid_references), + "silent_mutation": False, + } + + +def resolve_model_upgrade( + base: OrganizationModelTemplateDefinition, + local: OrganizationModelTemplateDefinition, + target: OrganizationModelTemplateDefinition, + *, + preview: Mapping[str, Any], + decisions: Mapping[str, Mapping[str, str | None]], +) -> OrganizationModelTemplateDefinition: + if int(preview.get("blocking_invalid_references", 0)): + raise OrganizationUpgradeError( + "Invalid tenant references must be repaired before an upgrade can be applied." + ) + entries = {entry["id"]: entry for entry in preview.get("entries", [])} + selected: dict[str, dict[str, dict[str, Any]]] = {} + mappings: dict[str, dict[str, str]] = {} + for collection in COLLECTIONS: + base_items = _collection_map(base, collection) + local_items = _collection_map(local, collection) + target_items = _collection_map(target, collection) + selected_items: dict[str, dict[str, Any]] = {} + for key in sorted(set(base_items) | set(local_items) | set(target_items)): + entry = entries.get(f"{collection}:{key}") + classification = entry["classification"] if entry else "unchanged" + if entry and entry["requires_decision"]: + decision = decisions.get(entry["id"]) + if decision is None: + raise OrganizationUpgradeError( + f"A bounded decision is required for {entry['id']}." + ) + action = str(decision.get("action") or "") + if action not in entry["allowed_actions"]: + raise OrganizationUpgradeError( + f"Unsupported upgrade decision for {entry['id']}." + ) + if action == "map_to": + target_key = str(decision.get("target_key") or "").strip() + if not target_key or target_key == key: + raise OrganizationUpgradeError( + f"Mapping {entry['id']} requires another target key." + ) + mappings.setdefault(collection, {})[key] = target_key + continue + value = local_items.get(key) if action == "keep_local" else target_items.get(key) + elif classification in {"compatible_addition", "compatible_change"}: + value = target_items.get(key) + elif classification == "unchanged": + value = local_items.get(key, target_items.get(key)) + else: + value = local_items.get(key) + if value is not None: + selected_items[key] = dict(value) + selected[collection] = selected_items + _validate_mapping_targets(selected, mappings) + payload = { + collection: list(_apply_reference_mappings(collection, selected[collection], mappings).values()) + for collection in COLLECTIONS + } + try: + definition = OrganizationModelTemplateDefinition.model_validate(payload) + canonical, _fingerprint = canonical_template_definition(definition) + except (OrganizationTemplateError, ValueError) as exc: + raise OrganizationUpgradeError( + f"The selected upgrade decisions produce an invalid model: {exc}" + ) from exc + return OrganizationModelTemplateDefinition.model_validate(canonical) + + +def _apply_definition( + session: Session, + *, + tenant_id: str, + definition: OrganizationModelTemplateDefinition, + target_version: OrganizationModelTemplateVersion, + upgrade_id: str, +) -> dict[str, int]: + provenance = { + "template_id": target_version.template_id, + "template_version_id": target_version.id, + "template_version": target_version.version, + "definition_sha256": target_version.definition_sha256, + "upgrade_id": upgrade_id, + } + unit_types = _upsert_slugged( + session, + OrganizationUnitType, + tenant_id, + definition.unit_types, + provenance, + ) + structures = _upsert_slugged( + session, + OrganizationStructure, + tenant_id, + definition.structures, + provenance, + extra_fields=("structure_kind",), + ) + session.flush() + relation_types = _upsert_slugged( + session, + OrganizationRelationType, + tenant_id, + definition.relation_types, + provenance, + extra_fields=("is_hierarchical", "allow_cycles"), + references={ + "structure_id": (structures, "structure_slug"), + "source_unit_type_id": (unit_types, "source_unit_type_slug"), + "target_unit_type_id": (unit_types, "target_unit_type_slug"), + }, + ) + function_types = _upsert_slugged( + session, + OrganizationFunctionType, + tenant_id, + definition.function_types, + provenance, + extra_fields=("delegable", "act_in_place_allowed"), + references={ + "organization_unit_type_id": ( + unit_types, + "organization_unit_type_slug", + ) + }, + ) + units = _upsert_slugged( + session, + OrganizationUnit, + tenant_id, + definition.units, + provenance, + references={"unit_type_id": (unit_types, "unit_type_slug")}, + ) + session.flush() + for source in definition.units: + units[source.slug].parent_id = _row_id(units, source.parent_slug) + + functions = _upsert_slugged( + session, + OrganizationFunction, + tenant_id, + definition.functions, + provenance, + extra_fields=("delegable", "act_in_place_allowed"), + references={ + "function_type_id": (function_types, "function_type_slug"), + "organization_unit_id": (units, "organization_unit_slug"), + }, + ) + session.flush() + relations = _upsert_relations( + session, + tenant_id=tenant_id, + definitions=definition.relations, + structures=structures, + relation_types=relation_types, + units=units, + provenance=provenance, + ) + session.flush() + return { + "unit_types": len(unit_types), + "structures": len(structures), + "relation_types": len(relation_types), + "units": len(units), + "relations": len(relations), + "function_types": len(function_types), + "functions": len(functions), + } + + +def _upsert_slugged( + session: Session, + model: type, + tenant_id: str, + definitions: list[Any], + provenance: Mapping[str, Any], + *, + extra_fields: tuple[str, ...] = (), + references: Mapping[str, tuple[Mapping[str, Any], str]] | None = None, +) -> dict[str, Any]: + existing = { + item.slug: item + for item in session.query(model).filter(model.tenant_id == tenant_id).all() + } + active: dict[str, Any] = {} + for source in definitions: + row = existing.get(source.slug) + if row is None: + row = model(tenant_id=tenant_id, slug=source.slug, name=source.name) + session.add(row) + row.name = source.name + row.description = source.description + row.is_active = source.is_active + row.settings = _settings_with_provenance( + source.settings, provenance, source.slug + ) + for field in extra_fields: + setattr(row, field, getattr(source, field)) + for field, (targets, source_field) in (references or {}).items(): + setattr(row, field, _row_id(targets, getattr(source, source_field))) + active[source.slug] = row + _retire_missing(existing, set(active), provenance) + return active + + +def _upsert_relations( + session: Session, + *, + tenant_id: str, + definitions: list[Any], + structures: Mapping[str, Any], + relation_types: Mapping[str, Any], + units: Mapping[str, Any], + provenance: Mapping[str, Any], +) -> dict[str, OrganizationRelation]: + existing_rows = ( + session.query(OrganizationRelation) + .filter(OrganizationRelation.tenant_id == tenant_id) + .all() + ) + structure_by_id = {item.id: slug for slug, item in structures.items()} + relation_type_by_id = {item.id: slug for slug, item in relation_types.items()} + unit_by_id = {item.id: slug for slug, item in units.items()} + existing: dict[str, OrganizationRelation] = {} + for row in existing_rows: + values = ( + structure_by_id.get(row.structure_id), + relation_type_by_id.get(row.relation_type_id), + unit_by_id.get(row.source_unit_id), + unit_by_id.get(row.target_unit_id), + ) + if all(values): + existing[":".join(str(value) for value in values)] = row + active: dict[str, OrganizationRelation] = {} + for source in definitions: + key = _definition_key("relations", source.model_dump(mode="json")) + row = existing.get(key) + if row is None: + row = OrganizationRelation( + tenant_id=tenant_id, + structure_id=structures[source.structure_slug].id, + relation_type_id=relation_types[source.relation_type_slug].id, + source_unit_id=units[source.source_unit_slug].id, + target_unit_id=units[source.target_unit_slug].id, + ) + session.add(row) + row.structure_id = structures[source.structure_slug].id + row.relation_type_id = relation_types[source.relation_type_slug].id + row.source_unit_id = units[source.source_unit_slug].id + row.target_unit_id = units[source.target_unit_slug].id + row.valid_from = source.valid_from + row.valid_until = source.valid_until + row.is_active = source.is_active + row.settings = _settings_with_provenance(source.settings, provenance, key) + active[key] = row + active_ids = {row.id for row in active.values() if row.id is not None} + for row in existing_rows: + if row.id not in active_ids: + key = next( + (candidate for candidate, value in existing.items() if value is row), + row.id, + ) + row.is_active = False + row.settings = _retired_settings(row.settings, provenance, key) + return active + + +def _retire_missing( + existing: Mapping[str, Any], + active: set[str], + provenance: Mapping[str, Any], +) -> None: + for slug, row in existing.items(): + if slug not in active: + row.is_active = False + row.settings = _retired_settings(row.settings, provenance, slug) + + +def _classify_change( + collection: str, + base: dict[str, Any] | None, + local: dict[str, Any] | None, + target: dict[str, Any] | None, +) -> tuple[str, bool]: + if local == target: + return "unchanged", False + if base is None and target is not None and local is None: + return "compatible_addition", False + if target == base and local != base: + return "local_divergence", False + if base is None and target is None and local is not None: + return "local_divergence", False + if base is not None and target is None: + return "destructive_remapping", local is not None + if local == base and target is not None: + changed_fields = _changed_fields(base, target) + if changed_fields & REFERENCE_FIELDS.get(collection, set()): + return "destructive_remapping", True + return "compatible_change", False + if base is None and local is not None and target is not None: + return "local_divergence", True + return "local_divergence", True + + +def _collection_map( + definition: OrganizationModelTemplateDefinition, + collection: str, +) -> dict[str, dict[str, Any]]: + return { + _definition_key(collection, value): value + for item in getattr(definition, collection) + for value in [item.model_dump(mode="json")] + } + + +def _definition_key(collection: str, value: Mapping[str, Any]) -> str: + if collection == "relations": + return ":".join( + str(value[field]) + for field in ( + "structure_slug", + "relation_type_slug", + "source_unit_slug", + "target_unit_slug", + ) + ) + return str(value["slug"]) + + +def _apply_reference_mappings( + collection: str, + values: Mapping[str, dict[str, Any]], + mappings: Mapping[str, Mapping[str, str]], +) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for value in values.values(): + item = dict(value) + if collection == "relation_types": + item["structure_slug"] = _mapped(mappings, "structures", item.get("structure_slug")) + item["source_unit_type_slug"] = _mapped(mappings, "unit_types", item.get("source_unit_type_slug")) + item["target_unit_type_slug"] = _mapped(mappings, "unit_types", item.get("target_unit_type_slug")) + elif collection == "units": + item["unit_type_slug"] = _mapped(mappings, "unit_types", item.get("unit_type_slug")) + item["parent_slug"] = _mapped(mappings, "units", item.get("parent_slug")) + elif collection == "relations": + item["structure_slug"] = _mapped(mappings, "structures", item.get("structure_slug")) + item["relation_type_slug"] = _mapped(mappings, "relation_types", item.get("relation_type_slug")) + item["source_unit_slug"] = _mapped(mappings, "units", item.get("source_unit_slug")) + item["target_unit_slug"] = _mapped(mappings, "units", item.get("target_unit_slug")) + elif collection == "function_types": + item["organization_unit_type_slug"] = _mapped(mappings, "unit_types", item.get("organization_unit_type_slug")) + elif collection == "functions": + item["function_type_slug"] = _mapped(mappings, "function_types", item.get("function_type_slug")) + item["organization_unit_slug"] = _mapped(mappings, "units", item.get("organization_unit_slug")) + key = _definition_key(collection, item) + if key in result and result[key] != item: + raise OrganizationUpgradeError( + f"Upgrade mappings create duplicate {collection} key {key}." + ) + result[key] = item + return result + + +def _validate_mapping_targets( + selected: Mapping[str, Mapping[str, dict[str, Any]]], + mappings: Mapping[str, Mapping[str, str]], +) -> None: + for collection, values in mappings.items(): + available = set(selected.get(collection, {})) + for source, target in values.items(): + if target not in available: + raise OrganizationUpgradeError( + f"Mapping target {collection}:{target} does not exist." + ) + if source == target: + raise OrganizationUpgradeError("A mapping cannot target itself.") + + +def _mapped( + mappings: Mapping[str, Mapping[str, str]], + collection: str, + value: Any, +) -> Any: + return mappings.get(collection, {}).get(value, value) + + +def _active_template_rows(rows: list[Any]) -> list[Any]: + return [ + row + for row in rows + if not dict(row.settings or {}) + .get("template_provenance", {}) + .get("retired_by_upgrade") + ] + + +def _slugged_payload(item: Any) -> dict[str, Any]: + return { + "slug": item.slug, + "name": item.name, + "description": item.description, + "is_active": item.is_active, + "settings": _public_settings(item.settings), + } + + +def _public_settings(settings: Mapping[str, Any] | None) -> dict[str, Any]: + value = dict(settings or {}) + value.pop("template_provenance", None) + return value + + +def _settings_with_provenance( + settings: Mapping[str, Any], + provenance: Mapping[str, Any], + source_key: str, +) -> dict[str, Any]: + return { + **_public_settings(settings), + "template_provenance": { + **dict(provenance), + "source_key": source_key, + "retired_by_upgrade": False, + }, + } + + +def _retired_settings( + settings: Mapping[str, Any] | None, + provenance: Mapping[str, Any], + source_key: str, +) -> dict[str, Any]: + return { + **_public_settings(settings), + "template_provenance": { + **dict(provenance), + "source_key": source_key, + "retired_by_upgrade": True, + }, + } + + +def _row_id(rows: Mapping[str, Any], slug: str | None) -> str | None: + return rows[slug].id if slug is not None else None + + +def _changed_fields( + left: Mapping[str, Any], right: Mapping[str, Any] +) -> set[str]: + return { + key for key in set(left) | set(right) if left.get(key) != right.get(key) + } + + +def _json_hash(value: Any) -> str: + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _locked_upgrade( + session: Session, + *, + tenant_id: str, + upgrade_id: str, +) -> OrganizationModelUpgrade: + item = ( + session.query(OrganizationModelUpgrade) + .filter( + OrganizationModelUpgrade.id == upgrade_id, + OrganizationModelUpgrade.tenant_id == tenant_id, + ) + .with_for_update() + .one_or_none() + ) + if item is None: + raise OrganizationUpgradeError("Organization model upgrade not found.") + return item + + +def _expected_revision(item: OrganizationModelUpgrade, expected: int) -> None: + if item.revision != expected: + raise OrganizationUpgradeError( + f"Stale upgrade revision: expected {expected}, current {item.revision}." + ) + + +__all__ = [ + "OrganizationUpgradeError", + "apply_model_upgrade", + "cancel_model_upgrade", + "compare_model_definitions", + "create_model_upgrade_preview", + "current_model_instantiation", + "list_model_upgrades", + "resolve_model_upgrade", + "tenant_model_definition", +] diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 7b42934..8aa0f04 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -14,7 +14,10 @@ class OrganizationsInterfaceDocumentationContractTests(unittest.TestCase): {route.path for route in frontend.routes}, # type: ignore[union-attr] ) self.assertEqual( - {"organizations.admin.tenant"}, + { + "organizations.admin.tenant", + "organizations.admin.template-upgrades", + }, {surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr] ) self.assertTrue( diff --git a/tests/test_model_templates.py b/tests/test_model_templates.py index 80efdbb..a41945e 100644 --- a/tests/test_model_templates.py +++ b/tests/test_model_templates.py @@ -15,6 +15,7 @@ from govoplan_organizations.backend.db.models import ( OrganizationModelInstantiation, OrganizationModelTemplate, OrganizationModelTemplateVersion, + OrganizationModelUpgrade, OrganizationRelation, OrganizationRelationType, OrganizationStructure, @@ -26,6 +27,13 @@ from govoplan_organizations.backend.templates import ( canonical_template_definition, instantiate_template_version, ) +from govoplan_organizations.backend.upgrades import ( + OrganizationUpgradeError, + apply_model_upgrade, + cancel_model_upgrade, + create_model_upgrade_preview, + current_model_instantiation, +) TABLES = [ @@ -39,6 +47,7 @@ TABLES = [ OrganizationFunction.__table__, OrganizationRelation.__table__, OrganizationModelInstantiation.__table__, + OrganizationModelUpgrade.__table__, ] @@ -216,6 +225,248 @@ class OrganizationModelTemplateTests(unittest.TestCase): ): canonical_template_definition(definition) + def test_three_way_upgrade_previews_additions_and_local_divergence(self) -> None: + template, source, target = self._instantiated_upgrade_fixture( + mutate_target=lambda definition: definition.units.append( + definition.units[1].model_copy( + update={ + "slug": "service-office", + "name": "Service office", + "parent_slug": "municipality", + } + ) + ) + ) + office = ( + self.session.query(OrganizationUnit) + .filter_by(tenant_id="tenant-1", slug="office") + .one() + ) + office.name = "Tenant-specific office" + self.session.commit() + + preview = create_model_upgrade_preview( + self.session, + tenant_id="tenant-1", + target_version=target, + actor_account_id="account-1", + idempotency_key="preview-additive", + ) + self.session.commit() + + self.assertEqual(1, preview.preview["counts"]["compatible_addition"]) + self.assertEqual(1, preview.preview["counts"]["local_divergence"]) + self.assertEqual(0, preview.preview["requires_decisions"]) + applied, instantiation = apply_model_upgrade( + self.session, + tenant_id="tenant-1", + upgrade_id=preview.id, + expected_revision=1, + decisions={}, + actor_account_id="account-2", + ) + self.session.commit() + + self.assertEqual("applied", applied.status) + self.assertEqual(target.id, instantiation.template_version_id) + self.assertEqual( + "Tenant-specific office", + self.session.query(OrganizationUnit) + .filter_by(tenant_id="tenant-1", slug="office") + .one() + .name, + ) + self.assertIsNotNone( + self.session.query(OrganizationUnit) + .filter_by(tenant_id="tenant-1", slug="service-office") + .one_or_none() + ) + self.assertEqual("superseded", source.status) + self.assertEqual(instantiation.id, current_model_instantiation(self.session, tenant_id="tenant-1").id) + self.assertEqual(template.id, instantiation.template_id) + + def test_divergent_upgrade_requires_bounded_decision_and_detects_stale_state(self) -> None: + _template, _source, target = self._instantiated_upgrade_fixture( + mutate_target=lambda definition: setattr( + definition.units[1], "name", "Template office" + ) + ) + office = self.session.query(OrganizationUnit).filter_by( + tenant_id="tenant-1", slug="office" + ).one() + office.name = "Local office" + self.session.commit() + preview = create_model_upgrade_preview( + self.session, + tenant_id="tenant-1", + target_version=target, + actor_account_id="account-1", + idempotency_key="preview-divergent", + ) + self.session.commit() + conflict = next( + entry + for entry in preview.preview["entries"] + if entry["id"] == "units:office" + ) + self.assertTrue(conflict["requires_decision"]) + with self.assertRaisesRegex(OrganizationUpgradeError, "decision is required"): + apply_model_upgrade( + self.session, + tenant_id="tenant-1", + upgrade_id=preview.id, + expected_revision=1, + decisions={}, + actor_account_id="account-2", + ) + self.session.rollback() + + office = self.session.query(OrganizationUnit).filter_by( + tenant_id="tenant-1", slug="office" + ).one() + office.description = "Changed after preview" + self.session.commit() + with self.assertRaisesRegex(OrganizationUpgradeError, "changed after this preview"): + apply_model_upgrade( + self.session, + tenant_id="tenant-1", + upgrade_id=preview.id, + expected_revision=1, + decisions={"units:office": {"action": "use_target"}}, + actor_account_id="account-2", + ) + + def test_divergent_upgrade_applies_explicit_target_decision(self) -> None: + _template, _source, target = self._instantiated_upgrade_fixture( + mutate_target=lambda definition: setattr( + definition.units[1], "name", "Template office" + ) + ) + office = self.session.query(OrganizationUnit).filter_by( + tenant_id="tenant-1", slug="office" + ).one() + office.name = "Local office" + self.session.commit() + preview = create_model_upgrade_preview( + self.session, + tenant_id="tenant-1", + target_version=target, + actor_account_id="account-1", + idempotency_key="preview-explicit-decision", + ) + self.session.commit() + applied, _instantiation = apply_model_upgrade( + self.session, + tenant_id="tenant-1", + upgrade_id=preview.id, + expected_revision=1, + decisions={"units:office": {"action": "use_target"}}, + actor_account_id="account-2", + ) + self.session.commit() + self.assertEqual("applied", applied.status) + self.assertEqual( + "Template office", + self.session.query(OrganizationUnit) + .filter_by(tenant_id="tenant-1", slug="office") + .one() + .name, + ) + self.assertEqual( + {"action": "use_target"}, + applied.decisions["units:office"], + ) + + def test_invalid_local_references_block_and_preview_can_be_cancelled(self) -> None: + _template, _source, target = self._instantiated_upgrade_fixture() + office = self.session.query(OrganizationUnit).filter_by( + tenant_id="tenant-1", slug="office" + ).one() + office.unit_type_id = "missing-unit-type" + self.session.commit() + preview = create_model_upgrade_preview( + self.session, + tenant_id="tenant-1", + target_version=target, + actor_account_id="account-1", + idempotency_key="preview-invalid", + ) + self.session.commit() + self.assertGreater(preview.preview["blocking_invalid_references"], 0) + with self.assertRaisesRegex(OrganizationUpgradeError, "Invalid tenant references"): + apply_model_upgrade( + self.session, + tenant_id="tenant-1", + upgrade_id=preview.id, + expected_revision=1, + decisions={}, + actor_account_id="account-2", + ) + self.session.rollback() + cancelled = cancel_model_upgrade( + self.session, + tenant_id="tenant-1", + upgrade_id=preview.id, + expected_revision=1, + actor_account_id="account-1", + ) + self.session.commit() + self.assertEqual("cancelled", cancelled.status) + self.assertEqual(2, cancelled.revision) + self.assertIsNone(cancelled.applied_at) + + def test_unchanged_upgrade_preview_has_no_changes(self) -> None: + _template, _source, target = self._instantiated_upgrade_fixture() + preview = create_model_upgrade_preview( + self.session, + tenant_id="tenant-1", + target_version=target, + actor_account_id="account-1", + idempotency_key="preview-unchanged", + ) + self.assertEqual([], preview.preview["entries"]) + self.assertEqual(0, preview.preview["requires_decisions"]) + + def _instantiated_upgrade_fixture(self, mutate_target=None): + source_definition = _definition() + source_payload, source_hash = canonical_template_definition(source_definition) + target_definition = source_definition.model_copy(deep=True) + if mutate_target is not None: + mutate_target(target_definition) + target_payload, target_hash = canonical_template_definition(target_definition) + template = OrganizationModelTemplate( + id="template-upgrade", + slug="upgrade-template", + name="Upgrade template", + ) + source_version = OrganizationModelTemplateVersion( + id="template-upgrade-v1", + template_id=template.id, + version="1.0.0", + status="published", + definition=source_payload, + definition_sha256=source_hash, + ) + target_version = OrganizationModelTemplateVersion( + id="template-upgrade-v2", + template_id=template.id, + version="2.0.0", + status="published", + definition=target_payload, + definition_sha256=target_hash, + ) + self.session.add_all([template, source_version, target_version]) + self.session.flush() + source = instantiate_template_version( + self.session, + tenant_id="tenant-1", + template=template, + version=source_version, + actor_account_id="account-1", + ) + self.session.commit() + return template, source, target_version + def _definition() -> OrganizationModelTemplateDefinition: return OrganizationModelTemplateDefinition.model_validate( diff --git a/webui/package.json b/webui/package.json index c25872c..a3ec9b7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -8,7 +8,8 @@ "types": "src/index.ts", "scripts": { "test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs", - "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs" + "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs", + "test:template-upgrades": "node scripts/test-template-upgrades.mjs" }, "exports": { ".": { diff --git a/webui/scripts/test-template-upgrades.mjs b/webui/scripts/test-template-upgrades.mjs new file mode 100644 index 0000000..c3f4630 --- /dev/null +++ b/webui/scripts/test-template-upgrades.mjs @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +const root = process.cwd(); +const panel = fs.readFileSync( + path.join(root, "src/features/organizations/OrganizationTemplateUpgradePanel.tsx"), + "utf8" +); +const api = fs.readFileSync(path.join(root, "src/api/organizations.ts"), "utf8"); +const moduleSource = fs.readFileSync(path.join(root, "src/module.ts"), "utf8"); + +const expectations = [ + [panel, "previewOrganizationModelUpgrade", "The panel must create a persisted preview."], + [panel, "applyOrganizationModelUpgrade", "The panel must explicitly apply a reviewed preview."], + [panel, "cancelOrganizationModelUpgrade", "The panel must support cancellation without mutation."], + [panel, "; + provenance: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationUpgradeDecisionAction = "keep_local" | "use_target" | "map_to"; + +export type OrganizationUpgradeDiffEntry = { + id: string; + collection: string; + key: string; + classification: string; + requires_decision: boolean; + base?: Record | null; + local?: Record | null; + target?: Record | null; + allowed_actions: OrganizationUpgradeDecisionAction[]; +}; + +export type OrganizationModelUpgrade = { + id: string; + tenant_id: string; + template_id: string; + source_instantiation_id: string; + source_template_version_id: string; + target_template_version_id: string; + status: string; + revision: number; + preview: { + entries: OrganizationUpgradeDiffEntry[]; + counts: Record; + requires_decisions: number; + blocking_invalid_references: number; + silent_mutation: boolean; + }; + decisions: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationModelUpgradeList = { + current_instantiation?: OrganizationModelInstantiation | null; + upgrades: OrganizationModelUpgrade[]; +}; + export type OrganizationChangeRequestPayload = { change_request_id?: string | null; }; @@ -190,6 +265,41 @@ export function getOrganizationModel(settings: ApiSettings): Promise(settings, "/api/v1/organizations/model"); } +export function getOrganizationTemplateCatalog(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/organizations/model-templates"); +} + +export function getOrganizationModelUpgrades(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/organizations/model-upgrades"); +} + +export function previewOrganizationModelUpgrade(settings: ApiSettings, targetTemplateVersionId: string): Promise { + return apiPostJson(settings, "/api/v1/organizations/model-upgrades/preview", { + target_template_version_id: targetTemplateVersionId, + idempotency_key: crypto.randomUUID() + }); +} + +export function cancelOrganizationModelUpgrade(settings: ApiSettings, upgradeId: string, expectedRevision: number): Promise { + return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/cancel`, { + expected_revision: expectedRevision + }); +} + +export function applyOrganizationModelUpgrade( + settings: ApiSettings, + upgradeId: string, + expectedRevision: number, + decisions: Record, + changeRequestId?: string +): Promise<{ upgrade: OrganizationModelUpgrade; instantiation: OrganizationModelInstantiation }> { + return apiPostJson(settings, `/api/v1/organizations/model-upgrades/${encodeURIComponent(upgradeId)}/apply`, { + expected_revision: expectedRevision, + decisions, + change_request_id: changeRequestId || null + }); +} + export function getOrganizationSettings(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/organizations/settings"); } diff --git a/webui/src/features/organizations/OrganizationTemplateUpgradePanel.tsx b/webui/src/features/organizations/OrganizationTemplateUpgradePanel.tsx new file mode 100644 index 0000000..c1a02e2 --- /dev/null +++ b/webui/src/features/organizations/OrganizationTemplateUpgradePanel.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Eye, GitCompareArrows, Play, RefreshCw, X } from "lucide-react"; +import { + Button, + Card, + ConfirmDialog, + DataGrid, + Dialog, + DismissibleAlert, + FormField, + LoadingFrame, + MetricCard, + StatusBadge, + TableActionGroup, + adminErrorMessage, + hasAnyScope, + type ApiSettings, + type AuthInfo, + type DataGridColumn +} from "@govoplan/core-webui"; +import { + applyOrganizationModelUpgrade, + cancelOrganizationModelUpgrade, + getOrganizationModelUpgrades, + getOrganizationTemplateCatalog, + previewOrganizationModelUpgrade, + type OrganizationModelInstantiation, + type OrganizationModelUpgrade, + type OrganizationTemplateCatalogItem, + type OrganizationTemplateVersion, + type OrganizationUpgradeDecisionAction, + type OrganizationUpgradeDiffEntry +} from "../../api/organizations"; + +type Decision = { action: OrganizationUpgradeDecisionAction; target_key?: string }; + +export default function OrganizationTemplateUpgradePanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + const canRead = hasAnyScope(auth, ["organizations:model:read", "admin:settings:read"]); + const canWrite = hasAnyScope(auth, ["organizations:model:write"]); + const [templates, setTemplates] = useState([]); + const [current, setCurrent] = useState(null); + const [upgrades, setUpgrades] = useState([]); + const [targetVersionId, setTargetVersionId] = useState(""); + const [selected, setSelected] = useState(null); + const [decisions, setDecisions] = useState>({}); + const [changeRequestId, setChangeRequestId] = useState(""); + const [applyConfirmation, setApplyConfirmation] = useState(false); + const [cancelTarget, setCancelTarget] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const load = useCallback(async () => { + if (!canRead) return; + setLoading(true); + setError(""); + try { + const [catalog, state] = await Promise.all([ + getOrganizationTemplateCatalog(settings), + getOrganizationModelUpgrades(settings) + ]); + setTemplates(catalog.templates); + setCurrent(state.current_instantiation ?? null); + setUpgrades(state.upgrades); + const available = availableVersions(catalog.templates, state.current_instantiation ?? null); + setTargetVersionId((value) => available.some((item) => item.id === value) ? value : available[0]?.id ?? ""); + setSelected((value) => value ? state.upgrades.find((item) => item.id === value.id) ?? null : null); + } catch (caught) { + setError(adminErrorMessage(caught)); + } finally { + setLoading(false); + } + }, [canRead, settings.accessToken, settings.apiBaseUrl]); + + useEffect(() => { void load(); }, [load]); + + const versions = useMemo(() => versionMap(templates), [templates]); + const targets = useMemo(() => availableVersions(templates, current), [templates, current]); + const currentVersion = current ? versions.get(current.template_version_id) : undefined; + const pending = upgrades.filter((item) => item.status === "previewed"); + + const upgradeColumns = useMemo[]>(() => [ + { id: "source", header: "Source version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id, value: (row) => versions.get(row.source_template_version_id)?.version ?? row.source_template_version_id }, + { id: "target", header: "Target version", width: 150, sortable: true, filterable: true, render: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id, value: (row) => versions.get(row.target_template_version_id)?.version ?? row.target_template_version_id }, + { id: "changes", header: "Changes", width: 105, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.entries.length, value: (row) => row.preview.entries.length }, + { id: "decisions", header: "Decisions", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.requires_decisions, value: (row) => row.preview.requires_decisions }, + { id: "invalid", header: "Invalid refs", width: 110, sortable: true, filterable: true, filterType: "integer", render: (row) => row.preview.blocking_invalid_references, value: (row) => row.preview.blocking_invalid_references }, + { id: "status", header: "Status", width: 120, sortable: true, filterable: true, render: (row) => , value: (row) => row.status }, + { id: "updated", header: "Updated", width: 175, sortable: true, filterable: true, filterType: "date", render: (row) => formatDateTime(row.updated_at), value: (row) => row.updated_at }, + { id: "actions", header: "Actions", width: 120, sticky: "end", align: "right", render: (row) =>