feat: add versioned organization model templates

This commit is contained in:
2026-07-30 17:42:07 +02:00
parent 5ebdffc0ec
commit 326bf3f56e
12 changed files with 1629 additions and 9 deletions
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import datetime
from datetime import UTC, datetime
import re
from typing import Any, TypeVar
@@ -31,6 +31,9 @@ from govoplan_core.db.session import get_session
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionType,
OrganizationModelInstantiation,
OrganizationModelTemplate,
OrganizationModelTemplateVersion,
OrganizationRelation,
OrganizationRelationType,
OrganizationTenantSettings,
@@ -44,9 +47,16 @@ from .schemas import (
FunctionTypeCreateRequest,
FunctionTypeUpdateRequest,
FunctionUpdateRequest,
OrganizationModelInstantiationItem,
OrganizationFunctionItem,
OrganizationFunctionTypeItem,
OrganizationModelResponse,
OrganizationModelTemplateCatalogItem,
OrganizationModelTemplateCatalogResponse,
OrganizationModelTemplateCreateRequest,
OrganizationModelTemplateItem,
OrganizationModelTemplateVersionCreateRequest,
OrganizationModelTemplateVersionItem,
OrganizationRelationItem,
OrganizationRelationTypeItem,
OrganizationSettingsItem,
@@ -65,6 +75,11 @@ from .schemas import (
UnitTypeUpdateRequest,
UnitUpdateRequest,
)
from govoplan_organizations.backend.templates import (
OrganizationTemplateError,
canonical_template_definition,
instantiate_template_version,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
@@ -84,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")
@@ -409,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}
@@ -481,6 +565,183 @@ 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),
@@ -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",
@@ -132,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,
@@ -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")
@@ -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",
]