915 lines
32 KiB
Python
915 lines
32 KiB
Python
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,
|
|
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"
|
|
|
|
|
|
def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
|
return OrganizationUnitRef(
|
|
id=item.id,
|
|
tenant_id=item.tenant_id,
|
|
slug=item.slug,
|
|
name=item.name,
|
|
unit_type_id=item.unit_type_id,
|
|
parent_id=item.parent_id,
|
|
description=item.description,
|
|
status=_status(item.is_active), # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
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,
|
|
tenant_id=item.tenant_id,
|
|
organization_unit_id=item.organization_unit_id,
|
|
slug=item.slug,
|
|
name=item.name,
|
|
function_type_id=item.function_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 _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 self._session() as session:
|
|
rows = (
|
|
session.query(OrganizationUnit)
|
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
|
.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 self._session() as session:
|
|
item = session.get(OrganizationFunction, function_id)
|
|
return _function_ref(item) if item is not None else None
|
|
|
|
def functions_for_organization_unit(
|
|
self,
|
|
organization_unit_id: str,
|
|
*,
|
|
include_subunits: bool = False,
|
|
) -> tuple[OrganizationFunctionRef, ...]:
|
|
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]
|
|
while pending:
|
|
parent_id = pending.pop()
|
|
child_ids = [
|
|
row[0]
|
|
for row in session.query(OrganizationUnit.id)
|
|
.filter(
|
|
OrganizationUnit.tenant_id == unit.tenant_id,
|
|
OrganizationUnit.parent_id == parent_id,
|
|
)
|
|
.all()
|
|
]
|
|
for child_id in child_ids:
|
|
if child_id not in unit_ids:
|
|
unit_ids.add(child_id)
|
|
pending.append(child_id)
|
|
rows = (
|
|
session.query(OrganizationFunction)
|
|
.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 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"]
|