Add organization tenant settings

This commit is contained in:
2026-07-10 17:54:14 +02:00
parent ad2561b50f
commit becbc8dd04
11 changed files with 830 additions and 79 deletions

View File

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

View File

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

View File

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

View File

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

View File

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