Add governed organization template upgrades
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",),
|
||||
|
||||
+10
@@ -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,
|
||||
)
|
||||
+102
@@ -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(
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
".": {
|
||||
|
||||
@@ -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.");
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user