Add organization tenant settings
This commit is contained in:
@@ -15,6 +15,7 @@ from govoplan_organizations.backend.db.models import (
|
||||
OrganizationFunctionType,
|
||||
OrganizationRelation,
|
||||
OrganizationRelationType,
|
||||
OrganizationTenantSettings,
|
||||
OrganizationStructure,
|
||||
OrganizationUnit,
|
||||
OrganizationUnitType,
|
||||
@@ -33,6 +34,8 @@ from .schemas import (
|
||||
OrganizationModelResponse,
|
||||
OrganizationRelationItem,
|
||||
OrganizationRelationTypeItem,
|
||||
OrganizationSettingsItem,
|
||||
OrganizationSettingsUpdateRequest,
|
||||
OrganizationStructureItem,
|
||||
OrganizationUnitItem,
|
||||
OrganizationUnitTypeItem,
|
||||
@@ -61,6 +64,8 @@ ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",)
|
||||
ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",)
|
||||
ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",)
|
||||
ORG_ASSIGN_SCOPES = ("organizations:function:assign",)
|
||||
ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read")
|
||||
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
|
||||
SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
@@ -129,6 +134,21 @@ def _item_unit_type(item: OrganizationUnitType) -> OrganizationUnitTypeItem:
|
||||
return OrganizationUnitTypeItem(**_row_fields(item))
|
||||
|
||||
|
||||
def _item_settings(item: OrganizationTenantSettings) -> OrganizationSettingsItem:
|
||||
return OrganizationSettingsItem(**_row_fields(item))
|
||||
|
||||
|
||||
def _default_settings(tenant_id: str) -> OrganizationSettingsItem:
|
||||
return OrganizationSettingsItem(
|
||||
tenant_id=tenant_id,
|
||||
allow_tenant_model_customization=True,
|
||||
require_model_change_requests=False,
|
||||
audit_detail_level="standard",
|
||||
change_retention_days=None,
|
||||
settings={},
|
||||
)
|
||||
|
||||
|
||||
def _item_structure(item: OrganizationStructure) -> OrganizationStructureItem:
|
||||
return OrganizationStructureItem(**_row_fields(item))
|
||||
|
||||
@@ -187,6 +207,48 @@ def _apply_slugged_update(session: Session, item: object, payload: object, tenan
|
||||
setattr(item, "settings", value)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=OrganizationSettingsItem)
|
||||
def get_organization_settings(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_SETTINGS_READ_SCOPES)),
|
||||
) -> OrganizationSettingsItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||
return _item_settings(item) if item is not None else _default_settings(tenant_id)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=OrganizationSettingsItem)
|
||||
def update_organization_settings(
|
||||
payload: OrganizationSettingsUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_SETTINGS_WRITE_SCOPES)),
|
||||
) -> OrganizationSettingsItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||
if item is None:
|
||||
item = OrganizationTenantSettings(
|
||||
tenant_id=tenant_id,
|
||||
allow_tenant_model_customization=True,
|
||||
require_model_change_requests=False,
|
||||
audit_detail_level="standard",
|
||||
change_retention_days=None,
|
||||
settings={},
|
||||
)
|
||||
session.add(item)
|
||||
fields = payload.model_fields_set
|
||||
for field in ("allow_tenant_model_customization", "require_model_change_requests", "audit_detail_level", "change_retention_days"):
|
||||
if field in fields:
|
||||
value = getattr(payload, field)
|
||||
if field != "change_retention_days" and value is None:
|
||||
raise _invalid(f"{field} cannot be empty.")
|
||||
setattr(item, field, value)
|
||||
if "settings" in fields:
|
||||
if payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
item.settings = payload.settings
|
||||
return _item_settings(_commit(session, item))
|
||||
|
||||
|
||||
@router.get("/model", response_model=OrganizationModelResponse)
|
||||
def get_organization_model(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -356,6 +418,8 @@ def update_unit(
|
||||
raise _invalid("An organization unit cannot be its own parent.")
|
||||
if payload.parent_id is not None:
|
||||
_get_tenant_row(session, OrganizationUnit, payload.parent_id, tenant_id, "Parent organization unit")
|
||||
if _would_create_parent_cycle(session, tenant_id, item.id, payload.parent_id):
|
||||
raise _invalid("This parent assignment would create an organization unit cycle.")
|
||||
item.parent_id = payload.parent_id
|
||||
return _item_unit(_commit(session, item))
|
||||
|
||||
@@ -718,3 +782,17 @@ def _would_create_cycle(
|
||||
query = query.filter(OrganizationRelation.id != exclude_relation_id)
|
||||
pending.extend(row[0] for row in query.all())
|
||||
return False
|
||||
|
||||
|
||||
def _would_create_parent_cycle(session: Session, tenant_id: str, unit_id: str, parent_id: str) -> bool:
|
||||
current: str | None = parent_id
|
||||
seen: set[str] = set()
|
||||
while current is not None:
|
||||
if current == unit_id or current in seen:
|
||||
return True
|
||||
seen.add(current)
|
||||
current = session.query(OrganizationUnit.parent_id).filter(
|
||||
OrganizationUnit.tenant_id == tenant_id,
|
||||
OrganizationUnit.id == current,
|
||||
).scalar()
|
||||
return False
|
||||
|
||||
@@ -7,6 +7,27 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
StructureKind = Literal["hierarchy", "network", "membership", "classification"]
|
||||
AuditDetailLevel = Literal["summary", "standard", "full"]
|
||||
|
||||
|
||||
class OrganizationSettingsItem(BaseModel):
|
||||
id: str | None = None
|
||||
tenant_id: str
|
||||
allow_tenant_model_customization: bool
|
||||
require_model_change_requests: bool
|
||||
audit_detail_level: AuditDetailLevel
|
||||
change_retention_days: int | None = None
|
||||
settings: dict[str, Any]
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class OrganizationSettingsUpdateRequest(BaseModel):
|
||||
allow_tenant_model_customization: bool | None = None
|
||||
require_model_change_requests: bool | None = None
|
||||
audit_detail_level: AuditDetailLevel | None = None
|
||||
change_retention_days: int | None = Field(default=None, ge=0)
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class OrganizationUnitTypeItem(BaseModel):
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
@@ -42,6 +42,19 @@ class OrganizationUnitType(Base, TimestampMixin):
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class OrganizationTenantSettings(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_tenant_settings"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", name="uq_organizations_tenant_settings_tenant"),)
|
||||
|
||||
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)
|
||||
allow_tenant_model_customization: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
require_model_change_requests: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
audit_detail_level: Mapped[str] = mapped_column(String(30), default="standard", nullable=False)
|
||||
change_retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class OrganizationStructure(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_structures"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"),)
|
||||
@@ -172,6 +185,7 @@ __all__ = [
|
||||
"OrganizationRelation",
|
||||
"OrganizationRelationType",
|
||||
"OrganizationStructure",
|
||||
"OrganizationTenantSettings",
|
||||
"OrganizationUnit",
|
||||
"OrganizationUnitType",
|
||||
"new_uuid",
|
||||
|
||||
@@ -46,6 +46,8 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
PERMISSIONS = (
|
||||
_permission("organizations:model:read", "View organization model", "Read organization meta-model definitions such as unit types, structures, and relation types."),
|
||||
_permission("organizations:model:write", "Manage organization model", "Create and edit organization meta-model definitions."),
|
||||
_permission("organizations:settings:read", "View organization settings", "Read organization governance, audit, and retention settings."),
|
||||
_permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."),
|
||||
_permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."),
|
||||
_permission("organizations:unit:write", "Manage organization units", "Create and edit concrete organization units and relations."),
|
||||
_permission("organizations:function:read", "View organization functions", "Read function definitions and identity-held assignments."),
|
||||
@@ -64,7 +66,7 @@ ROLE_TEMPLATES = (
|
||||
slug="organization_viewer",
|
||||
name="Organization viewer",
|
||||
description="Read organization model, organization units, and function assignments.",
|
||||
permissions=("organizations:model:read", "organizations:unit:read", "organizations:function:read"),
|
||||
permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -112,6 +114,7 @@ manifest = ModuleManifest(
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
organization_models.OrganizationUnitType,
|
||||
organization_models.OrganizationTenantSettings,
|
||||
organization_models.OrganizationStructure,
|
||||
organization_models.OrganizationRelationType,
|
||||
organization_models.OrganizationRelation,
|
||||
|
||||
@@ -71,6 +71,24 @@ def _create_unit_types() -> None:
|
||||
_create_index_if_missing(op.f("ix_organizations_unit_types_tenant_id"), "organizations_unit_types", ["tenant_id"])
|
||||
|
||||
|
||||
def _create_tenant_settings() -> None:
|
||||
if "organizations_tenant_settings" not in _tables():
|
||||
op.create_table(
|
||||
"organizations_tenant_settings",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("allow_tenant_model_customization", sa.Boolean(), nullable=False),
|
||||
sa.Column("require_model_change_requests", sa.Boolean(), nullable=False),
|
||||
sa.Column("audit_detail_level", sa.String(length=30), nullable=False),
|
||||
sa.Column("change_retention_days", sa.Integer(), nullable=True),
|
||||
_json_column("settings"),
|
||||
*_timestamp_columns(),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_tenant_settings")),
|
||||
sa.UniqueConstraint("tenant_id", name="uq_organizations_tenant_settings_tenant"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_organizations_tenant_settings_tenant_id"), "organizations_tenant_settings", ["tenant_id"])
|
||||
|
||||
|
||||
def _create_structures() -> None:
|
||||
if "organizations_structures" not in _tables():
|
||||
op.create_table(
|
||||
@@ -271,6 +289,7 @@ def _create_function_assignments() -> None:
|
||||
|
||||
def upgrade() -> None:
|
||||
_create_unit_types()
|
||||
_create_tenant_settings()
|
||||
_create_structures()
|
||||
_create_relation_types()
|
||||
_create_units()
|
||||
@@ -293,6 +312,7 @@ def downgrade() -> None:
|
||||
"organizations_relation_types",
|
||||
"organizations_structures",
|
||||
"organizations_function_types",
|
||||
"organizations_tenant_settings",
|
||||
"organizations_unit_types",
|
||||
):
|
||||
if table_name in _tables():
|
||||
|
||||
Reference in New Issue
Block a user