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