Add organization change-control workflow

This commit is contained in:
2026-07-10 18:57:32 +02:00
parent becbc8dd04
commit 669647a986
9 changed files with 421 additions and 71 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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");
}

View File

@@ -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 })}>

View File

@@ -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",

View File

@@ -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;