From 669647a98683f84fabc52fcd790fa177e4495130 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 18:57:32 +0200 Subject: [PATCH] Add organization change-control workflow --- AGENTS.md | 4 +- README.md | 11 +- docs/ORGANIZATION_MODEL.md | 12 +- .../backend/api/v1/routes.py | 197 ++++++++++++++++-- .../backend/api/v1/schemas.py | 6 + webui/src/api/organizations.ts | 33 ++- .../organizations/OrganizationsPage.tsx | 184 ++++++++++++---- webui/src/i18n/generatedTranslations.ts | 14 ++ webui/src/styles/organizations.css | 31 +++ 9 files changed, 421 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d645c3..557b91d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,8 @@ facts to roles, rights, permission decisions, and explainability. - Treat function assignment as organizational responsibility, not as a login permission by itself. - Keep the organization modelling UI as a standalone module route. Admin-only - organization controls should be contributed by this module through the - platform `admin.sections` WebUI capability. + organization policy/settings controls should be contributed by this module + through the platform `admin.sections` WebUI capability. ## Local Workflow diff --git a/README.md b/README.md index e36f0e9..06d878f 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,14 @@ Organizations does not own: - external IDM connector internals The WebUI exposed by this repository is a normal module UI at -`/organizations`. Admin-only controls are contributed by this module through the -platform `admin.sections` WebUI capability, so the admin shell can show them -when the organizations module is active. +`/organizations`. It is the editing surface for the meta-model, concrete +organization tree, structures, functions, and assignments. + +Admin-only controls are contributed by this module through the platform +`admin.sections` WebUI capability, so the admin shell can show them when the +organizations module is active. Those controls are limited to governance and +policy settings such as tenant model customization, change-request +requirements, audit detail, and retention behavior. ## Module Contract diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md index 77ae583..fa26f00 100644 --- a/docs/ORGANIZATION_MODEL.md +++ b/docs/ORGANIZATION_MODEL.md @@ -40,9 +40,15 @@ policy changes. ## UI Boundary The organization workspace at `/organizations` is the primary module UI for -modelling concrete units, structures, functions, and assignments. Admin-only -organization controls are still owned by this module, but are contributed to the -platform admin shell through the `admin.sections` WebUI capability. +modelling the meta-model, concrete units, structures, functions, and +assignments. Admin-only organization controls are still owned by this module, +but are contributed to the platform admin shell through the `admin.sections` +WebUI capability. + +The admin contribution must not duplicate the organization editor. It is for +governance and policy settings: whether tenant admins may customize the +meta-model, whether model changes require recorded change requests, and how +audit detail and retention behave for organization changes. ## Boundary With IDM diff --git a/src/govoplan_organizations/backend/api/v1/routes.py b/src/govoplan_organizations/backend/api/v1/routes.py index ea826ed..ed73965 100644 --- a/src/govoplan_organizations/backend/api/v1/routes.py +++ b/src/govoplan_organizations/backend/api/v1/routes.py @@ -8,6 +8,12 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, require_any_scope +from govoplan_core.core.configuration_control import ( + ConfigurationChangeApproval, + ConfigurationControlError, + ensure_configuration_change_allowed, + record_configuration_change_applied, +) from govoplan_core.db.session import get_session from govoplan_organizations.backend.db.models import ( OrganizationFunction, @@ -66,6 +72,8 @@ ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",) ORG_ASSIGN_SCOPES = ("organizations:function:assign",) ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read") ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write") +ORG_CHANGE_CONTROL_KEY = "organizations.model" +ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated" SLUG_RE = re.compile(r"[^a-z0-9]+") ModelT = TypeVar("ModelT") @@ -93,6 +101,10 @@ def _invalid(message: str) -> HTTPException: return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message) +def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException: + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": str(exc), "plan": exc.plan}) + + def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_id: str, label: str) -> ModelT: item = session.get(model, item_id) if item is None or getattr(item, "tenant_id") != tenant_id: @@ -130,6 +142,83 @@ def _commit(session: Session, item: ModelT) -> ModelT: return item +def _requires_organization_change_request(session: Session, tenant_id: str) -> bool: + item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none() + return bool(item and item.require_model_change_requests) + + +def _actor_id(principal: ApiPrincipal) -> str: + return principal.membership_id or principal.account_id + + +def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]: + if hasattr(payload, "model_dump"): + values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined] + else: + values = {} + return {"resource_type": resource_type, "operation": operation, "payload": values} + + +def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]: + target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation} + if resource_id is not None: + target["resource_id"] = resource_id + return target + + +def _ensure_organization_change_allowed( + session: Session, + principal: ApiPrincipal, + *, + tenant_id: str, + resource_type: str, + operation: str, + payload: object, + resource_id: str | None = None, +) -> tuple[ConfigurationChangeApproval | None, dict[str, Any], dict[str, Any]]: + value = _payload_for_control(resource_type, operation, payload) + target = _target_for_control(tenant_id, resource_type, operation, resource_id) + if not _requires_organization_change_request(session, tenant_id): + return None, target, value + try: + approval = ensure_configuration_change_allowed( + session, + key=ORG_CHANGE_CONTROL_KEY, + value=value, + actor_user_id=_actor_id(principal), + actor_scopes=tuple(principal.scopes), + change_request_id=getattr(payload, "change_request_id", None), + target=target, + ) + except ConfigurationControlError as exc: + raise _configuration_control_http_error(exc) from exc + return approval, target, value + + +def _record_organization_change_applied( + session: Session, + principal: ApiPrincipal, + *, + approval: ConfigurationChangeApproval | None, + target: dict[str, Any], + before: object, + after: object, +) -> None: + if approval is None: + return + record_configuration_change_applied( + session, + key=ORG_CHANGE_CONTROL_KEY, + before_value=before, + after_value=after, + actor_user_id=_actor_id(principal), + approval=approval, + target=target, + audit_event=ORG_CHANGE_AUDIT_EVENT, + ) + session.commit() + + def _item_unit_type(item: OrganizationUnitType) -> OrganizationUnitTypeItem: return OrganizationUnitTypeItem(**_row_fields(item)) @@ -274,11 +363,15 @@ def create_unit_type( principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> OrganizationUnitTypeItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="unit_type", operation="created", payload=payload) slug = _slug(payload.slug, payload.name) _ensure_unique_slug(session, OrganizationUnitType, tenant_id, slug) item = OrganizationUnitType(tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, is_active=payload.is_active, settings=payload.settings) session.add(item) - return _item_unit_type(_commit(session, item)) + saved = _commit(session, item) + result = _item_unit_type(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/unit-types/{item_id}", response_model=OrganizationUnitTypeItem) @@ -290,8 +383,12 @@ def update_unit_type( ) -> OrganizationUnitTypeItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationUnitType, item_id, tenant_id, "Organization unit type") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="unit_type", operation="updated", payload=payload, resource_id=item_id) _apply_slugged_update(session, item, payload, tenant_id, OrganizationUnitType) - return _item_unit_type(_commit(session, item)) + result = _item_unit_type(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/structures", response_model=OrganizationStructureItem, status_code=status.HTTP_201_CREATED) @@ -301,11 +398,15 @@ def create_structure( principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> OrganizationStructureItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="structure", operation="created", payload=payload) slug = _slug(payload.slug, payload.name) _ensure_unique_slug(session, OrganizationStructure, tenant_id, slug) item = OrganizationStructure(tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, structure_kind=payload.structure_kind, is_active=payload.is_active, settings=payload.settings) session.add(item) - return _item_structure(_commit(session, item)) + saved = _commit(session, item) + result = _item_structure(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/structures/{item_id}", response_model=OrganizationStructureItem) @@ -317,12 +418,16 @@ def update_structure( ) -> OrganizationStructureItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationStructure, item_id, tenant_id, "Organization structure") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="structure", operation="updated", payload=payload, resource_id=item_id) _apply_slugged_update(session, item, payload, tenant_id, OrganizationStructure) if "structure_kind" in payload.model_fields_set: if payload.structure_kind is None: raise _invalid("Structure kind cannot be empty.") item.structure_kind = payload.structure_kind - return _item_structure(_commit(session, item)) + result = _item_structure(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/relation-types", response_model=OrganizationRelationTypeItem, status_code=status.HTTP_201_CREATED) @@ -332,6 +437,7 @@ def create_relation_type( principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> OrganizationRelationTypeItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="relation_type", operation="created", payload=payload) _ensure_optional_structure(session, tenant_id, payload.structure_id) _ensure_optional_unit_type(session, tenant_id, payload.source_unit_type_id) _ensure_optional_unit_type(session, tenant_id, payload.target_unit_type_id) @@ -351,7 +457,10 @@ def create_relation_type( settings=payload.settings, ) session.add(item) - return _item_relation_type(_commit(session, item)) + saved = _commit(session, item) + result = _item_relation_type(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/relation-types/{item_id}", response_model=OrganizationRelationTypeItem) @@ -363,6 +472,8 @@ def update_relation_type( ) -> OrganizationRelationTypeItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationRelationType, item_id, tenant_id, "Organization relation type") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="relation_type", operation="updated", payload=payload, resource_id=item_id) _apply_slugged_update(session, item, payload, tenant_id, OrganizationRelationType) fields = payload.model_fields_set if "structure_id" in fields: @@ -380,7 +491,9 @@ def update_relation_type( if value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) - return _item_relation_type(_commit(session, item)) + result = _item_relation_type(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED) @@ -390,6 +503,7 @@ def create_unit( principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), ) -> OrganizationUnitItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="unit", operation="created", payload=payload) _ensure_optional_unit_type(session, tenant_id, payload.unit_type_id) if payload.parent_id is not None: _get_tenant_row(session, OrganizationUnit, payload.parent_id, tenant_id, "Parent organization unit") @@ -397,7 +511,10 @@ def create_unit( _ensure_unique_slug(session, OrganizationUnit, tenant_id, slug) item = OrganizationUnit(tenant_id=tenant_id, unit_type_id=payload.unit_type_id, parent_id=payload.parent_id, slug=slug, name=payload.name.strip(), description=payload.description, is_active=payload.is_active, settings=payload.settings) session.add(item) - return _item_unit(_commit(session, item)) + saved = _commit(session, item) + result = _item_unit(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/units/{item_id}", response_model=OrganizationUnitItem) @@ -409,6 +526,8 @@ def update_unit( ) -> OrganizationUnitItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationUnit, item_id, tenant_id, "Organization unit") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="unit", operation="updated", payload=payload, resource_id=item_id) _apply_slugged_update(session, item, payload, tenant_id, OrganizationUnit) if "unit_type_id" in payload.model_fields_set: _ensure_optional_unit_type(session, tenant_id, payload.unit_type_id) @@ -421,7 +540,9 @@ def update_unit( if _would_create_parent_cycle(session, tenant_id, item.id, payload.parent_id): raise _invalid("This parent assignment would create an organization unit cycle.") item.parent_id = payload.parent_id - return _item_unit(_commit(session, item)) + result = _item_unit(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/relations", response_model=OrganizationRelationItem, status_code=status.HTTP_201_CREATED) @@ -431,7 +552,8 @@ def create_relation( principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), ) -> OrganizationRelationItem: tenant_id = _tenant_id(principal) - structure, relation_type, source, target = _validated_relation_parts( + approval, control_target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="relation", operation="created", payload=payload) + structure, relation_type, source, target_unit = _validated_relation_parts( session, tenant_id, structure_id=payload.structure_id, @@ -439,20 +561,23 @@ def create_relation( source_unit_id=payload.source_unit_id, target_unit_id=payload.target_unit_id, ) - _validate_relation_edge(session, tenant_id, structure, relation_type, source, target) + _validate_relation_edge(session, tenant_id, structure, relation_type, source, target_unit) item = OrganizationRelation( tenant_id=tenant_id, structure_id=structure.id, relation_type_id=relation_type.id, source_unit_id=source.id, - target_unit_id=target.id, + target_unit_id=target_unit.id, valid_from=payload.valid_from, valid_until=payload.valid_until, is_active=payload.is_active, settings=payload.settings, ) session.add(item) - return _item_relation(_commit(session, item)) + saved = _commit(session, item) + result = _item_relation(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**control_target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/relations/{item_id}", response_model=OrganizationRelationItem) @@ -464,13 +589,15 @@ def update_relation( ) -> OrganizationRelationItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationRelation, item_id, tenant_id, "Organization relation") + before = _row_fields(item) + approval, control_target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="relation", operation="updated", payload=payload, resource_id=item_id) structure_id = payload.structure_id if "structure_id" in payload.model_fields_set else item.structure_id relation_type_id = payload.relation_type_id if "relation_type_id" in payload.model_fields_set else item.relation_type_id source_unit_id = payload.source_unit_id if "source_unit_id" in payload.model_fields_set else item.source_unit_id target_unit_id = payload.target_unit_id if "target_unit_id" in payload.model_fields_set else item.target_unit_id if structure_id is None or relation_type_id is None or source_unit_id is None or target_unit_id is None: raise _invalid("Relation structure, type, source, and target are required.") - structure, relation_type, source, target = _validated_relation_parts( + structure, relation_type, source, target_unit = _validated_relation_parts( session, tenant_id, structure_id=structure_id, @@ -478,15 +605,17 @@ def update_relation( source_unit_id=source_unit_id, target_unit_id=target_unit_id, ) - _validate_relation_edge(session, tenant_id, structure, relation_type, source, target, exclude_relation_id=item.id) + _validate_relation_edge(session, tenant_id, structure, relation_type, source, target_unit, exclude_relation_id=item.id) item.structure_id = structure.id item.relation_type_id = relation_type.id item.source_unit_id = source.id - item.target_unit_id = target.id + item.target_unit_id = target_unit.id for field in ("valid_from", "valid_until", "is_active", "settings"): if field in payload.model_fields_set: setattr(item, field, getattr(payload, field)) - return _item_relation(_commit(session, item)) + result = _item_relation(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=control_target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/function-types", response_model=OrganizationFunctionTypeItem, status_code=status.HTTP_201_CREATED) @@ -496,6 +625,7 @@ def create_function_type( principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> OrganizationFunctionTypeItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_type", operation="created", payload=payload) _ensure_optional_unit_type(session, tenant_id, payload.organization_unit_type_id) slug = _slug(payload.slug, payload.name) _ensure_unique_slug(session, OrganizationFunctionType, tenant_id, slug) @@ -511,7 +641,10 @@ def create_function_type( settings=payload.settings, ) session.add(item) - return _item_function_type(_commit(session, item)) + saved = _commit(session, item) + result = _item_function_type(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/function-types/{item_id}", response_model=OrganizationFunctionTypeItem) @@ -523,6 +656,8 @@ def update_function_type( ) -> OrganizationFunctionTypeItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationFunctionType, item_id, tenant_id, "Organization function type") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_type", operation="updated", payload=payload, resource_id=item_id) _apply_slugged_update(session, item, payload, tenant_id, OrganizationFunctionType) if "organization_unit_type_id" in payload.model_fields_set: _ensure_optional_unit_type(session, tenant_id, payload.organization_unit_type_id) @@ -533,7 +668,9 @@ def update_function_type( if value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) - return _item_function_type(_commit(session, item)) + result = _item_function_type(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/functions", response_model=OrganizationFunctionItem, status_code=status.HTTP_201_CREATED) @@ -543,6 +680,7 @@ def create_function( principal: ApiPrincipal = Depends(require_any_scope(*ORG_FUNCTION_WRITE_SCOPES)), ) -> OrganizationFunctionItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function", operation="created", payload=payload) unit = _get_tenant_row(session, OrganizationUnit, payload.organization_unit_id, tenant_id, "Organization unit") function_type = _optional_function_type(session, tenant_id, payload.function_type_id) slug = _slug(payload.slug, payload.name) @@ -562,7 +700,10 @@ def create_function( settings=payload.settings, ) session.add(item) - return _item_function(_commit(session, item)) + saved = _commit(session, item) + result = _item_function(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/functions/{item_id}", response_model=OrganizationFunctionItem) @@ -574,6 +715,8 @@ def update_function( ) -> OrganizationFunctionItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationFunction, item_id, tenant_id, "Organization function") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function", operation="updated", payload=payload, resource_id=item_id) _apply_slugged_update_for_function(session, item, payload, tenant_id) if "organization_unit_id" in payload.model_fields_set: if payload.organization_unit_id is None: @@ -590,7 +733,9 @@ def update_function( if value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) - return _item_function(_commit(session, item)) + result = _item_function(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result @router.post("/function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED) @@ -600,6 +745,7 @@ def create_function_assignment( principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)), ) -> OrganizationFunctionAssignmentItem: tenant_id = _tenant_id(principal) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_assignment", operation="created", payload=payload) function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") item = OrganizationFunctionAssignment( tenant_id=tenant_id, @@ -619,7 +765,10 @@ def create_function_assignment( if item.delegated_from_assignment_id is not None: _get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment") session.add(item) - return _item_assignment(_commit(session, item)) + saved = _commit(session, item) + result = _item_assignment(saved) + _record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json")) + return result @router.patch("/function-assignments/{item_id}", response_model=OrganizationFunctionAssignmentItem) @@ -631,6 +780,8 @@ def update_function_assignment( ) -> OrganizationFunctionAssignmentItem: tenant_id = _tenant_id(principal) item = _get_tenant_row(session, OrganizationFunctionAssignment, item_id, tenant_id, "Organization function assignment") + before = _row_fields(item) + approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_assignment", operation="updated", payload=payload, resource_id=item_id) if "function_id" in payload.model_fields_set: if payload.function_id is None: raise _invalid("Function is required.") @@ -665,7 +816,9 @@ def update_function_assignment( raise _invalid("A function assignment cannot delegate from itself.") if item.delegated_from_assignment_id is not None: _get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment") - return _item_assignment(_commit(session, item)) + result = _item_assignment(_commit(session, item)) + _record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json")) + return result def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None: diff --git a/src/govoplan_organizations/backend/api/v1/schemas.py b/src/govoplan_organizations/backend/api/v1/schemas.py index 724625f..849ec64 100644 --- a/src/govoplan_organizations/backend/api/v1/schemas.py +++ b/src/govoplan_organizations/backend/api/v1/schemas.py @@ -168,6 +168,7 @@ class SluggedCreateRequest(BaseModel): description: str | None = None is_active: bool = True settings: dict[str, Any] = Field(default_factory=dict) + change_request_id: str | None = None class SluggedUpdateRequest(BaseModel): @@ -176,6 +177,7 @@ class SluggedUpdateRequest(BaseModel): description: str | None = None is_active: bool | None = None settings: dict[str, Any] | None = None + change_request_id: str | None = None class UnitTypeCreateRequest(SluggedCreateRequest): @@ -229,6 +231,7 @@ class RelationCreateRequest(BaseModel): valid_until: datetime | None = None is_active: bool = True settings: dict[str, Any] = Field(default_factory=dict) + change_request_id: str | None = None class RelationUpdateRequest(BaseModel): @@ -240,6 +243,7 @@ class RelationUpdateRequest(BaseModel): valid_until: datetime | None = None is_active: bool | None = None settings: dict[str, Any] | None = None + change_request_id: str | None = None class FunctionTypeCreateRequest(SluggedCreateRequest): @@ -280,6 +284,7 @@ class FunctionAssignmentCreateRequest(BaseModel): valid_until: datetime | None = None is_active: bool = True settings: dict[str, Any] = Field(default_factory=dict) + change_request_id: str | None = None class FunctionAssignmentUpdateRequest(BaseModel): @@ -294,3 +299,4 @@ class FunctionAssignmentUpdateRequest(BaseModel): valid_until: datetime | None = None is_active: bool | None = None settings: dict[str, Any] | None = None + change_request_id: str | None = None diff --git a/webui/src/api/organizations.ts b/webui/src/api/organizations.ts index 470d25a..a44ab52 100644 --- a/webui/src/api/organizations.ts +++ b/webui/src/api/organizations.ts @@ -155,13 +155,31 @@ export type OrganizationModel = { function_assignments: OrganizationFunctionAssignmentItem[]; }; +export type IdentityOption = { + id: string; + display_name?: string | null; + external_subject?: string | null; + source: string; + primary_account_id?: string | null; + account_ids: string[]; + status: string; +}; + +export type IdentityListResponse = { + identities: IdentityOption[]; +}; + +export type OrganizationChangeRequestPayload = { + change_request_id?: string | null; +}; + export type SluggedCreatePayload = { slug?: string | null; name: string; description?: string | null; is_active?: boolean; settings?: Record; -}; +} & OrganizationChangeRequestPayload; export type StructureCreatePayload = SluggedCreatePayload & { structure_kind: OrganizationStructureKind; @@ -187,7 +205,7 @@ export type RelationCreatePayload = { target_unit_id: string; is_active?: boolean; settings?: Record; -}; +} & OrganizationChangeRequestPayload; export type FunctionTypeCreatePayload = SluggedCreatePayload & { organization_unit_type_id?: string | null; @@ -212,7 +230,7 @@ export type FunctionAssignmentCreatePayload = { acting_for_account_id?: string | null; is_active?: boolean; settings?: Record; -}; +} & OrganizationChangeRequestPayload; function post>(settings: ApiSettings, path: string, payload: P): Promise { return apiFetch(settings, path, { method: "POST", body: JSON.stringify(payload) }); @@ -226,6 +244,15 @@ export function getOrganizationModel(settings: ApiSettings): Promise(settings, "/api/v1/organizations/model"); } +export async function searchIdentityOptions(settings: ApiSettings, query = "", limit = 25): Promise { + const params = new URLSearchParams(); + const trimmed = query.trim(); + if (trimmed) params.set("query", trimmed); + params.set("limit", String(limit)); + const response = await apiFetch(settings, `/api/v1/identity/identities?${params.toString()}`); + return response.identities; +} + export function getOrganizationSettings(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/organizations/settings"); } diff --git a/webui/src/features/organizations/OrganizationsPage.tsx b/webui/src/features/organizations/OrganizationsPage.tsx index e1f6501..51574ec 100644 --- a/webui/src/features/organizations/OrganizationsPage.tsx +++ b/webui/src/features/organizations/OrganizationsPage.tsx @@ -36,9 +36,11 @@ import { patchStructure, patchUnit, patchUnitType, + searchIdentityOptions, type FunctionAssignmentCreatePayload, type FunctionCreatePayload, type FunctionTypeCreatePayload, + type IdentityOption, type OrganizationFunctionAssignmentItem, type OrganizationFunctionItem, type OrganizationFunctionTypeItem, @@ -293,7 +295,16 @@ function sectionGroupsFor(sections: ReadonlySet): ModuleSub } function apiErrorMessage(error: unknown): string { - if (error instanceof ApiError) return error.message; + if (error instanceof ApiError) { + try { + const parsed = JSON.parse(error.body) as { detail?: string | { message?: string } }; + if (typeof parsed.detail === "string") return parsed.detail; + if (parsed.detail && typeof parsed.detail.message === "string") return parsed.detail.message; + } catch { + // Fall back to the transport message below. + } + return error.message; + } if (error instanceof Error) return error.message; return String(error); } @@ -306,6 +317,21 @@ function activeStatus(active: boolean): JSX.Element { return ; } +function identityOptionLabel(identity: IdentityOption): string { + return identity.display_name || identity.external_subject || identity.primary_account_id || identity.id; +} + +function identityDisplay(identityId: string, identities: Map): JSX.Element { + const identity = identities.get(identityId); + if (!identity) return {identityId}; + return ( + + {identityOptionLabel(identity)} + {identity.id} + + ); +} + function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; disabled: boolean; onToggle: () => void }) { const label = item.is_active ? "i18n:govoplan-organizations.deactivate.46ab070d" : "i18n:govoplan-organizations.reactivate.58f2855d"; return ( @@ -392,6 +418,11 @@ export default function OrganizationsPage({ const [editingFunctionId, setEditingFunctionId] = useState(null); const [editingAssignmentId, setEditingAssignmentId] = useState(null); const [selectedUnitId, setSelectedUnitId] = useState(""); + const [changeRequestId, setChangeRequestId] = useState(""); + const [identitySearch, setIdentitySearch] = useState(""); + const [identityOptions, setIdentityOptions] = useState([]); + const [identityLoading, setIdentityLoading] = useState(false); + const [identityLookupAvailable, setIdentityLookupAvailable] = useState(true); const canWriteModel = hasScope(auth, "organizations:model:write"); const canWriteUnits = hasScope(auth, "organizations:unit:write"); @@ -404,6 +435,7 @@ export default function OrganizationsPage({ const unitById = useMemo(() => mapById(model.units), [model.units]); const functionTypeById = useMemo(() => mapById(model.function_types), [model.function_types]); const functionById = useMemo(() => mapById(model.functions), [model.functions]); + const identityOptionById = useMemo(() => mapById(identityOptions), [identityOptions]); const unitsByParentId = useMemo(() => { const mapped = new Map(); for (const unit of model.units) { @@ -435,6 +467,11 @@ export default function OrganizationsPage({ } }, [settings]); + const withChangeRequest = useCallback((payload: T): T & { change_request_id?: string | null } => { + const requestId = textOrNull(changeRequestId); + return requestId ? { ...payload, change_request_id: requestId } : payload; + }, [changeRequestId]); + useEffect(() => { void loadModel(); }, [loadModel]); @@ -447,6 +484,36 @@ export default function OrganizationsPage({ if (selectedUnitId && !unitById.has(selectedUnitId)) setSelectedUnitId(""); }, [selectedUnitId, unitById]); + useEffect(() => { + if (active !== "assignments" || !identityLookupAvailable) return undefined; + let cancelled = false; + const timeout = window.setTimeout(() => { + setIdentityLoading(true); + searchIdentityOptions(settings, identitySearch, 50). + then((items) => { + if (cancelled) return; + setIdentityOptions(items); + setIdentityLookupAvailable(true); + }). + catch((caught) => { + if (cancelled) return; + if (caught instanceof ApiError && (caught.status === 403 || caught.status === 404)) { + setIdentityLookupAvailable(false); + setIdentityOptions([]); + return; + } + setError(apiErrorMessage(caught)); + }). + finally(() => { + if (!cancelled) setIdentityLoading(false); + }); + }, 250); + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [active, identityLookupAvailable, identitySearch, settings]); + const runAction = useCallback(async (action: () => Promise, successMessage = ""): Promise => { setBusy(true); setError(""); @@ -498,7 +565,7 @@ export default function OrganizationsPage({ if (!canWriteModel) return rejectMissingWritePermission(); if (!unitTypeDraft.name.trim()) return rejectMissingName(); const ok = await runAction( - () => editingUnitTypeId ? patchUnitType(settings, editingUnitTypeId, sluggedPatchPayload(unitTypeDraft)) : createUnitType(settings, sluggedPayload(unitTypeDraft)), + () => editingUnitTypeId ? patchUnitType(settings, editingUnitTypeId, withChangeRequest(sluggedPatchPayload(unitTypeDraft))) : createUnitType(settings, withChangeRequest(sluggedPayload(unitTypeDraft))), editingUnitTypeId ? "i18n:govoplan-organizations.unit_type_updated.6d8f2a16" : "i18n:govoplan-organizations.unit_type_added.e017dc8c" ); if (ok) { @@ -506,7 +573,7 @@ export default function OrganizationsPage({ setEditingUnitTypeId(null); } return ok; - }, [canWriteModel, editingUnitTypeId, runAction, settings, unitTypeDraft]); + }, [canWriteModel, editingUnitTypeId, runAction, settings, unitTypeDraft, withChangeRequest]); const submitStructure = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -515,7 +582,7 @@ export default function OrganizationsPage({ const payload: StructureCreatePayload = { ...sluggedPayload(structureDraft), structure_kind: structureDraft.structure_kind }; const patchPayload: Partial = { ...sluggedPatchPayload(structureDraft), structure_kind: structureDraft.structure_kind }; const ok = await runAction( - () => editingStructureId ? patchStructure(settings, editingStructureId, patchPayload) : createStructure(settings, payload), + () => editingStructureId ? patchStructure(settings, editingStructureId, withChangeRequest(patchPayload)) : createStructure(settings, withChangeRequest(payload)), editingStructureId ? "i18n:govoplan-organizations.structure_updated.2591e75f" : "i18n:govoplan-organizations.structure_added.f6cf8e74" ); if (ok) { @@ -523,7 +590,7 @@ export default function OrganizationsPage({ setEditingStructureId(null); } return ok; - }, [canWriteModel, editingStructureId, runAction, settings, structureDraft]); + }, [canWriteModel, editingStructureId, runAction, settings, structureDraft, withChangeRequest]); const submitRelationType = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -546,7 +613,7 @@ export default function OrganizationsPage({ allow_cycles: relationTypeDraft.allow_cycles }; const ok = await runAction( - () => editingRelationTypeId ? patchRelationType(settings, editingRelationTypeId, patchPayload) : createRelationType(settings, payload), + () => editingRelationTypeId ? patchRelationType(settings, editingRelationTypeId, withChangeRequest(patchPayload)) : createRelationType(settings, withChangeRequest(payload)), editingRelationTypeId ? "i18n:govoplan-organizations.relation_type_updated.5ac57ec7" : "i18n:govoplan-organizations.relation_type_added.6f1edff1" ); if (ok) { @@ -554,7 +621,7 @@ export default function OrganizationsPage({ setEditingRelationTypeId(null); } return ok; - }, [canWriteModel, editingRelationTypeId, relationTypeDraft, runAction, settings]); + }, [canWriteModel, editingRelationTypeId, relationTypeDraft, runAction, settings, withChangeRequest]); const submitUnit = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -571,7 +638,7 @@ export default function OrganizationsPage({ parent_id: textOrNull(unitDraft.parent_id) }; const ok = await runAction( - () => editingUnitId ? patchUnit(settings, editingUnitId, patchPayload) : createUnit(settings, payload), + () => editingUnitId ? patchUnit(settings, editingUnitId, withChangeRequest(patchPayload)) : createUnit(settings, withChangeRequest(payload)), editingUnitId ? "i18n:govoplan-organizations.unit_updated.15f02216" : "i18n:govoplan-organizations.unit_added.760a8512" ); if (ok) { @@ -579,7 +646,7 @@ export default function OrganizationsPage({ setEditingUnitId(null); } return ok; - }, [canWriteUnits, editingUnitId, runAction, settings, unitDraft]); + }, [canWriteUnits, editingUnitId, runAction, settings, unitDraft, withChangeRequest]); const submitRelation = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -591,7 +658,7 @@ export default function OrganizationsPage({ const payload: RelationCreatePayload = { ...relationDraft, settings: {} }; const patchPayload: Partial = { ...relationDraft }; const ok = await runAction( - () => editingRelationId ? patchRelation(settings, editingRelationId, patchPayload) : createRelation(settings, payload), + () => editingRelationId ? patchRelation(settings, editingRelationId, withChangeRequest(patchPayload)) : createRelation(settings, withChangeRequest(payload)), editingRelationId ? "i18n:govoplan-organizations.relation_updated.c6212776" : "i18n:govoplan-organizations.relation_added.ca90461a" ); if (ok) { @@ -599,7 +666,7 @@ export default function OrganizationsPage({ setEditingRelationId(null); } return ok; - }, [canWriteUnits, editingRelationId, relationDraft, runAction, settings]); + }, [canWriteUnits, editingRelationId, relationDraft, runAction, settings, withChangeRequest]); const submitFunctionType = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -618,7 +685,7 @@ export default function OrganizationsPage({ act_in_place_allowed: functionTypeDraft.act_in_place_allowed }; const ok = await runAction( - () => editingFunctionTypeId ? patchFunctionType(settings, editingFunctionTypeId, patchPayload) : createFunctionType(settings, payload), + () => editingFunctionTypeId ? patchFunctionType(settings, editingFunctionTypeId, withChangeRequest(patchPayload)) : createFunctionType(settings, withChangeRequest(payload)), editingFunctionTypeId ? "i18n:govoplan-organizations.function_type_updated.1a4f29f6" : "i18n:govoplan-organizations.function_type_added.2cd9e899" ); if (ok) { @@ -626,7 +693,7 @@ export default function OrganizationsPage({ setEditingFunctionTypeId(null); } return ok; - }, [canWriteModel, editingFunctionTypeId, functionTypeDraft, runAction, settings]); + }, [canWriteModel, editingFunctionTypeId, functionTypeDraft, runAction, settings, withChangeRequest]); const submitFunction = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -651,7 +718,7 @@ export default function OrganizationsPage({ act_in_place_allowed: functionDraft.act_in_place_allowed }; const ok = await runAction( - () => editingFunctionId ? patchFunction(settings, editingFunctionId, patchPayload) : createFunction(settings, payload), + () => editingFunctionId ? patchFunction(settings, editingFunctionId, withChangeRequest(patchPayload)) : createFunction(settings, withChangeRequest(payload)), editingFunctionId ? "i18n:govoplan-organizations.function_updated.65016009" : "i18n:govoplan-organizations.function_added.e2266702" ); if (ok) { @@ -659,7 +726,7 @@ export default function OrganizationsPage({ setEditingFunctionId(null); } return ok; - }, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings]); + }, [canWriteFunctions, editingFunctionId, functionDraft, runAction, settings, withChangeRequest]); const submitAssignment = useCallback(async (event?: FormEvent): Promise => { event?.preventDefault(); @@ -680,7 +747,7 @@ export default function OrganizationsPage({ settings: {} }; const ok = await runAction( - () => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, payload) : createFunctionAssignment(settings, payload), + () => editingAssignmentId ? patchFunctionAssignment(settings, editingAssignmentId, withChangeRequest(payload)) : createFunctionAssignment(settings, withChangeRequest(payload)), editingAssignmentId ? "i18n:govoplan-organizations.assignment_updated.680b6e33" : "i18n:govoplan-organizations.assignment_added.94263d1b" ); if (ok) { @@ -688,7 +755,7 @@ export default function OrganizationsPage({ setEditingAssignmentId(null); } return ok; - }, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings]); + }, [assignmentDraft, canAssignFunctions, editingAssignmentId, runAction, settings, withChangeRequest]); const saveDrafts = useCallback(async (): Promise => { if (isSluggedDirty(unitTypeDraft) && !(await submitUnitType())) return false; @@ -728,36 +795,36 @@ export default function OrganizationsPage({ }); const toggleUnitType = useCallback((item: OrganizationUnitTypeItem) => { - void runAction(() => patchUnitType(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchUnitType(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleStructure = useCallback((item: OrganizationStructureItem) => { - void runAction(() => patchStructure(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchStructure(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleRelationType = useCallback((item: OrganizationRelationTypeItem) => { - void runAction(() => patchRelationType(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchRelationType(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleUnit = useCallback((item: OrganizationUnitItem) => { - void runAction(() => patchUnit(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchUnit(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleRelation = useCallback((item: OrganizationRelationItem) => { - void runAction(() => patchRelation(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchRelation(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleFunctionType = useCallback((item: OrganizationFunctionTypeItem) => { - void runAction(() => patchFunctionType(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchFunctionType(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleFunction = useCallback((item: OrganizationFunctionItem) => { - void runAction(() => patchFunction(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchFunction(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const toggleAssignment = useCallback((item: OrganizationFunctionAssignmentItem) => { - void runAction(() => patchFunctionAssignment(settings, item.id, { is_active: !item.is_active })); - }, [runAction, settings]); + void runAction(() => patchFunctionAssignment(settings, item.id, withChangeRequest({ is_active: !item.is_active }))); + }, [runAction, settings, withChangeRequest]); const editUnitType = useCallback((item: OrganizationUnitTypeItem) => { setEditingUnitTypeId(item.id); @@ -913,7 +980,7 @@ export default function OrganizationsPage({ ]; const assignmentColumns: DataGridColumn[] = [ - { id: "identity", header: "i18n:govoplan-organizations.identity_id.b6ef5010", minWidth: 220, render: (row) => {row.identity_id} }, + { id: "identity", header: "i18n:govoplan-organizations.identity.3252f35f", minWidth: 220, render: (row) => identityDisplay(row.identity_id, identityOptionById) }, { id: "function", header: "i18n:govoplan-organizations.function.28822f3a", minWidth: 190, render: (row) => optionalLabel(functionById.get(row.function_id)?.name) }, { id: "unit", header: "i18n:govoplan-organizations.unit.8fe4d595", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.organization_unit_id)?.name) }, { id: "subunits", header: "i18n:govoplan-organizations.applies_to_subunits.7a15f741", width: 150, render: (row) => activeStatus(row.applies_to_subunits) }, @@ -936,6 +1003,10 @@ export default function OrganizationsPage({

{description}

+ @@ -1232,15 +1303,52 @@ export default function OrganizationsPage({ } function renderAssignmentsSection() { + const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id); return (
void submitAssignment(event)}> - - setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} /> - + {identityLookupAvailable ? ( +
+ + setIdentitySearch(event.target.value)} /> + + + + +
+ ) : ( + <> +

i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2

+ + setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} /> + + + )} - setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} /> + {selectedIdentity && selectedIdentity.account_ids.length > 0 ? ( + + ) : ( + setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} /> + )}