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