1597 lines
63 KiB
Python
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
|