feat: add versioned organization model templates
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user