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

View File

@@ -16,6 +16,31 @@ The organization model answers where responsibility lives.
- Function: a named responsibility in an organization unit, such as clerk, - Function: a named responsibility in an organization unit, such as clerk,
reviewer, approver, committee secretary, intake desk, or resource manager. 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 ## Boundary With Identity And IDM
This module does not own login accounts, identity lifecycle, account linking, This module does not own login accounts, identity lifecycle, account linking,

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import UTC, datetime
import re import re
from typing import Any, TypeVar from typing import Any, TypeVar
@@ -31,6 +31,9 @@ from govoplan_core.db.session import get_session
from govoplan_organizations.backend.db.models import ( from govoplan_organizations.backend.db.models import (
OrganizationFunction, OrganizationFunction,
OrganizationFunctionType, OrganizationFunctionType,
OrganizationModelInstantiation,
OrganizationModelTemplate,
OrganizationModelTemplateVersion,
OrganizationRelation, OrganizationRelation,
OrganizationRelationType, OrganizationRelationType,
OrganizationTenantSettings, OrganizationTenantSettings,
@@ -44,9 +47,16 @@ from .schemas import (
FunctionTypeCreateRequest, FunctionTypeCreateRequest,
FunctionTypeUpdateRequest, FunctionTypeUpdateRequest,
FunctionUpdateRequest, FunctionUpdateRequest,
OrganizationModelInstantiationItem,
OrganizationFunctionItem, OrganizationFunctionItem,
OrganizationFunctionTypeItem, OrganizationFunctionTypeItem,
OrganizationModelResponse, OrganizationModelResponse,
OrganizationModelTemplateCatalogItem,
OrganizationModelTemplateCatalogResponse,
OrganizationModelTemplateCreateRequest,
OrganizationModelTemplateItem,
OrganizationModelTemplateVersionCreateRequest,
OrganizationModelTemplateVersionItem,
OrganizationRelationItem, OrganizationRelationItem,
OrganizationRelationTypeItem, OrganizationRelationTypeItem,
OrganizationSettingsItem, OrganizationSettingsItem,
@@ -65,6 +75,11 @@ from .schemas import (
UnitTypeUpdateRequest, UnitTypeUpdateRequest,
UnitUpdateRequest, UnitUpdateRequest,
) )
from govoplan_organizations.backend.templates import (
OrganizationTemplateError,
canonical_template_definition,
instantiate_template_version,
)
router = APIRouter(prefix="/organizations", tags=["organizations"]) 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_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
ORG_CHANGE_CONTROL_KEY = "organizations.model" ORG_CHANGE_CONTROL_KEY = "organizations.model"
ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated" ORG_CHANGE_AUDIT_EVENT = "organizations.model.updated"
ORG_TEMPLATE_ADMIN_SCOPES = ("system:settings:write",)
SLUG_RE = re.compile(r"[^a-z0-9]+") SLUG_RE = re.compile(r"[^a-z0-9]+")
ModelT = TypeVar("ModelT") ModelT = TypeVar("ModelT")
@@ -409,6 +425,74 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
return OrganizationFunctionItem(**_row_fields(item)) 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]: def _row_fields(item: object) -> dict[str, Any]:
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined] keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
return {key: getattr(item, key) for key in keys} return {key: getattr(item, key) for key in keys}
@@ -481,6 +565,183 @@ def update_organization_settings(
return _item_settings(_commit(session, item)) 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) @router.get("/model", response_model=OrganizationModelResponse)
def get_organization_model( def get_organization_model(
session: Session = Depends(get_session), session: Session = Depends(get_session),

View File

@@ -3,11 +3,181 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
StructureKind = Literal["hierarchy", "network", "membership", "classification"] StructureKind = Literal["hierarchy", "network", "membership", "classification"]
AuditDetailLevel = Literal["summary", "standard", "full"] 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): class OrganizationSettingsItem(BaseModel):

View File

@@ -55,6 +55,107 @@ class OrganizationTenantSettings(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) 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): class OrganizationStructure(Base, TimestampMixin):
__tablename__ = "organizations_structures" __tablename__ = "organizations_structures"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),) __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
@@ -153,6 +254,9 @@ class OrganizationFunction(Base, TimestampMixin):
__all__ = [ __all__ = [
"OrganizationFunction", "OrganizationFunction",
"OrganizationFunctionType", "OrganizationFunctionType",
"OrganizationModelInstantiation",
"OrganizationModelTemplate",
"OrganizationModelTemplateVersion",
"OrganizationRelation", "OrganizationRelation",
"OrganizationRelationType", "OrganizationRelationType",
"OrganizationStructure", "OrganizationStructure",

View File

@@ -132,6 +132,9 @@ manifest = ModuleManifest(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
organization_models.OrganizationUnitType, organization_models.OrganizationUnitType,
organization_models.OrganizationTenantSettings, organization_models.OrganizationTenantSettings,
organization_models.OrganizationModelTemplate,
organization_models.OrganizationModelTemplateVersion,
organization_models.OrganizationModelInstantiation,
organization_models.OrganizationStructure, organization_models.OrganizationStructure,
organization_models.OrganizationRelationType, organization_models.OrganizationRelationType,
organization_models.OrganizationRelation, organization_models.OrganizationRelation,

View File

@@ -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()

View File

@@ -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")

View File

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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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: "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: "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: "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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> } { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
]; ];
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [ const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
@@ -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: "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: "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: "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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> } { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
]; ];
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [ const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
@@ -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: "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: "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: "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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> } { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
]; ];
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [ const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
@@ -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: "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: "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: "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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> } { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
]; ];
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [ const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
@@ -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: "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: "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: "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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> } { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
]; ];
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [ const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
@@ -916,7 +916,7 @@ export default function OrganizationsPage({
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) }, { 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: "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: "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) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> } { id: "actions", header: "i18n:govoplan-core.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
]; ];
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [ const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
@@ -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: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
{ {
id: "actions", id: "actions",
header: "i18n:govoplan-organizations.actions.c3cd636a", header: "i18n:govoplan-core.actions.c3cd636a",
width: 88 + (functionActionContributions.length * 40), width: 88 + (functionActionContributions.length * 40),
sticky: "end", sticky: "end",
resizable: false, resizable: false,

View File

@@ -23,6 +23,8 @@ const organizationAdminSections: AdminSectionsUiCapability = {
sections: [ sections: [
{ {
id: "tenant-organization-settings", id: "tenant-organization-settings",
moduleId: "organizations",
kind: "settings",
surfaceId: "organizations.admin.tenant", surfaceId: "organizations.admin.tenant",
label: "i18n:govoplan-organizations.organizations.220edf64", label: "i18n:govoplan-organizations.organizations.220edf64",
group: "TENANT", group: "TENANT",