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

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