Compare commits
6 Commits
28f799e426
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 326bf3f56e | |||
| 5ebdffc0ec | |||
| 84ca4f39ae | |||
| 58857654e9 | |||
| 00212ea331 | |||
| 35aebe8759 |
@@ -42,8 +42,13 @@ requirements, audit detail, and retention behavior.
|
||||
|
||||
## Module Contract
|
||||
|
||||
The module registers the `organizations.directory` capability from
|
||||
`govoplan_core.core.organizations`.
|
||||
The module registers two capabilities from
|
||||
`govoplan_core.core.organizations`:
|
||||
|
||||
- `organizations.directory` for backward-compatible direct unit/function
|
||||
lookup;
|
||||
- `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly
|
||||
structure-scoped hierarchy and path resolution.
|
||||
|
||||
Feature modules should consume the capability instead of importing
|
||||
organization ORM models.
|
||||
|
||||
@@ -16,6 +16,31 @@ The organization model answers where responsibility lives.
|
||||
- Function: a named responsibility in an organization unit, such as clerk,
|
||||
reviewer, approver, committee secretary, intake desk, or resource manager.
|
||||
|
||||
## Governance And Templates
|
||||
|
||||
Concrete organization models are tenant-owned. Units, structures, relation
|
||||
types, relations, function types, and functions are never shared as one live
|
||||
global hierarchy across tenants.
|
||||
|
||||
The system may provide versioned organization-model templates. A tenant
|
||||
explicitly instantiates one template version and receives tenant-owned concrete
|
||||
records. The template reference and version are provenance, not a live parent
|
||||
model:
|
||||
|
||||
- tenants may run different template versions;
|
||||
- a template update never silently mutates a tenant hierarchy;
|
||||
- upgrading is an explicit diff and migration with preview, conflict
|
||||
reporting, and recorded provenance;
|
||||
- system policy may constrain permitted unit, relation, structure, and
|
||||
function types;
|
||||
- tenant administrators customize concrete records only within the effective
|
||||
policy.
|
||||
|
||||
This avoids ambiguous inheritance when institutions model responsibility
|
||||
differently. Template catalogue, instantiation, and upgrade orchestration are a
|
||||
separate implementation slice; the existing organization tables remain the
|
||||
canonical tenant-local state.
|
||||
|
||||
## Boundary With Identity And IDM
|
||||
|
||||
This module does not own login accounts, identity lifecycle, account linking,
|
||||
@@ -66,6 +91,36 @@ The close-out condition is that Access role resolution works with canonical
|
||||
Organizations plus IDM installed and still works through projection fallback
|
||||
for transition deployments.
|
||||
|
||||
## Directory Contracts
|
||||
|
||||
`organizations.directory` remains the small compatibility contract for direct
|
||||
unit and function lookup. It deliberately retains the legacy `parent_id`
|
||||
projection needed by existing Access and IDM integrations.
|
||||
|
||||
`organizations.hierarchyDirectory` is the structure-aware contract for new
|
||||
consumers. It provides:
|
||||
|
||||
- tenant-scoped unit-type and function-type references;
|
||||
- function resolution by type and an explicit set of units;
|
||||
- unit resolution by type, optionally bounded to one named structure;
|
||||
- batched ancestor, descendant, and path resolution with a required structure,
|
||||
optional relation-type filter, and bounded depth;
|
||||
- the exact structure, relation type, and relation edge for every path step;
|
||||
- explicit missing, inactive, unreachable, invalid-filter, cycle, and
|
||||
depth-limit state.
|
||||
|
||||
An edge is traversed from `source_unit_id` to `target_unit_id` for descendant
|
||||
lookups and in reverse for ancestor lookups. Consumers must select a structure;
|
||||
the provider never treats `parent_id` or one arbitrary structure as the
|
||||
institution's universal hierarchy.
|
||||
|
||||
Committed changes emit versioned `organizations.<resource>.<action>.v1`
|
||||
platform events for units, functions, their types, structures, relation types,
|
||||
and relation edges. The event payload contains schema version `1`, tenant and
|
||||
resource references, status, changed fields, and routing-relevant IDs so
|
||||
directory consumers can invalidate derived addresses without importing this
|
||||
module's models.
|
||||
|
||||
## UI Boundary
|
||||
|
||||
The organization workspace at `/organizations` is the primary module UI for
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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
|
||||
|
||||
@@ -14,10 +16,24 @@ from govoplan_core.core.configuration_control import (
|
||||
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 (
|
||||
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,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationTenantSettings,
|
||||
@@ -31,9 +47,16 @@ from .schemas import (
|
||||
FunctionTypeCreateRequest,
|
||||
FunctionTypeUpdateRequest,
|
||||
FunctionUpdateRequest,
|
||||
OrganizationModelInstantiationItem,
|
||||
OrganizationFunctionItem,
|
||||
OrganizationFunctionTypeItem,
|
||||
OrganizationModelResponse,
|
||||
OrganizationModelTemplateCatalogItem,
|
||||
OrganizationModelTemplateCatalogResponse,
|
||||
OrganizationModelTemplateCreateRequest,
|
||||
OrganizationModelTemplateItem,
|
||||
OrganizationModelTemplateVersionCreateRequest,
|
||||
OrganizationModelTemplateVersionItem,
|
||||
OrganizationRelationItem,
|
||||
OrganizationRelationTypeItem,
|
||||
OrganizationSettingsItem,
|
||||
@@ -52,9 +75,16 @@ from .schemas import (
|
||||
UnitTypeUpdateRequest,
|
||||
UnitUpdateRequest,
|
||||
)
|
||||
from govoplan_organizations.backend.templates import (
|
||||
OrganizationTemplateError,
|
||||
canonical_template_definition,
|
||||
instantiate_template_version,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000
|
||||
ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000
|
||||
|
||||
ORG_READ_SCOPES = (
|
||||
"organizations:model:read",
|
||||
@@ -69,6 +99,7 @@ ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:
|
||||
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")
|
||||
@@ -128,7 +159,29 @@ def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str
|
||||
|
||||
|
||||
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()
|
||||
@@ -137,6 +190,121 @@ def _commit(session: Session, item: ModelT) -> ModelT:
|
||||
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)
|
||||
@@ -257,6 +425,74 @@ 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 _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}
|
||||
@@ -329,21 +565,273 @@ def update_organization_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", 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)
|
||||
return OrganizationModelResponse(
|
||||
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
|
||||
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
|
||||
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
|
||||
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
|
||||
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()],
|
||||
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
|
||||
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
|
||||
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)
|
||||
|
||||
@@ -3,11 +3,181 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
||||
AuditDetailLevel = Literal["summary", "standard", "full"]
|
||||
TemplateVersionStatus = Literal["draft", "published", "retired"]
|
||||
|
||||
|
||||
class OrganizationTemplateBaseDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str = Field(
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||
)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OrganizationTemplateUnitTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||
pass
|
||||
|
||||
|
||||
class OrganizationTemplateStructureDefinition(OrganizationTemplateBaseDefinition):
|
||||
structure_kind: StructureKind = "hierarchy"
|
||||
|
||||
|
||||
class OrganizationTemplateRelationTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||
structure_slug: str | None = Field(default=None, max_length=100)
|
||||
source_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||
target_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||
is_hierarchical: bool = True
|
||||
allow_cycles: bool = False
|
||||
|
||||
|
||||
class OrganizationTemplateUnitDefinition(OrganizationTemplateBaseDefinition):
|
||||
unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||
parent_slug: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
class OrganizationTemplateRelationDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
structure_slug: str = Field(min_length=1, max_length=100)
|
||||
relation_type_slug: str = Field(min_length=1, max_length=100)
|
||||
source_unit_slug: str = Field(min_length=1, max_length=100)
|
||||
target_unit_slug: str = Field(min_length=1, max_length=100)
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OrganizationTemplateFunctionTypeDefinition(OrganizationTemplateBaseDefinition):
|
||||
organization_unit_type_slug: str | None = Field(default=None, max_length=100)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
|
||||
|
||||
class OrganizationTemplateFunctionDefinition(OrganizationTemplateBaseDefinition):
|
||||
function_type_slug: str | None = Field(default=None, max_length=100)
|
||||
organization_unit_slug: str = Field(min_length=1, max_length=100)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
|
||||
|
||||
class OrganizationModelTemplateDefinition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
unit_types: list[OrganizationTemplateUnitTypeDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
structures: list[OrganizationTemplateStructureDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
relation_types: list[OrganizationTemplateRelationTypeDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
units: list[OrganizationTemplateUnitDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
relations: list[OrganizationTemplateRelationDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
function_types: list[OrganizationTemplateFunctionTypeDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
functions: list[OrganizationTemplateFunctionDefinition] = Field(
|
||||
default_factory=list,
|
||||
max_length=5000,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationModelTemplateCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str = Field(
|
||||
min_length=1,
|
||||
max_length=100,
|
||||
pattern=r"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||
)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OrganizationModelTemplateVersionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version: str = Field(
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._+-]*$",
|
||||
)
|
||||
definition: OrganizationModelTemplateDefinition
|
||||
release_notes: str | None = None
|
||||
|
||||
|
||||
class OrganizationModelTemplateItem(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
settings: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationModelTemplateVersionItem(BaseModel):
|
||||
id: str
|
||||
template_id: str
|
||||
version: str
|
||||
schema_version: int
|
||||
status: TemplateVersionStatus
|
||||
definition: OrganizationModelTemplateDefinition
|
||||
definition_sha256: str
|
||||
release_notes: str | None = None
|
||||
published_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationModelTemplateCatalogItem(OrganizationModelTemplateItem):
|
||||
versions: list[OrganizationModelTemplateVersionItem] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class OrganizationModelTemplateCatalogResponse(BaseModel):
|
||||
templates: list[OrganizationModelTemplateCatalogItem] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class OrganizationModelInstantiationItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
template_id: str
|
||||
template_version_id: str
|
||||
source_definition_sha256: str
|
||||
status: str
|
||||
object_counts: dict[str, int]
|
||||
provenance: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationSettingsItem(BaseModel):
|
||||
|
||||
@@ -55,6 +55,107 @@ class OrganizationTenantSettings(Base, TimestampMixin):
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class OrganizationModelTemplate(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_model_templates"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
slug: Mapped[str] = mapped_column(
|
||||
String(100),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class OrganizationModelTemplateVersion(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_model_template_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"template_id",
|
||||
"version",
|
||||
name="uq_organizations_model_template_versions",
|
||||
),
|
||||
Index(
|
||||
"ix_org_model_template_versions_template_status",
|
||||
"template_id",
|
||||
"status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations_model_templates.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
version: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
schema_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="draft", nullable=False)
|
||||
definition: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
release_notes: Mapped[str | None] = mapped_column(Text)
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
published_by_account_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
|
||||
|
||||
class OrganizationModelInstantiation(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_model_instantiations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"template_version_id",
|
||||
name="uq_organizations_model_instantiation_version",
|
||||
),
|
||||
Index(
|
||||
"ix_organizations_model_instantiations_tenant",
|
||||
"tenant_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations_model_templates.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
template_version_id: Mapped[str] = mapped_column(
|
||||
ForeignKey(
|
||||
"organizations_model_template_versions.id",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_definition_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="applied",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
instantiated_by_account_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
index=True,
|
||||
)
|
||||
object_counts: Mapped[dict[str, int]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationStructure(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_structures"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
||||
@@ -153,6 +254,9 @@ class OrganizationFunction(Base, TimestampMixin):
|
||||
__all__ = [
|
||||
"OrganizationFunction",
|
||||
"OrganizationFunctionType",
|
||||
"OrganizationModelInstantiation",
|
||||
"OrganizationModelTemplate",
|
||||
"OrganizationModelTemplateVersion",
|
||||
"OrganizationRelation",
|
||||
"OrganizationRelationType",
|
||||
"OrganizationStructure",
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.organizations import (
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionRef,
|
||||
OrganizationFunctionTypeRef,
|
||||
OrganizationFunctionTypeResolution,
|
||||
OrganizationHierarchyCatalogRef,
|
||||
OrganizationHierarchyDirection,
|
||||
OrganizationHierarchyDirectory,
|
||||
OrganizationHierarchyEdgeRef,
|
||||
OrganizationHierarchyMatchRef,
|
||||
OrganizationHierarchyPathResolution,
|
||||
OrganizationHierarchyResolution,
|
||||
OrganizationRelationTypeRef,
|
||||
OrganizationResolutionStatus,
|
||||
OrganizationStructureRef,
|
||||
OrganizationUnitRef,
|
||||
OrganizationUnitTypeRef,
|
||||
OrganizationUnitTypeResolution,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
MAX_DIRECTORY_BATCH = 500
|
||||
MAX_HIERARCHY_DEPTH = 100
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
return "active" if active else "inactive"
|
||||
|
||||
@@ -29,6 +61,17 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
||||
)
|
||||
|
||||
|
||||
def _unit_type_ref(item: OrganizationUnitType) -> OrganizationUnitTypeRef:
|
||||
return OrganizationUnitTypeRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||
return OrganizationFunctionRef(
|
||||
id=item.id,
|
||||
@@ -44,24 +87,143 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||
)
|
||||
|
||||
|
||||
class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
with get_database().session() as session:
|
||||
def _function_type_ref(
|
||||
item: OrganizationFunctionType,
|
||||
) -> OrganizationFunctionTypeRef:
|
||||
return OrganizationFunctionTypeRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
organization_unit_type_id=item.organization_unit_type_id,
|
||||
description=item.description,
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _structure_ref(item: OrganizationStructure) -> OrganizationStructureRef:
|
||||
return OrganizationStructureRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
structure_kind=item.structure_kind,
|
||||
description=item.description,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _relation_type_ref(
|
||||
item: OrganizationRelationType,
|
||||
) -> OrganizationRelationTypeRef:
|
||||
return OrganizationRelationTypeRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
structure_id=item.structure_id,
|
||||
source_unit_type_id=item.source_unit_type_id,
|
||||
target_unit_type_id=item.target_unit_type_id,
|
||||
is_hierarchical=item.is_hierarchical,
|
||||
allow_cycles=item.allow_cycles,
|
||||
description=item.description,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _unique_ids(values: Sequence[str], *, label: str) -> tuple[str, ...]:
|
||||
result = tuple(dict.fromkeys(str(value).strip() for value in values))
|
||||
if any(not value for value in result):
|
||||
raise ValueError(f"{label} must not contain empty IDs.")
|
||||
if len(result) > MAX_DIRECTORY_BATCH:
|
||||
raise ValueError(
|
||||
f"{label} is limited to {MAX_DIRECTORY_BATCH} entries."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _validated_direction(
|
||||
value: OrganizationHierarchyDirection,
|
||||
) -> OrganizationHierarchyDirection:
|
||||
if value not in {"ancestors", "descendants"}:
|
||||
raise ValueError(
|
||||
"Hierarchy direction must be ancestors or descendants."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validated_depth(value: int) -> int:
|
||||
depth = int(value)
|
||||
if depth < 1 or depth > MAX_HIERARCHY_DEPTH:
|
||||
raise ValueError(
|
||||
f"Hierarchy depth must be between 1 and {MAX_HIERARCHY_DEPTH}."
|
||||
)
|
||||
return depth
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _HierarchyGraph:
|
||||
status: OrganizationResolutionStatus
|
||||
structure_id: str
|
||||
structure: OrganizationStructureRef | None
|
||||
relation_type_ids: tuple[str, ...]
|
||||
units: dict[str, OrganizationUnitRef]
|
||||
outgoing: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||
incoming: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||
diagnostics: tuple[str, ...]
|
||||
|
||||
|
||||
class SqlOrganizationDirectory(
|
||||
OrganizationDirectory,
|
||||
OrganizationHierarchyDirectory,
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: Callable[[], Session] | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@contextmanager
|
||||
def _session(self) -> Iterator[Session]:
|
||||
if self._session_factory is None:
|
||||
with get_database().session() as session:
|
||||
yield session
|
||||
return
|
||||
session = self._session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
) -> OrganizationUnitRef | None:
|
||||
with self._session() as session:
|
||||
item = session.get(OrganizationUnit, organization_unit_id)
|
||||
return _unit_ref(item) if item is not None else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
||||
with get_database().session() as session:
|
||||
def organization_units_for_tenant(
|
||||
self,
|
||||
tenant_id: str,
|
||||
) -> tuple[OrganizationUnitRef, ...]:
|
||||
with self._session() as session:
|
||||
rows = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.order_by(OrganizationUnit.name.asc(), OrganizationUnit.id)
|
||||
.all()
|
||||
)
|
||||
return tuple(_unit_ref(item) for item in rows)
|
||||
|
||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||
with get_database().session() as session:
|
||||
def get_function(
|
||||
self,
|
||||
function_id: str,
|
||||
) -> OrganizationFunctionRef | None:
|
||||
with self._session() as session:
|
||||
item = session.get(OrganizationFunction, function_id)
|
||||
return _function_ref(item) if item is not None else None
|
||||
|
||||
@@ -71,7 +233,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> tuple[OrganizationFunctionRef, ...]:
|
||||
with get_database().session() as session:
|
||||
with self._session() as session:
|
||||
unit = session.get(OrganizationUnit, organization_unit_id)
|
||||
if unit is None:
|
||||
return ()
|
||||
unit_ids = {organization_unit_id}
|
||||
if include_subunits:
|
||||
pending = [organization_unit_id]
|
||||
@@ -80,7 +245,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
child_ids = [
|
||||
row[0]
|
||||
for row in session.query(OrganizationUnit.id)
|
||||
.filter(OrganizationUnit.parent_id == parent_id)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == unit.tenant_id,
|
||||
OrganizationUnit.parent_id == parent_id,
|
||||
)
|
||||
.all()
|
||||
]
|
||||
for child_id in child_ids:
|
||||
@@ -89,8 +257,688 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
||||
pending.append(child_id)
|
||||
rows = (
|
||||
session.query(OrganizationFunction)
|
||||
.filter(OrganizationFunction.organization_unit_id.in_(unit_ids))
|
||||
.order_by(OrganizationFunction.name.asc())
|
||||
.filter(
|
||||
OrganizationFunction.tenant_id == unit.tenant_id,
|
||||
OrganizationFunction.organization_unit_id.in_(unit_ids),
|
||||
)
|
||||
.order_by(
|
||||
OrganizationFunction.name.asc(),
|
||||
OrganizationFunction.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return tuple(_function_ref(item) for item in rows)
|
||||
|
||||
def get_unit_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_type_id: str,
|
||||
) -> OrganizationUnitTypeRef | None:
|
||||
with self._session() as session:
|
||||
item = session.get(OrganizationUnitType, unit_type_id)
|
||||
if item is None or item.tenant_id != tenant_id:
|
||||
return None
|
||||
return _unit_type_ref(item)
|
||||
|
||||
def hierarchy_catalog(
|
||||
self,
|
||||
tenant_id: str,
|
||||
) -> OrganizationHierarchyCatalogRef:
|
||||
with self._session() as session:
|
||||
structures = (
|
||||
session.query(OrganizationStructure)
|
||||
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||
.order_by(OrganizationStructure.name, OrganizationStructure.id)
|
||||
.all()
|
||||
)
|
||||
relation_types = (
|
||||
session.query(OrganizationRelationType)
|
||||
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||
.order_by(
|
||||
OrganizationRelationType.structure_id,
|
||||
OrganizationRelationType.name,
|
||||
OrganizationRelationType.id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return OrganizationHierarchyCatalogRef(
|
||||
tenant_id=tenant_id,
|
||||
structures=tuple(_structure_ref(item) for item in structures),
|
||||
relation_types=tuple(
|
||||
_relation_type_ref(item) for item in relation_types
|
||||
),
|
||||
)
|
||||
|
||||
def get_function_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
function_type_id: str,
|
||||
) -> OrganizationFunctionTypeRef | None:
|
||||
with self._session() as session:
|
||||
item = session.get(OrganizationFunctionType, function_type_id)
|
||||
if item is None or item.tenant_id != tenant_id:
|
||||
return None
|
||||
return _function_type_ref(item)
|
||||
|
||||
def resolve_functions_by_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
function_type_id: str,
|
||||
*,
|
||||
organization_unit_ids: Sequence[str] = (),
|
||||
) -> OrganizationFunctionTypeResolution:
|
||||
requested_ids = _unique_ids(
|
||||
organization_unit_ids,
|
||||
label="Organization unit scope",
|
||||
)
|
||||
with self._session() as session:
|
||||
function_type = session.get(
|
||||
OrganizationFunctionType,
|
||||
function_type_id,
|
||||
)
|
||||
if (
|
||||
function_type is None
|
||||
or function_type.tenant_id != tenant_id
|
||||
):
|
||||
return OrganizationFunctionTypeResolution(
|
||||
tenant_id=tenant_id,
|
||||
function_type_id=function_type_id,
|
||||
requested_unit_ids=requested_ids,
|
||||
status="missing",
|
||||
diagnostics=("function_type_missing",),
|
||||
)
|
||||
units: dict[str, OrganizationUnit] = {}
|
||||
if requested_ids:
|
||||
units = {
|
||||
item.id: item
|
||||
for item in session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == tenant_id,
|
||||
OrganizationUnit.id.in_(requested_ids),
|
||||
)
|
||||
.all()
|
||||
}
|
||||
query = session.query(OrganizationFunction).filter(
|
||||
OrganizationFunction.tenant_id == tenant_id,
|
||||
OrganizationFunction.function_type_id == function_type_id,
|
||||
)
|
||||
if requested_ids:
|
||||
query = query.filter(
|
||||
OrganizationFunction.organization_unit_id.in_(
|
||||
tuple(units)
|
||||
)
|
||||
)
|
||||
rows = query.order_by(
|
||||
OrganizationFunction.organization_unit_id,
|
||||
OrganizationFunction.name,
|
||||
OrganizationFunction.id,
|
||||
).all()
|
||||
relevant_unit_ids = (
|
||||
requested_ids
|
||||
if requested_ids
|
||||
else tuple(
|
||||
dict.fromkeys(
|
||||
item.organization_unit_id for item in rows
|
||||
)
|
||||
)
|
||||
)
|
||||
if not requested_ids and relevant_unit_ids:
|
||||
units = {
|
||||
item.id: item
|
||||
for item in session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == tenant_id,
|
||||
OrganizationUnit.id.in_(relevant_unit_ids),
|
||||
)
|
||||
.all()
|
||||
}
|
||||
missing = tuple(
|
||||
item_id
|
||||
for item_id in relevant_unit_ids
|
||||
if item_id not in units
|
||||
)
|
||||
inactive = tuple(
|
||||
item_id
|
||||
for item_id in relevant_unit_ids
|
||||
if item_id in units and not units[item_id].is_active
|
||||
)
|
||||
diagnostics = []
|
||||
if missing:
|
||||
diagnostics.append("organization_units_missing")
|
||||
if inactive:
|
||||
diagnostics.append("organization_units_inactive")
|
||||
if not rows:
|
||||
diagnostics.append("no_matching_functions")
|
||||
return OrganizationFunctionTypeResolution(
|
||||
tenant_id=tenant_id,
|
||||
function_type_id=function_type_id,
|
||||
requested_unit_ids=requested_ids,
|
||||
status=(
|
||||
"active" if function_type.is_active else "inactive"
|
||||
),
|
||||
function_type=_function_type_ref(function_type),
|
||||
matches=tuple(_function_ref(item) for item in rows),
|
||||
missing_unit_ids=missing,
|
||||
inactive_unit_ids=inactive,
|
||||
diagnostics=tuple(diagnostics),
|
||||
)
|
||||
|
||||
def resolve_units_by_type(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_type_id: str,
|
||||
*,
|
||||
structure_id: str | None = None,
|
||||
root_unit_id: str | None = None,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "descendants",
|
||||
max_depth: int = 10,
|
||||
) -> OrganizationUnitTypeResolution:
|
||||
relation_ids = _unique_ids(
|
||||
relation_type_ids,
|
||||
label="Relation type filter",
|
||||
)
|
||||
direction = _validated_direction(direction)
|
||||
depth = _validated_depth(max_depth)
|
||||
if bool(structure_id) != bool(root_unit_id):
|
||||
return OrganizationUnitTypeResolution(
|
||||
tenant_id=tenant_id,
|
||||
unit_type_id=unit_type_id,
|
||||
status="invalid",
|
||||
structure_id=structure_id,
|
||||
root_unit_id=root_unit_id,
|
||||
diagnostics=(
|
||||
"structure_and_root_must_be_supplied_together",
|
||||
),
|
||||
)
|
||||
with self._session() as session:
|
||||
unit_type = session.get(OrganizationUnitType, unit_type_id)
|
||||
if unit_type is None or unit_type.tenant_id != tenant_id:
|
||||
return OrganizationUnitTypeResolution(
|
||||
tenant_id=tenant_id,
|
||||
unit_type_id=unit_type_id,
|
||||
status="missing",
|
||||
structure_id=structure_id,
|
||||
root_unit_id=root_unit_id,
|
||||
diagnostics=("unit_type_missing",),
|
||||
)
|
||||
rows = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == tenant_id,
|
||||
OrganizationUnit.unit_type_id == unit_type_id,
|
||||
)
|
||||
.order_by(OrganizationUnit.name, OrganizationUnit.id)
|
||||
.all()
|
||||
)
|
||||
diagnostics: tuple[str, ...] = ()
|
||||
status: OrganizationResolutionStatus = (
|
||||
"active" if unit_type.is_active else "inactive"
|
||||
)
|
||||
if structure_id and root_unit_id:
|
||||
hierarchy = self.resolve_hierarchy_relatives(
|
||||
tenant_id,
|
||||
(root_unit_id,),
|
||||
structure_id=structure_id,
|
||||
relation_type_ids=relation_ids,
|
||||
direction=direction,
|
||||
max_depth=depth,
|
||||
)[0]
|
||||
scope_ids = {
|
||||
root_unit_id,
|
||||
*(match.unit.id for match in hierarchy.matches),
|
||||
}
|
||||
rows = [item for item in rows if item.id in scope_ids]
|
||||
diagnostics = hierarchy.diagnostics
|
||||
if hierarchy.status in {"missing", "invalid"}:
|
||||
status = hierarchy.status
|
||||
elif hierarchy.status == "inactive" and status == "active":
|
||||
status = "inactive"
|
||||
return OrganizationUnitTypeResolution(
|
||||
tenant_id=tenant_id,
|
||||
unit_type_id=unit_type_id,
|
||||
status=status,
|
||||
unit_type=_unit_type_ref(unit_type),
|
||||
structure_id=structure_id,
|
||||
root_unit_id=root_unit_id,
|
||||
matches=tuple(_unit_ref(item) for item in rows),
|
||||
diagnostics=diagnostics,
|
||||
)
|
||||
|
||||
def resolve_hierarchy_relatives(
|
||||
self,
|
||||
tenant_id: str,
|
||||
organization_unit_ids: Sequence[str],
|
||||
*,
|
||||
structure_id: str,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "ancestors",
|
||||
max_depth: int = 10,
|
||||
) -> tuple[OrganizationHierarchyResolution, ...]:
|
||||
root_ids = _unique_ids(
|
||||
organization_unit_ids,
|
||||
label="Organization hierarchy roots",
|
||||
)
|
||||
direction = _validated_direction(direction)
|
||||
depth = _validated_depth(max_depth)
|
||||
relation_ids = _unique_ids(
|
||||
relation_type_ids,
|
||||
label="Relation type filter",
|
||||
)
|
||||
with self._session() as session:
|
||||
graph = self._hierarchy_graph(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
structure_id=structure_id,
|
||||
relation_type_ids=relation_ids,
|
||||
required_unit_ids=root_ids,
|
||||
)
|
||||
return tuple(
|
||||
self._relative_resolution(
|
||||
graph,
|
||||
tenant_id=tenant_id,
|
||||
root_unit_id=root_id,
|
||||
direction=direction,
|
||||
max_depth=depth,
|
||||
)
|
||||
for root_id in root_ids
|
||||
)
|
||||
|
||||
def resolve_hierarchy_paths(
|
||||
self,
|
||||
tenant_id: str,
|
||||
unit_pairs: Sequence[tuple[str, str]],
|
||||
*,
|
||||
structure_id: str,
|
||||
relation_type_ids: Sequence[str] = (),
|
||||
direction: OrganizationHierarchyDirection = "descendants",
|
||||
max_depth: int = 10,
|
||||
) -> tuple[OrganizationHierarchyPathResolution, ...]:
|
||||
if len(unit_pairs) > MAX_DIRECTORY_BATCH:
|
||||
raise ValueError(
|
||||
f"Organization path batches are limited to "
|
||||
f"{MAX_DIRECTORY_BATCH} entries."
|
||||
)
|
||||
pairs = tuple(
|
||||
(str(source).strip(), str(target).strip())
|
||||
for source, target in unit_pairs
|
||||
)
|
||||
if any(not source or not target for source, target in pairs):
|
||||
raise ValueError("Organization paths require source and target IDs.")
|
||||
direction = _validated_direction(direction)
|
||||
depth = _validated_depth(max_depth)
|
||||
relation_ids = _unique_ids(
|
||||
relation_type_ids,
|
||||
label="Relation type filter",
|
||||
)
|
||||
required_ids = tuple(
|
||||
dict.fromkeys(
|
||||
item
|
||||
for pair in pairs
|
||||
for item in pair
|
||||
)
|
||||
)
|
||||
with self._session() as session:
|
||||
graph = self._hierarchy_graph(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
structure_id=structure_id,
|
||||
relation_type_ids=relation_ids,
|
||||
required_unit_ids=required_ids,
|
||||
)
|
||||
cache: dict[str, OrganizationHierarchyResolution] = {}
|
||||
results: list[OrganizationHierarchyPathResolution] = []
|
||||
for source_id, target_id in pairs:
|
||||
if source_id not in cache:
|
||||
cache[source_id] = self._relative_resolution(
|
||||
graph,
|
||||
tenant_id=tenant_id,
|
||||
root_unit_id=source_id,
|
||||
direction=direction,
|
||||
max_depth=depth,
|
||||
)
|
||||
relative = cache[source_id]
|
||||
target = graph.units.get(target_id)
|
||||
match = next(
|
||||
(
|
||||
item
|
||||
for item in relative.matches
|
||||
if item.unit.id == target_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if source_id == target_id and relative.root is not None:
|
||||
status: OrganizationResolutionStatus = (
|
||||
"active"
|
||||
if relative.root.status == "active"
|
||||
else "inactive"
|
||||
)
|
||||
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
|
||||
elif relative.root is None or target is None:
|
||||
status = "missing"
|
||||
path = ()
|
||||
elif match is None:
|
||||
status = (
|
||||
"invalid"
|
||||
if relative.status == "invalid"
|
||||
else "unreachable"
|
||||
)
|
||||
path = ()
|
||||
else:
|
||||
status = (
|
||||
"active"
|
||||
if (
|
||||
relative.root.status == "active"
|
||||
and target.status == "active"
|
||||
)
|
||||
else "inactive"
|
||||
)
|
||||
path = match.path
|
||||
results.append(
|
||||
OrganizationHierarchyPathResolution(
|
||||
tenant_id=tenant_id,
|
||||
source_unit_id=source_id,
|
||||
target_unit_id=target_id,
|
||||
direction=direction,
|
||||
structure_id=structure_id,
|
||||
relation_type_ids=graph.relation_type_ids,
|
||||
max_depth=depth,
|
||||
status=status,
|
||||
source=relative.root,
|
||||
target=target,
|
||||
path=path,
|
||||
cycle_detected=relative.cycle_detected,
|
||||
depth_limited=relative.depth_limited,
|
||||
diagnostics=relative.diagnostics,
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
def _hierarchy_graph(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
structure_id: str,
|
||||
relation_type_ids: tuple[str, ...],
|
||||
required_unit_ids: tuple[str, ...],
|
||||
) -> _HierarchyGraph:
|
||||
structure = session.get(OrganizationStructure, structure_id)
|
||||
if structure is None or structure.tenant_id != tenant_id:
|
||||
return _HierarchyGraph(
|
||||
status="missing",
|
||||
structure_id=structure_id,
|
||||
structure=None,
|
||||
relation_type_ids=relation_type_ids,
|
||||
units={},
|
||||
outgoing={},
|
||||
incoming={},
|
||||
diagnostics=("structure_missing",),
|
||||
)
|
||||
relation_query = session.query(OrganizationRelationType).filter(
|
||||
OrganizationRelationType.tenant_id == tenant_id,
|
||||
OrganizationRelationType.is_hierarchical.is_(True),
|
||||
or_(
|
||||
OrganizationRelationType.structure_id.is_(None),
|
||||
OrganizationRelationType.structure_id == structure_id,
|
||||
),
|
||||
)
|
||||
if relation_type_ids:
|
||||
relation_query = relation_query.filter(
|
||||
OrganizationRelationType.id.in_(relation_type_ids)
|
||||
)
|
||||
relation_types = relation_query.all()
|
||||
relation_type_by_id = {
|
||||
item.id: item
|
||||
for item in relation_types
|
||||
if item.is_active
|
||||
}
|
||||
diagnostics: list[str] = []
|
||||
invalid_relation_filter = False
|
||||
if relation_type_ids:
|
||||
missing_relation_ids = tuple(
|
||||
item
|
||||
for item in relation_type_ids
|
||||
if item not in {row.id for row in relation_types}
|
||||
)
|
||||
inactive_relation_ids = tuple(
|
||||
item.id for item in relation_types if not item.is_active
|
||||
)
|
||||
if missing_relation_ids:
|
||||
diagnostics.append("relation_types_missing_or_invalid")
|
||||
invalid_relation_filter = True
|
||||
if inactive_relation_ids:
|
||||
diagnostics.append("relation_types_inactive")
|
||||
invalid_relation_filter = True
|
||||
selected_ids = tuple(sorted(relation_type_by_id))
|
||||
now = datetime.now(timezone.utc)
|
||||
relations = []
|
||||
if selected_ids:
|
||||
relations = (
|
||||
session.query(OrganizationRelation)
|
||||
.filter(
|
||||
OrganizationRelation.tenant_id == tenant_id,
|
||||
OrganizationRelation.structure_id == structure_id,
|
||||
OrganizationRelation.relation_type_id.in_(selected_ids),
|
||||
OrganizationRelation.is_active.is_(True),
|
||||
)
|
||||
.order_by(OrganizationRelation.id)
|
||||
.all()
|
||||
)
|
||||
relations = [
|
||||
item
|
||||
for item in relations
|
||||
if (
|
||||
(
|
||||
item.valid_from is None
|
||||
or _utc_datetime(item.valid_from) <= now
|
||||
)
|
||||
and (
|
||||
item.valid_until is None
|
||||
or _utc_datetime(item.valid_until) > now
|
||||
)
|
||||
)
|
||||
]
|
||||
unit_ids = {
|
||||
*required_unit_ids,
|
||||
*(item.source_unit_id for item in relations),
|
||||
*(item.target_unit_id for item in relations),
|
||||
}
|
||||
units = {
|
||||
item.id: _unit_ref(item)
|
||||
for item in session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == tenant_id,
|
||||
OrganizationUnit.id.in_(unit_ids),
|
||||
)
|
||||
.all()
|
||||
} if unit_ids else {}
|
||||
structure_ref = _structure_ref(structure)
|
||||
outgoing_lists: dict[
|
||||
str,
|
||||
list[OrganizationHierarchyEdgeRef],
|
||||
] = {}
|
||||
incoming_lists: dict[
|
||||
str,
|
||||
list[OrganizationHierarchyEdgeRef],
|
||||
] = {}
|
||||
for relation in relations:
|
||||
relation_type = relation_type_by_id[relation.relation_type_id]
|
||||
if (
|
||||
relation.source_unit_id not in units
|
||||
or relation.target_unit_id not in units
|
||||
):
|
||||
diagnostics.append(
|
||||
f"relation_unit_missing:{relation.id}"
|
||||
)
|
||||
continue
|
||||
edge = OrganizationHierarchyEdgeRef(
|
||||
id=relation.id,
|
||||
tenant_id=tenant_id,
|
||||
structure=structure_ref,
|
||||
relation_type=_relation_type_ref(relation_type),
|
||||
source_unit_id=relation.source_unit_id,
|
||||
target_unit_id=relation.target_unit_id,
|
||||
valid_from=relation.valid_from,
|
||||
valid_until=relation.valid_until,
|
||||
)
|
||||
outgoing_lists.setdefault(edge.source_unit_id, []).append(edge)
|
||||
incoming_lists.setdefault(edge.target_unit_id, []).append(edge)
|
||||
return _HierarchyGraph(
|
||||
status=(
|
||||
"inactive"
|
||||
if not structure.is_active
|
||||
else "invalid"
|
||||
if invalid_relation_filter
|
||||
else "active"
|
||||
),
|
||||
structure_id=structure_id,
|
||||
structure=structure_ref,
|
||||
relation_type_ids=selected_ids,
|
||||
units=units,
|
||||
outgoing={
|
||||
key: tuple(sorted(value, key=lambda item: item.id))
|
||||
for key, value in outgoing_lists.items()
|
||||
},
|
||||
incoming={
|
||||
key: tuple(sorted(value, key=lambda item: item.id))
|
||||
for key, value in incoming_lists.items()
|
||||
},
|
||||
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||
)
|
||||
|
||||
def _relative_resolution(
|
||||
self,
|
||||
graph: _HierarchyGraph,
|
||||
*,
|
||||
tenant_id: str,
|
||||
root_unit_id: str,
|
||||
direction: OrganizationHierarchyDirection,
|
||||
max_depth: int,
|
||||
) -> OrganizationHierarchyResolution:
|
||||
root = graph.units.get(root_unit_id)
|
||||
if root is None:
|
||||
return OrganizationHierarchyResolution(
|
||||
tenant_id=tenant_id,
|
||||
root_unit_id=root_unit_id,
|
||||
direction=direction,
|
||||
structure_id=(
|
||||
graph.structure_id
|
||||
),
|
||||
relation_type_ids=graph.relation_type_ids,
|
||||
max_depth=max_depth,
|
||||
status="missing",
|
||||
diagnostics=tuple(
|
||||
dict.fromkeys(
|
||||
(*graph.diagnostics, "root_unit_missing")
|
||||
)
|
||||
),
|
||||
)
|
||||
if graph.status in {"missing", "invalid"}:
|
||||
return OrganizationHierarchyResolution(
|
||||
tenant_id=tenant_id,
|
||||
root_unit_id=root_unit_id,
|
||||
direction=direction,
|
||||
structure_id=(
|
||||
graph.structure_id
|
||||
),
|
||||
relation_type_ids=graph.relation_type_ids,
|
||||
max_depth=max_depth,
|
||||
status=graph.status,
|
||||
root=root,
|
||||
diagnostics=graph.diagnostics,
|
||||
)
|
||||
adjacency = (
|
||||
graph.incoming
|
||||
if direction == "ancestors"
|
||||
else graph.outgoing
|
||||
)
|
||||
pending = deque(
|
||||
[(root_unit_id, 0, (), frozenset({root_unit_id}))]
|
||||
)
|
||||
best_depth = {root_unit_id: 0}
|
||||
matches: dict[str, OrganizationHierarchyMatchRef] = {}
|
||||
diagnostics = list(graph.diagnostics)
|
||||
cycle_detected = False
|
||||
depth_limited = False
|
||||
while pending:
|
||||
current_id, current_depth, path, path_units = pending.popleft()
|
||||
edges = adjacency.get(current_id, ())
|
||||
if current_depth >= max_depth:
|
||||
if edges:
|
||||
depth_limited = True
|
||||
continue
|
||||
for edge in edges:
|
||||
next_id = (
|
||||
edge.source_unit_id
|
||||
if direction == "ancestors"
|
||||
else edge.target_unit_id
|
||||
)
|
||||
if next_id in path_units:
|
||||
cycle_detected = True
|
||||
diagnostics.append(f"cycle_detected:{edge.id}")
|
||||
continue
|
||||
unit = graph.units.get(next_id)
|
||||
if unit is None:
|
||||
diagnostics.append(f"unit_missing:{next_id}")
|
||||
continue
|
||||
next_depth = current_depth + 1
|
||||
previous_depth = best_depth.get(next_id)
|
||||
if (
|
||||
previous_depth is not None
|
||||
and previous_depth <= next_depth
|
||||
):
|
||||
continue
|
||||
next_path = (*path, edge)
|
||||
best_depth[next_id] = next_depth
|
||||
matches[next_id] = OrganizationHierarchyMatchRef(
|
||||
unit=unit,
|
||||
depth=next_depth,
|
||||
path=next_path,
|
||||
)
|
||||
pending.append(
|
||||
(
|
||||
next_id,
|
||||
next_depth,
|
||||
next_path,
|
||||
path_units | {next_id},
|
||||
)
|
||||
)
|
||||
if depth_limited:
|
||||
diagnostics.append("maximum_depth_reached")
|
||||
return OrganizationHierarchyResolution(
|
||||
tenant_id=tenant_id,
|
||||
root_unit_id=root_unit_id,
|
||||
direction=direction,
|
||||
structure_id=graph.structure_id,
|
||||
relation_type_ids=graph.relation_type_ids,
|
||||
max_depth=max_depth,
|
||||
status=(
|
||||
"active"
|
||||
if root.status == "active" and graph.status == "active"
|
||||
else "inactive"
|
||||
),
|
||||
root=root,
|
||||
matches=tuple(
|
||||
sorted(
|
||||
matches.values(),
|
||||
key=lambda item: (
|
||||
item.depth,
|
||||
item.unit.name.casefold(),
|
||||
item.unit.id,
|
||||
),
|
||||
)
|
||||
),
|
||||
cycle_detected=cycle_detected,
|
||||
depth_limited=depth_limited,
|
||||
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||
)
|
||||
|
||||
|
||||
def _utc_datetime(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
__all__ = ["SqlOrganizationDirectory"]
|
||||
|
||||
@@ -10,12 +10,17 @@ from govoplan_core.core.modules import (
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.organizations import (
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||
|
||||
@@ -89,6 +94,16 @@ manifest = ModuleManifest(
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(
|
||||
name="organizations.directory",
|
||||
version="0.1.0",
|
||||
),
|
||||
ModuleInterfaceProvider(
|
||||
name="organizations.hierarchy_directory",
|
||||
version="0.1.0",
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
@@ -98,6 +113,15 @@ manifest = ModuleManifest(
|
||||
package_name="@govoplan/organizations-webui",
|
||||
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="organizations.admin.tenant",
|
||||
module_id="organizations",
|
||||
kind="section",
|
||||
label="Organizations administration",
|
||||
order=85,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="organizations",
|
||||
@@ -108,6 +132,9 @@ manifest = ModuleManifest(
|
||||
persistent_table_uninstall_guard(
|
||||
organization_models.OrganizationUnitType,
|
||||
organization_models.OrganizationTenantSettings,
|
||||
organization_models.OrganizationModelTemplate,
|
||||
organization_models.OrganizationModelTemplateVersion,
|
||||
organization_models.OrganizationModelInstantiation,
|
||||
organization_models.OrganizationStructure,
|
||||
organization_models.OrganizationRelationType,
|
||||
organization_models.OrganizationRelation,
|
||||
@@ -119,6 +146,9 @@ manifest = ModuleManifest(
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (
|
||||
_organization_directory
|
||||
),
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""organization model templates
|
||||
|
||||
Revision ID: 7e8f9a0b1c2d
|
||||
Revises: 6d7e8f9a0b1c
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
revision = "7e8f9a0b1c2d"
|
||||
down_revision = "6d7e8f9a0b1c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_release = import_module(
|
||||
"govoplan_organizations.backend.migrations.versions."
|
||||
"7e8f9a0b1c2d_organization_model_templates"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_release.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_release.downgrade()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""organization model templates
|
||||
|
||||
Revision ID: 7e8f9a0b1c2d
|
||||
Revises: 6d7e8f9a0b1c
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "7e8f9a0b1c2d"
|
||||
down_revision = "6d7e8f9a0b1c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"organizations_model_templates",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_organizations_model_templates"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"slug",
|
||||
name=op.f("uq_organizations_model_templates_slug"),
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_templates_slug"),
|
||||
"organizations_model_templates",
|
||||
["slug"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_templates_created_by_account_id"),
|
||||
"organizations_model_templates",
|
||||
["created_by_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"organizations_model_template_versions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("version", sa.String(length=50), nullable=False),
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("definition", sa.JSON(), nullable=False),
|
||||
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("release_notes", sa.Text(), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("published_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["organizations_model_templates.id"],
|
||||
name=op.f(
|
||||
"fk_organizations_model_template_versions_template_id_organizations_model_templates"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_organizations_model_template_versions"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"template_id",
|
||||
"version",
|
||||
name="uq_organizations_model_template_versions",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_template_versions_template_id"),
|
||||
"organizations_model_template_versions",
|
||||
["template_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f(
|
||||
"ix_organizations_model_template_versions_published_by_account_id"
|
||||
),
|
||||
"organizations_model_template_versions",
|
||||
["published_by_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_org_model_template_versions_template_status",
|
||||
"organizations_model_template_versions",
|
||||
["template_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"organizations_model_instantiations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("template_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_definition_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("instantiated_by_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("object_counts", sa.JSON(), nullable=False),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_id"],
|
||||
["organizations_model_templates.id"],
|
||||
name=op.f(
|
||||
"fk_organizations_model_instantiations_template_id_organizations_model_templates"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["template_version_id"],
|
||||
["organizations_model_template_versions.id"],
|
||||
name=op.f(
|
||||
"fk_organizations_model_instantiations_template_version_id_organizations_model_template_versions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_organizations_model_instantiations"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"template_version_id",
|
||||
name="uq_organizations_model_instantiation_version",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_tenant_id"),
|
||||
"organizations_model_instantiations",
|
||||
["tenant_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_template_id"),
|
||||
"organizations_model_instantiations",
|
||||
["template_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_template_version_id"),
|
||||
"organizations_model_instantiations",
|
||||
["template_version_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_organizations_model_instantiations_status"),
|
||||
"organizations_model_instantiations",
|
||||
["status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f(
|
||||
"ix_organizations_model_instantiations_instantiated_by_account_id"
|
||||
),
|
||||
"organizations_model_instantiations",
|
||||
["instantiated_by_account_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_organizations_model_instantiations_tenant",
|
||||
"organizations_model_instantiations",
|
||||
["tenant_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("organizations_model_instantiations")
|
||||
op.drop_table("organizations_model_template_versions")
|
||||
op.drop_table("organizations_model_templates")
|
||||
442
src/govoplan_organizations/backend/templates.py
Normal file
442
src/govoplan_organizations/backend/templates.py
Normal file
@@ -0,0 +1,442 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
OrganizationModelTemplateDefinition,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelTemplate,
|
||||
OrganizationModelTemplateVersion,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationTemplateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
TENANT_MODEL_TYPES = (
|
||||
OrganizationRelation,
|
||||
OrganizationFunction,
|
||||
OrganizationUnit,
|
||||
OrganizationRelationType,
|
||||
OrganizationFunctionType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
|
||||
|
||||
def canonical_template_definition(
|
||||
definition: OrganizationModelTemplateDefinition,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
_validate_definition_references(definition)
|
||||
payload = definition.model_dump(mode="json")
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return payload, hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def instantiate_template_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
template: OrganizationModelTemplate,
|
||||
version: OrganizationModelTemplateVersion,
|
||||
actor_account_id: str | None,
|
||||
) -> OrganizationModelInstantiation:
|
||||
if version.template_id != template.id or version.status != "published":
|
||||
raise OrganizationTemplateError(
|
||||
"Only a published version of the selected template can be instantiated"
|
||||
)
|
||||
if any(
|
||||
session.query(model).filter(model.tenant_id == tenant_id).first()
|
||||
is not None
|
||||
for model in TENANT_MODEL_TYPES
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
"Organization templates can currently be instantiated only into an empty tenant model"
|
||||
)
|
||||
existing = (
|
||||
session.query(OrganizationModelInstantiation)
|
||||
.filter(
|
||||
OrganizationModelInstantiation.tenant_id == tenant_id,
|
||||
OrganizationModelInstantiation.template_version_id == version.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
definition = OrganizationModelTemplateDefinition.model_validate(
|
||||
version.definition
|
||||
)
|
||||
_validate_definition_references(definition)
|
||||
provenance_base = {
|
||||
"template_id": template.id,
|
||||
"template_slug": template.slug,
|
||||
"template_version_id": version.id,
|
||||
"template_version": version.version,
|
||||
"definition_sha256": version.definition_sha256,
|
||||
}
|
||||
|
||||
unit_types = {
|
||||
item.slug: OrganizationUnitType(
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.unit_types
|
||||
}
|
||||
structures = {
|
||||
item.slug: OrganizationStructure(
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
structure_kind=item.structure_kind,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.structures
|
||||
}
|
||||
session.add_all([*unit_types.values(), *structures.values()])
|
||||
session.flush()
|
||||
|
||||
relation_types = {
|
||||
item.slug: OrganizationRelationType(
|
||||
tenant_id=tenant_id,
|
||||
structure_id=_ref_id(structures, item.structure_slug),
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
source_unit_type_id=_ref_id(
|
||||
unit_types,
|
||||
item.source_unit_type_slug,
|
||||
),
|
||||
target_unit_type_id=_ref_id(
|
||||
unit_types,
|
||||
item.target_unit_type_slug,
|
||||
),
|
||||
is_hierarchical=item.is_hierarchical,
|
||||
allow_cycles=item.allow_cycles,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.relation_types
|
||||
}
|
||||
function_types = {
|
||||
item.slug: OrganizationFunctionType(
|
||||
tenant_id=tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
organization_unit_type_id=_ref_id(
|
||||
unit_types,
|
||||
item.organization_unit_type_slug,
|
||||
),
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.function_types
|
||||
}
|
||||
session.add_all([*relation_types.values(), *function_types.values()])
|
||||
session.flush()
|
||||
|
||||
units = {
|
||||
item.slug: OrganizationUnit(
|
||||
tenant_id=tenant_id,
|
||||
unit_type_id=_ref_id(unit_types, item.unit_type_slug),
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.units
|
||||
}
|
||||
session.add_all(list(units.values()))
|
||||
session.flush()
|
||||
for item in definition.units:
|
||||
units[item.slug].parent_id = _ref_id(units, item.parent_slug)
|
||||
|
||||
relations = [
|
||||
OrganizationRelation(
|
||||
tenant_id=tenant_id,
|
||||
structure_id=structures[item.structure_slug].id,
|
||||
relation_type_id=relation_types[item.relation_type_slug].id,
|
||||
source_unit_id=units[item.source_unit_slug].id,
|
||||
target_unit_id=units[item.target_unit_slug].id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(
|
||||
item.settings,
|
||||
provenance_base,
|
||||
(
|
||||
f"{item.structure_slug}:{item.relation_type_slug}:"
|
||||
f"{item.source_unit_slug}:{item.target_unit_slug}"
|
||||
),
|
||||
),
|
||||
)
|
||||
for item in definition.relations
|
||||
]
|
||||
functions = [
|
||||
OrganizationFunction(
|
||||
tenant_id=tenant_id,
|
||||
function_type_id=_ref_id(
|
||||
function_types,
|
||||
item.function_type_slug,
|
||||
),
|
||||
organization_unit_id=units[item.organization_unit_slug].id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
is_active=item.is_active,
|
||||
settings=_settings(item.settings, provenance_base, item.slug),
|
||||
)
|
||||
for item in definition.functions
|
||||
]
|
||||
session.add_all([*relations, *functions])
|
||||
|
||||
counts = {
|
||||
"unit_types": len(unit_types),
|
||||
"structures": len(structures),
|
||||
"relation_types": len(relation_types),
|
||||
"units": len(units),
|
||||
"relations": len(relations),
|
||||
"function_types": len(function_types),
|
||||
"functions": len(functions),
|
||||
}
|
||||
instantiation = OrganizationModelInstantiation(
|
||||
tenant_id=tenant_id,
|
||||
template_id=template.id,
|
||||
template_version_id=version.id,
|
||||
source_definition_sha256=version.definition_sha256,
|
||||
instantiated_by_account_id=actor_account_id,
|
||||
object_counts=counts,
|
||||
provenance={
|
||||
**provenance_base,
|
||||
"copy_semantics": "tenant_owned_no_live_inheritance",
|
||||
},
|
||||
)
|
||||
session.add(instantiation)
|
||||
session.flush()
|
||||
return instantiation
|
||||
|
||||
|
||||
def _validate_definition_references(
|
||||
definition: OrganizationModelTemplateDefinition,
|
||||
) -> None:
|
||||
collections = {
|
||||
"unit type": [item.slug for item in definition.unit_types],
|
||||
"structure": [item.slug for item in definition.structures],
|
||||
"relation type": [item.slug for item in definition.relation_types],
|
||||
"unit": [item.slug for item in definition.units],
|
||||
"function type": [item.slug for item in definition.function_types],
|
||||
"function": [item.slug for item in definition.functions],
|
||||
}
|
||||
for label, slugs in collections.items():
|
||||
if len(slugs) != len(set(slugs)):
|
||||
raise OrganizationTemplateError(
|
||||
f"Template contains duplicate {label} slugs"
|
||||
)
|
||||
unit_types = set(collections["unit type"])
|
||||
structures = set(collections["structure"])
|
||||
relation_types = set(collections["relation type"])
|
||||
units = set(collections["unit"])
|
||||
function_types = set(collections["function type"])
|
||||
unit_by_slug = {item.slug: item for item in definition.units}
|
||||
relation_type_by_slug = {
|
||||
item.slug: item for item in definition.relation_types
|
||||
}
|
||||
function_type_by_slug = {
|
||||
item.slug: item for item in definition.function_types
|
||||
}
|
||||
for item in definition.relation_types:
|
||||
_require_ref(structures, item.structure_slug, "structure")
|
||||
_require_ref(unit_types, item.source_unit_type_slug, "source unit type")
|
||||
_require_ref(unit_types, item.target_unit_type_slug, "target unit type")
|
||||
for item in definition.units:
|
||||
_require_ref(unit_types, item.unit_type_slug, "unit type")
|
||||
_require_ref(units, item.parent_slug, "parent unit")
|
||||
if item.parent_slug == item.slug:
|
||||
raise OrganizationTemplateError("A unit cannot be its own parent")
|
||||
_require_acyclic_graph(
|
||||
{
|
||||
item.slug: {item.parent_slug}
|
||||
for item in definition.units
|
||||
if item.parent_slug is not None
|
||||
},
|
||||
label="unit parent hierarchy",
|
||||
)
|
||||
relation_edges: dict[str, dict[str, set[str]]] = {}
|
||||
relation_keys: set[tuple[str, str, str, str]] = set()
|
||||
for item in definition.relations:
|
||||
_require_ref(structures, item.structure_slug, "structure")
|
||||
_require_ref(relation_types, item.relation_type_slug, "relation type")
|
||||
_require_ref(units, item.source_unit_slug, "source unit")
|
||||
_require_ref(units, item.target_unit_slug, "target unit")
|
||||
if (
|
||||
item.valid_from is not None
|
||||
and item.valid_until is not None
|
||||
and item.valid_until < item.valid_from
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
"A relation validity end cannot precede its start"
|
||||
)
|
||||
relation_type = relation_type_by_slug[item.relation_type_slug]
|
||||
if (
|
||||
relation_type.structure_slug is not None
|
||||
and relation_type.structure_slug != item.structure_slug
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Relation {item.relation_type_slug!r} belongs to structure "
|
||||
f"{relation_type.structure_slug!r}, not {item.structure_slug!r}"
|
||||
)
|
||||
source_unit_type = unit_by_slug[item.source_unit_slug].unit_type_slug
|
||||
target_unit_type = unit_by_slug[item.target_unit_slug].unit_type_slug
|
||||
if (
|
||||
relation_type.source_unit_type_slug is not None
|
||||
and relation_type.source_unit_type_slug != source_unit_type
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Relation {item.relation_type_slug!r} does not allow source "
|
||||
f"unit {item.source_unit_slug!r}"
|
||||
)
|
||||
if (
|
||||
relation_type.target_unit_type_slug is not None
|
||||
and relation_type.target_unit_type_slug != target_unit_type
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Relation {item.relation_type_slug!r} does not allow target "
|
||||
f"unit {item.target_unit_slug!r}"
|
||||
)
|
||||
relation_key = (
|
||||
item.structure_slug,
|
||||
item.relation_type_slug,
|
||||
item.source_unit_slug,
|
||||
item.target_unit_slug,
|
||||
)
|
||||
if relation_key in relation_keys:
|
||||
raise OrganizationTemplateError(
|
||||
"Template contains a duplicate organization relation"
|
||||
)
|
||||
relation_keys.add(relation_key)
|
||||
if relation_type.is_hierarchical and not relation_type.allow_cycles:
|
||||
targets = relation_edges.setdefault(item.relation_type_slug, {})
|
||||
targets.setdefault(item.source_unit_slug, set()).add(
|
||||
item.target_unit_slug
|
||||
)
|
||||
for relation_type_slug, edges in relation_edges.items():
|
||||
_require_acyclic_graph(
|
||||
edges,
|
||||
label=f"relation hierarchy {relation_type_slug!r}",
|
||||
)
|
||||
for item in definition.function_types:
|
||||
_require_ref(
|
||||
unit_types,
|
||||
item.organization_unit_type_slug,
|
||||
"organization unit type",
|
||||
)
|
||||
for item in definition.functions:
|
||||
_require_ref(function_types, item.function_type_slug, "function type")
|
||||
_require_ref(units, item.organization_unit_slug, "organization unit")
|
||||
if item.function_type_slug is None:
|
||||
continue
|
||||
expected_unit_type = function_type_by_slug[
|
||||
item.function_type_slug
|
||||
].organization_unit_type_slug
|
||||
actual_unit_type = unit_by_slug[
|
||||
item.organization_unit_slug
|
||||
].unit_type_slug
|
||||
if (
|
||||
expected_unit_type is not None
|
||||
and expected_unit_type != actual_unit_type
|
||||
):
|
||||
raise OrganizationTemplateError(
|
||||
f"Function type {item.function_type_slug!r} does not apply to "
|
||||
f"unit {item.organization_unit_slug!r}"
|
||||
)
|
||||
|
||||
|
||||
def _require_ref(
|
||||
values: set[str],
|
||||
value: str | None,
|
||||
label: str,
|
||||
) -> None:
|
||||
if value is not None and value not in values:
|
||||
raise OrganizationTemplateError(
|
||||
f"Template references an unknown {label}: {value}"
|
||||
)
|
||||
|
||||
|
||||
def _ref_id(rows: dict[str, Any], slug: str | None) -> str | None:
|
||||
return rows[slug].id if slug is not None else None
|
||||
|
||||
|
||||
def _require_acyclic_graph(
|
||||
edges: dict[str, set[str]],
|
||||
*,
|
||||
label: str,
|
||||
) -> None:
|
||||
complete: set[str] = set()
|
||||
active: set[str] = set()
|
||||
|
||||
def visit(node: str) -> None:
|
||||
if node in complete:
|
||||
return
|
||||
if node in active:
|
||||
raise OrganizationTemplateError(
|
||||
f"Template contains a cycle in {label}"
|
||||
)
|
||||
active.add(node)
|
||||
for target in edges.get(node, ()):
|
||||
visit(target)
|
||||
active.remove(node)
|
||||
complete.add(node)
|
||||
|
||||
for start in edges:
|
||||
visit(start)
|
||||
|
||||
|
||||
def _settings(
|
||||
settings: dict[str, Any],
|
||||
provenance_base: dict[str, Any],
|
||||
source_key: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**dict(settings),
|
||||
"template_provenance": {
|
||||
**provenance_base,
|
||||
"source_key": source_key,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OrganizationTemplateError",
|
||||
"canonical_template_definition",
|
||||
"instantiate_template_version",
|
||||
]
|
||||
444
tests/test_hierarchy_directory.py
Normal file
444
tests/test_hierarchy_directory.py
Normal file
@@ -0,0 +1,444 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.api.v1.routes import _commit
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
from govoplan_organizations.backend.directory import (
|
||||
SqlOrganizationDirectory,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationHierarchyDirectoryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
OrganizationUnitType.__table__,
|
||||
OrganizationStructure.__table__,
|
||||
OrganizationRelationType.__table__,
|
||||
OrganizationUnit.__table__,
|
||||
OrganizationRelation.__table__,
|
||||
OrganizationFunctionType.__table__,
|
||||
OrganizationFunction.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
self.session: Session = self.Session()
|
||||
self._seed()
|
||||
self.directory = SqlOrganizationDirectory(
|
||||
session_factory=self.Session,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
OrganizationFunction.__table__,
|
||||
OrganizationFunctionType.__table__,
|
||||
OrganizationRelation.__table__,
|
||||
OrganizationUnit.__table__,
|
||||
OrganizationRelationType.__table__,
|
||||
OrganizationStructure.__table__,
|
||||
OrganizationUnitType.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
self.unit_type = OrganizationUnitType(
|
||||
id="type-department",
|
||||
tenant_id="tenant-1",
|
||||
slug="department",
|
||||
name="Department",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.function_type = OrganizationFunctionType(
|
||||
id="type-intake",
|
||||
tenant_id="tenant-1",
|
||||
slug="intake",
|
||||
name="Intake",
|
||||
organization_unit_type_id=self.unit_type.id,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.employer = OrganizationStructure(
|
||||
id="structure-employer",
|
||||
tenant_id="tenant-1",
|
||||
slug="employer",
|
||||
name="Employer hierarchy",
|
||||
structure_kind="hierarchy",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.project = OrganizationStructure(
|
||||
id="structure-project",
|
||||
tenant_id="tenant-1",
|
||||
slug="project",
|
||||
name="Project hierarchy",
|
||||
structure_kind="hierarchy",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.employer_parent = OrganizationRelationType(
|
||||
id="relation-type-employer",
|
||||
tenant_id="tenant-1",
|
||||
structure_id=self.employer.id,
|
||||
slug="contains",
|
||||
name="Contains",
|
||||
is_hierarchical=True,
|
||||
allow_cycles=False,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.project_parent = OrganizationRelationType(
|
||||
id="relation-type-project",
|
||||
tenant_id="tenant-1",
|
||||
structure_id=self.project.id,
|
||||
slug="project-contains",
|
||||
name="Contains",
|
||||
is_hierarchical=True,
|
||||
allow_cycles=False,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.root = self._unit("unit-root", "Root")
|
||||
self.child = self._unit("unit-child", "Child")
|
||||
self.grandchild = self._unit("unit-grandchild", "Grandchild")
|
||||
self.project_root = self._unit("unit-project", "Project root")
|
||||
self.other_tenant = self._unit(
|
||||
"unit-other-tenant",
|
||||
"Other tenant",
|
||||
tenant_id="tenant-2",
|
||||
)
|
||||
self.employer_child = OrganizationRelation(
|
||||
id="edge-employer-child",
|
||||
tenant_id="tenant-1",
|
||||
structure_id=self.employer.id,
|
||||
relation_type_id=self.employer_parent.id,
|
||||
source_unit_id=self.root.id,
|
||||
target_unit_id=self.child.id,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.employer_grandchild = OrganizationRelation(
|
||||
id="edge-employer-grandchild",
|
||||
tenant_id="tenant-1",
|
||||
structure_id=self.employer.id,
|
||||
relation_type_id=self.employer_parent.id,
|
||||
source_unit_id=self.child.id,
|
||||
target_unit_id=self.grandchild.id,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.project_child = OrganizationRelation(
|
||||
id="edge-project-child",
|
||||
tenant_id="tenant-1",
|
||||
structure_id=self.project.id,
|
||||
relation_type_id=self.project_parent.id,
|
||||
source_unit_id=self.project_root.id,
|
||||
target_unit_id=self.child.id,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.function = OrganizationFunction(
|
||||
id="function-child-intake",
|
||||
tenant_id="tenant-1",
|
||||
function_type_id=self.function_type.id,
|
||||
organization_unit_id=self.child.id,
|
||||
slug="intake",
|
||||
name="Child intake",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
self.unit_type,
|
||||
self.function_type,
|
||||
self.employer,
|
||||
self.project,
|
||||
self.employer_parent,
|
||||
self.project_parent,
|
||||
self.root,
|
||||
self.child,
|
||||
self.grandchild,
|
||||
self.project_root,
|
||||
self.other_tenant,
|
||||
self.employer_child,
|
||||
self.employer_grandchild,
|
||||
self.project_child,
|
||||
self.function,
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def _unit(
|
||||
self,
|
||||
item_id: str,
|
||||
name: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
) -> OrganizationUnit:
|
||||
return OrganizationUnit(
|
||||
id=item_id,
|
||||
tenant_id=tenant_id,
|
||||
unit_type_id=(
|
||||
self.unit_type.id if tenant_id == "tenant-1" else None
|
||||
),
|
||||
slug=item_id,
|
||||
name=name,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
|
||||
def test_parallel_structures_preserve_distinct_ancestor_provenance(
|
||||
self,
|
||||
) -> None:
|
||||
employer = self.directory.resolve_hierarchy_relatives(
|
||||
"tenant-1",
|
||||
(self.child.id,),
|
||||
structure_id=self.employer.id,
|
||||
direction="ancestors",
|
||||
)[0]
|
||||
project = self.directory.resolve_hierarchy_relatives(
|
||||
"tenant-1",
|
||||
(self.child.id,),
|
||||
structure_id=self.project.id,
|
||||
direction="ancestors",
|
||||
)[0]
|
||||
|
||||
self.assertEqual([self.root.id], [item.unit.id for item in employer.matches])
|
||||
self.assertEqual(
|
||||
[self.project_root.id],
|
||||
[item.unit.id for item in project.matches],
|
||||
)
|
||||
self.assertEqual(
|
||||
self.employer.id,
|
||||
employer.matches[0].path[0].structure.id,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.project_parent.id,
|
||||
project.matches[0].path[0].relation_type.id,
|
||||
)
|
||||
|
||||
def test_hierarchy_catalog_is_tenant_scoped_and_preserves_structure_links(
|
||||
self,
|
||||
) -> None:
|
||||
catalog = self.directory.hierarchy_catalog("tenant-1")
|
||||
empty = self.directory.hierarchy_catalog("tenant-2")
|
||||
|
||||
self.assertEqual(
|
||||
{self.employer.id, self.project.id},
|
||||
{item.id for item in catalog.structures},
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
(self.employer_parent.id, self.employer.id),
|
||||
(self.project_parent.id, self.project.id),
|
||||
},
|
||||
{
|
||||
(item.id, item.structure_id)
|
||||
for item in catalog.relation_types
|
||||
},
|
||||
)
|
||||
self.assertEqual((), empty.structures)
|
||||
self.assertEqual((), empty.relation_types)
|
||||
|
||||
def test_bounded_paths_report_depth_and_cycles(self) -> None:
|
||||
bounded = self.directory.resolve_hierarchy_relatives(
|
||||
"tenant-1",
|
||||
(self.root.id,),
|
||||
structure_id=self.employer.id,
|
||||
direction="descendants",
|
||||
max_depth=1,
|
||||
)[0]
|
||||
path = self.directory.resolve_hierarchy_paths(
|
||||
"tenant-1",
|
||||
((self.child.id, self.root.id),),
|
||||
structure_id=self.employer.id,
|
||||
direction="ancestors",
|
||||
)[0]
|
||||
|
||||
self.assertEqual([self.child.id], [item.unit.id for item in bounded.matches])
|
||||
self.assertTrue(bounded.depth_limited)
|
||||
self.assertEqual("active", path.status)
|
||||
self.assertEqual(
|
||||
[self.employer_child.id],
|
||||
[edge.id for edge in path.path],
|
||||
)
|
||||
|
||||
self.employer_parent.allow_cycles = True
|
||||
self.session.add(
|
||||
OrganizationRelation(
|
||||
id="edge-cycle",
|
||||
tenant_id="tenant-1",
|
||||
structure_id=self.employer.id,
|
||||
relation_type_id=self.employer_parent.id,
|
||||
source_unit_id=self.grandchild.id,
|
||||
target_unit_id=self.root.id,
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
cycle = self.directory.resolve_hierarchy_relatives(
|
||||
"tenant-1",
|
||||
(self.root.id,),
|
||||
structure_id=self.employer.id,
|
||||
direction="descendants",
|
||||
)[0]
|
||||
self.assertTrue(cycle.cycle_detected)
|
||||
self.assertTrue(
|
||||
any(item.startswith("cycle_detected:") for item in cycle.diagnostics)
|
||||
)
|
||||
|
||||
def test_function_and_unit_type_resolution_explains_missing_state(
|
||||
self,
|
||||
) -> None:
|
||||
functions = self.directory.resolve_functions_by_type(
|
||||
"tenant-1",
|
||||
self.function_type.id,
|
||||
organization_unit_ids=(self.child.id, "missing-unit"),
|
||||
)
|
||||
missing_type = self.directory.resolve_functions_by_type(
|
||||
"tenant-1",
|
||||
"missing-type",
|
||||
organization_unit_ids=(self.child.id,),
|
||||
)
|
||||
units = self.directory.resolve_units_by_type(
|
||||
"tenant-1",
|
||||
self.unit_type.id,
|
||||
structure_id=self.employer.id,
|
||||
root_unit_id=self.root.id,
|
||||
direction="descendants",
|
||||
)
|
||||
|
||||
self.assertEqual([self.function.id], [item.id for item in functions.matches])
|
||||
self.assertEqual(("missing-unit",), functions.missing_unit_ids)
|
||||
self.assertEqual("missing", missing_type.status)
|
||||
self.assertEqual(
|
||||
{self.root.id, self.child.id, self.grandchild.id},
|
||||
{item.id for item in units.matches},
|
||||
)
|
||||
|
||||
def test_moved_deactivated_and_cross_tenant_units_are_current(
|
||||
self,
|
||||
) -> None:
|
||||
self.employer_child.source_unit_id = self.project_root.id
|
||||
self.child.is_active = False
|
||||
self.session.commit()
|
||||
|
||||
moved = self.directory.resolve_hierarchy_relatives(
|
||||
"tenant-1",
|
||||
(self.child.id,),
|
||||
structure_id=self.employer.id,
|
||||
direction="ancestors",
|
||||
)[0]
|
||||
isolated = self.directory.resolve_hierarchy_relatives(
|
||||
"tenant-1",
|
||||
(self.other_tenant.id,),
|
||||
structure_id=self.employer.id,
|
||||
direction="ancestors",
|
||||
)[0]
|
||||
functions = self.directory.resolve_functions_by_type(
|
||||
"tenant-1",
|
||||
self.function_type.id,
|
||||
organization_unit_ids=(self.child.id,),
|
||||
)
|
||||
|
||||
self.assertEqual("inactive", moved.status)
|
||||
self.assertEqual(
|
||||
[self.project_root.id],
|
||||
[item.unit.id for item in moved.matches],
|
||||
)
|
||||
self.assertEqual("missing", isolated.status)
|
||||
self.assertEqual((self.child.id,), functions.inactive_unit_ids)
|
||||
|
||||
|
||||
class OrganizationLifecycleEventTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_changes_emit_versioned_commit_coupled_events(self) -> None:
|
||||
events = []
|
||||
bus = EventBus()
|
||||
bus.subscribe("*", events.append)
|
||||
with event_bus_context(bus):
|
||||
unit_type = OrganizationUnitType(
|
||||
tenant_id="tenant-1",
|
||||
slug="department",
|
||||
name="Department",
|
||||
is_active=True,
|
||||
settings={},
|
||||
)
|
||||
self.session.add(unit_type)
|
||||
_commit(self.session, unit_type)
|
||||
unit_type.name = "Office"
|
||||
_commit(self.session, unit_type)
|
||||
unit_type.is_active = False
|
||||
_commit(self.session, unit_type)
|
||||
|
||||
organization_events = [
|
||||
event
|
||||
for event in events
|
||||
if event.module_id == "organizations"
|
||||
]
|
||||
self.assertEqual(
|
||||
[
|
||||
"organizations.unit_type.created.v1",
|
||||
"organizations.unit_type.updated.v1",
|
||||
"organizations.unit_type.deactivated.v1",
|
||||
],
|
||||
[event.type for event in organization_events],
|
||||
)
|
||||
self.assertTrue(unit_type.id)
|
||||
self.assertEqual(
|
||||
1,
|
||||
organization_events[0].payload["schema_version"],
|
||||
)
|
||||
self.assertEqual(
|
||||
unit_type.id,
|
||||
organization_events[0].payload["resource_id"],
|
||||
)
|
||||
self.assertIn(
|
||||
"name",
|
||||
organization_events[1].payload["changed_fields"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"inactive",
|
||||
organization_events[2].payload["status"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
285
tests/test_model_templates.py
Normal file
285
tests/test_model_templates.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
OrganizationModelTemplateDefinition,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunction,
|
||||
OrganizationFunctionType,
|
||||
OrganizationModelInstantiation,
|
||||
OrganizationModelTemplate,
|
||||
OrganizationModelTemplateVersion,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
)
|
||||
from govoplan_organizations.backend.templates import (
|
||||
OrganizationTemplateError,
|
||||
canonical_template_definition,
|
||||
instantiate_template_version,
|
||||
)
|
||||
|
||||
|
||||
TABLES = [
|
||||
OrganizationModelTemplate.__table__,
|
||||
OrganizationModelTemplateVersion.__table__,
|
||||
OrganizationUnitType.__table__,
|
||||
OrganizationStructure.__table__,
|
||||
OrganizationRelationType.__table__,
|
||||
OrganizationFunctionType.__table__,
|
||||
OrganizationUnit.__table__,
|
||||
OrganizationFunction.__table__,
|
||||
OrganizationRelation.__table__,
|
||||
OrganizationModelInstantiation.__table__,
|
||||
]
|
||||
|
||||
|
||||
class OrganizationModelTemplateTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=TABLES)
|
||||
self.session: Session = sessionmaker(
|
||||
bind=self.engine,
|
||||
expire_on_commit=False,
|
||||
)()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(self.engine, tables=reversed(TABLES))
|
||||
self.engine.dispose()
|
||||
|
||||
def test_published_template_is_copied_into_tenant_owned_records(self) -> None:
|
||||
definition, fingerprint = canonical_template_definition(_definition())
|
||||
template = OrganizationModelTemplate(
|
||||
id="template-1",
|
||||
slug="municipality",
|
||||
name="Municipality",
|
||||
)
|
||||
version = OrganizationModelTemplateVersion(
|
||||
id="template-version-1",
|
||||
template_id=template.id,
|
||||
version="1.0.0",
|
||||
status="published",
|
||||
definition=definition,
|
||||
definition_sha256=fingerprint,
|
||||
)
|
||||
self.session.add_all([template, version])
|
||||
self.session.commit()
|
||||
|
||||
result = instantiate_template_version(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
template=template,
|
||||
version=version,
|
||||
actor_account_id="account-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"unit_types": 1,
|
||||
"structures": 1,
|
||||
"relation_types": 1,
|
||||
"units": 2,
|
||||
"relations": 1,
|
||||
"function_types": 1,
|
||||
"functions": 1,
|
||||
},
|
||||
result.object_counts,
|
||||
)
|
||||
office = (
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == "tenant-1",
|
||||
OrganizationUnit.slug == "office",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
root = (
|
||||
self.session.query(OrganizationUnit)
|
||||
.filter(
|
||||
OrganizationUnit.tenant_id == "tenant-1",
|
||||
OrganizationUnit.slug == "municipality",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(root.id, office.parent_id)
|
||||
self.assertEqual(
|
||||
"tenant_owned_no_live_inheritance",
|
||||
result.provenance["copy_semantics"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"1.0.0",
|
||||
office.settings["template_provenance"]["template_version"],
|
||||
)
|
||||
|
||||
version.definition = {}
|
||||
self.session.commit()
|
||||
self.assertEqual("Office", office.name)
|
||||
|
||||
def test_instantiation_refuses_implicit_merge_into_existing_model(self) -> None:
|
||||
definition, fingerprint = canonical_template_definition(_definition())
|
||||
template = OrganizationModelTemplate(
|
||||
id="template-2",
|
||||
slug="municipality-2",
|
||||
name="Municipality",
|
||||
)
|
||||
version = OrganizationModelTemplateVersion(
|
||||
id="template-version-2",
|
||||
template_id=template.id,
|
||||
version="1",
|
||||
status="published",
|
||||
definition=definition,
|
||||
definition_sha256=fingerprint,
|
||||
)
|
||||
self.session.add_all(
|
||||
[
|
||||
template,
|
||||
version,
|
||||
OrganizationUnitType(
|
||||
tenant_id="tenant-1",
|
||||
slug="existing",
|
||||
name="Existing",
|
||||
),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"only into an empty tenant model",
|
||||
):
|
||||
instantiate_template_version(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
template=template,
|
||||
version=version,
|
||||
actor_account_id="account-1",
|
||||
)
|
||||
|
||||
def test_unknown_template_reference_is_rejected_before_storage(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.units[1].unit_type_slug = "unknown"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"unknown unit type",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_parent_cycles_are_rejected_before_storage(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.units[0].parent_slug = "office"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"cycle in unit parent hierarchy",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_relation_unit_type_mismatch_is_rejected(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.unit_types.append(
|
||||
definition.unit_types[0].model_copy(
|
||||
update={"slug": "other", "name": "Other"}
|
||||
)
|
||||
)
|
||||
definition.units[1].unit_type_slug = "other"
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"does not allow source unit",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
def test_function_unit_type_mismatch_is_rejected(self) -> None:
|
||||
definition = _definition().model_copy(deep=True)
|
||||
definition.unit_types.append(
|
||||
definition.unit_types[0].model_copy(
|
||||
update={"slug": "other", "name": "Other"}
|
||||
)
|
||||
)
|
||||
definition.units[1].unit_type_slug = "other"
|
||||
definition.relations = []
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
OrganizationTemplateError,
|
||||
"does not apply to unit",
|
||||
):
|
||||
canonical_template_definition(definition)
|
||||
|
||||
|
||||
def _definition() -> OrganizationModelTemplateDefinition:
|
||||
return OrganizationModelTemplateDefinition.model_validate(
|
||||
{
|
||||
"unit_types": [
|
||||
{
|
||||
"slug": "administrative-unit",
|
||||
"name": "Administrative unit",
|
||||
}
|
||||
],
|
||||
"structures": [
|
||||
{
|
||||
"slug": "administrative",
|
||||
"name": "Administrative hierarchy",
|
||||
}
|
||||
],
|
||||
"relation_types": [
|
||||
{
|
||||
"slug": "reports-to",
|
||||
"name": "Reports to",
|
||||
"structure_slug": "administrative",
|
||||
"source_unit_type_slug": "administrative-unit",
|
||||
"target_unit_type_slug": "administrative-unit",
|
||||
}
|
||||
],
|
||||
"units": [
|
||||
{
|
||||
"slug": "municipality",
|
||||
"name": "Municipality",
|
||||
"unit_type_slug": "administrative-unit",
|
||||
},
|
||||
{
|
||||
"slug": "office",
|
||||
"name": "Office",
|
||||
"unit_type_slug": "administrative-unit",
|
||||
"parent_slug": "municipality",
|
||||
},
|
||||
],
|
||||
"relations": [
|
||||
{
|
||||
"structure_slug": "administrative",
|
||||
"relation_type_slug": "reports-to",
|
||||
"source_unit_slug": "office",
|
||||
"target_unit_slug": "municipality",
|
||||
}
|
||||
],
|
||||
"function_types": [
|
||||
{
|
||||
"slug": "head",
|
||||
"name": "Head",
|
||||
"organization_unit_type_slug": "administrative-unit",
|
||||
}
|
||||
],
|
||||
"functions": [
|
||||
{
|
||||
"slug": "head",
|
||||
"name": "Office head",
|
||||
"function_type_slug": "head",
|
||||
"organization_unit_slug": "office",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
113
tests/test_settings_routes.py
Normal file
113
tests/test_settings_routes.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.api.v1.routes import (
|
||||
get_organization_settings,
|
||||
update_organization_settings,
|
||||
)
|
||||
from govoplan_organizations.backend.api.v1.schemas import (
|
||||
OrganizationSettingsUpdateRequest,
|
||||
)
|
||||
from govoplan_organizations.backend.db.models import OrganizationTenantSettings
|
||||
|
||||
|
||||
class OrganizationSettingsRouteTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
ChangeSequenceEntry.__table__,
|
||||
OrganizationTenantSettings.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
OrganizationTenantSettings.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
@staticmethod
|
||||
def _principal(tenant_id: str) -> object:
|
||||
return SimpleNamespace(tenant_id=tenant_id)
|
||||
|
||||
def test_missing_settings_row_returns_tenant_defaults(self) -> None:
|
||||
result = get_organization_settings(
|
||||
session=self.session,
|
||||
principal=self._principal("tenant-1"),
|
||||
)
|
||||
|
||||
self.assertEqual(result.tenant_id, "tenant-1")
|
||||
self.assertTrue(result.allow_tenant_model_customization)
|
||||
self.assertFalse(result.require_model_change_requests)
|
||||
self.assertEqual(result.audit_detail_level, "standard")
|
||||
self.assertIsNone(result.change_retention_days)
|
||||
self.assertEqual(result.settings, {})
|
||||
self.assertEqual(self.session.query(OrganizationTenantSettings).count(), 0)
|
||||
|
||||
def test_first_update_creates_only_the_active_tenant_settings(self) -> None:
|
||||
result = update_organization_settings(
|
||||
OrganizationSettingsUpdateRequest(
|
||||
allow_tenant_model_customization=False,
|
||||
require_model_change_requests=True,
|
||||
audit_detail_level="full",
|
||||
change_retention_days=365,
|
||||
settings={"template": {"id": "municipality", "version": "2"}},
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal("tenant-1"),
|
||||
)
|
||||
|
||||
self.assertEqual(result.tenant_id, "tenant-1")
|
||||
self.assertFalse(result.allow_tenant_model_customization)
|
||||
self.assertTrue(result.require_model_change_requests)
|
||||
self.assertEqual(result.change_retention_days, 365)
|
||||
self.assertEqual(
|
||||
result.settings,
|
||||
{"template": {"id": "municipality", "version": "2"}},
|
||||
)
|
||||
self.assertIsNone(
|
||||
self.session.query(OrganizationTenantSettings)
|
||||
.filter(OrganizationTenantSettings.tenant_id == "tenant-2")
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
def test_settings_reads_are_tenant_isolated(self) -> None:
|
||||
self.session.add(
|
||||
OrganizationTenantSettings(
|
||||
tenant_id="tenant-1",
|
||||
allow_tenant_model_customization=False,
|
||||
require_model_change_requests=True,
|
||||
audit_detail_level="detailed",
|
||||
settings={"private": "tenant-1"},
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
other = get_organization_settings(
|
||||
session=self.session,
|
||||
principal=self._principal("tenant-2"),
|
||||
)
|
||||
|
||||
self.assertEqual(other.tenant_id, "tenant-2")
|
||||
self.assertTrue(other.allow_tenant_model_customization)
|
||||
self.assertEqual(other.settings, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,7 +22,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-router-dom": ">=7.18.2 <8",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
|
||||
@@ -18,8 +18,11 @@ import {
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
hasScope,
|
||||
isViewSurfaceVisible,
|
||||
useEffectiveView,
|
||||
useUnsavedDraftGuard,
|
||||
usePlatformUiCapabilities,
|
||||
useViewSurfaces,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn,
|
||||
@@ -378,6 +381,8 @@ export default function OrganizationsPage({
|
||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||
const [changeRequestId, setChangeRequestId] = useState("");
|
||||
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
|
||||
const canWriteModel = hasScope(auth, "organizations:model:write");
|
||||
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
||||
@@ -393,8 +398,11 @@ export default function OrganizationsPage({
|
||||
() => functionActionCapabilities
|
||||
.flatMap((capability) => capability.actions)
|
||||
.filter((contribution) => contributionVisible(auth, contribution))
|
||||
.filter((contribution) =>
|
||||
isViewSurfaceVisible(effectiveView, contribution.surfaceId, viewSurfaces)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[auth, functionActionCapabilities]
|
||||
[auth, effectiveView, functionActionCapabilities, viewSurfaces]
|
||||
);
|
||||
const unitsByParentId = useMemo(() => {
|
||||
const mapped = new Map<string, OrganizationUnitItem[]>();
|
||||
@@ -863,7 +871,7 @@ export default function OrganizationsPage({
|
||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
|
||||
];
|
||||
|
||||
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
|
||||
@@ -871,7 +879,7 @@ export default function OrganizationsPage({
|
||||
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
|
||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
|
||||
];
|
||||
|
||||
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
|
||||
@@ -881,7 +889,7 @@ export default function OrganizationsPage({
|
||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
|
||||
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
|
||||
];
|
||||
|
||||
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
|
||||
@@ -890,7 +898,7 @@ export default function OrganizationsPage({
|
||||
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
|
||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
|
||||
];
|
||||
|
||||
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
|
||||
@@ -899,7 +907,7 @@ export default function OrganizationsPage({
|
||||
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
|
||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
|
||||
];
|
||||
|
||||
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
|
||||
@@ -908,7 +916,7 @@ export default function OrganizationsPage({
|
||||
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
|
||||
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
|
||||
{ id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
|
||||
];
|
||||
|
||||
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
|
||||
@@ -919,7 +927,7 @@ export default function OrganizationsPage({
|
||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-organizations.actions.c3cd636a",
|
||||
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||
width: 88 + (functionActionContributions.length * 40),
|
||||
sticky: "end",
|
||||
resizable: false,
|
||||
|
||||
@@ -23,6 +23,9 @@ const organizationAdminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "tenant-organization-settings",
|
||||
moduleId: "organizations",
|
||||
kind: "settings",
|
||||
surfaceId: "organizations.admin.tenant",
|
||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||
group: "TENANT",
|
||||
order: 85,
|
||||
@@ -45,6 +48,15 @@ export const organizationsModule: PlatformWebModule = {
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["admin"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "organizations.admin.tenant",
|
||||
moduleId: "organizations",
|
||||
kind: "section",
|
||||
label: "Organizations administration",
|
||||
order: 85
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/organizations",
|
||||
|
||||
Reference in New Issue
Block a user