Add organization change-control workflow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
11
README.md
11
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
} & OrganizationChangeRequestPayload;
|
||||
|
||||
export type StructureCreatePayload = SluggedCreatePayload & {
|
||||
structure_kind: OrganizationStructureKind;
|
||||
@@ -187,7 +205,7 @@ export type RelationCreatePayload = {
|
||||
target_unit_id: string;
|
||||
is_active?: boolean;
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
} & 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<string, unknown>;
|
||||
};
|
||||
} & OrganizationChangeRequestPayload;
|
||||
|
||||
function post<T, P extends Record<string, unknown>>(settings: ApiSettings, path: string, payload: P): Promise<T> {
|
||||
return apiFetch<T>(settings, path, { method: "POST", body: JSON.stringify(payload) });
|
||||
@@ -226,6 +244,15 @@ export function getOrganizationModel(settings: ApiSettings): Promise<Organizatio
|
||||
return apiFetch<OrganizationModel>(settings, "/api/v1/organizations/model");
|
||||
}
|
||||
|
||||
export async function searchIdentityOptions(settings: ApiSettings, query = "", limit = 25): Promise<IdentityOption[]> {
|
||||
const params = new URLSearchParams();
|
||||
const trimmed = query.trim();
|
||||
if (trimmed) params.set("query", trimmed);
|
||||
params.set("limit", String(limit));
|
||||
const response = await apiFetch<IdentityListResponse>(settings, `/api/v1/identity/identities?${params.toString()}`);
|
||||
return response.identities;
|
||||
}
|
||||
|
||||
export function getOrganizationSettings(settings: ApiSettings): Promise<OrganizationSettingsItem> {
|
||||
return apiFetch<OrganizationSettingsItem>(settings, "/api/v1/organizations/settings");
|
||||
}
|
||||
|
||||
@@ -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<OrganizationSection>): 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 <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
|
||||
}
|
||||
|
||||
function identityOptionLabel(identity: IdentityOption): string {
|
||||
return identity.display_name || identity.external_subject || identity.primary_account_id || identity.id;
|
||||
}
|
||||
|
||||
function identityDisplay(identityId: string, identities: Map<string, IdentityOption>): JSX.Element {
|
||||
const identity = identities.get(identityId);
|
||||
if (!identity) return <span className="organizations-id">{identityId}</span>;
|
||||
return (
|
||||
<span className="organizations-identity">
|
||||
<span>{identityOptionLabel(identity)}</span>
|
||||
<span className="organizations-id">{identity.id}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<string | null>(null);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const [changeRequestId, setChangeRequestId] = useState("");
|
||||
const [identitySearch, setIdentitySearch] = useState("");
|
||||
const [identityOptions, setIdentityOptions] = useState<IdentityOption[]>([]);
|
||||
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<string, OrganizationUnitItem[]>();
|
||||
for (const unit of model.units) {
|
||||
@@ -435,6 +467,11 @@ export default function OrganizationsPage({
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const withChangeRequest = useCallback(<T extends object,>(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<unknown>, successMessage = ""): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
event?.preventDefault();
|
||||
@@ -515,7 +582,7 @@ export default function OrganizationsPage({
|
||||
const payload: StructureCreatePayload = { ...sluggedPayload(structureDraft), structure_kind: structureDraft.structure_kind };
|
||||
const patchPayload: Partial<StructureCreatePayload> = { ...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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
event?.preventDefault();
|
||||
@@ -591,7 +658,7 @@ export default function OrganizationsPage({
|
||||
const payload: RelationCreatePayload = { ...relationDraft, settings: {} };
|
||||
const patchPayload: Partial<RelationCreatePayload> = { ...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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<boolean> => {
|
||||
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<OrganizationFunctionAssignmentItem>[] = [
|
||||
{ id: "identity", header: "i18n:govoplan-organizations.identity_id.b6ef5010", minWidth: 220, render: (row) => <span className="organizations-id">{row.identity_id}</span> },
|
||||
{ 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({
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="organizations-toolbar">
|
||||
<label className="organizations-change-request">
|
||||
<span>i18n:govoplan-organizations.change_request_id.a6e7fd6b</span>
|
||||
<input value={changeRequestId} onChange={(event) => setChangeRequestId(event.target.value)} placeholder="cfgreq-..." disabled={busy} />
|
||||
</label>
|
||||
<Button type="button" onClick={() => void loadModel()} disabled={loading || busy} title="i18n:govoplan-organizations.reload.cce71553">
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-organizations.reload.cce71553
|
||||
</Button>
|
||||
@@ -1232,15 +1303,52 @@ export default function OrganizationsPage({
|
||||
}
|
||||
|
||||
function renderAssignmentsSection() {
|
||||
const selectedIdentity = identityOptionById.get(assignmentDraft.identity_id);
|
||||
return (
|
||||
<div className="organizations-section-grid">
|
||||
<Card title="i18n:govoplan-organizations.add_assignment.6682f5f5">
|
||||
<form className="organizations-form-grid" onSubmit={(event) => void submitAssignment(event)}>
|
||||
<FormField label="i18n:govoplan-organizations.identity_id.b6ef5010">
|
||||
<input value={assignmentDraft.identity_id} placeholder="i18n:govoplan-organizations.identity_id_placeholder.298cbd85" disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} />
|
||||
</FormField>
|
||||
{identityLookupAvailable ? (
|
||||
<div className="organizations-identity-picker wide">
|
||||
<FormField label="i18n:govoplan-organizations.identity_search.eff6eb16">
|
||||
<input value={identitySearch} placeholder="i18n:govoplan-organizations.search_identities.004afdfd" disabled={!canAssignFunctions || busy} onChange={(event) => setIdentitySearch(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-organizations.identity.3252f35f">
|
||||
<select
|
||||
value={assignmentDraft.identity_id}
|
||||
disabled={!canAssignFunctions || busy || identityLoading}
|
||||
onChange={(event) => {
|
||||
const identity = identityOptionById.get(event.target.value);
|
||||
setAssignmentDraft({
|
||||
...assignmentDraft,
|
||||
identity_id: event.target.value,
|
||||
account_id: identity?.primary_account_id ?? identity?.account_ids[0] ?? ""
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{identityLoading ? "i18n:govoplan-organizations.loading_identities.9c31d2b5" : "i18n:govoplan-organizations.select_identity.510f1134"}</option>
|
||||
{assignmentDraft.identity_id && !identityOptionById.has(assignmentDraft.identity_id) && <option value={assignmentDraft.identity_id}>{assignmentDraft.identity_id}</option>}
|
||||
{identityOptions.map((identity) => <option key={identity.id} value={identity.id}>{identityOptionLabel(identity)}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="organizations-muted wide">i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2</p>
|
||||
<FormField label="i18n:govoplan-organizations.identity_id.b6ef5010">
|
||||
<input value={assignmentDraft.identity_id} placeholder="i18n:govoplan-organizations.identity_id_placeholder.298cbd85" disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, identity_id: event.target.value })} />
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
<FormField label="i18n:govoplan-organizations.optional_account_id.5fcf495c">
|
||||
<input value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} />
|
||||
{selectedIdentity && selectedIdentity.account_ids.length > 0 ? (
|
||||
<select value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })}>
|
||||
<option value="">i18n:govoplan-organizations.none.334c4a4c</option>
|
||||
{selectedIdentity.account_ids.map((accountId) => <option key={accountId} value={accountId}>{accountId}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={assignmentDraft.account_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, account_id: event.target.value })} />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-organizations.function.28822f3a">
|
||||
<select value={assignmentDraft.function_id} disabled={!canAssignFunctions || busy} onChange={(event) => setAssignmentDraft({ ...assignmentDraft, function_id: event.target.value })}>
|
||||
|
||||
@@ -28,6 +28,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.classification.3e9f6c3a": "Classification",
|
||||
"i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Tenant admins may customize the organization meta-model",
|
||||
"i18n:govoplan-organizations.change_retention_days.71bbd140": "Retain organization change detail for days",
|
||||
"i18n:govoplan-organizations.change_request_id.a6e7fd6b": "Change request ID",
|
||||
"i18n:govoplan-organizations.cancel_edit.309c2a6f": "Cancel edit",
|
||||
"i18n:govoplan-organizations.concrete_model.4e9c531a": "Concrete model",
|
||||
"i18n:govoplan-organizations.deactivate.46ab070d": "Deactivate",
|
||||
@@ -47,10 +48,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.functions.805dc49b": "Functions",
|
||||
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchical",
|
||||
"i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchy",
|
||||
"i18n:govoplan-organizations.identity.3252f35f": "Identity",
|
||||
"i18n:govoplan-organizations.identity_id.b6ef5010": "Identity ID",
|
||||
"i18n:govoplan-organizations.identity_id_placeholder.298cbd85": "identity UUID",
|
||||
"i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2": "Identity lookup is unavailable. Enable the identity module or enter an identity ID manually.",
|
||||
"i18n:govoplan-organizations.identity_search.eff6eb16": "Identity search",
|
||||
"i18n:govoplan-organizations.inactive.09af574c": "Inactive",
|
||||
"i18n:govoplan-organizations.kind.794c9d9c": "Kind",
|
||||
"i18n:govoplan-organizations.loading_identities.9c31d2b5": "Loading identities...",
|
||||
"i18n:govoplan-organizations.loading_organization_model.846aa317": "Loading organization model...",
|
||||
"i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Loading organization settings...",
|
||||
"i18n:govoplan-organizations.membership.4531d86d": "Membership",
|
||||
@@ -94,8 +99,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.save_settings.913aba9f": "Save settings",
|
||||
"i18n:govoplan-organizations.save_drafts.606f9401": "Save drafts",
|
||||
"i18n:govoplan-organizations.saving.56a2285c": "Saving...",
|
||||
"i18n:govoplan-organizations.search_identities.004afdfd": "Search by name, subject, account, or ID",
|
||||
"i18n:govoplan-organizations.select_function.4895a67d": "Select function",
|
||||
"i18n:govoplan-organizations.select_function_type.2f426c43": "Select function type",
|
||||
"i18n:govoplan-organizations.select_identity.510f1134": "Select identity",
|
||||
"i18n:govoplan-organizations.select_relation_type.73f92478": "Select relation type",
|
||||
"i18n:govoplan-organizations.select_source_unit.9b5a29c8": "Select source unit",
|
||||
"i18n:govoplan-organizations.select_structure.c10f551c": "Select structure",
|
||||
@@ -159,6 +166,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.classification.3e9f6c3a": "Klassifikation",
|
||||
"i18n:govoplan-organizations.allow_tenant_model_customization.2425d751": "Mandantenadmins dürfen das Organisations-Metamodell anpassen",
|
||||
"i18n:govoplan-organizations.change_retention_days.71bbd140": "Änderungsdetails zur Organisation in Tagen aufbewahren",
|
||||
"i18n:govoplan-organizations.change_request_id.a6e7fd6b": "Änderungsantrags-ID",
|
||||
"i18n:govoplan-organizations.cancel_edit.309c2a6f": "Bearbeitung abbrechen",
|
||||
"i18n:govoplan-organizations.concrete_model.4e9c531a": "Konkretes Modell",
|
||||
"i18n:govoplan-organizations.deactivate.46ab070d": "Deaktivieren",
|
||||
@@ -178,10 +186,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.functions.805dc49b": "Funktionen",
|
||||
"i18n:govoplan-organizations.hierarchical.8964f313": "Hierarchisch",
|
||||
"i18n:govoplan-organizations.hierarchy.74797f37": "Hierarchie",
|
||||
"i18n:govoplan-organizations.identity.3252f35f": "Identität",
|
||||
"i18n:govoplan-organizations.identity_id.b6ef5010": "Identitäts-ID",
|
||||
"i18n:govoplan-organizations.identity_id_placeholder.298cbd85": "Identitäts-UUID",
|
||||
"i18n:govoplan-organizations.identity_lookup_unavailable.5a3e77b2": "Die Identitätssuche ist nicht verfügbar. Aktiviere das Identitätsmodul oder trage eine Identitäts-ID manuell ein.",
|
||||
"i18n:govoplan-organizations.identity_search.eff6eb16": "Identitätssuche",
|
||||
"i18n:govoplan-organizations.inactive.09af574c": "Inaktiv",
|
||||
"i18n:govoplan-organizations.kind.794c9d9c": "Art",
|
||||
"i18n:govoplan-organizations.loading_identities.9c31d2b5": "Identitäten werden geladen...",
|
||||
"i18n:govoplan-organizations.loading_organization_model.846aa317": "Organisationsmodell wird geladen...",
|
||||
"i18n:govoplan-organizations.loading_organization_settings.c6008db8": "Organisationseinstellungen werden geladen...",
|
||||
"i18n:govoplan-organizations.membership.4531d86d": "Mitgliedschaft",
|
||||
@@ -225,8 +237,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-organizations.save_settings.913aba9f": "Einstellungen speichern",
|
||||
"i18n:govoplan-organizations.save_drafts.606f9401": "Entwürfe speichern",
|
||||
"i18n:govoplan-organizations.saving.56a2285c": "Speichern...",
|
||||
"i18n:govoplan-organizations.search_identities.004afdfd": "Nach Name, Subject, Konto oder ID suchen",
|
||||
"i18n:govoplan-organizations.select_function.4895a67d": "Funktion auswählen",
|
||||
"i18n:govoplan-organizations.select_function_type.2f426c43": "Funktionstyp auswählen",
|
||||
"i18n:govoplan-organizations.select_identity.510f1134": "Identität auswählen",
|
||||
"i18n:govoplan-organizations.select_relation_type.73f92478": "Beziehungstyp auswählen",
|
||||
"i18n:govoplan-organizations.select_source_unit.9b5a29c8": "Quell-Einheit auswählen",
|
||||
"i18n:govoplan-organizations.select_structure.c10f551c": "Struktur auswählen",
|
||||
|
||||
@@ -31,6 +31,19 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.organizations-change-request {
|
||||
display: inline-grid;
|
||||
gap: 4px;
|
||||
min-width: 220px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.organizations-change-request input {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.organizations-section-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
@@ -148,6 +161,24 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.organizations-muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.organizations-identity-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.organizations-identity {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.organizations-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user