from __future__ import annotations from datetime import UTC, datetime import re from typing import Any, TypeVar from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import inspect as sqlalchemy_inspect 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.core.principal_cache import invalidate_auth_principals from govoplan_core.core.organizations import ( ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION, organization_lifecycle_event_type, ) from govoplan_core.core.events import ( EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event, ) from govoplan_core.db.session import get_session from govoplan_organizations.backend.db.models import ( OrganizationFunction, OrganizationFunctionType, OrganizationModelInstantiation, OrganizationModelTemplate, OrganizationModelTemplateVersion, OrganizationModelUpgrade, OrganizationRelation, OrganizationRelationType, OrganizationTenantSettings, OrganizationStructure, OrganizationUnit, OrganizationUnitType, ) from .schemas import ( FunctionCreateRequest, FunctionTypeCreateRequest, FunctionTypeUpdateRequest, FunctionUpdateRequest, OrganizationModelInstantiationItem, OrganizationModelUpgradeApplyRequest, OrganizationModelUpgradeApplyResponse, OrganizationModelUpgradeCancelRequest, OrganizationModelUpgradeItem, OrganizationModelUpgradeListResponse, OrganizationModelUpgradePreviewRequest, OrganizationFunctionItem, OrganizationFunctionTypeItem, OrganizationModelResponse, OrganizationModelTemplateCatalogItem, OrganizationModelTemplateCatalogResponse, OrganizationModelTemplateCreateRequest, OrganizationModelTemplateItem, OrganizationModelTemplateVersionCreateRequest, OrganizationModelTemplateVersionItem, OrganizationRelationItem, OrganizationRelationTypeItem, OrganizationSettingsItem, OrganizationSettingsUpdateRequest, OrganizationStructureItem, OrganizationUnitItem, OrganizationUnitTypeItem, RelationCreateRequest, RelationTypeCreateRequest, RelationTypeUpdateRequest, RelationUpdateRequest, StructureCreateRequest, StructureUpdateRequest, UnitCreateRequest, UnitTypeCreateRequest, UnitTypeUpdateRequest, UnitUpdateRequest, ) from govoplan_organizations.backend.templates import ( OrganizationTemplateError, canonical_template_definition, instantiate_template_version, ) from govoplan_organizations.backend.upgrades import ( OrganizationUpgradeError, apply_model_upgrade, cancel_model_upgrade, create_model_upgrade_preview, current_model_instantiation, list_model_upgrades, ) router = APIRouter(prefix="/organizations", tags=["organizations"]) ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000 ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000 ORG_READ_SCOPES = ( "organizations:model:read", "organizations:unit:read", "organizations:function:read", "admin:settings:read", ) ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",) ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",) ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",) 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" ORG_TEMPLATE_ADMIN_SCOPES = ("system:settings:write",) SLUG_RE = re.compile(r"[^a-z0-9]+") ModelT = TypeVar("ModelT") def _slug(value: str | None, fallback: str) -> str: source = value or fallback normalized = SLUG_RE.sub("-", source.strip().casefold()).strip("-") return normalized[:100] or "item" def _tenant_id(principal: ApiPrincipal) -> str: return principal.tenant_id def _not_found(label: str) -> HTTPException: return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found") def _conflict(message: str) -> HTTPException: return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) 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: raise _not_found(label) return item def _ensure_optional_unit_type(session: Session, tenant_id: str, unit_type_id: str | None) -> None: if unit_type_id is None: return _get_tenant_row(session, OrganizationUnitType, unit_type_id, tenant_id, "Organization unit type") def _ensure_optional_structure(session: Session, tenant_id: str, structure_id: str | None) -> None: if structure_id is None: return _get_tenant_row(session, OrganizationStructure, structure_id, tenant_id, "Organization structure") def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str, *, exclude_id: str | None = None) -> None: query = session.query(model).filter(model.tenant_id == tenant_id, model.slug == slug) if exclude_id is not None: query = query.filter(model.id != exclude_id) if query.count(): raise _conflict(f"Slug already exists in this tenant: {slug}") def _commit(session: Session, item: ModelT) -> ModelT: lifecycle = _organization_lifecycle_change(item) try: session.flush() invalidate_auth_principals( session, tenant_id=getattr(item, "tenant_id", None), source_module="organizations", resource_type=item.__class__.__name__, resource_id=str( getattr( item, "id", getattr(item, "tenant_id", "system"), ) ), ) if lifecycle is not None: _emit_organization_lifecycle_event( session, item, action=lifecycle[0], changes=lifecycle[1], ) session.commit() except IntegrityError as exc: session.rollback() raise _conflict("The organization model change conflicts with existing data.") from exc session.refresh(item) return item _LIFECYCLE_RESOURCES = { OrganizationUnitType: "unit_type", OrganizationStructure: "structure", OrganizationRelationType: "relation_type", OrganizationUnit: "unit", OrganizationRelation: "relation", OrganizationFunctionType: "function_type", OrganizationFunction: "function", } def _organization_lifecycle_change( item: object, ) -> tuple[str, dict[str, dict[str, object | None]]] | None: resource_type = _LIFECYCLE_RESOURCES.get(type(item)) if resource_type is None: return None state = sqlalchemy_inspect(item) changes: dict[str, dict[str, object | None]] = {} for attribute in state.mapper.column_attrs: history = state.attrs[attribute.key].history if not history.has_changes(): continue changes[attribute.key] = { "before": ( _organization_event_value(history.deleted[0]) if history.deleted else None ), "after": ( _organization_event_value(history.added[0]) if history.added else None ), } if state.pending: action = "created" elif ( "is_active" in changes and changes["is_active"]["after"] is False ): action = "deactivated" elif resource_type == "unit" and "parent_id" in changes: action = "moved" else: action = "updated" return action, changes def _organization_event_value(value: object) -> object: if isinstance(value, datetime): return value.isoformat() return value def _emit_organization_lifecycle_event( session: Session, item: object, *, action: str, changes: dict[str, dict[str, object | None]], ) -> None: resource_type = _LIFECYCLE_RESOURCES[type(item)] tenant_id = str(getattr(item, "tenant_id")) item_id = str(getattr(item, "id")) payload: dict[str, object] = { "schema_version": ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION, "tenant_id": tenant_id, "resource_type": resource_type, "resource_id": item_id, "status": ( "active" if bool(getattr(item, "is_active", True)) else "inactive" ), "changed_fields": sorted(changes), "changes": changes, } for field in ( "slug", "unit_type_id", "organization_unit_id", "function_type_id", "structure_id", "relation_type_id", "source_unit_id", "target_unit_id", ): value = getattr(item, field, None) if value is not None: payload[field] = str(value) emit_platform_event( session, PlatformEvent( type=organization_lifecycle_event_type( resource_type, # type: ignore[arg-type] action, # type: ignore[arg-type] ), module_id="organizations", tenant=EventTenantRef(id=tenant_id), subject=EventObjectRef( type=f"organization_{resource_type}", id=item_id, label=str(getattr(item, "name", None) or "") or None, ), resource=EventObjectRef( type=f"organization_{resource_type}", id=item_id, ), payload=payload, classification="internal", ), ) 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)) def _item_settings(item: OrganizationTenantSettings) -> OrganizationSettingsItem: return OrganizationSettingsItem(**_row_fields(item)) def _default_settings(tenant_id: str) -> OrganizationSettingsItem: return OrganizationSettingsItem( tenant_id=tenant_id, allow_tenant_model_customization=True, require_model_change_requests=False, audit_detail_level="standard", change_retention_days=None, settings={}, ) def _item_structure(item: OrganizationStructure) -> OrganizationStructureItem: return OrganizationStructureItem(**_row_fields(item)) def _item_relation_type(item: OrganizationRelationType) -> OrganizationRelationTypeItem: return OrganizationRelationTypeItem(**_row_fields(item)) def _item_unit(item: OrganizationUnit) -> OrganizationUnitItem: return OrganizationUnitItem(**_row_fields(item)) def _item_relation(item: OrganizationRelation) -> OrganizationRelationItem: return OrganizationRelationItem(**_row_fields(item)) def _item_function_type(item: OrganizationFunctionType) -> OrganizationFunctionTypeItem: return OrganizationFunctionTypeItem(**_row_fields(item)) def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem: return OrganizationFunctionItem(**_row_fields(item)) def _template_item( item: OrganizationModelTemplate, ) -> OrganizationModelTemplateItem: return OrganizationModelTemplateItem( id=item.id, slug=item.slug, name=item.name, description=item.description, is_active=item.is_active, settings=dict(item.settings or {}), created_at=item.created_at, updated_at=item.updated_at, ) def _template_version_item( item: OrganizationModelTemplateVersion, ) -> OrganizationModelTemplateVersionItem: return OrganizationModelTemplateVersionItem( id=item.id, template_id=item.template_id, version=item.version, schema_version=item.schema_version, status=item.status, definition=item.definition, definition_sha256=item.definition_sha256, release_notes=item.release_notes, published_at=item.published_at, created_at=item.created_at, updated_at=item.updated_at, ) def _instantiation_item( item: OrganizationModelInstantiation, ) -> OrganizationModelInstantiationItem: return OrganizationModelInstantiationItem( id=item.id, tenant_id=item.tenant_id, template_id=item.template_id, template_version_id=item.template_version_id, source_definition_sha256=item.source_definition_sha256, status=item.status, object_counts=dict(item.object_counts or {}), provenance=dict(item.provenance or {}), created_at=item.created_at, updated_at=item.updated_at, ) def _upgrade_item(item: OrganizationModelUpgrade) -> OrganizationModelUpgradeItem: return OrganizationModelUpgradeItem( id=item.id, tenant_id=item.tenant_id, template_id=item.template_id, source_instantiation_id=item.source_instantiation_id, source_template_version_id=item.source_template_version_id, target_template_version_id=item.target_template_version_id, status=item.status, revision=item.revision, base_definition_sha256=item.base_definition_sha256, local_definition_sha256=item.local_definition_sha256, target_definition_sha256=item.target_definition_sha256, preview=item.preview, decisions=item.decisions, requested_by_account_id=item.requested_by_account_id, applied_by_account_id=item.applied_by_account_id, cancelled_by_account_id=item.cancelled_by_account_id, applied_at=item.applied_at, cancelled_at=item.cancelled_at, provenance=dict(item.provenance or {}), created_at=item.created_at, updated_at=item.updated_at, ) def _emit_model_upgrade_event( session: Session, principal: ApiPrincipal, item: OrganizationModelUpgrade, action: str, ) -> None: emit_platform_event( session, PlatformEvent( type=f"organizations.model_upgrade.{action}.v1", module_id="organizations", tenant=EventTenantRef(id=item.tenant_id), actor=EventActorRef(type="account", id=principal.account_id), subject=EventObjectRef(type="organization_model_upgrade", id=item.id), resource=EventObjectRef( type="organization_model_template_version", id=item.target_template_version_id, ), payload={ "schema_version": "1", "tenant_id": item.tenant_id, "upgrade_id": item.id, "status": item.status, "revision": item.revision, "source_template_version_id": item.source_template_version_id, "target_template_version_id": item.target_template_version_id, "requires_decisions": int( dict(item.preview or {}).get("requires_decisions", 0) ), "blocking_invalid_references": int( dict(item.preview or {}).get( "blocking_invalid_references", 0 ) ), "decision_count": len(dict(item.decisions or {})), "silent_mutation": False, }, classification="internal", ), ) def _template_version( session: Session, template_id: str, version: str, ) -> OrganizationModelTemplateVersion: item = ( session.query(OrganizationModelTemplateVersion) .filter( OrganizationModelTemplateVersion.template_id == template_id, OrganizationModelTemplateVersion.version == version, ) .one_or_none() ) if item is None: raise _not_found("Organization model template version") return item def _row_fields(item: object) -> dict[str, Any]: keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined] return {key: getattr(item, key) for key in keys} def _apply_slugged_update(session: Session, item: object, payload: object, tenant_id: str, model: type) -> None: fields = payload.model_fields_set # type: ignore[attr-defined] if "slug" in fields: slug = _slug(getattr(payload, "slug"), getattr(item, "name", "item")) _ensure_unique_slug(session, model, tenant_id, slug, exclude_id=getattr(item, "id")) setattr(item, "slug", slug) if "name" in fields: value = getattr(payload, "name") if value is None: raise _invalid("Name cannot be empty.") setattr(item, "name", value.strip()) if "description" in fields: setattr(item, "description", getattr(payload, "description")) if "is_active" in fields: value = getattr(payload, "is_active") if value is None: raise _invalid("Active state cannot be empty.") setattr(item, "is_active", value) if "settings" in fields: value = getattr(payload, "settings") if value is None: raise _invalid("Settings cannot be empty.") setattr(item, "settings", value) @router.get("/settings", response_model=OrganizationSettingsItem) def get_organization_settings( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_SETTINGS_READ_SCOPES)), ) -> OrganizationSettingsItem: tenant_id = _tenant_id(principal) item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none() return _item_settings(item) if item is not None else _default_settings(tenant_id) @router.patch("/settings", response_model=OrganizationSettingsItem) def update_organization_settings( payload: OrganizationSettingsUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_SETTINGS_WRITE_SCOPES)), ) -> OrganizationSettingsItem: tenant_id = _tenant_id(principal) item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none() if item is None: item = OrganizationTenantSettings( tenant_id=tenant_id, allow_tenant_model_customization=True, require_model_change_requests=False, audit_detail_level="standard", change_retention_days=None, settings={}, ) session.add(item) fields = payload.model_fields_set for field in ("allow_tenant_model_customization", "require_model_change_requests", "audit_detail_level", "change_retention_days"): if field in fields: value = getattr(payload, field) if field != "change_retention_days" and value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) if "settings" in fields: if payload.settings is None: raise _invalid("Settings cannot be empty.") item.settings = payload.settings return _item_settings(_commit(session, item)) @router.get( "/model-templates", response_model=OrganizationModelTemplateCatalogResponse, ) def list_organization_model_templates( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)), ) -> OrganizationModelTemplateCatalogResponse: del principal templates = ( session.query(OrganizationModelTemplate) .filter(OrganizationModelTemplate.is_active.is_(True)) .order_by( OrganizationModelTemplate.name.asc(), OrganizationModelTemplate.id.asc(), ) .all() ) versions = ( session.query(OrganizationModelTemplateVersion) .filter( OrganizationModelTemplateVersion.template_id.in_( [item.id for item in templates] ), OrganizationModelTemplateVersion.status == "published", ) .order_by( OrganizationModelTemplateVersion.template_id.asc(), OrganizationModelTemplateVersion.published_at.desc(), OrganizationModelTemplateVersion.version.desc(), ) .all() if templates else [] ) by_template: dict[str, list[OrganizationModelTemplateVersion]] = {} for version in versions: by_template.setdefault(version.template_id, []).append(version) return OrganizationModelTemplateCatalogResponse( templates=[ OrganizationModelTemplateCatalogItem( **_template_item(item).model_dump(), versions=[ _template_version_item(version) for version in by_template.get(item.id, ()) ], ) for item in templates ] ) @router.post( "/model-templates", response_model=OrganizationModelTemplateItem, status_code=status.HTTP_201_CREATED, ) def create_organization_model_template( payload: OrganizationModelTemplateCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES) ), ) -> OrganizationModelTemplateItem: item = OrganizationModelTemplate( slug=payload.slug, name=payload.name, description=payload.description, settings=payload.settings, created_by_account_id=principal.account_id, ) session.add(item) try: session.commit() except IntegrityError as exc: session.rollback() raise _conflict("An organization model template with this slug already exists") from exc session.refresh(item) return _template_item(item) @router.post( "/model-templates/{template_id}/versions", response_model=OrganizationModelTemplateVersionItem, status_code=status.HTTP_201_CREATED, ) def create_organization_model_template_version( template_id: str, payload: OrganizationModelTemplateVersionCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES) ), ) -> OrganizationModelTemplateVersionItem: template = session.get(OrganizationModelTemplate, template_id) if template is None: raise _not_found("Organization model template") try: definition, definition_sha256 = canonical_template_definition( payload.definition ) except OrganizationTemplateError as exc: raise _invalid(str(exc)) from exc item = OrganizationModelTemplateVersion( template_id=template.id, version=payload.version, definition=definition, definition_sha256=definition_sha256, release_notes=payload.release_notes, ) session.add(item) try: session.commit() except IntegrityError as exc: session.rollback() raise _conflict("This organization model template version already exists") from exc session.refresh(item) return _template_version_item(item) @router.post( "/model-templates/{template_id}/versions/{version}/publish", response_model=OrganizationModelTemplateVersionItem, ) def publish_organization_model_template_version( template_id: str, version: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_TEMPLATE_ADMIN_SCOPES) ), ) -> OrganizationModelTemplateVersionItem: item = _template_version(session, template_id, version) if item.status == "retired": raise _conflict("A retired organization template version cannot be published") if item.status == "draft": item.status = "published" item.published_at = datetime.now(UTC) item.published_by_account_id = principal.account_id session.commit() session.refresh(item) return _template_version_item(item) @router.post( "/model-templates/{template_id}/versions/{version}/instantiate", response_model=OrganizationModelInstantiationItem, status_code=status.HTTP_201_CREATED, ) def instantiate_organization_model_template( template_id: str, version: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_MODEL_WRITE_SCOPES) ), ) -> OrganizationModelInstantiationItem: template = session.get(OrganizationModelTemplate, template_id) if template is None or not template.is_active: raise _not_found("Organization model template") item = _template_version(session, template_id, version) try: instantiation = instantiate_template_version( session, tenant_id=principal.tenant_id, template=template, version=item, actor_account_id=principal.account_id, ) session.commit() session.refresh(instantiation) except OrganizationTemplateError as exc: session.rollback() raise _conflict(str(exc)) from exc return _instantiation_item(instantiation) @router.get( "/model-upgrades", response_model=OrganizationModelUpgradeListResponse, ) def list_organization_model_upgrades( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)), ) -> OrganizationModelUpgradeListResponse: current = current_model_instantiation( session, tenant_id=principal.tenant_id, ) return OrganizationModelUpgradeListResponse( current_instantiation=( _instantiation_item(current) if current is not None else None ), upgrades=[ _upgrade_item(item) for item in list_model_upgrades( session, tenant_id=principal.tenant_id, ) ], ) @router.post( "/model-upgrades/preview", response_model=OrganizationModelUpgradeItem, status_code=status.HTTP_201_CREATED, ) def preview_organization_model_upgrade( payload: OrganizationModelUpgradePreviewRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_MODEL_WRITE_SCOPES) ), ) -> OrganizationModelUpgradeItem: target = session.get( OrganizationModelTemplateVersion, payload.target_template_version_id, ) if target is None: raise _not_found("Organization model template version") try: item = create_model_upgrade_preview( session, tenant_id=principal.tenant_id, target_version=target, actor_account_id=principal.account_id, idempotency_key=payload.idempotency_key, ) _emit_model_upgrade_event(session, principal, item, "previewed") session.commit() session.refresh(item) except OrganizationUpgradeError as exc: session.rollback() raise _conflict(str(exc)) from exc return _upgrade_item(item) @router.post( "/model-upgrades/{upgrade_id}/cancel", response_model=OrganizationModelUpgradeItem, ) def cancel_organization_model_upgrade( upgrade_id: str, payload: OrganizationModelUpgradeCancelRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_MODEL_WRITE_SCOPES) ), ) -> OrganizationModelUpgradeItem: try: item = cancel_model_upgrade( session, tenant_id=principal.tenant_id, upgrade_id=upgrade_id, expected_revision=payload.expected_revision, actor_account_id=principal.account_id, ) _emit_model_upgrade_event(session, principal, item, "cancelled") session.commit() session.refresh(item) except OrganizationUpgradeError as exc: session.rollback() raise _conflict(str(exc)) from exc return _upgrade_item(item) @router.post( "/model-upgrades/{upgrade_id}/apply", response_model=OrganizationModelUpgradeApplyResponse, ) def apply_organization_model_upgrade( upgrade_id: str, payload: OrganizationModelUpgradeApplyRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends( require_any_scope(*ORG_MODEL_WRITE_SCOPES) ), ) -> OrganizationModelUpgradeApplyResponse: approval, control_target, _value = _ensure_organization_change_allowed( session, principal, tenant_id=principal.tenant_id, resource_type="model_upgrade", operation="applied", payload=payload, resource_id=upgrade_id, ) before = _upgrade_item( _get_tenant_row( session, OrganizationModelUpgrade, upgrade_id, principal.tenant_id, "Organization model upgrade", ) ).model_dump(mode="json") try: item, instantiation = apply_model_upgrade( session, tenant_id=principal.tenant_id, upgrade_id=upgrade_id, expected_revision=payload.expected_revision, decisions={ key: value.model_dump(exclude_none=True) for key, value in payload.decisions.items() }, actor_account_id=principal.account_id, ) invalidate_auth_principals( session, tenant_id=principal.tenant_id, source_module="organizations", resource_type="organization_model_upgrade", resource_id=item.id, ) _emit_model_upgrade_event(session, principal, item, "applied") response = OrganizationModelUpgradeApplyResponse( upgrade=_upgrade_item(item), instantiation=_instantiation_item(instantiation), ) if approval is None: session.commit() else: _record_organization_change_applied( session, principal, approval=approval, target=control_target, before=before, after=response.model_dump(mode="json"), ) session.refresh(item) session.refresh(instantiation) except OrganizationUpgradeError as exc: session.rollback() raise _conflict(str(exc)) from exc return OrganizationModelUpgradeApplyResponse( upgrade=_upgrade_item(item), instantiation=_instantiation_item(instantiation), ) @router.get("/model", response_model=OrganizationModelResponse) def get_organization_model( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)), ) -> OrganizationModelResponse: tenant_id = _tenant_id(principal) unit_types = _bounded_organization_rows( session.query(OrganizationUnitType) .filter(OrganizationUnitType.tenant_id == tenant_id) .order_by(OrganizationUnitType.name.asc()), "unit types", ) structures = _bounded_organization_rows( session.query(OrganizationStructure) .filter(OrganizationStructure.tenant_id == tenant_id) .order_by(OrganizationStructure.name.asc()), "structures", ) relation_types = _bounded_organization_rows( session.query(OrganizationRelationType) .filter(OrganizationRelationType.tenant_id == tenant_id) .order_by(OrganizationRelationType.name.asc()), "relation types", ) units = _bounded_organization_rows( session.query(OrganizationUnit) .filter(OrganizationUnit.tenant_id == tenant_id) .order_by(OrganizationUnit.name.asc()), "units", ) relations = _bounded_organization_rows( session.query(OrganizationRelation) .filter(OrganizationRelation.tenant_id == tenant_id) .order_by(OrganizationRelation.created_at.asc()), "relations", ) function_types = _bounded_organization_rows( session.query(OrganizationFunctionType) .filter(OrganizationFunctionType.tenant_id == tenant_id) .order_by(OrganizationFunctionType.name.asc()), "function types", ) functions = _bounded_organization_rows( session.query(OrganizationFunction) .filter(OrganizationFunction.tenant_id == tenant_id) .order_by(OrganizationFunction.name.asc()), "functions", ) if sum( len(items) for items in ( unit_types, structures, relation_types, units, relations, function_types, functions, ) ) > ORGANIZATION_MODEL_TOTAL_LIMIT: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=( "The organization model is too large for the aggregate " "endpoint and cannot be returned as one response." ), ) return OrganizationModelResponse( unit_types=[_item_unit_type(item) for item in unit_types], structures=[_item_structure(item) for item in structures], relation_types=[_item_relation_type(item) for item in relation_types], units=[_item_unit(item) for item in units], relations=[_item_relation(item) for item in relations], function_types=[_item_function_type(item) for item in function_types], functions=[_item_function(item) for item in functions], ) def _bounded_organization_rows(query: Any, label: str) -> list[Any]: rows = query.limit(ORGANIZATION_MODEL_COLLECTION_LIMIT + 1).all() if len(rows) > ORGANIZATION_MODEL_COLLECTION_LIMIT: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=( f"The organization model has more than " f"{ORGANIZATION_MODEL_COLLECTION_LIMIT} {label} and cannot " "be returned as one response." ), ) return rows @router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED) def create_unit_type( payload: UnitTypeCreateRequest, session: Session = Depends(get_session), 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) 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) def update_unit_type( item_id: str, payload: UnitTypeUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> 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) 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) def create_structure( payload: StructureCreateRequest, session: Session = Depends(get_session), 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) 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) def update_structure( item_id: str, payload: StructureUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> 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 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) def create_relation_type( payload: RelationTypeCreateRequest, session: Session = Depends(get_session), 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) slug = _slug(payload.slug, payload.name) _ensure_unique_slug(session, OrganizationRelationType, tenant_id, slug) item = OrganizationRelationType( tenant_id=tenant_id, structure_id=payload.structure_id, slug=slug, name=payload.name.strip(), description=payload.description, source_unit_type_id=payload.source_unit_type_id, target_unit_type_id=payload.target_unit_type_id, is_hierarchical=payload.is_hierarchical, allow_cycles=payload.allow_cycles, is_active=payload.is_active, settings=payload.settings, ) session.add(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) def update_relation_type( item_id: str, payload: RelationTypeUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> 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: _ensure_optional_structure(session, tenant_id, payload.structure_id) item.structure_id = payload.structure_id if "source_unit_type_id" in fields: _ensure_optional_unit_type(session, tenant_id, payload.source_unit_type_id) item.source_unit_type_id = payload.source_unit_type_id if "target_unit_type_id" in fields: _ensure_optional_unit_type(session, tenant_id, payload.target_unit_type_id) item.target_unit_type_id = payload.target_unit_type_id for field in ("is_hierarchical", "allow_cycles"): if field in fields: value = getattr(payload, field) if value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) 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) def create_unit( payload: UnitCreateRequest, session: Session = Depends(get_session), 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") slug = _slug(payload.slug, payload.name) _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) 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) def update_unit( item_id: str, payload: UnitUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), ) -> 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) item.unit_type_id = payload.unit_type_id if "parent_id" in payload.model_fields_set: if payload.parent_id == item.id: raise _invalid("An organization unit cannot be its own parent.") if payload.parent_id is not None: _get_tenant_row(session, OrganizationUnit, payload.parent_id, tenant_id, "Parent organization 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 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) def create_relation( payload: RelationCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), ) -> OrganizationRelationItem: tenant_id = _tenant_id(principal) 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, relation_type_id=payload.relation_type_id, 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_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_unit.id, valid_from=payload.valid_from, valid_until=payload.valid_until, is_active=payload.is_active, settings=payload.settings, ) session.add(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) def update_relation( item_id: str, payload: RelationUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), ) -> 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_unit = _validated_relation_parts( session, tenant_id, structure_id=structure_id, relation_type_id=relation_type_id, source_unit_id=source_unit_id, target_unit_id=target_unit_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_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)) 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) def create_function_type( payload: FunctionTypeCreateRequest, session: Session = Depends(get_session), 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) item = OrganizationFunctionType( tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, organization_unit_type_id=payload.organization_unit_type_id, delegable=payload.delegable, act_in_place_allowed=payload.act_in_place_allowed, is_active=payload.is_active, settings=payload.settings, ) session.add(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) def update_function_type( item_id: str, payload: FunctionTypeUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), ) -> 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) item.organization_unit_type_id = payload.organization_unit_type_id for field in ("delegable", "act_in_place_allowed"): if field in payload.model_fields_set: value = getattr(payload, field) if value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) 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) def create_function( payload: FunctionCreateRequest, session: Session = Depends(get_session), 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) _ensure_function_slug(session, tenant_id, unit.id, slug) delegable = payload.delegable if payload.delegable is not None else (function_type.delegable if function_type else False) act_in_place_allowed = payload.act_in_place_allowed if payload.act_in_place_allowed is not None else (function_type.act_in_place_allowed if function_type else False) item = OrganizationFunction( tenant_id=tenant_id, function_type_id=payload.function_type_id, organization_unit_id=unit.id, slug=slug, name=payload.name.strip(), description=payload.description, delegable=delegable, act_in_place_allowed=act_in_place_allowed, is_active=payload.is_active, settings=payload.settings, ) session.add(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) def update_function( item_id: str, payload: FunctionUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope(*ORG_FUNCTION_WRITE_SCOPES)), ) -> 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: raise _invalid("Organization unit is required.") unit = _get_tenant_row(session, OrganizationUnit, payload.organization_unit_id, tenant_id, "Organization unit") _ensure_function_slug(session, tenant_id, unit.id, item.slug, exclude_id=item.id) item.organization_unit_id = unit.id if "function_type_id" in payload.model_fields_set: _optional_function_type(session, tenant_id, payload.function_type_id) item.function_type_id = payload.function_type_id for field in ("delegable", "act_in_place_allowed"): if field in payload.model_fields_set: value = getattr(payload, field) if value is None: raise _invalid(f"{field} cannot be empty.") setattr(item, field, value) 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 def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None: if function_type_id is None: return None return _get_tenant_row(session, OrganizationFunctionType, function_type_id, tenant_id, "Organization function type") def _ensure_function_slug(session: Session, tenant_id: str, organization_unit_id: str, slug: str, *, exclude_id: str | None = None) -> None: query = session.query(OrganizationFunction).filter( OrganizationFunction.tenant_id == tenant_id, OrganizationFunction.organization_unit_id == organization_unit_id, OrganizationFunction.slug == slug, ) if exclude_id is not None: query = query.filter(OrganizationFunction.id != exclude_id) if query.count(): raise _conflict(f"Function slug already exists in this organization unit: {slug}") def _apply_slugged_update_for_function(session: Session, item: OrganizationFunction, payload: FunctionUpdateRequest, tenant_id: str) -> None: fields = payload.model_fields_set if "slug" in fields: slug = _slug(payload.slug, item.name) _ensure_function_slug(session, tenant_id, item.organization_unit_id, slug, exclude_id=item.id) item.slug = slug if "name" in fields: if payload.name is None: raise _invalid("Name cannot be empty.") item.name = payload.name.strip() if "description" in fields: item.description = payload.description if "is_active" in fields: if payload.is_active is None: raise _invalid("Active state cannot be empty.") item.is_active = payload.is_active if "settings" in fields: if payload.settings is None: raise _invalid("Settings cannot be empty.") item.settings = payload.settings def _validated_relation_parts( session: Session, tenant_id: str, *, structure_id: str, relation_type_id: str, source_unit_id: str, target_unit_id: str, ) -> tuple[OrganizationStructure, OrganizationRelationType, OrganizationUnit, OrganizationUnit]: structure = _get_tenant_row(session, OrganizationStructure, structure_id, tenant_id, "Organization structure") relation_type = _get_tenant_row(session, OrganizationRelationType, relation_type_id, tenant_id, "Organization relation type") source = _get_tenant_row(session, OrganizationUnit, source_unit_id, tenant_id, "Source organization unit") target = _get_tenant_row(session, OrganizationUnit, target_unit_id, tenant_id, "Target organization unit") if relation_type.structure_id is not None and relation_type.structure_id != structure.id: raise _invalid("Relation type is bound to another structure.") if source.id == target.id: raise _invalid("A relation cannot connect an organization unit to itself.") if relation_type.source_unit_type_id is not None and source.unit_type_id != relation_type.source_unit_type_id: raise _invalid("Source organization unit type is not valid for this relation type.") if relation_type.target_unit_type_id is not None and target.unit_type_id != relation_type.target_unit_type_id: raise _invalid("Target organization unit type is not valid for this relation type.") return structure, relation_type, source, target def _validate_relation_edge( session: Session, tenant_id: str, structure: OrganizationStructure, relation_type: OrganizationRelationType, source: OrganizationUnit, target: OrganizationUnit, *, exclude_relation_id: str | None = None, ) -> None: if relation_type.is_hierarchical and not relation_type.allow_cycles: if _would_create_cycle( session, tenant_id=tenant_id, structure_id=structure.id, source_unit_id=source.id, target_unit_id=target.id, exclude_relation_id=exclude_relation_id, ): raise _invalid("This hierarchical relation would create a cycle.") def _would_create_cycle( session: Session, *, tenant_id: str, structure_id: str, source_unit_id: str, target_unit_id: str, exclude_relation_id: str | None = None, ) -> bool: pending = [target_unit_id] seen: set[str] = set() while pending: current = pending.pop() if current == source_unit_id: return True if current in seen: continue seen.add(current) query = session.query(OrganizationRelation.target_unit_id).filter( OrganizationRelation.tenant_id == tenant_id, OrganizationRelation.structure_id == structure_id, OrganizationRelation.source_unit_id == current, OrganizationRelation.is_active.is_(True), ) if exclude_relation_id is not None: query = query.filter(OrganizationRelation.id != exclude_relation_id) pending.extend(row[0] for row in query.all()) return False def _would_create_parent_cycle(session: Session, tenant_id: str, unit_id: str, parent_id: str) -> bool: current: str | None = parent_id seen: set[str] = set() while current is not None: if current == unit_id or current in seen: return True seen.add(current) current = session.query(OrganizationUnit.parent_id).filter( OrganizationUnit.tenant_id == tenant_id, OrganizationUnit.id == current, ).scalar() return False