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) =>