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"]