diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md
index a1fe56d..c478e2b 100644
--- a/docs/ORGANIZATION_MODEL.md
+++ b/docs/ORGANIZATION_MODEL.md
@@ -16,6 +16,31 @@ The organization model answers where responsibility lives.
- Function: a named responsibility in an organization unit, such as clerk,
reviewer, approver, committee secretary, intake desk, or resource manager.
+## Governance And Templates
+
+Concrete organization models are tenant-owned. Units, structures, relation
+types, relations, function types, and functions are never shared as one live
+global hierarchy across tenants.
+
+The system may provide versioned organization-model templates. A tenant
+explicitly instantiates one template version and receives tenant-owned concrete
+records. The template reference and version are provenance, not a live parent
+model:
+
+- tenants may run different template versions;
+- a template update never silently mutates a tenant hierarchy;
+- upgrading is an explicit diff and migration with preview, conflict
+ reporting, and recorded provenance;
+- system policy may constrain permitted unit, relation, structure, and
+ function types;
+- tenant administrators customize concrete records only within the effective
+ policy.
+
+This avoids ambiguous inheritance when institutions model responsibility
+differently. Template catalogue, instantiation, and upgrade orchestration are a
+separate implementation slice; the existing organization tables remain the
+canonical tenant-local state.
+
## Boundary With Identity And IDM
This module does not own login accounts, identity lifecycle, account linking,
diff --git a/src/govoplan_organizations/backend/api/v1/routes.py b/src/govoplan_organizations/backend/api/v1/routes.py
index 2459e07..ded65dc 100644
--- a/src/govoplan_organizations/backend/api/v1/routes.py
+++ b/src/govoplan_organizations/backend/api/v1/routes.py
@@ -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),
diff --git a/src/govoplan_organizations/backend/api/v1/schemas.py b/src/govoplan_organizations/backend/api/v1/schemas.py
index 1cacfa8..79661f7 100644
--- a/src/govoplan_organizations/backend/api/v1/schemas.py
+++ b/src/govoplan_organizations/backend/api/v1/schemas.py
@@ -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):
diff --git a/src/govoplan_organizations/backend/db/models.py b/src/govoplan_organizations/backend/db/models.py
index 012927f..904f55a 100644
--- a/src/govoplan_organizations/backend/db/models.py
+++ b/src/govoplan_organizations/backend/db/models.py
@@ -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",
diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py
index e5941b4..e42234f 100644
--- a/src/govoplan_organizations/backend/manifest.py
+++ b/src/govoplan_organizations/backend/manifest.py
@@ -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,
diff --git a/src/govoplan_organizations/backend/migrations/dev_versions/7e8f9a0b1c2d_organization_model_templates.py b/src/govoplan_organizations/backend/migrations/dev_versions/7e8f9a0b1c2d_organization_model_templates.py
new file mode 100644
index 0000000..489a0e0
--- /dev/null
+++ b/src/govoplan_organizations/backend/migrations/dev_versions/7e8f9a0b1c2d_organization_model_templates.py
@@ -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()
diff --git a/src/govoplan_organizations/backend/migrations/versions/7e8f9a0b1c2d_organization_model_templates.py b/src/govoplan_organizations/backend/migrations/versions/7e8f9a0b1c2d_organization_model_templates.py
new file mode 100644
index 0000000..a7a62f2
--- /dev/null
+++ b/src/govoplan_organizations/backend/migrations/versions/7e8f9a0b1c2d_organization_model_templates.py
@@ -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")
diff --git a/src/govoplan_organizations/backend/templates.py b/src/govoplan_organizations/backend/templates.py
new file mode 100644
index 0000000..8218ae5
--- /dev/null
+++ b/src/govoplan_organizations/backend/templates.py
@@ -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",
+]
diff --git a/tests/test_model_templates.py b/tests/test_model_templates.py
new file mode 100644
index 0000000..80efdbb
--- /dev/null
+++ b/tests/test_model_templates.py
@@ -0,0 +1,285 @@
+from __future__ import annotations
+
+import unittest
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from govoplan_core.db.base import Base
+from govoplan_organizations.backend.api.v1.schemas import (
+ OrganizationModelTemplateDefinition,
+)
+from govoplan_organizations.backend.db.models import (
+ OrganizationFunction,
+ OrganizationFunctionType,
+ OrganizationModelInstantiation,
+ OrganizationModelTemplate,
+ OrganizationModelTemplateVersion,
+ OrganizationRelation,
+ OrganizationRelationType,
+ OrganizationStructure,
+ OrganizationUnit,
+ OrganizationUnitType,
+)
+from govoplan_organizations.backend.templates import (
+ OrganizationTemplateError,
+ canonical_template_definition,
+ instantiate_template_version,
+)
+
+
+TABLES = [
+ OrganizationModelTemplate.__table__,
+ OrganizationModelTemplateVersion.__table__,
+ OrganizationUnitType.__table__,
+ OrganizationStructure.__table__,
+ OrganizationRelationType.__table__,
+ OrganizationFunctionType.__table__,
+ OrganizationUnit.__table__,
+ OrganizationFunction.__table__,
+ OrganizationRelation.__table__,
+ OrganizationModelInstantiation.__table__,
+]
+
+
+class OrganizationModelTemplateTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(self.engine, tables=TABLES)
+ self.session: Session = sessionmaker(
+ bind=self.engine,
+ expire_on_commit=False,
+ )()
+
+ def tearDown(self) -> None:
+ self.session.close()
+ Base.metadata.drop_all(self.engine, tables=reversed(TABLES))
+ self.engine.dispose()
+
+ def test_published_template_is_copied_into_tenant_owned_records(self) -> None:
+ definition, fingerprint = canonical_template_definition(_definition())
+ template = OrganizationModelTemplate(
+ id="template-1",
+ slug="municipality",
+ name="Municipality",
+ )
+ version = OrganizationModelTemplateVersion(
+ id="template-version-1",
+ template_id=template.id,
+ version="1.0.0",
+ status="published",
+ definition=definition,
+ definition_sha256=fingerprint,
+ )
+ self.session.add_all([template, version])
+ self.session.commit()
+
+ result = instantiate_template_version(
+ self.session,
+ tenant_id="tenant-1",
+ template=template,
+ version=version,
+ actor_account_id="account-1",
+ )
+ self.session.commit()
+
+ self.assertEqual(
+ {
+ "unit_types": 1,
+ "structures": 1,
+ "relation_types": 1,
+ "units": 2,
+ "relations": 1,
+ "function_types": 1,
+ "functions": 1,
+ },
+ result.object_counts,
+ )
+ office = (
+ self.session.query(OrganizationUnit)
+ .filter(
+ OrganizationUnit.tenant_id == "tenant-1",
+ OrganizationUnit.slug == "office",
+ )
+ .one()
+ )
+ root = (
+ self.session.query(OrganizationUnit)
+ .filter(
+ OrganizationUnit.tenant_id == "tenant-1",
+ OrganizationUnit.slug == "municipality",
+ )
+ .one()
+ )
+ self.assertEqual(root.id, office.parent_id)
+ self.assertEqual(
+ "tenant_owned_no_live_inheritance",
+ result.provenance["copy_semantics"],
+ )
+ self.assertEqual(
+ "1.0.0",
+ office.settings["template_provenance"]["template_version"],
+ )
+
+ version.definition = {}
+ self.session.commit()
+ self.assertEqual("Office", office.name)
+
+ def test_instantiation_refuses_implicit_merge_into_existing_model(self) -> None:
+ definition, fingerprint = canonical_template_definition(_definition())
+ template = OrganizationModelTemplate(
+ id="template-2",
+ slug="municipality-2",
+ name="Municipality",
+ )
+ version = OrganizationModelTemplateVersion(
+ id="template-version-2",
+ template_id=template.id,
+ version="1",
+ status="published",
+ definition=definition,
+ definition_sha256=fingerprint,
+ )
+ self.session.add_all(
+ [
+ template,
+ version,
+ OrganizationUnitType(
+ tenant_id="tenant-1",
+ slug="existing",
+ name="Existing",
+ ),
+ ]
+ )
+ self.session.commit()
+
+ with self.assertRaisesRegex(
+ OrganizationTemplateError,
+ "only into an empty tenant model",
+ ):
+ instantiate_template_version(
+ self.session,
+ tenant_id="tenant-1",
+ template=template,
+ version=version,
+ actor_account_id="account-1",
+ )
+
+ def test_unknown_template_reference_is_rejected_before_storage(self) -> None:
+ definition = _definition().model_copy(deep=True)
+ definition.units[1].unit_type_slug = "unknown"
+
+ with self.assertRaisesRegex(
+ OrganizationTemplateError,
+ "unknown unit type",
+ ):
+ canonical_template_definition(definition)
+
+ def test_parent_cycles_are_rejected_before_storage(self) -> None:
+ definition = _definition().model_copy(deep=True)
+ definition.units[0].parent_slug = "office"
+
+ with self.assertRaisesRegex(
+ OrganizationTemplateError,
+ "cycle in unit parent hierarchy",
+ ):
+ canonical_template_definition(definition)
+
+ def test_relation_unit_type_mismatch_is_rejected(self) -> None:
+ definition = _definition().model_copy(deep=True)
+ definition.unit_types.append(
+ definition.unit_types[0].model_copy(
+ update={"slug": "other", "name": "Other"}
+ )
+ )
+ definition.units[1].unit_type_slug = "other"
+
+ with self.assertRaisesRegex(
+ OrganizationTemplateError,
+ "does not allow source unit",
+ ):
+ canonical_template_definition(definition)
+
+ def test_function_unit_type_mismatch_is_rejected(self) -> None:
+ definition = _definition().model_copy(deep=True)
+ definition.unit_types.append(
+ definition.unit_types[0].model_copy(
+ update={"slug": "other", "name": "Other"}
+ )
+ )
+ definition.units[1].unit_type_slug = "other"
+ definition.relations = []
+
+ with self.assertRaisesRegex(
+ OrganizationTemplateError,
+ "does not apply to unit",
+ ):
+ canonical_template_definition(definition)
+
+
+def _definition() -> OrganizationModelTemplateDefinition:
+ return OrganizationModelTemplateDefinition.model_validate(
+ {
+ "unit_types": [
+ {
+ "slug": "administrative-unit",
+ "name": "Administrative unit",
+ }
+ ],
+ "structures": [
+ {
+ "slug": "administrative",
+ "name": "Administrative hierarchy",
+ }
+ ],
+ "relation_types": [
+ {
+ "slug": "reports-to",
+ "name": "Reports to",
+ "structure_slug": "administrative",
+ "source_unit_type_slug": "administrative-unit",
+ "target_unit_type_slug": "administrative-unit",
+ }
+ ],
+ "units": [
+ {
+ "slug": "municipality",
+ "name": "Municipality",
+ "unit_type_slug": "administrative-unit",
+ },
+ {
+ "slug": "office",
+ "name": "Office",
+ "unit_type_slug": "administrative-unit",
+ "parent_slug": "municipality",
+ },
+ ],
+ "relations": [
+ {
+ "structure_slug": "administrative",
+ "relation_type_slug": "reports-to",
+ "source_unit_slug": "office",
+ "target_unit_slug": "municipality",
+ }
+ ],
+ "function_types": [
+ {
+ "slug": "head",
+ "name": "Head",
+ "organization_unit_type_slug": "administrative-unit",
+ }
+ ],
+ "functions": [
+ {
+ "slug": "head",
+ "name": "Office head",
+ "function_type_slug": "head",
+ "organization_unit_slug": "office",
+ }
+ ],
+ }
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_settings_routes.py b/tests/test_settings_routes.py
new file mode 100644
index 0000000..15364cc
--- /dev/null
+++ b/tests/test_settings_routes.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import unittest
+from types import SimpleNamespace
+
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session, sessionmaker
+
+from govoplan_core.core.change_sequence import ChangeSequenceEntry
+from govoplan_core.db.base import Base
+from govoplan_organizations.backend.api.v1.routes import (
+ get_organization_settings,
+ update_organization_settings,
+)
+from govoplan_organizations.backend.api.v1.schemas import (
+ OrganizationSettingsUpdateRequest,
+)
+from govoplan_organizations.backend.db.models import OrganizationTenantSettings
+
+
+class OrganizationSettingsRouteTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self.engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(
+ self.engine,
+ tables=[
+ ChangeSequenceEntry.__table__,
+ OrganizationTenantSettings.__table__,
+ ],
+ )
+ self.Session = sessionmaker(bind=self.engine, expire_on_commit=False)
+ self.session: Session = self.Session()
+
+ def tearDown(self) -> None:
+ self.session.close()
+ Base.metadata.drop_all(
+ self.engine,
+ tables=[
+ OrganizationTenantSettings.__table__,
+ ChangeSequenceEntry.__table__,
+ ],
+ )
+ self.engine.dispose()
+
+ @staticmethod
+ def _principal(tenant_id: str) -> object:
+ return SimpleNamespace(tenant_id=tenant_id)
+
+ def test_missing_settings_row_returns_tenant_defaults(self) -> None:
+ result = get_organization_settings(
+ session=self.session,
+ principal=self._principal("tenant-1"),
+ )
+
+ self.assertEqual(result.tenant_id, "tenant-1")
+ self.assertTrue(result.allow_tenant_model_customization)
+ self.assertFalse(result.require_model_change_requests)
+ self.assertEqual(result.audit_detail_level, "standard")
+ self.assertIsNone(result.change_retention_days)
+ self.assertEqual(result.settings, {})
+ self.assertEqual(self.session.query(OrganizationTenantSettings).count(), 0)
+
+ def test_first_update_creates_only_the_active_tenant_settings(self) -> None:
+ result = update_organization_settings(
+ OrganizationSettingsUpdateRequest(
+ allow_tenant_model_customization=False,
+ require_model_change_requests=True,
+ audit_detail_level="full",
+ change_retention_days=365,
+ settings={"template": {"id": "municipality", "version": "2"}},
+ ),
+ session=self.session,
+ principal=self._principal("tenant-1"),
+ )
+
+ self.assertEqual(result.tenant_id, "tenant-1")
+ self.assertFalse(result.allow_tenant_model_customization)
+ self.assertTrue(result.require_model_change_requests)
+ self.assertEqual(result.change_retention_days, 365)
+ self.assertEqual(
+ result.settings,
+ {"template": {"id": "municipality", "version": "2"}},
+ )
+ self.assertIsNone(
+ self.session.query(OrganizationTenantSettings)
+ .filter(OrganizationTenantSettings.tenant_id == "tenant-2")
+ .one_or_none()
+ )
+
+ def test_settings_reads_are_tenant_isolated(self) -> None:
+ self.session.add(
+ OrganizationTenantSettings(
+ tenant_id="tenant-1",
+ allow_tenant_model_customization=False,
+ require_model_change_requests=True,
+ audit_detail_level="detailed",
+ settings={"private": "tenant-1"},
+ )
+ )
+ self.session.commit()
+
+ other = get_organization_settings(
+ session=self.session,
+ principal=self._principal("tenant-2"),
+ )
+
+ self.assertEqual(other.tenant_id, "tenant-2")
+ self.assertTrue(other.allow_tenant_model_customization)
+ self.assertEqual(other.settings, {})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/webui/src/features/organizations/OrganizationsPage.tsx b/webui/src/features/organizations/OrganizationsPage.tsx
index 61ee437..566efe1 100644
--- a/webui/src/features/organizations/OrganizationsPage.tsx
+++ b/webui/src/features/organizations/OrganizationsPage.tsx
@@ -871,7 +871,7 @@ export default function OrganizationsPage({
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
- { id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
];
const structureColumns: DataGridColumn[] = [
@@ -879,7 +879,7 @@ export default function OrganizationsPage({
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
];
const relationTypeColumns: DataGridColumn[] = [
@@ -889,7 +889,7 @@ export default function OrganizationsPage({
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
];
const unitColumns: DataGridColumn[] = [
@@ -898,7 +898,7 @@ export default function OrganizationsPage({
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
];
const relationColumns: DataGridColumn[] = [
@@ -907,7 +907,7 @@ export default function OrganizationsPage({
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
];
const functionTypeColumns: DataGridColumn[] = [
@@ -916,7 +916,7 @@ export default function OrganizationsPage({
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
- { id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
+ { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => , disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
];
const functionColumns: DataGridColumn[] = [
@@ -927,7 +927,7 @@ export default function OrganizationsPage({
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{
id: "actions",
- header: "i18n:govoplan-organizations.actions.c3cd636a",
+ header: "i18n:govoplan-core.actions.c3cd636a",
width: 88 + (functionActionContributions.length * 40),
sticky: "end",
resizable: false,
diff --git a/webui/src/module.ts b/webui/src/module.ts
index 8936949..95d6275 100644
--- a/webui/src/module.ts
+++ b/webui/src/module.ts
@@ -23,6 +23,8 @@ const organizationAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "tenant-organization-settings",
+ moduleId: "organizations",
+ kind: "settings",
surfaceId: "organizations.admin.tenant",
label: "i18n:govoplan-organizations.organizations.220edf64",
group: "TENANT",