Add governed organization template upgrades

This commit is contained in:
2026-08-04 01:46:36 +02:00
parent 97acfcba7d
commit fcfd67b0b5
17 changed files with 2357 additions and 6 deletions
@@ -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),