feat: add versioned organization model templates
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""organization model templates
|
||||||
|
|
||||||
|
Revision ID: 7e8f9a0b1c2d
|
||||||
|
Revises: 6d7e8f9a0b1c
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
|
revision = "7e8f9a0b1c2d"
|
||||||
|
down_revision = "6d7e8f9a0b1c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_release = import_module(
|
||||||
|
"govoplan_organizations.backend.migrations.versions."
|
||||||
|
"7e8f9a0b1c2d_organization_model_templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_release.upgrade()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_release.downgrade()
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""organization model templates
|
||||||
|
|
||||||
|
Revision ID: 7e8f9a0b1c2d
|
||||||
|
Revises: 6d7e8f9a0b1c
|
||||||
|
Create Date: 2026-07-30 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "7e8f9a0b1c2d"
|
||||||
|
down_revision = "6d7e8f9a0b1c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_templates",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("settings", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_organizations_model_templates"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"slug",
|
||||||
|
name=op.f("uq_organizations_model_templates_slug"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_templates_slug"),
|
||||||
|
"organizations_model_templates",
|
||||||
|
["slug"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_templates_created_by_account_id"),
|
||||||
|
"organizations_model_templates",
|
||||||
|
["created_by_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("version", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("definition", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("release_notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("published_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["template_id"],
|
||||||
|
["organizations_model_templates.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_organizations_model_template_versions_template_id_organizations_model_templates"
|
||||||
|
),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_organizations_model_template_versions"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"template_id",
|
||||||
|
"version",
|
||||||
|
name="uq_organizations_model_template_versions",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_template_versions_template_id"),
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
["template_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f(
|
||||||
|
"ix_organizations_model_template_versions_published_by_account_id"
|
||||||
|
),
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
["published_by_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_org_model_template_versions_template_status",
|
||||||
|
"organizations_model_template_versions",
|
||||||
|
["template_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("template_version_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("source_definition_sha256", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("instantiated_by_account_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("object_counts", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["template_id"],
|
||||||
|
["organizations_model_templates.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_organizations_model_instantiations_template_id_organizations_model_templates"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["template_version_id"],
|
||||||
|
["organizations_model_template_versions.id"],
|
||||||
|
name=op.f(
|
||||||
|
"fk_organizations_model_instantiations_template_version_id_organizations_model_template_versions"
|
||||||
|
),
|
||||||
|
ondelete="RESTRICT",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint(
|
||||||
|
"id",
|
||||||
|
name=op.f("pk_organizations_model_instantiations"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"template_version_id",
|
||||||
|
name="uq_organizations_model_instantiation_version",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_tenant_id"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_template_id"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["template_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_template_version_id"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["template_version_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_organizations_model_instantiations_status"),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f(
|
||||||
|
"ix_organizations_model_instantiations_instantiated_by_account_id"
|
||||||
|
),
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["instantiated_by_account_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_organizations_model_instantiations_tenant",
|
||||||
|
"organizations_model_instantiations",
|
||||||
|
["tenant_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("organizations_model_instantiations")
|
||||||
|
op.drop_table("organizations_model_template_versions")
|
||||||
|
op.drop_table("organizations_model_templates")
|
||||||
442
src/govoplan_organizations/backend/templates.py
Normal file
442
src/govoplan_organizations/backend/templates.py
Normal 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",
|
||||||
|
]
|
||||||
285
tests/test_model_templates.py
Normal file
285
tests/test_model_templates.py
Normal 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()
|
||||||
113
tests/test_settings_routes.py
Normal file
113
tests/test_settings_routes.py
Normal 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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user