Add governed organization template upgrades
This commit is contained in:
@@ -44,9 +44,19 @@ model:
|
|||||||
policy.
|
policy.
|
||||||
|
|
||||||
This avoids ambiguous inheritance when institutions model responsibility
|
This avoids ambiguous inheritance when institutions model responsibility
|
||||||
differently. Template catalogue, instantiation, and upgrade orchestration are a
|
differently. Template catalogue, instantiation, and explicit upgrades are
|
||||||
separate implementation slice; the existing organization tables remain the
|
available through the Organizations API and administration surface. The
|
||||||
canonical tenant-local state.
|
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
|
## Boundary With Identity And IDM
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from govoplan_core.core.organizations import (
|
|||||||
organization_lifecycle_event_type,
|
organization_lifecycle_event_type,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
|
EventActorRef,
|
||||||
EventObjectRef,
|
EventObjectRef,
|
||||||
EventTenantRef,
|
EventTenantRef,
|
||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
@@ -34,6 +35,7 @@ from govoplan_organizations.backend.db.models import (
|
|||||||
OrganizationModelInstantiation,
|
OrganizationModelInstantiation,
|
||||||
OrganizationModelTemplate,
|
OrganizationModelTemplate,
|
||||||
OrganizationModelTemplateVersion,
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
OrganizationRelation,
|
OrganizationRelation,
|
||||||
OrganizationRelationType,
|
OrganizationRelationType,
|
||||||
OrganizationTenantSettings,
|
OrganizationTenantSettings,
|
||||||
@@ -48,6 +50,12 @@ from .schemas import (
|
|||||||
FunctionTypeUpdateRequest,
|
FunctionTypeUpdateRequest,
|
||||||
FunctionUpdateRequest,
|
FunctionUpdateRequest,
|
||||||
OrganizationModelInstantiationItem,
|
OrganizationModelInstantiationItem,
|
||||||
|
OrganizationModelUpgradeApplyRequest,
|
||||||
|
OrganizationModelUpgradeApplyResponse,
|
||||||
|
OrganizationModelUpgradeCancelRequest,
|
||||||
|
OrganizationModelUpgradeItem,
|
||||||
|
OrganizationModelUpgradeListResponse,
|
||||||
|
OrganizationModelUpgradePreviewRequest,
|
||||||
OrganizationFunctionItem,
|
OrganizationFunctionItem,
|
||||||
OrganizationFunctionTypeItem,
|
OrganizationFunctionTypeItem,
|
||||||
OrganizationModelResponse,
|
OrganizationModelResponse,
|
||||||
@@ -80,6 +88,14 @@ from govoplan_organizations.backend.templates import (
|
|||||||
canonical_template_definition,
|
canonical_template_definition,
|
||||||
instantiate_template_version,
|
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"])
|
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(
|
def _template_version(
|
||||||
session: Session,
|
session: Session,
|
||||||
template_id: str,
|
template_id: str,
|
||||||
@@ -742,6 +826,172 @@ def instantiate_organization_model_template(
|
|||||||
return _instantiation_item(instantiation)
|
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)
|
@router.get("/model", response_model=OrganizationModelResponse)
|
||||||
def get_organization_model(
|
def get_organization_model(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
@@ -180,6 +180,102 @@ class OrganizationModelInstantiationItem(BaseModel):
|
|||||||
updated_at: datetime
|
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):
|
class OrganizationSettingsItem(BaseModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
tenant_id: str
|
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):
|
class OrganizationStructure(Base, TimestampMixin):
|
||||||
__tablename__ = "organizations_structures"
|
__tablename__ = "organizations_structures"
|
||||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
||||||
@@ -257,6 +328,7 @@ __all__ = [
|
|||||||
"OrganizationModelInstantiation",
|
"OrganizationModelInstantiation",
|
||||||
"OrganizationModelTemplate",
|
"OrganizationModelTemplate",
|
||||||
"OrganizationModelTemplateVersion",
|
"OrganizationModelTemplateVersion",
|
||||||
|
"OrganizationModelUpgrade",
|
||||||
"OrganizationRelation",
|
"OrganizationRelation",
|
||||||
"OrganizationRelationType",
|
"OrganizationRelationType",
|
||||||
"OrganizationStructure",
|
"OrganizationStructure",
|
||||||
|
|||||||
@@ -189,6 +189,14 @@ manifest = ModuleManifest(
|
|||||||
label="Organizations administration",
|
label="Organizations administration",
|
||||||
order=85,
|
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(
|
migration_spec=MigrationSpec(
|
||||||
@@ -203,6 +211,7 @@ manifest = ModuleManifest(
|
|||||||
organization_models.OrganizationModelTemplate,
|
organization_models.OrganizationModelTemplate,
|
||||||
organization_models.OrganizationModelTemplateVersion,
|
organization_models.OrganizationModelTemplateVersion,
|
||||||
organization_models.OrganizationModelInstantiation,
|
organization_models.OrganizationModelInstantiation,
|
||||||
|
organization_models.OrganizationModelUpgrade,
|
||||||
organization_models.OrganizationStructure,
|
organization_models.OrganizationStructure,
|
||||||
organization_models.OrganizationRelationType,
|
organization_models.OrganizationRelationType,
|
||||||
organization_models.OrganizationRelation,
|
organization_models.OrganizationRelation,
|
||||||
@@ -217,6 +226,53 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (_organization_directory),
|
||||||
},
|
},
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="organizations.model",
|
id="organizations.model",
|
||||||
title="Organization model",
|
title="Organization model",
|
||||||
@@ -320,7 +376,9 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/ORGANIZATION_MODEL.md",
|
documentation_ref="docs/ORGANIZATION_MODEL.md",
|
||||||
test_ref="tests/test_model_templates.py",
|
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"),
|
owned_concepts=("organization unit", "organization structure", "organization relation", "organization function"),
|
||||||
non_owned_concepts=("function incumbency", "identity", "application role", "mandate"),
|
non_owned_concepts=("function incumbency", "identity", "application role", "mandate"),
|
||||||
recovery_docs=("docs/ORGANIZATION_MODEL.md",),
|
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]
|
{route.path for route in frontend.routes}, # type: ignore[union-attr]
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{"organizations.admin.tenant"},
|
{
|
||||||
|
"organizations.admin.tenant",
|
||||||
|
"organizations.admin.template-upgrades",
|
||||||
|
},
|
||||||
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from govoplan_organizations.backend.db.models import (
|
|||||||
OrganizationModelInstantiation,
|
OrganizationModelInstantiation,
|
||||||
OrganizationModelTemplate,
|
OrganizationModelTemplate,
|
||||||
OrganizationModelTemplateVersion,
|
OrganizationModelTemplateVersion,
|
||||||
|
OrganizationModelUpgrade,
|
||||||
OrganizationRelation,
|
OrganizationRelation,
|
||||||
OrganizationRelationType,
|
OrganizationRelationType,
|
||||||
OrganizationStructure,
|
OrganizationStructure,
|
||||||
@@ -26,6 +27,13 @@ from govoplan_organizations.backend.templates import (
|
|||||||
canonical_template_definition,
|
canonical_template_definition,
|
||||||
instantiate_template_version,
|
instantiate_template_version,
|
||||||
)
|
)
|
||||||
|
from govoplan_organizations.backend.upgrades import (
|
||||||
|
OrganizationUpgradeError,
|
||||||
|
apply_model_upgrade,
|
||||||
|
cancel_model_upgrade,
|
||||||
|
create_model_upgrade_preview,
|
||||||
|
current_model_instantiation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TABLES = [
|
TABLES = [
|
||||||
@@ -39,6 +47,7 @@ TABLES = [
|
|||||||
OrganizationFunction.__table__,
|
OrganizationFunction.__table__,
|
||||||
OrganizationRelation.__table__,
|
OrganizationRelation.__table__,
|
||||||
OrganizationModelInstantiation.__table__,
|
OrganizationModelInstantiation.__table__,
|
||||||
|
OrganizationModelUpgrade.__table__,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -216,6 +225,248 @@ class OrganizationModelTemplateTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
canonical_template_definition(definition)
|
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:
|
def _definition() -> OrganizationModelTemplateDefinition:
|
||||||
return OrganizationModelTemplateDefinition.model_validate(
|
return OrganizationModelTemplateDefinition.model_validate(
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,8 @@
|
|||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs",
|
"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": {
|
"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[];
|
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 = {
|
export type OrganizationChangeRequestPayload = {
|
||||||
change_request_id?: string | null;
|
change_request_id?: string | null;
|
||||||
};
|
};
|
||||||
@@ -190,6 +265,41 @@ export function getOrganizationModel(settings: ApiSettings): Promise<Organizatio
|
|||||||
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
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> {
|
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
|
||||||
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
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,
|
ORGANIZATIONS_INTERFACE_I18N,
|
||||||
organizationWriteReason
|
organizationWriteReason
|
||||||
} from "./interfacePatterns";
|
} from "./interfacePatterns";
|
||||||
|
import OrganizationTemplateUpgradePanel from "./OrganizationTemplateUpgradePanel";
|
||||||
|
|
||||||
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
const FALLBACK_SETTINGS: OrganizationSettingsItem = {
|
||||||
tenant_id: "",
|
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>
|
<p className="muted small-note">i18n:govoplan-organizations.audit_retention_help.42dec57d</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
<OrganizationTemplateUpgradePanel settings={settings} auth={auth} />
|
||||||
</AdminPageLayout>
|
</AdminPageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const organizationsModule: PlatformWebModule = {
|
|||||||
kind: "section",
|
kind: "section",
|
||||||
label: "i18n:govoplan-organizations.organizations_administration",
|
label: "i18n:govoplan-organizations.organizations_administration",
|
||||||
order: 85
|
order: 85
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "organizations.admin.template-upgrades",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "section",
|
||||||
|
label: "Organization template upgrades",
|
||||||
|
parentId: "organizations.admin.tenant",
|
||||||
|
order: 86
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
|
|||||||
@@ -77,3 +77,40 @@
|
|||||||
grid-template-columns: 1fr;
|
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