Files
govoplan-organizations/src/govoplan_organizations/backend/api/v1/routes.py
T

1597 lines
63 KiB
Python

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