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
+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(