Add governed organization template upgrades

This commit is contained in:
2026-08-04 01:46:36 +02:00
parent 97acfcba7d
commit fcfd67b0b5
17 changed files with 2357 additions and 6 deletions
+13 -3
View File
@@ -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
@@ -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),
@@ -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
@@ -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",
+59 -1
View File
@@ -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",),
@@ -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,
)
@@ -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")
File diff suppressed because it is too large Load Diff
@@ -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(
+251
View File
@@ -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(
+2 -1
View File
@@ -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": {
".": {
+35
View File
@@ -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, "<ConfirmDialog open={applyConfirmation}", "Apply must use a separate confirmation step."],
[panel, "<DataGrid id={`organization-model-upgrade-diff-", "The comparison must use the shared DataGrid."],
[panel, "<DismissibleAlert", "Errors and outcomes must use the shared alert component."],
[api, '"/api/v1/organizations/model-upgrades/preview"', "The preview API must be declared."],
[api, "/apply`", "The apply API must be declared."],
[moduleSource, 'id: "organizations.admin.template-upgrades"', "Views must be able to target the upgrade surface."]
];
for (const [source, marker, message] of expectations) {
if (!source.includes(marker)) {
throw new Error(message);
}
}
if (/useEffect\([^)]*applyOrganizationModelUpgrade/s.test(panel)) {
throw new Error("An organization template upgrade must never apply from an effect.");
}
console.log("Organization template upgrade interface contract passed.");
+110
View File
@@ -135,6 +135,81 @@ export type OrganizationModel = {
functions: OrganizationFunctionItem[];
};
export type OrganizationTemplateVersion = {
id: string;
template_id: string;
version: string;
status: string;
definition_sha256: string;
release_notes?: string | null;
published_at?: string | null;
};
export type OrganizationTemplateCatalogItem = {
id: string;
slug: string;
name: string;
description?: string | null;
versions: OrganizationTemplateVersion[];
};
export type OrganizationTemplateCatalog = {
templates: OrganizationTemplateCatalogItem[];
};
export type OrganizationModelInstantiation = {
id: string;
tenant_id: string;
template_id: string;
template_version_id: string;
source_definition_sha256: string;
status: string;
object_counts: Record<string, number>;
provenance: Record<string, unknown>;
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<string, unknown> | null;
local?: Record<string, unknown> | null;
target?: Record<string, unknown> | 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<string, number>;
requires_decisions: number;
blocking_invalid_references: number;
silent_mutation: boolean;
};
decisions: Record<string, { action: OrganizationUpgradeDecisionAction; target_key?: string | null }>;
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<Organizatio
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
}
export function getOrganizationTemplateCatalog(settings: ApiSettings): Promise<OrganizationTemplateCatalog> {
return apiFetch<OrganizationTemplateCatalog>(settings, "/api/v1/organizations/model-templates");
}
export function getOrganizationModelUpgrades(settings: ApiSettings): Promise<OrganizationModelUpgradeList> {
return apiFetch<OrganizationModelUpgradeList>(settings, "/api/v1/organizations/model-upgrades");
}
export function previewOrganizationModelUpgrade(settings: ApiSettings, targetTemplateVersionId: string): Promise<OrganizationModelUpgrade> {
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<OrganizationModelUpgrade> {
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<string, { action: OrganizationUpgradeDecisionAction; target_key?: string }>,
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<OrganizationSettingsItem> {
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
}
@@ -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<OrganizationTemplateCatalogItem[]>([]);
const [current, setCurrent] = useState<OrganizationModelInstantiation | null>(null);
const [upgrades, setUpgrades] = useState<OrganizationModelUpgrade[]>([]);
const [targetVersionId, setTargetVersionId] = useState("");
const [selected, setSelected] = useState<OrganizationModelUpgrade | null>(null);
const [decisions, setDecisions] = useState<Record<string, Decision>>({});
const [changeRequestId, setChangeRequestId] = useState("");
const [applyConfirmation, setApplyConfirmation] = useState(false);
const [cancelTarget, setCancelTarget] = useState<OrganizationModelUpgrade | null>(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<DataGridColumn<OrganizationModelUpgrade>[]>(() => [
{ 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) => <StatusBadge status={row.status} />, 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) => <TableActionGroup minimumSlots={2} actions={[
{ id: "inspect", label: "Inspect upgrade", icon: <Eye aria-hidden="true" />, onClick: () => openUpgrade(row) },
{ id: "cancel", label: "Cancel preview", icon: <X aria-hidden="true" />, variant: "danger", disabled: !canWrite || row.status !== "previewed", onClick: () => setCancelTarget(row) }
]} /> }
], [canWrite, versions]);
const diffColumns = useMemo<DataGridColumn<OrganizationUpgradeDiffEntry>[]>(() => [
{ id: "collection", header: "Area", width: 140, sortable: true, filterable: true, render: (row) => humanize(row.collection), value: (row) => row.collection },
{ id: "key", header: "Object", width: 210, sortable: true, filterable: true, render: (row) => row.key, value: (row) => row.key },
{ id: "classification", header: "Classification", width: 185, sortable: true, filterable: true, render: (row) => <StatusBadge status={row.classification} />, value: (row) => row.classification },
{ id: "summary", header: "Comparison", width: 330, render: (row) => comparisonSummary(row), value: (row) => comparisonSummary(row) },
{ id: "decision", header: "Decision", width: 280, render: (row) => row.requires_decision ? <div className="organization-upgrade-decision"><select value={decisions[row.id]?.action ?? ""} disabled={!canWrite || busy || selected?.status !== "previewed"} onChange={(event) => setDecision(row, event.target.value as OrganizationUpgradeDecisionAction)}><option value="">Select decision</option>{row.allowed_actions.map((action) => <option key={action} value={action}>{decisionLabel(action)}</option>)}</select>{decisions[row.id]?.action === "map_to" && <input value={decisions[row.id]?.target_key ?? ""} placeholder="Target key" disabled={!canWrite || busy} onChange={(event) => setDecisions((value) => ({ ...value, [row.id]: { ...value[row.id], target_key: event.target.value } }))} />}</div> : "Automatic", value: (row) => decisions[row.id]?.action ?? "automatic" }
], [busy, canWrite, decisions, selected?.status]);
function setDecision(entry: OrganizationUpgradeDiffEntry, action: OrganizationUpgradeDecisionAction) {
if (!action) {
setDecisions((value) => { const next = { ...value }; delete next[entry.id]; return next; });
return;
}
setDecisions((value) => ({ ...value, [entry.id]: { action } }));
}
function openUpgrade(upgrade: OrganizationModelUpgrade) {
setSelected(upgrade);
setDecisions(upgrade.decisions ?? {});
setChangeRequestId("");
}
async function createPreview() {
if (!targetVersionId || busy) return;
setBusy(true);
setError("");
setSuccess("");
try {
const created = await previewOrganizationModelUpgrade(settings, targetVersionId);
setSuccess("The three-way comparison was recorded. No tenant model data was changed.");
await load();
openUpgrade(created);
} catch (caught) {
setError(adminErrorMessage(caught));
} finally {
setBusy(false);
}
}
async function applyUpgrade() {
if (!selected || busy) return;
setBusy(true);
setError("");
setSuccess("");
try {
await applyOrganizationModelUpgrade(settings, selected.id, selected.revision, decisions, changeRequestId.trim() || undefined);
setApplyConfirmation(false);
setSelected(null);
setSuccess("The template upgrade was applied as a new tenant-owned model instantiation.");
await load();
} catch (caught) {
setError(adminErrorMessage(caught));
setApplyConfirmation(false);
} finally {
setBusy(false);
}
}
async function cancelUpgrade() {
if (!cancelTarget || busy) return;
setBusy(true);
setError("");
try {
await cancelOrganizationModelUpgrade(settings, cancelTarget.id, cancelTarget.revision);
setCancelTarget(null);
if (selected?.id === cancelTarget.id) setSelected(null);
setSuccess("The upgrade preview was cancelled without changing the tenant model.");
await load();
} catch (caught) {
setError(adminErrorMessage(caught));
setCancelTarget(null);
} finally {
setBusy(false);
}
}
const decisionsComplete = selected ? selected.preview.entries.every((entry) => !entry.requires_decision || Boolean(decisions[entry.id]?.action) && (decisions[entry.id].action !== "map_to" || Boolean(decisions[entry.id].target_key?.trim()))) : false;
const applyDisabled = !selected || !canWrite || busy || selected.status !== "previewed" || selected.preview.blocking_invalid_references > 0 || !decisionsComplete;
if (!canRead) return null;
return <>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert>}
<Card title="Organization template upgrades" actions={<Button onClick={() => void load()} disabled={loading || busy}><RefreshCw aria-hidden="true" /> Reload</Button>}>
<LoadingFrame loading={loading} label="Loading organization template upgrade state">
<div className="metric-grid compact">
<MetricCard label="Applied version" value={currentVersion?.version ?? "Custom model"} tone="info" />
<MetricCard label="Available upgrades" value={targets.length} tone={targets.length ? "info" : "good"} />
<MetricCard label="Open previews" value={pending.length} tone={pending.length ? "warning" : "good"} />
<MetricCard label="Copy semantics" value="Tenant-owned" tone="good" />
</div>
<p className="muted small-note">Template versions are immutable sources. A tenant model never live-inherits changes: every upgrade is a recorded three-way comparison, explicit decision set, and confirmed new instantiation.</p>
<div className="organization-upgrade-toolbar">
<FormField label="Published target version"><select value={targetVersionId} disabled={!canWrite || busy || !targets.length} onChange={(event) => setTargetVersionId(event.target.value)}>{targets.length ? targets.map((version) => <option key={version.id} value={version.id}>{templateVersionLabel(templates, version)}</option>) : <option value="">No newer published version</option>}</select></FormField>
<Button variant="primary" onClick={() => void createPreview()} disabled={!canWrite || busy || !targetVersionId}><GitCompareArrows aria-hidden="true" /> Create preview</Button>
</div>
<div className="organization-upgrade-table"><DataGrid id="organization-model-upgrades" rows={upgrades} columns={upgradeColumns} initialFit="container" getRowKey={(row) => row.id} emptyText={current ? "No organization template upgrades have been previewed." : "This tenant model was not instantiated from a system template."} /></div>
</LoadingFrame>
</Card>
<Dialog open={Boolean(selected)} title="Organization model upgrade preview" className="organization-upgrade-dialog" onClose={() => !busy && setSelected(null)} closeDisabled={busy} footer={<><Button onClick={() => setSelected(null)} disabled={busy}>Close</Button>{selected?.status === "previewed" && <><Button variant="danger" onClick={() => setCancelTarget(selected)} disabled={!canWrite || busy}>Cancel preview</Button><Button variant="primary" onClick={() => setApplyConfirmation(true)} disabled={applyDisabled}><Play aria-hidden="true" /> Review and apply</Button></>}</>}>
{selected && <>
<div className="metric-grid compact">
<MetricCard label="Changes" value={selected.preview.entries.length} tone="info" />
<MetricCard label="Required decisions" value={selected.preview.requires_decisions} tone={selected.preview.requires_decisions ? "warning" : "good"} />
<MetricCard label="Invalid references" value={selected.preview.blocking_invalid_references} tone={selected.preview.blocking_invalid_references ? "danger" : "good"} />
<MetricCard label="Status" value={humanize(selected.status)} tone="info" />
</div>
<p className="muted small-note">Compatible additions and non-conflicting changes apply automatically. Local-only divergence is preserved. Destructive remapping and competing edits require an explicit bounded decision.</p>
<div className="organization-upgrade-diff"><DataGrid id={`organization-model-upgrade-diff-${selected.id}`} rows={selected.preview.entries} columns={diffColumns} initialFit="container" getRowKey={(row) => row.id} emptyText="The versions and tenant model are equivalent." /></div>
<FormField label="Approved change request (when required by tenant policy)"><input value={changeRequestId} disabled={!canWrite || busy || selected.status !== "previewed"} onChange={(event) => setChangeRequestId(event.target.value)} /></FormField>
</>}
</Dialog>
<ConfirmDialog open={applyConfirmation} title="Apply organization model upgrade" message="Apply this reviewed comparison and its explicit decisions? The current instantiation will be superseded, the resulting model remains tenant-owned, and the operation is recorded for audit and event consumers." confirmLabel="Apply upgrade" busy={busy} onConfirm={() => void applyUpgrade()} onCancel={() => !busy && setApplyConfirmation(false)} />
<ConfirmDialog open={Boolean(cancelTarget)} title="Cancel organization model upgrade" message="Cancel this preview? No tenant organization data will be changed and the cancellation remains recorded in the upgrade history." confirmLabel="Cancel preview" tone="danger" busy={busy} onConfirm={() => void cancelUpgrade()} onCancel={() => !busy && setCancelTarget(null)} />
</>;
}
function versionMap(templates: OrganizationTemplateCatalogItem[]): Map<string, OrganizationTemplateVersion> {
return new Map(templates.flatMap((template) => template.versions.map((version) => [version.id, version] as const)));
}
function availableVersions(templates: OrganizationTemplateCatalogItem[], current: OrganizationModelInstantiation | null): OrganizationTemplateVersion[] {
if (!current) return [];
const template = templates.find((item) => item.id === current.template_id);
return (template?.versions ?? []).filter((version) => version.id !== current.template_version_id && version.status === "published");
}
function templateVersionLabel(templates: OrganizationTemplateCatalogItem[], version: OrganizationTemplateVersion): string {
const template = templates.find((item) => item.id === version.template_id);
return `${template?.name ?? "Template"} · ${version.version}`;
}
function comparisonSummary(entry: OrganizationUpgradeDiffEntry): string {
if (entry.classification === "compatible_addition") return "Added by target template";
if (entry.classification === "compatible_change") return "Target changed; tenant still matches source";
if (entry.classification === "destructive_remapping") return "Removal or reference remapping";
if (entry.classification === "invalid_reference") return String(entry.local?.diagnostic ?? "Invalid tenant reference");
return entry.requires_decision ? "Both tenant and target changed" : "Tenant-only customization is preserved";
}
function decisionLabel(action: OrganizationUpgradeDecisionAction): string {
if (action === "keep_local") return "Keep tenant value";
if (action === "use_target") return "Use template value";
return "Map references to another key";
}
function humanize(value: string): string {
return value.replace(/_/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function formatDateTime(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
}
@@ -26,6 +26,7 @@ import {
ORGANIZATIONS_INTERFACE_I18N,
organizationWriteReason
} from "./interfacePatterns";
import OrganizationTemplateUpgradePanel from "./OrganizationTemplateUpgradePanel";
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
tenant_id: "",
@@ -203,6 +204,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
</Card>
</div>
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
</AdminPageLayout>
);
}
+8
View File
@@ -55,6 +55,14 @@ export const organizationsModule: PlatformWebModule = {
kind: "section",
label: "i18n:govoplan-organizations.organizations_administration",
order: 85
},
{
id: "organizations.admin.template-upgrades",
moduleId: "organizations",
kind: "section",
label: "Organization template upgrades",
parentId: "organizations.admin.tenant",
order: 86
}
],
navItems: [
+37
View File
@@ -77,3 +77,40 @@
grid-template-columns: 1fr;
}
}
.organization-upgrade-toolbar {
display: grid;
grid-template-columns: minmax(16rem, 1fr) auto;
align-items: end;
gap: 0.8rem;
margin: 0.9rem 0;
}
.organization-upgrade-table {
min-height: 10rem;
max-height: 24rem;
overflow: auto;
}
.organization-upgrade-dialog {
width: min(92vw, 78rem);
height: min(88vh, 54rem);
}
.organization-upgrade-diff {
min-height: 12rem;
max-height: 25rem;
overflow: auto;
margin: 0.8rem 0;
}
.organization-upgrade-decision {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.35rem;
}
@media (max-width: 760px) {
.organization-upgrade-toolbar {
grid-template-columns: minmax(0, 1fr);
}
}