From ad2561b50f040965647494f5a141592f2c523719 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 17:33:53 +0200 Subject: [PATCH] Add organizations module surface --- AGENTS.md | 9 +- README.md | 22 +- docs/ORGANIZATION_MODEL.md | 32 +- package.json | 36 + .../backend/api/__init__.py | 1 + .../backend/api/v1/__init__.py | 1 + .../backend/api/v1/routes.py | 720 +++++++++++++ .../backend/api/v1/schemas.py | 275 +++++ .../backend/db/models.py | 114 +- .../backend/directory.py | 48 +- .../backend/manifest.py | 109 +- .../backend/migrations/__init__.py | 1 + .../6d7e8f9a0b1c_organization_meta_model.py | 299 ++++++ .../backend/migrations/versions/__init__.py | 1 + webui/package.json | 31 + webui/src/api/organizations.ts | 270 +++++ .../organizations/OrganizationsAdminPanel.tsx | 18 + .../organizations/OrganizationsPage.tsx | 974 ++++++++++++++++++ webui/src/i18n/generatedTranslations.ts | 184 ++++ webui/src/index.ts | 5 + webui/src/module.ts | 63 ++ webui/src/styles/organizations.css | 106 ++ 22 files changed, 3284 insertions(+), 35 deletions(-) create mode 100644 package.json create mode 100644 src/govoplan_organizations/backend/api/__init__.py create mode 100644 src/govoplan_organizations/backend/api/v1/__init__.py create mode 100644 src/govoplan_organizations/backend/api/v1/routes.py create mode 100644 src/govoplan_organizations/backend/api/v1/schemas.py create mode 100644 src/govoplan_organizations/backend/migrations/__init__.py create mode 100644 src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py create mode 100644 src/govoplan_organizations/backend/migrations/versions/__init__.py create mode 100644 webui/package.json create mode 100644 webui/src/api/organizations.ts create mode 100644 webui/src/features/organizations/OrganizationsAdminPanel.tsx create mode 100644 webui/src/features/organizations/OrganizationsPage.tsx create mode 100644 webui/src/i18n/generatedTranslations.ts create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/organizations.css diff --git a/AGENTS.md b/AGENTS.md index 1efbbb8..9d645c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Scope This repository owns the canonical GovOPlaN organizational model: tenant-local -organization units, hierarchy, functions, and account-held function +organization units, parallel structures, functions, and identity-held function assignments. The module describes responsibility in the institution. It does not decide @@ -14,11 +14,16 @@ facts to roles, rights, permission decisions, and explainability. - Depend on kernel contracts from `govoplan-core`. - Depend on `govoplan-tenancy` for tenant ownership. -- Do not import access, identity, postbox, workflow, or portal internals. +- Depend on identity only through kernel capabilities when resolving account to + identity relationships. +- Do not import access, admin, identity, postbox, workflow, or portal internals. - Expose integration through manifests, capabilities, API routes, events, and typed DTOs. - Treat function assignment as organizational responsibility, not as a login permission by itself. +- Keep the organization modelling UI as a standalone module route. Admin-only + organization controls should be contributed by this module through the + platform `admin.sections` WebUI capability. ## Local Workflow diff --git a/README.md b/README.md index c730690..e36f0e9 100644 --- a/README.md +++ b/README.md @@ -3,29 +3,35 @@ `govoplan-organizations` is the canonical organizational model module for GovOPlaN. -It owns organization units, organizational hierarchy, functions, and -account-held function assignments. These facts exist independently of -authentication and authorization. IDM can import them, Identity can link them -to people/accounts, and Access can project them into roles and rights. +It owns organization unit types, organization structures, concrete units, +functions, and identity-held function assignments. These facts exist +independently of authentication and authorization. Identity links people to +accounts, and Access projects organization facts into roles and rights. ## Boundary Organizations owns: -- tenant-local organization units -- parent/child organizational hierarchy +- tenant-local organization unit types +- tenant-local concrete organization units +- parallel organization structures and relation types - functions in organization units - whether a function may be delegated or used for acting in place -- account-held function assignments +- identity-held function assignments, optionally constrained to one account - whether an assignment applies only to one unit or also to subunits Organizations does not own: -- login accounts or identity lifecycle +- login accounts, identity lifecycle, or account linking - role and permission evaluation - postboxes, workflows, portals, files, or cases - external IDM connector internals +The WebUI exposed by this repository is a normal module UI at +`/organizations`. Admin-only controls are contributed by this module through the +platform `admin.sections` WebUI capability, so the admin shell can show them +when the organizations module is active. + ## Module Contract The module registers the `organizations.directory` capability from diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md index af66914..77ae583 100644 --- a/docs/ORGANIZATION_MODEL.md +++ b/docs/ORGANIZATION_MODEL.md @@ -5,19 +5,28 @@ The organization model answers where responsibility lives. ## Layers - Tenant: the administrative space. -- Organization unit: a department, office, committee, service desk, school, - institute, or other structural unit. +- Organization unit type: an abstract type such as faculty, institute, + committee, department, team, service desk, or legal entity. +- Organization structure: a named way to look at the institution. Several + structures may exist in parallel, for example employer hierarchy, academic + structure, committees, memberships, or classifications. +- Relation type: the permitted edge between units inside a structure. +- Organization unit: a concrete department, office, committee, service desk, + school, institute, or other unit. - Function: a named responsibility in an organization unit, such as clerk, reviewer, approver, committee secretary, intake desk, or resource manager. -- Function assignment: an account holds a function in an organization unit, - optionally for that unit and all subunits. +- Function assignment: an identity holds a function in an organization unit, + optionally for that unit and all subunits. The assignment may be constrained + to a specific account, but the real-world responsibility belongs to the + identity. ## Boundary With Identity -Organization assignments refer to account and identity IDs, but this module -does not own login accounts or identity lifecycle. `govoplan-identity` owns the -identity/account directory. The organization module only records that an -account or identity currently holds an organizational function. +Organization assignments refer to identity IDs and may optionally carry account +IDs for account-specific exercise of a function. This module does not own login +accounts, identity lifecycle, or account linking. `govoplan-identity` owns the +identity/account directory. The organization module records that an identity +currently holds an organizational function. ## Boundary With Access @@ -28,6 +37,13 @@ enforcement, and explain responses. This separation keeps the institution model stable even when authorization policy changes. +## UI Boundary + +The organization workspace at `/organizations` is the primary module UI for +modelling concrete units, structures, functions, and assignments. Admin-only +organization controls are still owned by this module, but are contributed to the +platform admin shell through the `admin.sections` WebUI capability. + ## Boundary With IDM `govoplan-idm` may import organization units, functions, and assignments from diff --git a/package.json b/package.json new file mode 100644 index 0000000..f9e66b4 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "@govoplan/organizations-webui", + "version": "0.1.6", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/organizations.css": "./webui/src/styles/organizations.css" + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.6", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/src/govoplan_organizations/backend/api/__init__.py b/src/govoplan_organizations/backend/api/__init__.py new file mode 100644 index 0000000..0d9144c --- /dev/null +++ b/src/govoplan_organizations/backend/api/__init__.py @@ -0,0 +1 @@ +"""Organizations API package.""" diff --git a/src/govoplan_organizations/backend/api/v1/__init__.py b/src/govoplan_organizations/backend/api/v1/__init__.py new file mode 100644 index 0000000..ad6d4fa --- /dev/null +++ b/src/govoplan_organizations/backend/api/v1/__init__.py @@ -0,0 +1 @@ +"""Organizations API v1 package.""" diff --git a/src/govoplan_organizations/backend/api/v1/routes.py b/src/govoplan_organizations/backend/api/v1/routes.py new file mode 100644 index 0000000..55c8d00 --- /dev/null +++ b/src/govoplan_organizations/backend/api/v1/routes.py @@ -0,0 +1,720 @@ +from __future__ import annotations + +import re +from typing import Any, TypeVar + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, require_any_scope +from govoplan_core.db.session import get_session +from govoplan_organizations.backend.db.models import ( + OrganizationFunction, + OrganizationFunctionAssignment, + OrganizationFunctionType, + OrganizationRelation, + OrganizationRelationType, + OrganizationStructure, + OrganizationUnit, + OrganizationUnitType, +) + +from .schemas import ( + FunctionAssignmentCreateRequest, + FunctionAssignmentUpdateRequest, + FunctionCreateRequest, + FunctionTypeCreateRequest, + FunctionTypeUpdateRequest, + FunctionUpdateRequest, + OrganizationFunctionAssignmentItem, + OrganizationFunctionItem, + OrganizationFunctionTypeItem, + OrganizationModelResponse, + OrganizationRelationItem, + OrganizationRelationTypeItem, + OrganizationStructureItem, + OrganizationUnitItem, + OrganizationUnitTypeItem, + RelationCreateRequest, + RelationTypeCreateRequest, + RelationTypeUpdateRequest, + RelationUpdateRequest, + StructureCreateRequest, + StructureUpdateRequest, + UnitCreateRequest, + UnitTypeCreateRequest, + UnitTypeUpdateRequest, + UnitUpdateRequest, +) + + +router = APIRouter(prefix="/organizations", tags=["organizations"]) + +ORG_READ_SCOPES = ( + "organizations:model:read", + "organizations:unit:read", + "organizations:function:read", + "admin:settings:read", +) +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",) +SLUG_RE = re.compile(r"[^a-z0-9]+") + +ModelT = TypeVar("ModelT") + + +def _slug(value: str | None, fallback: str) -> str: + source = value or fallback + normalized = SLUG_RE.sub("-", source.strip().casefold()).strip("-") + return normalized[:100] or "item" + + +def _tenant_id(principal: ApiPrincipal) -> str: + return principal.tenant_id + + +def _not_found(label: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found") + + +def _conflict(message: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) + + +def _invalid(message: str) -> HTTPException: + return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message) + + +def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_id: str, label: str) -> ModelT: + item = session.get(model, item_id) + if item is None or getattr(item, "tenant_id") != tenant_id: + raise _not_found(label) + return item + + +def _ensure_optional_unit_type(session: Session, tenant_id: str, unit_type_id: str | None) -> None: + if unit_type_id is None: + return + _get_tenant_row(session, OrganizationUnitType, unit_type_id, tenant_id, "Organization unit type") + + +def _ensure_optional_structure(session: Session, tenant_id: str, structure_id: str | None) -> None: + if structure_id is None: + return + _get_tenant_row(session, OrganizationStructure, structure_id, tenant_id, "Organization structure") + + +def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str, *, exclude_id: str | None = None) -> None: + query = session.query(model).filter(model.tenant_id == tenant_id, model.slug == slug) + if exclude_id is not None: + query = query.filter(model.id != exclude_id) + if query.count(): + raise _conflict(f"Slug already exists in this tenant: {slug}") + + +def _commit(session: Session, item: ModelT) -> ModelT: + try: + session.commit() + except IntegrityError as exc: + session.rollback() + raise _conflict("The organization model change conflicts with existing data.") from exc + session.refresh(item) + return item + + +def _item_unit_type(item: OrganizationUnitType) -> OrganizationUnitTypeItem: + return OrganizationUnitTypeItem(**_row_fields(item)) + + +def _item_structure(item: OrganizationStructure) -> OrganizationStructureItem: + return OrganizationStructureItem(**_row_fields(item)) + + +def _item_relation_type(item: OrganizationRelationType) -> OrganizationRelationTypeItem: + return OrganizationRelationTypeItem(**_row_fields(item)) + + +def _item_unit(item: OrganizationUnit) -> OrganizationUnitItem: + return OrganizationUnitItem(**_row_fields(item)) + + +def _item_relation(item: OrganizationRelation) -> OrganizationRelationItem: + return OrganizationRelationItem(**_row_fields(item)) + + +def _item_function_type(item: OrganizationFunctionType) -> OrganizationFunctionTypeItem: + return OrganizationFunctionTypeItem(**_row_fields(item)) + + +def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem: + return OrganizationFunctionItem(**_row_fields(item)) + + +def _item_assignment(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem: + return OrganizationFunctionAssignmentItem(**_row_fields(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} + + +def _apply_slugged_update(session: Session, item: object, payload: object, tenant_id: str, model: type) -> None: + fields = payload.model_fields_set # type: ignore[attr-defined] + if "slug" in fields: + slug = _slug(getattr(payload, "slug"), getattr(item, "name", "item")) + _ensure_unique_slug(session, model, tenant_id, slug, exclude_id=getattr(item, "id")) + setattr(item, "slug", slug) + if "name" in fields: + value = getattr(payload, "name") + if value is None: + raise _invalid("Name cannot be empty.") + setattr(item, "name", value.strip()) + if "description" in fields: + setattr(item, "description", getattr(payload, "description")) + if "is_active" in fields: + value = getattr(payload, "is_active") + if value is None: + raise _invalid("Active state cannot be empty.") + setattr(item, "is_active", value) + if "settings" in fields: + value = getattr(payload, "settings") + if value is None: + raise _invalid("Settings cannot be empty.") + setattr(item, "settings", value) + + +@router.get("/model", response_model=OrganizationModelResponse) +def get_organization_model( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)), +) -> OrganizationModelResponse: + tenant_id = _tenant_id(principal) + return OrganizationModelResponse( + unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()], + structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()], + relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()], + units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()], + relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()], + function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()], + functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()], + function_assignments=[_item_assignment(item) for item in session.query(OrganizationFunctionAssignment).filter(OrganizationFunctionAssignment.tenant_id == tenant_id).order_by(OrganizationFunctionAssignment.created_at.asc()).all()], + ) + + +@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED) +def create_unit_type( + payload: UnitTypeCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationUnitTypeItem: + tenant_id = _tenant_id(principal) + slug = _slug(payload.slug, payload.name) + _ensure_unique_slug(session, OrganizationUnitType, tenant_id, slug) + item = OrganizationUnitType(tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, is_active=payload.is_active, settings=payload.settings) + session.add(item) + return _item_unit_type(_commit(session, item)) + + +@router.patch("/unit-types/{item_id}", response_model=OrganizationUnitTypeItem) +def update_unit_type( + item_id: str, + payload: UnitTypeUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationUnitTypeItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationUnitType, item_id, tenant_id, "Organization unit type") + _apply_slugged_update(session, item, payload, tenant_id, OrganizationUnitType) + return _item_unit_type(_commit(session, item)) + + +@router.post("/structures", response_model=OrganizationStructureItem, status_code=status.HTTP_201_CREATED) +def create_structure( + payload: StructureCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationStructureItem: + tenant_id = _tenant_id(principal) + slug = _slug(payload.slug, payload.name) + _ensure_unique_slug(session, OrganizationStructure, tenant_id, slug) + item = OrganizationStructure(tenant_id=tenant_id, slug=slug, name=payload.name.strip(), description=payload.description, structure_kind=payload.structure_kind, is_active=payload.is_active, settings=payload.settings) + session.add(item) + return _item_structure(_commit(session, item)) + + +@router.patch("/structures/{item_id}", response_model=OrganizationStructureItem) +def update_structure( + item_id: str, + payload: StructureUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationStructureItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationStructure, item_id, tenant_id, "Organization structure") + _apply_slugged_update(session, item, payload, tenant_id, OrganizationStructure) + if "structure_kind" in payload.model_fields_set: + if payload.structure_kind is None: + raise _invalid("Structure kind cannot be empty.") + item.structure_kind = payload.structure_kind + return _item_structure(_commit(session, item)) + + +@router.post("/relation-types", response_model=OrganizationRelationTypeItem, status_code=status.HTTP_201_CREATED) +def create_relation_type( + payload: RelationTypeCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationRelationTypeItem: + tenant_id = _tenant_id(principal) + _ensure_optional_structure(session, tenant_id, payload.structure_id) + _ensure_optional_unit_type(session, tenant_id, payload.source_unit_type_id) + _ensure_optional_unit_type(session, tenant_id, payload.target_unit_type_id) + slug = _slug(payload.slug, payload.name) + _ensure_unique_slug(session, OrganizationRelationType, tenant_id, slug) + item = OrganizationRelationType( + tenant_id=tenant_id, + structure_id=payload.structure_id, + slug=slug, + name=payload.name.strip(), + description=payload.description, + source_unit_type_id=payload.source_unit_type_id, + target_unit_type_id=payload.target_unit_type_id, + is_hierarchical=payload.is_hierarchical, + allow_cycles=payload.allow_cycles, + is_active=payload.is_active, + settings=payload.settings, + ) + session.add(item) + return _item_relation_type(_commit(session, item)) + + +@router.patch("/relation-types/{item_id}", response_model=OrganizationRelationTypeItem) +def update_relation_type( + item_id: str, + payload: RelationTypeUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationRelationTypeItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationRelationType, item_id, tenant_id, "Organization relation type") + _apply_slugged_update(session, item, payload, tenant_id, OrganizationRelationType) + fields = payload.model_fields_set + if "structure_id" in fields: + _ensure_optional_structure(session, tenant_id, payload.structure_id) + item.structure_id = payload.structure_id + if "source_unit_type_id" in fields: + _ensure_optional_unit_type(session, tenant_id, payload.source_unit_type_id) + item.source_unit_type_id = payload.source_unit_type_id + if "target_unit_type_id" in fields: + _ensure_optional_unit_type(session, tenant_id, payload.target_unit_type_id) + item.target_unit_type_id = payload.target_unit_type_id + for field in ("is_hierarchical", "allow_cycles"): + if field in fields: + value = getattr(payload, field) + if value is None: + raise _invalid(f"{field} cannot be empty.") + setattr(item, field, value) + return _item_relation_type(_commit(session, item)) + + +@router.post("/units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED) +def create_unit( + payload: UnitCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), +) -> OrganizationUnitItem: + tenant_id = _tenant_id(principal) + _ensure_optional_unit_type(session, tenant_id, payload.unit_type_id) + if payload.parent_id is not None: + _get_tenant_row(session, OrganizationUnit, payload.parent_id, tenant_id, "Parent organization unit") + slug = _slug(payload.slug, payload.name) + _ensure_unique_slug(session, OrganizationUnit, tenant_id, slug) + item = OrganizationUnit(tenant_id=tenant_id, unit_type_id=payload.unit_type_id, parent_id=payload.parent_id, slug=slug, name=payload.name.strip(), description=payload.description, is_active=payload.is_active, settings=payload.settings) + session.add(item) + return _item_unit(_commit(session, item)) + + +@router.patch("/units/{item_id}", response_model=OrganizationUnitItem) +def update_unit( + item_id: str, + payload: UnitUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), +) -> OrganizationUnitItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationUnit, item_id, tenant_id, "Organization unit") + _apply_slugged_update(session, item, payload, tenant_id, OrganizationUnit) + if "unit_type_id" in payload.model_fields_set: + _ensure_optional_unit_type(session, tenant_id, payload.unit_type_id) + item.unit_type_id = payload.unit_type_id + if "parent_id" in payload.model_fields_set: + if payload.parent_id == item.id: + 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") + item.parent_id = payload.parent_id + return _item_unit(_commit(session, item)) + + +@router.post("/relations", response_model=OrganizationRelationItem, status_code=status.HTTP_201_CREATED) +def create_relation( + payload: RelationCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), +) -> OrganizationRelationItem: + tenant_id = _tenant_id(principal) + structure, relation_type, source, target = _validated_relation_parts( + session, + tenant_id, + structure_id=payload.structure_id, + relation_type_id=payload.relation_type_id, + source_unit_id=payload.source_unit_id, + target_unit_id=payload.target_unit_id, + ) + _validate_relation_edge(session, tenant_id, structure, relation_type, source, target) + item = OrganizationRelation( + tenant_id=tenant_id, + structure_id=structure.id, + relation_type_id=relation_type.id, + source_unit_id=source.id, + target_unit_id=target.id, + valid_from=payload.valid_from, + valid_until=payload.valid_until, + is_active=payload.is_active, + settings=payload.settings, + ) + session.add(item) + return _item_relation(_commit(session, item)) + + +@router.patch("/relations/{item_id}", response_model=OrganizationRelationItem) +def update_relation( + item_id: str, + payload: RelationUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_UNIT_WRITE_SCOPES)), +) -> OrganizationRelationItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationRelation, item_id, tenant_id, "Organization relation") + structure_id = payload.structure_id if "structure_id" in payload.model_fields_set else item.structure_id + relation_type_id = payload.relation_type_id if "relation_type_id" in payload.model_fields_set else item.relation_type_id + source_unit_id = payload.source_unit_id if "source_unit_id" in payload.model_fields_set else item.source_unit_id + target_unit_id = payload.target_unit_id if "target_unit_id" in payload.model_fields_set else item.target_unit_id + if structure_id is None or relation_type_id is None or source_unit_id is None or target_unit_id is None: + raise _invalid("Relation structure, type, source, and target are required.") + structure, relation_type, source, target = _validated_relation_parts( + session, + tenant_id, + structure_id=structure_id, + relation_type_id=relation_type_id, + source_unit_id=source_unit_id, + target_unit_id=target_unit_id, + ) + _validate_relation_edge(session, tenant_id, structure, relation_type, source, target, exclude_relation_id=item.id) + item.structure_id = structure.id + item.relation_type_id = relation_type.id + item.source_unit_id = source.id + item.target_unit_id = target.id + for field in ("valid_from", "valid_until", "is_active", "settings"): + if field in payload.model_fields_set: + setattr(item, field, getattr(payload, field)) + return _item_relation(_commit(session, item)) + + +@router.post("/function-types", response_model=OrganizationFunctionTypeItem, status_code=status.HTTP_201_CREATED) +def create_function_type( + payload: FunctionTypeCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationFunctionTypeItem: + tenant_id = _tenant_id(principal) + _ensure_optional_unit_type(session, tenant_id, payload.organization_unit_type_id) + slug = _slug(payload.slug, payload.name) + _ensure_unique_slug(session, OrganizationFunctionType, tenant_id, slug) + item = OrganizationFunctionType( + tenant_id=tenant_id, + slug=slug, + name=payload.name.strip(), + description=payload.description, + organization_unit_type_id=payload.organization_unit_type_id, + delegable=payload.delegable, + act_in_place_allowed=payload.act_in_place_allowed, + is_active=payload.is_active, + settings=payload.settings, + ) + session.add(item) + return _item_function_type(_commit(session, item)) + + +@router.patch("/function-types/{item_id}", response_model=OrganizationFunctionTypeItem) +def update_function_type( + item_id: str, + payload: FunctionTypeUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_MODEL_WRITE_SCOPES)), +) -> OrganizationFunctionTypeItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationFunctionType, item_id, tenant_id, "Organization function type") + _apply_slugged_update(session, item, payload, tenant_id, OrganizationFunctionType) + if "organization_unit_type_id" in payload.model_fields_set: + _ensure_optional_unit_type(session, tenant_id, payload.organization_unit_type_id) + item.organization_unit_type_id = payload.organization_unit_type_id + for field in ("delegable", "act_in_place_allowed"): + if field in payload.model_fields_set: + value = getattr(payload, field) + if value is None: + raise _invalid(f"{field} cannot be empty.") + setattr(item, field, value) + return _item_function_type(_commit(session, item)) + + +@router.post("/functions", response_model=OrganizationFunctionItem, status_code=status.HTTP_201_CREATED) +def create_function( + payload: FunctionCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_FUNCTION_WRITE_SCOPES)), +) -> OrganizationFunctionItem: + tenant_id = _tenant_id(principal) + unit = _get_tenant_row(session, OrganizationUnit, payload.organization_unit_id, tenant_id, "Organization unit") + function_type = _optional_function_type(session, tenant_id, payload.function_type_id) + slug = _slug(payload.slug, payload.name) + _ensure_function_slug(session, tenant_id, unit.id, slug) + delegable = payload.delegable if payload.delegable is not None else (function_type.delegable if function_type else False) + act_in_place_allowed = payload.act_in_place_allowed if payload.act_in_place_allowed is not None else (function_type.act_in_place_allowed if function_type else False) + item = OrganizationFunction( + tenant_id=tenant_id, + function_type_id=payload.function_type_id, + organization_unit_id=unit.id, + slug=slug, + name=payload.name.strip(), + description=payload.description, + delegable=delegable, + act_in_place_allowed=act_in_place_allowed, + is_active=payload.is_active, + settings=payload.settings, + ) + session.add(item) + return _item_function(_commit(session, item)) + + +@router.patch("/functions/{item_id}", response_model=OrganizationFunctionItem) +def update_function( + item_id: str, + payload: FunctionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_FUNCTION_WRITE_SCOPES)), +) -> OrganizationFunctionItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationFunction, item_id, tenant_id, "Organization function") + _apply_slugged_update_for_function(session, item, payload, tenant_id) + if "organization_unit_id" in payload.model_fields_set: + if payload.organization_unit_id is None: + raise _invalid("Organization unit is required.") + unit = _get_tenant_row(session, OrganizationUnit, payload.organization_unit_id, tenant_id, "Organization unit") + _ensure_function_slug(session, tenant_id, unit.id, item.slug, exclude_id=item.id) + item.organization_unit_id = unit.id + if "function_type_id" in payload.model_fields_set: + _optional_function_type(session, tenant_id, payload.function_type_id) + item.function_type_id = payload.function_type_id + for field in ("delegable", "act_in_place_allowed"): + if field in payload.model_fields_set: + value = getattr(payload, field) + if value is None: + raise _invalid(f"{field} cannot be empty.") + setattr(item, field, value) + return _item_function(_commit(session, item)) + + +@router.post("/function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED) +def create_function_assignment( + payload: FunctionAssignmentCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)), +) -> OrganizationFunctionAssignmentItem: + tenant_id = _tenant_id(principal) + function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") + item = OrganizationFunctionAssignment( + tenant_id=tenant_id, + identity_id=payload.identity_id, + account_id=payload.account_id, + function_id=function.id, + organization_unit_id=function.organization_unit_id, + applies_to_subunits=payload.applies_to_subunits, + source=payload.source, + delegated_from_assignment_id=payload.delegated_from_assignment_id, + acting_for_account_id=payload.acting_for_account_id, + valid_from=payload.valid_from, + valid_until=payload.valid_until, + is_active=payload.is_active, + settings=payload.settings, + ) + if item.delegated_from_assignment_id is not None: + _get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment") + session.add(item) + return _item_assignment(_commit(session, item)) + + +@router.patch("/function-assignments/{item_id}", response_model=OrganizationFunctionAssignmentItem) +def update_function_assignment( + item_id: str, + payload: FunctionAssignmentUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)), +) -> OrganizationFunctionAssignmentItem: + tenant_id = _tenant_id(principal) + item = _get_tenant_row(session, OrganizationFunctionAssignment, item_id, tenant_id, "Organization function assignment") + if "function_id" in payload.model_fields_set: + if payload.function_id is None: + raise _invalid("Function is required.") + function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function") + item.function_id = function.id + item.organization_unit_id = function.organization_unit_id + if "identity_id" in payload.model_fields_set and payload.identity_id is None: + raise _invalid("Identity is required.") + if "source" in payload.model_fields_set and payload.source is None: + raise _invalid("Assignment source is required.") + if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None: + raise _invalid("Subunit applicability cannot be empty.") + if "is_active" in payload.model_fields_set and payload.is_active is None: + raise _invalid("Active state cannot be empty.") + if "settings" in payload.model_fields_set and payload.settings is None: + raise _invalid("Settings cannot be empty.") + for field in ( + "identity_id", + "account_id", + "applies_to_subunits", + "source", + "delegated_from_assignment_id", + "acting_for_account_id", + "valid_from", + "valid_until", + "is_active", + "settings", + ): + if field in payload.model_fields_set: + setattr(item, field, getattr(payload, field)) + if item.delegated_from_assignment_id == item.id: + raise _invalid("A function assignment cannot delegate from itself.") + if item.delegated_from_assignment_id is not None: + _get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment") + return _item_assignment(_commit(session, item)) + + +def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None: + if function_type_id is None: + return None + return _get_tenant_row(session, OrganizationFunctionType, function_type_id, tenant_id, "Organization function type") + + +def _ensure_function_slug(session: Session, tenant_id: str, organization_unit_id: str, slug: str, *, exclude_id: str | None = None) -> None: + query = session.query(OrganizationFunction).filter( + OrganizationFunction.tenant_id == tenant_id, + OrganizationFunction.organization_unit_id == organization_unit_id, + OrganizationFunction.slug == slug, + ) + if exclude_id is not None: + query = query.filter(OrganizationFunction.id != exclude_id) + if query.count(): + raise _conflict(f"Function slug already exists in this organization unit: {slug}") + + +def _apply_slugged_update_for_function(session: Session, item: OrganizationFunction, payload: FunctionUpdateRequest, tenant_id: str) -> None: + fields = payload.model_fields_set + if "slug" in fields: + slug = _slug(payload.slug, item.name) + _ensure_function_slug(session, tenant_id, item.organization_unit_id, slug, exclude_id=item.id) + item.slug = slug + if "name" in fields: + if payload.name is None: + raise _invalid("Name cannot be empty.") + item.name = payload.name.strip() + if "description" in fields: + item.description = payload.description + if "is_active" in fields: + if payload.is_active is None: + raise _invalid("Active state cannot be empty.") + item.is_active = payload.is_active + if "settings" in fields: + if payload.settings is None: + raise _invalid("Settings cannot be empty.") + item.settings = payload.settings + + +def _validated_relation_parts( + session: Session, + tenant_id: str, + *, + structure_id: str, + relation_type_id: str, + source_unit_id: str, + target_unit_id: str, +) -> tuple[OrganizationStructure, OrganizationRelationType, OrganizationUnit, OrganizationUnit]: + structure = _get_tenant_row(session, OrganizationStructure, structure_id, tenant_id, "Organization structure") + relation_type = _get_tenant_row(session, OrganizationRelationType, relation_type_id, tenant_id, "Organization relation type") + source = _get_tenant_row(session, OrganizationUnit, source_unit_id, tenant_id, "Source organization unit") + target = _get_tenant_row(session, OrganizationUnit, target_unit_id, tenant_id, "Target organization unit") + if relation_type.structure_id is not None and relation_type.structure_id != structure.id: + raise _invalid("Relation type is bound to another structure.") + if source.id == target.id: + raise _invalid("A relation cannot connect an organization unit to itself.") + if relation_type.source_unit_type_id is not None and source.unit_type_id != relation_type.source_unit_type_id: + raise _invalid("Source organization unit type is not valid for this relation type.") + if relation_type.target_unit_type_id is not None and target.unit_type_id != relation_type.target_unit_type_id: + raise _invalid("Target organization unit type is not valid for this relation type.") + return structure, relation_type, source, target + + +def _validate_relation_edge( + session: Session, + tenant_id: str, + structure: OrganizationStructure, + relation_type: OrganizationRelationType, + source: OrganizationUnit, + target: OrganizationUnit, + *, + exclude_relation_id: str | None = None, +) -> None: + if relation_type.is_hierarchical and not relation_type.allow_cycles: + if _would_create_cycle( + session, + tenant_id=tenant_id, + structure_id=structure.id, + source_unit_id=source.id, + target_unit_id=target.id, + exclude_relation_id=exclude_relation_id, + ): + raise _invalid("This hierarchical relation would create a cycle.") + + +def _would_create_cycle( + session: Session, + *, + tenant_id: str, + structure_id: str, + source_unit_id: str, + target_unit_id: str, + exclude_relation_id: str | None = None, +) -> bool: + pending = [target_unit_id] + seen: set[str] = set() + while pending: + current = pending.pop() + if current == source_unit_id: + return True + if current in seen: + continue + seen.add(current) + query = session.query(OrganizationRelation.target_unit_id).filter( + OrganizationRelation.tenant_id == tenant_id, + OrganizationRelation.structure_id == structure_id, + OrganizationRelation.source_unit_id == current, + OrganizationRelation.is_active.is_(True), + ) + if exclude_relation_id is not None: + query = query.filter(OrganizationRelation.id != exclude_relation_id) + pending.extend(row[0] for row in query.all()) + return False diff --git a/src/govoplan_organizations/backend/api/v1/schemas.py b/src/govoplan_organizations/backend/api/v1/schemas.py new file mode 100644 index 0000000..1006f72 --- /dev/null +++ b/src/govoplan_organizations/backend/api/v1/schemas.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +StructureKind = Literal["hierarchy", "network", "membership", "classification"] + + +class OrganizationUnitTypeItem(BaseModel): + id: str + tenant_id: str + slug: str + name: str + description: str | None = None + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationStructureItem(BaseModel): + id: str + tenant_id: str + slug: str + name: str + description: str | None = None + structure_kind: str + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationRelationTypeItem(BaseModel): + id: str + tenant_id: str + structure_id: str | None = None + slug: str + name: str + description: str | None = None + source_unit_type_id: str | None = None + target_unit_type_id: str | None = None + is_hierarchical: bool + allow_cycles: bool + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationUnitItem(BaseModel): + id: str + tenant_id: str + unit_type_id: str | None = None + parent_id: str | None = None + slug: str + name: str + description: str | None = None + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationRelationItem(BaseModel): + id: str + tenant_id: str + structure_id: str + relation_type_id: str + source_unit_id: str + target_unit_id: str + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationFunctionTypeItem(BaseModel): + id: str + tenant_id: str + slug: str + name: str + description: str | None = None + organization_unit_type_id: str | None = None + delegable: bool + act_in_place_allowed: bool + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationFunctionItem(BaseModel): + id: str + tenant_id: str + function_type_id: str | None = None + organization_unit_id: str + slug: str + name: str + description: str | None = None + delegable: bool + act_in_place_allowed: bool + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationFunctionAssignmentItem(BaseModel): + id: str + tenant_id: str + identity_id: str + account_id: str | None = None + function_id: str + organization_unit_id: str + applies_to_subunits: bool + source: str + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool + settings: dict[str, Any] + created_at: datetime + updated_at: datetime + + +class OrganizationModelResponse(BaseModel): + unit_types: list[OrganizationUnitTypeItem] + structures: list[OrganizationStructureItem] + relation_types: list[OrganizationRelationTypeItem] + units: list[OrganizationUnitItem] + relations: list[OrganizationRelationItem] + function_types: list[OrganizationFunctionTypeItem] + functions: list[OrganizationFunctionItem] + function_assignments: list[OrganizationFunctionAssignmentItem] + + +class SluggedCreateRequest(BaseModel): + slug: str | None = Field(default=None, max_length=100) + 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 SluggedUpdateRequest(BaseModel): + slug: str | None = Field(default=None, max_length=100) + name: str | None = Field(default=None, max_length=255) + description: str | None = None + is_active: bool | None = None + settings: dict[str, Any] | None = None + + +class UnitTypeCreateRequest(SluggedCreateRequest): + pass + + +class UnitTypeUpdateRequest(SluggedUpdateRequest): + pass + + +class StructureCreateRequest(SluggedCreateRequest): + structure_kind: StructureKind = "hierarchy" + + +class StructureUpdateRequest(SluggedUpdateRequest): + structure_kind: StructureKind | None = None + + +class RelationTypeCreateRequest(SluggedCreateRequest): + structure_id: str | None = None + source_unit_type_id: str | None = None + target_unit_type_id: str | None = None + is_hierarchical: bool = True + allow_cycles: bool = False + + +class RelationTypeUpdateRequest(SluggedUpdateRequest): + structure_id: str | None = None + source_unit_type_id: str | None = None + target_unit_type_id: str | None = None + is_hierarchical: bool | None = None + allow_cycles: bool | None = None + + +class UnitCreateRequest(SluggedCreateRequest): + unit_type_id: str | None = None + parent_id: str | None = None + + +class UnitUpdateRequest(SluggedUpdateRequest): + unit_type_id: str | None = None + parent_id: str | None = None + + +class RelationCreateRequest(BaseModel): + structure_id: str + relation_type_id: str + source_unit_id: str + target_unit_id: str + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool = True + settings: dict[str, Any] = Field(default_factory=dict) + + +class RelationUpdateRequest(BaseModel): + structure_id: str | None = None + relation_type_id: str | None = None + source_unit_id: str | None = None + target_unit_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = None + settings: dict[str, Any] | None = None + + +class FunctionTypeCreateRequest(SluggedCreateRequest): + organization_unit_type_id: str | None = None + delegable: bool = False + act_in_place_allowed: bool = False + + +class FunctionTypeUpdateRequest(SluggedUpdateRequest): + organization_unit_type_id: str | None = None + delegable: bool | None = None + act_in_place_allowed: bool | None = None + + +class FunctionCreateRequest(SluggedCreateRequest): + organization_unit_id: str + function_type_id: str | None = None + delegable: bool | None = None + act_in_place_allowed: bool | None = None + + +class FunctionUpdateRequest(SluggedUpdateRequest): + organization_unit_id: str | None = None + function_type_id: str | None = None + delegable: bool | None = None + act_in_place_allowed: bool | None = None + + +class FunctionAssignmentCreateRequest(BaseModel): + identity_id: str + function_id: str + account_id: str | None = None + applies_to_subunits: bool = False + source: str = "direct" + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool = True + settings: dict[str, Any] = Field(default_factory=dict) + + +class FunctionAssignmentUpdateRequest(BaseModel): + identity_id: str | None = None + function_id: str | None = None + account_id: str | None = None + applies_to_subunits: bool | None = None + source: str | None = None + delegated_from_assignment_id: str | None = None + acting_for_account_id: str | None = None + valid_from: datetime | None = None + valid_until: datetime | None = None + is_active: bool | None = None + settings: dict[str, Any] | None = None diff --git a/src/govoplan_organizations/backend/db/models.py b/src/govoplan_organizations/backend/db/models.py index 14ad4a4..32aa5bf 100644 --- a/src/govoplan_organizations/backend/db/models.py +++ b/src/govoplan_organizations/backend/db/models.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from typing import Any -from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text, UniqueConstraint +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from govoplan_core.db.base import Base, TimestampMixin @@ -19,7 +19,8 @@ class OrganizationUnit(Base, TimestampMixin): __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_units_tenant_slug"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True) parent_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_units.id", ondelete="SET NULL"), nullable=True, index=True) slug: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) @@ -28,12 +29,104 @@ class OrganizationUnit(Base, TimestampMixin): settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) +class OrganizationUnitType(Base, TimestampMixin): + __tablename__ = "organizations_unit_types" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_unit_types_tenant_slug"),) + + 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) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + 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) + 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"),) + + 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) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + structure_kind: Mapped[str] = mapped_column(String(30), default="hierarchy", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class OrganizationRelationType(Base, TimestampMixin): + __tablename__ = "organizations_relation_types" + __table_args__ = ( + UniqueConstraint("tenant_id", "slug", name="uq_organizations_relation_types_tenant_slug"), + Index("ix_organizations_relation_types_structure", "tenant_id", "structure_id"), + ) + + 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) + structure_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_structures.id", ondelete="SET NULL"), nullable=True, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + source_unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True) + target_unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True) + is_hierarchical: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_cycles: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class OrganizationRelation(Base, TimestampMixin): + __tablename__ = "organizations_relations" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "structure_id", + "relation_type_id", + "source_unit_id", + "target_unit_id", + name="uq_organizations_relations_edge", + ), + Index("ix_organizations_relations_structure_source", "tenant_id", "structure_id", "source_unit_id"), + Index("ix_organizations_relations_structure_target", "tenant_id", "structure_id", "target_unit_id"), + ) + + 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) + structure_id: Mapped[str] = mapped_column(ForeignKey("organizations_structures.id", ondelete="CASCADE"), nullable=False, index=True) + relation_type_id: Mapped[str] = mapped_column(ForeignKey("organizations_relation_types.id", ondelete="CASCADE"), nullable=False, index=True) + source_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) + target_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) + valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class OrganizationFunctionType(Base, TimestampMixin): + __tablename__ = "organizations_function_types" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_function_types_tenant_slug"),) + + 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) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + organization_unit_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_unit_types.id", ondelete="SET NULL"), nullable=True, index=True) + delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + class OrganizationFunction(Base, TimestampMixin): __tablename__ = "organizations_functions" __table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_organizations_functions_tenant_unit_slug"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + function_type_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_function_types.id", ondelete="SET NULL"), nullable=True, index=True) organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) slug: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) @@ -49,17 +142,17 @@ class OrganizationFunctionAssignment(Base, TimestampMixin): __table_args__ = ( UniqueConstraint( "tenant_id", - "account_id", + "identity_id", "function_id", "organization_unit_id", - name="uq_organizations_function_assignments_account_scope", + name="uq_organizations_function_assignments_identity_scope", ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) - tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) - account_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) - identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + identity_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True) organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True) applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -75,6 +168,11 @@ class OrganizationFunctionAssignment(Base, TimestampMixin): __all__ = [ "OrganizationFunction", "OrganizationFunctionAssignment", + "OrganizationFunctionType", + "OrganizationRelation", + "OrganizationRelationType", + "OrganizationStructure", "OrganizationUnit", + "OrganizationUnitType", "new_uuid", ] diff --git a/src/govoplan_organizations/backend/directory.py b/src/govoplan_organizations/backend/directory.py index c54611f..636a31f 100644 --- a/src/govoplan_organizations/backend/directory.py +++ b/src/govoplan_organizations/backend/directory.py @@ -8,6 +8,7 @@ from govoplan_core.core.organizations import ( OrganizationFunctionRef, OrganizationUnitRef, ) +from govoplan_core.core.identity import IdentityDirectory from govoplan_core.db.session import get_database from govoplan_core.security.time import utc_now from govoplan_organizations.backend.db.models import ( @@ -27,6 +28,7 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef: tenant_id=item.tenant_id, slug=item.slug, name=item.name, + unit_type_id=item.unit_type_id, parent_id=item.parent_id, description=item.description, status=_status(item.is_active), # type: ignore[arg-type] @@ -40,6 +42,7 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef: organization_unit_id=item.organization_unit_id, slug=item.slug, name=item.name, + function_type_id=item.function_type_id, description=item.description, delegable=item.delegable, act_in_place_allowed=item.act_in_place_allowed, @@ -51,8 +54,8 @@ def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctio return OrganizationFunctionAssignmentRef( id=item.id, tenant_id=item.tenant_id, - account_id=item.account_id, identity_id=item.identity_id, + account_id=item.account_id, function_id=item.function_id, organization_unit_id=item.organization_unit_id, applies_to_subunits=item.applies_to_subunits, @@ -66,6 +69,9 @@ def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctio class SqlOrganizationDirectory(OrganizationDirectory): + def __init__(self, *, identity_directory: IdentityDirectory | None = None) -> None: + self._identity_directory = identity_directory + def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: with get_database().session() as session: item = session.get(OrganizationUnit, organization_unit_id) @@ -126,14 +132,52 @@ class SqlOrganizationDirectory(OrganizationDirectory): account_id: str, *, tenant_id: str | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + identity_ids: set[str] = set() + if self._identity_directory is not None: + identity = self._identity_directory.identity_for_account(account_id) + if identity is not None: + identity_ids.add(identity.id) + if not identity_ids: + return self._function_assignments_for_identity_ids( + identity_ids=(), + tenant_id=tenant_id, + fallback_account_id=account_id, + ) + return self._function_assignments_for_identity_ids( + identity_ids=tuple(identity_ids), + tenant_id=tenant_id, + fallback_account_id=account_id, + ) + + def function_assignments_for_identity( + self, + identity_id: str, + *, + tenant_id: str | None = None, + ) -> tuple[OrganizationFunctionAssignmentRef, ...]: + return self._function_assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id) + + def _function_assignments_for_identity_ids( + self, + *, + identity_ids: tuple[str, ...], + tenant_id: str | None = None, + fallback_account_id: str | None = None, ) -> tuple[OrganizationFunctionAssignmentRef, ...]: now = utc_now() with get_database().session() as session: + identity_or_account_filter = OrganizationFunctionAssignment.identity_id.in_(identity_ids) + if fallback_account_id: + identity_or_account_filter = or_( + identity_or_account_filter, + OrganizationFunctionAssignment.account_id == fallback_account_id, + ) query = ( session.query(OrganizationFunctionAssignment) .join(OrganizationFunction, OrganizationFunction.id == OrganizationFunctionAssignment.function_id) .filter( - OrganizationFunctionAssignment.account_id == account_id, + identity_or_account_filter, OrganizationFunctionAssignment.is_active.is_(True), OrganizationFunction.is_active.is_(True), or_(OrganizationFunctionAssignment.valid_from.is_(None), OrganizationFunctionAssignment.valid_from <= now), diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py index 42ad34d..5706c37 100644 --- a/src/govoplan_organizations/backend/manifest.py +++ b/src/govoplan_organizations/backend/manifest.py @@ -1,28 +1,122 @@ from __future__ import annotations +from pathlib import Path + +from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory from govoplan_core.core.module_guards import persistent_table_uninstall_guard -from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.core.modules import ( + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleManifest, + NavItem, + PermissionDefinition, + RoleTemplate, +) from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY from govoplan_core.db.base import Base from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata -def _organization_directory(context: ModuleContext) -> object: +ORGANIZATIONS_READ_SCOPES = ( + "organizations:model:read", + "organizations:unit:read", + "organizations:function:read", + "admin:settings:read", +) + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Organizations", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +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: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."), + _permission("organizations:function:write", "Manage organization functions", "Create and edit function definitions."), + _permission("organizations:function:assign", "Assign organization functions", "Assign identity-held functions in organization units."), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="organization_modeler", + name="Organization modeler", + description="Manage organization meta-model, concrete units, structures, functions, and assignments.", + permissions=tuple(permission.scope for permission in PERMISSIONS), + ), + RoleTemplate( + 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"), + ), +) + + +def _route_factory(context: ModuleContext): del context + from govoplan_organizations.backend.api.v1.routes import router + + return router + + +def _organization_directory(context: ModuleContext) -> object: + identity_directory: IdentityDirectory | None = None + registry = context.registry + if hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY): + capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY) + if isinstance(capability, IdentityDirectory): + identity_directory = capability from govoplan_organizations.backend.directory import SqlOrganizationDirectory - return SqlOrganizationDirectory() + return SqlOrganizationDirectory(identity_directory=identity_directory) manifest = ModuleManifest( id="organizations", name="Organizations", version="0.1.6", - dependencies=("tenancy",), - migration_spec=MigrationSpec(module_id="organizations", metadata=Base.metadata), + required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + optional_dependencies=("tenancy", "identity", "access", "audit", "policy"), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + route_factory=_route_factory, + nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),), + frontend=FrontendModule( + module_id="organizations", + package_name="@govoplan/organizations-webui", + routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),), + nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),), + ), + migration_spec=MigrationSpec( + module_id="organizations", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + ), uninstall_guard_providers=( persistent_table_uninstall_guard( + organization_models.OrganizationUnitType, + organization_models.OrganizationStructure, + organization_models.OrganizationRelationType, + organization_models.OrganizationRelation, organization_models.OrganizationUnit, + organization_models.OrganizationFunctionType, organization_models.OrganizationFunction, organization_models.OrganizationFunctionAssignment, label="Organizations", @@ -37,8 +131,9 @@ manifest = ModuleManifest( title="Organization model", summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.", body=( - "Use organization functions to model responsibilities that outlive people. " - "A function assignment can apply to one organization unit or to that unit and all subunits." + "Use organization unit types, structures, and relation types to model how the institution describes itself. " + "A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. " + "Functions are held by identities in organization units. Accounts only exercise those functions through identity and access policy." ), layer="configured", documentation_types=("admin", "user"), diff --git a/src/govoplan_organizations/backend/migrations/__init__.py b/src/govoplan_organizations/backend/migrations/__init__.py new file mode 100644 index 0000000..b6df8cd --- /dev/null +++ b/src/govoplan_organizations/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Organizations module migrations.""" diff --git a/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py b/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py new file mode 100644 index 0000000..7691d02 --- /dev/null +++ b/src/govoplan_organizations/backend/migrations/versions/6d7e8f9a0b1c_organization_meta_model.py @@ -0,0 +1,299 @@ +"""organization meta-model + +Revision ID: 6d7e8f9a0b1c +Revises: 2e3f4a5b6c7d +Create Date: 2026-07-10 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "6d7e8f9a0b1c" +down_revision = "2e3f4a5b6c7d" +branch_labels = None +depends_on = None + + +def _tables() -> set[str]: + return set(sa.inspect(op.get_bind()).get_table_names()) + + +def _columns(table_name: str) -> set[str]: + if table_name not in _tables(): + return set() + return {item["name"] for item in sa.inspect(op.get_bind()).get_columns(table_name)} + + +def _indexes(table_name: str) -> set[str]: + if table_name not in _tables(): + return set() + return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)} + + +def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False) -> None: + if table_name in _tables() and name not in _indexes(table_name): + op.create_index(name, table_name, columns, unique=unique) + + +def _drop_index_if_exists(name: str, table_name: str) -> None: + if table_name in _tables() and name in _indexes(table_name): + op.drop_index(name, table_name=table_name) + + +def _json_column(name: str) -> sa.Column: + return sa.Column(name, sa.JSON(), nullable=False) + + +def _timestamp_columns() -> tuple[sa.Column, sa.Column]: + return ( + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + + +def _create_unit_types() -> None: + if "organizations_unit_types" not in _tables(): + op.create_table( + "organizations_unit_types", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_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), + _json_column("settings"), + *_timestamp_columns(), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_unit_types")), + sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_unit_types_tenant_slug"), + ) + _create_index_if_missing(op.f("ix_organizations_unit_types_tenant_id"), "organizations_unit_types", ["tenant_id"]) + + +def _create_structures() -> None: + if "organizations_structures" not in _tables(): + op.create_table( + "organizations_structures", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_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("structure_kind", sa.String(length=30), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + _json_column("settings"), + *_timestamp_columns(), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_structures")), + sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_structures_tenant_slug"), + ) + _create_index_if_missing(op.f("ix_organizations_structures_tenant_id"), "organizations_structures", ["tenant_id"]) + + +def _create_relation_types() -> None: + if "organizations_relation_types" not in _tables(): + op.create_table( + "organizations_relation_types", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("structure_id", sa.String(length=36), nullable=True), + 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("source_unit_type_id", sa.String(length=36), nullable=True), + sa.Column("target_unit_type_id", sa.String(length=36), nullable=True), + sa.Column("is_hierarchical", sa.Boolean(), nullable=False), + sa.Column("allow_cycles", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + _json_column("settings"), + *_timestamp_columns(), + sa.ForeignKeyConstraint(["source_unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_relation_types_source_unit_type_id_organizations_unit_types"), ondelete="SET NULL"), + sa.ForeignKeyConstraint(["structure_id"], ["organizations_structures.id"], name=op.f("fk_organizations_relation_types_structure_id_organizations_structures"), ondelete="SET NULL"), + sa.ForeignKeyConstraint(["target_unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_relation_types_target_unit_type_id_organizations_unit_types"), ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_relation_types")), + sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_relation_types_tenant_slug"), + ) + for column in ("tenant_id", "source_unit_type_id", "structure_id", "target_unit_type_id"): + _create_index_if_missing(op.f(f"ix_organizations_relation_types_{column}"), "organizations_relation_types", [column]) + _create_index_if_missing("ix_organizations_relation_types_structure", "organizations_relation_types", ["tenant_id", "structure_id"]) + + +def _create_units() -> None: + if "organizations_units" not in _tables(): + op.create_table( + "organizations_units", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("unit_type_id", sa.String(length=36), nullable=True), + sa.Column("parent_id", sa.String(length=36), nullable=True), + 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), + _json_column("settings"), + *_timestamp_columns(), + sa.ForeignKeyConstraint(["parent_id"], ["organizations_units.id"], name=op.f("fk_organizations_units_parent_id_organizations_units"), ondelete="SET NULL"), + sa.ForeignKeyConstraint(["unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_units_unit_type_id_organizations_unit_types"), ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_units")), + sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_units_tenant_slug"), + ) + elif "unit_type_id" not in _columns("organizations_units"): + op.add_column("organizations_units", sa.Column("unit_type_id", sa.String(length=36), nullable=True)) + for column in ("parent_id", "tenant_id", "unit_type_id"): + _create_index_if_missing(op.f(f"ix_organizations_units_{column}"), "organizations_units", [column]) + + +def _create_relations() -> None: + if "organizations_relations" not in _tables(): + op.create_table( + "organizations_relations", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("structure_id", sa.String(length=36), nullable=False), + sa.Column("relation_type_id", sa.String(length=36), nullable=False), + sa.Column("source_unit_id", sa.String(length=36), nullable=False), + sa.Column("target_unit_id", sa.String(length=36), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + _json_column("settings"), + *_timestamp_columns(), + sa.ForeignKeyConstraint(["relation_type_id"], ["organizations_relation_types.id"], name=op.f("fk_organizations_relations_relation_type_id_organizations_relation_types"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["source_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_relations_source_unit_id_organizations_units"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["structure_id"], ["organizations_structures.id"], name=op.f("fk_organizations_relations_structure_id_organizations_structures"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["target_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_relations_target_unit_id_organizations_units"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_relations")), + sa.UniqueConstraint("tenant_id", "structure_id", "relation_type_id", "source_unit_id", "target_unit_id", name="uq_organizations_relations_edge"), + ) + for column in ("relation_type_id", "source_unit_id", "structure_id", "target_unit_id", "tenant_id"): + _create_index_if_missing(op.f(f"ix_organizations_relations_{column}"), "organizations_relations", [column]) + _create_index_if_missing("ix_organizations_relations_structure_source", "organizations_relations", ["tenant_id", "structure_id", "source_unit_id"]) + _create_index_if_missing("ix_organizations_relations_structure_target", "organizations_relations", ["tenant_id", "structure_id", "target_unit_id"]) + + +def _create_function_types() -> None: + if "organizations_function_types" not in _tables(): + op.create_table( + "organizations_function_types", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_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("organization_unit_type_id", sa.String(length=36), nullable=True), + sa.Column("delegable", sa.Boolean(), nullable=False), + sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + _json_column("settings"), + *_timestamp_columns(), + sa.ForeignKeyConstraint(["organization_unit_type_id"], ["organizations_unit_types.id"], name=op.f("fk_organizations_function_types_organization_unit_type_id_organizations_unit_types"), ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_function_types")), + sa.UniqueConstraint("tenant_id", "slug", name="uq_organizations_function_types_tenant_slug"), + ) + for column in ("organization_unit_type_id", "tenant_id"): + _create_index_if_missing(op.f(f"ix_organizations_function_types_{column}"), "organizations_function_types", [column]) + + +def _create_functions() -> None: + if "organizations_functions" not in _tables(): + op.create_table( + "organizations_functions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("function_type_id", sa.String(length=36), nullable=True), + sa.Column("organization_unit_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("delegable", sa.Boolean(), nullable=False), + sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + _json_column("settings"), + *_timestamp_columns(), + sa.ForeignKeyConstraint(["function_type_id"], ["organizations_function_types.id"], name=op.f("fk_organizations_functions_function_type_id_organizations_function_types"), ondelete="SET NULL"), + sa.ForeignKeyConstraint(["organization_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_functions_organization_unit_id_organizations_units"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_functions")), + sa.UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_organizations_functions_tenant_unit_slug"), + ) + elif "function_type_id" not in _columns("organizations_functions"): + op.add_column("organizations_functions", sa.Column("function_type_id", sa.String(length=36), nullable=True)) + for column in ("function_type_id", "organization_unit_id", "tenant_id"): + _create_index_if_missing(op.f(f"ix_organizations_functions_{column}"), "organizations_functions", [column]) + + +def _create_function_assignments() -> None: + if "organizations_function_assignments" not in _tables(): + op.create_table( + "organizations_function_assignments", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("identity_id", sa.String(length=36), nullable=False), + sa.Column("account_id", sa.String(length=36), nullable=True), + sa.Column("function_id", sa.String(length=36), nullable=False), + sa.Column("organization_unit_id", sa.String(length=36), nullable=False), + sa.Column("applies_to_subunits", sa.Boolean(), nullable=False), + sa.Column("source", sa.String(length=50), nullable=False), + sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True), + sa.Column("acting_for_account_id", sa.String(length=36), nullable=True), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + _json_column("settings"), + *_timestamp_columns(), + sa.ForeignKeyConstraint(["delegated_from_assignment_id"], ["organizations_function_assignments.id"], name=op.f("fk_organizations_function_assignments_delegated_from_assignment_id_organizations_function_assignments"), ondelete="SET NULL"), + sa.ForeignKeyConstraint(["function_id"], ["organizations_functions.id"], name=op.f("fk_organizations_function_assignments_function_id_organizations_functions"), ondelete="CASCADE"), + sa.ForeignKeyConstraint(["organization_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_function_assignments_organization_unit_id_organizations_units"), ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_function_assignments")), + sa.UniqueConstraint("tenant_id", "identity_id", "function_id", "organization_unit_id", name="uq_organizations_function_assignments_identity_scope"), + ) + else: + columns = _columns("organizations_function_assignments") + if "identity_id" not in columns: + op.add_column("organizations_function_assignments", sa.Column("identity_id", sa.String(length=36), nullable=True)) + op.execute(sa.text("UPDATE organizations_function_assignments SET identity_id = account_id WHERE identity_id IS NULL")) + for column in ( + "account_id", + "acting_for_account_id", + "delegated_from_assignment_id", + "function_id", + "identity_id", + "organization_unit_id", + "tenant_id", + ): + _create_index_if_missing(op.f(f"ix_organizations_function_assignments_{column}"), "organizations_function_assignments", [column]) + _create_index_if_missing( + "uq_organizations_function_assignments_identity_scope", + "organizations_function_assignments", + ["tenant_id", "identity_id", "function_id", "organization_unit_id"], + unique=True, + ) + + +def upgrade() -> None: + _create_unit_types() + _create_structures() + _create_relation_types() + _create_units() + _create_relations() + _create_function_types() + _create_functions() + _create_function_assignments() + + +def downgrade() -> None: + for table_name, indexes in ( + ("organizations_function_assignments", ("uq_organizations_function_assignments_identity_scope",)), + ("organizations_relations", ("ix_organizations_relations_structure_source", "ix_organizations_relations_structure_target")), + ("organizations_relation_types", ("ix_organizations_relation_types_structure",)), + ): + for index_name in indexes: + _drop_index_if_exists(index_name, table_name) + for table_name in ( + "organizations_relations", + "organizations_relation_types", + "organizations_structures", + "organizations_function_types", + "organizations_unit_types", + ): + if table_name in _tables(): + op.drop_table(table_name) diff --git a/src/govoplan_organizations/backend/migrations/versions/__init__.py b/src/govoplan_organizations/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..363497f --- /dev/null +++ b/src/govoplan_organizations/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Organizations Alembic revisions.""" diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..5e4e70d --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/organizations-webui", + "version": "0.1.6", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/organizations.css": "./src/styles/organizations.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.6", + "@vitejs/plugin-react": "^4.3.4", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "typescript": "^5.7.2", + "vite": "^6.0.6" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/organizations.ts b/webui/src/api/organizations.ts new file mode 100644 index 0000000..9fe0128 --- /dev/null +++ b/webui/src/api/organizations.ts @@ -0,0 +1,270 @@ +import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; + +export type OrganizationUnitTypeItem = { + id: string; + tenant_id: string; + slug: string; + name: string; + description?: string | null; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationStructureKind = "hierarchy" | "network" | "membership" | "classification"; + +export type OrganizationStructureItem = { + id: string; + tenant_id: string; + slug: string; + name: string; + description?: string | null; + structure_kind: OrganizationStructureKind | string; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationRelationTypeItem = { + id: string; + tenant_id: string; + structure_id?: string | null; + slug: string; + name: string; + description?: string | null; + source_unit_type_id?: string | null; + target_unit_type_id?: string | null; + is_hierarchical: boolean; + allow_cycles: boolean; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationUnitItem = { + id: string; + tenant_id: string; + unit_type_id?: string | null; + parent_id?: string | null; + slug: string; + name: string; + description?: string | null; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationRelationItem = { + id: string; + tenant_id: string; + structure_id: string; + relation_type_id: string; + source_unit_id: string; + target_unit_id: string; + valid_from?: string | null; + valid_until?: string | null; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationFunctionTypeItem = { + id: string; + tenant_id: string; + slug: string; + name: string; + description?: string | null; + organization_unit_type_id?: string | null; + delegable: boolean; + act_in_place_allowed: boolean; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationFunctionItem = { + id: string; + tenant_id: string; + function_type_id?: string | null; + organization_unit_id: string; + slug: string; + name: string; + description?: string | null; + delegable: boolean; + act_in_place_allowed: boolean; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationFunctionAssignmentItem = { + id: string; + tenant_id: string; + identity_id: string; + account_id?: string | null; + function_id: string; + organization_unit_id: string; + applies_to_subunits: boolean; + source: string; + delegated_from_assignment_id?: string | null; + acting_for_account_id?: string | null; + valid_from?: string | null; + valid_until?: string | null; + is_active: boolean; + settings: Record; + created_at: string; + updated_at: string; +}; + +export type OrganizationModel = { + unit_types: OrganizationUnitTypeItem[]; + structures: OrganizationStructureItem[]; + relation_types: OrganizationRelationTypeItem[]; + units: OrganizationUnitItem[]; + relations: OrganizationRelationItem[]; + function_types: OrganizationFunctionTypeItem[]; + functions: OrganizationFunctionItem[]; + function_assignments: OrganizationFunctionAssignmentItem[]; +}; + +export type SluggedCreatePayload = { + slug?: string | null; + name: string; + description?: string | null; + is_active?: boolean; + settings?: Record; +}; + +export type StructureCreatePayload = SluggedCreatePayload & { + structure_kind: OrganizationStructureKind; +}; + +export type RelationTypeCreatePayload = SluggedCreatePayload & { + structure_id?: string | null; + source_unit_type_id?: string | null; + target_unit_type_id?: string | null; + is_hierarchical: boolean; + allow_cycles: boolean; +}; + +export type UnitCreatePayload = SluggedCreatePayload & { + unit_type_id?: string | null; + parent_id?: string | null; +}; + +export type RelationCreatePayload = { + structure_id: string; + relation_type_id: string; + source_unit_id: string; + target_unit_id: string; + is_active?: boolean; + settings?: Record; +}; + +export type FunctionTypeCreatePayload = SluggedCreatePayload & { + organization_unit_type_id?: string | null; + delegable: boolean; + act_in_place_allowed: boolean; +}; + +export type FunctionCreatePayload = SluggedCreatePayload & { + organization_unit_id: string; + function_type_id?: string | null; + delegable?: boolean | null; + act_in_place_allowed?: boolean | null; +}; + +export type FunctionAssignmentCreatePayload = { + identity_id: string; + account_id?: string | null; + function_id: string; + applies_to_subunits: boolean; + source: string; + delegated_from_assignment_id?: string | null; + acting_for_account_id?: string | null; + is_active?: boolean; + settings?: Record; +}; + +function post>(settings: ApiSettings, path: string, payload: P): Promise { + return apiFetch(settings, path, { method: "POST", body: JSON.stringify(payload) }); +} + +function patch>(settings: ApiSettings, path: string, payload: P): Promise { + return apiFetch(settings, path, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function getOrganizationModel(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/organizations/model"); +} + +export function createUnitType(settings: ApiSettings, payload: SluggedCreatePayload): Promise { + return post(settings, "/api/v1/organizations/unit-types", payload); +} + +export function patchUnitType(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/unit-types/${encodeURIComponent(id)}`, payload); +} + +export function createStructure(settings: ApiSettings, payload: StructureCreatePayload): Promise { + return post(settings, "/api/v1/organizations/structures", payload); +} + +export function patchStructure(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/structures/${encodeURIComponent(id)}`, payload); +} + +export function createRelationType(settings: ApiSettings, payload: RelationTypeCreatePayload): Promise { + return post(settings, "/api/v1/organizations/relation-types", payload); +} + +export function patchRelationType(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/relation-types/${encodeURIComponent(id)}`, payload); +} + +export function createUnit(settings: ApiSettings, payload: UnitCreatePayload): Promise { + return post(settings, "/api/v1/organizations/units", payload); +} + +export function patchUnit(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/units/${encodeURIComponent(id)}`, payload); +} + +export function createRelation(settings: ApiSettings, payload: RelationCreatePayload): Promise { + return post(settings, "/api/v1/organizations/relations", payload); +} + +export function patchRelation(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/relations/${encodeURIComponent(id)}`, payload); +} + +export function createFunctionType(settings: ApiSettings, payload: FunctionTypeCreatePayload): Promise { + return post(settings, "/api/v1/organizations/function-types", payload); +} + +export function patchFunctionType(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/function-types/${encodeURIComponent(id)}`, payload); +} + +export function createFunction(settings: ApiSettings, payload: FunctionCreatePayload): Promise { + return post(settings, "/api/v1/organizations/functions", payload); +} + +export function patchFunction(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/functions/${encodeURIComponent(id)}`, payload); +} + +export function createFunctionAssignment(settings: ApiSettings, payload: FunctionAssignmentCreatePayload): Promise { + return post(settings, "/api/v1/organizations/function-assignments", payload); +} + +export function patchFunctionAssignment(settings: ApiSettings, id: string, payload: Partial): Promise { + return patch(settings, `/api/v1/organizations/function-assignments/${encodeURIComponent(id)}`, payload); +} diff --git a/webui/src/features/organizations/OrganizationsAdminPanel.tsx b/webui/src/features/organizations/OrganizationsAdminPanel.tsx new file mode 100644 index 0000000..e59a3be --- /dev/null +++ b/webui/src/features/organizations/OrganizationsAdminPanel.tsx @@ -0,0 +1,18 @@ +import type { ApiSettings, AuthInfo } from "@govoplan/core-webui"; +import OrganizationsPage, { type OrganizationSection } from "./OrganizationsPage"; + +const MODEL_SECTIONS: OrganizationSection[] = ["model"]; + +export default function OrganizationsAdminPanel({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { + return ( + + ); +} diff --git a/webui/src/features/organizations/OrganizationsPage.tsx b/webui/src/features/organizations/OrganizationsPage.tsx new file mode 100644 index 0000000..480d1d2 --- /dev/null +++ b/webui/src/features/organizations/OrganizationsPage.tsx @@ -0,0 +1,974 @@ +import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; +import { RefreshCw, Save, XCircle } from "lucide-react"; +import { + ApiError, + Button, + Card, + DataGrid, + DismissibleAlert, + FormField, + LoadingFrame, + ModuleSubnav, + PageTitle, + StatusBadge, + hasScope, + useUnsavedDraftGuard, + type ApiSettings, + type AuthInfo, + type DataGridColumn, + type ModuleSubnavGroup +} from "@govoplan/core-webui"; +import { + createFunction, + createFunctionAssignment, + createFunctionType, + createRelation, + createRelationType, + createStructure, + createUnit, + createUnitType, + getOrganizationModel, + patchFunction, + patchFunctionAssignment, + patchFunctionType, + patchRelation, + patchRelationType, + patchStructure, + patchUnit, + patchUnitType, + type FunctionAssignmentCreatePayload, + type FunctionCreatePayload, + type FunctionTypeCreatePayload, + type OrganizationFunctionAssignmentItem, + type OrganizationFunctionItem, + type OrganizationFunctionTypeItem, + type OrganizationModel, + type OrganizationRelationItem, + type OrganizationRelationTypeItem, + type OrganizationStructureItem, + type OrganizationStructureKind, + type OrganizationUnitItem, + type OrganizationUnitTypeItem, + type RelationCreatePayload, + type RelationTypeCreatePayload, + type SluggedCreatePayload, + type StructureCreatePayload, + type UnitCreatePayload +} from "../../api/organizations"; + +export type OrganizationSection = "model" | "units" | "relations" | "functions" | "assignments"; +type OrganizationsPageMode = "workspace" | "admin"; + +type OrganizationsPageProps = { + settings: ApiSettings; + auth: AuthInfo; + mode?: OrganizationsPageMode; + initialSection?: OrganizationSection; + availableSections?: OrganizationSection[]; + title?: string; + description?: string; +}; + +type SluggedDraft = { + name: string; + slug: string; + description: string; + is_active: boolean; +}; + +type StructureDraft = SluggedDraft & { + structure_kind: OrganizationStructureKind; +}; + +type RelationTypeDraft = SluggedDraft & { + structure_id: string; + source_unit_type_id: string; + target_unit_type_id: string; + is_hierarchical: boolean; + allow_cycles: boolean; +}; + +type UnitDraft = SluggedDraft & { + unit_type_id: string; + parent_id: string; +}; + +type RelationDraft = { + structure_id: string; + relation_type_id: string; + source_unit_id: string; + target_unit_id: string; + is_active: boolean; +}; + +type FunctionTypeDraft = SluggedDraft & { + organization_unit_type_id: string; + delegable: boolean; + act_in_place_allowed: boolean; +}; + +type FunctionDraft = SluggedDraft & { + organization_unit_id: string; + function_type_id: string; + delegable: boolean; + act_in_place_allowed: boolean; +}; + +type AssignmentDraft = { + identity_id: string; + account_id: string; + function_id: string; + applies_to_subunits: boolean; + source: string; + delegated_from_assignment_id: string; + acting_for_account_id: string; + is_active: boolean; +}; + +type ActiveItem = { + id: string; + is_active: boolean; +}; + +const EMPTY_MODEL: OrganizationModel = { + unit_types: [], + structures: [], + relation_types: [], + units: [], + relations: [], + function_types: [], + functions: [], + function_assignments: [] +}; + +const SECTION_GROUPS: ModuleSubnavGroup[] = [ + { + title: "i18n:govoplan-organizations.organizations.220edf64", + items: [ + { id: "model", label: "i18n:govoplan-organizations.meta_model.7398487c", primary: true }, + { id: "units", label: "i18n:govoplan-organizations.units.e14d0d92" }, + { id: "relations", label: "i18n:govoplan-organizations.relations.1c796711" }, + { id: "functions", label: "i18n:govoplan-organizations.functions.805dc49b" }, + { id: "assignments", label: "i18n:govoplan-organizations.assignments.278f513e" } + ] + } +]; + +const STRUCTURE_KINDS: Array<{ value: OrganizationStructureKind; label: string }> = [ + { value: "hierarchy", label: "i18n:govoplan-organizations.hierarchy.74797f37" }, + { value: "network", label: "i18n:govoplan-organizations.network.a24d31c3" }, + { value: "membership", label: "i18n:govoplan-organizations.membership.4531d86d" }, + { value: "classification", label: "i18n:govoplan-organizations.classification.3e9f6c3a" } +]; + +const ALL_SECTIONS: OrganizationSection[] = ["model", "units", "relations", "functions", "assignments"]; + +function emptySluggedDraft(): SluggedDraft { + return { name: "", slug: "", description: "", is_active: true }; +} + +function emptyStructureDraft(): StructureDraft { + return { ...emptySluggedDraft(), structure_kind: "hierarchy" }; +} + +function emptyRelationTypeDraft(): RelationTypeDraft { + return { + ...emptySluggedDraft(), + structure_id: "", + source_unit_type_id: "", + target_unit_type_id: "", + is_hierarchical: true, + allow_cycles: false + }; +} + +function emptyUnitDraft(): UnitDraft { + return { ...emptySluggedDraft(), unit_type_id: "", parent_id: "" }; +} + +function emptyRelationDraft(): RelationDraft { + return { structure_id: "", relation_type_id: "", source_unit_id: "", target_unit_id: "", is_active: true }; +} + +function emptyFunctionTypeDraft(): FunctionTypeDraft { + return { ...emptySluggedDraft(), organization_unit_type_id: "", delegable: false, act_in_place_allowed: false }; +} + +function emptyFunctionDraft(): FunctionDraft { + return { ...emptySluggedDraft(), organization_unit_id: "", function_type_id: "", delegable: false, act_in_place_allowed: false }; +} + +function emptyAssignmentDraft(): AssignmentDraft { + return { + identity_id: "", + account_id: "", + function_id: "", + applies_to_subunits: false, + source: "direct", + delegated_from_assignment_id: "", + acting_for_account_id: "", + is_active: true + }; +} + +function textOrNull(value: string): string | null { + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function sluggedPayload(draft: SluggedDraft): SluggedCreatePayload { + return { + name: draft.name.trim(), + slug: textOrNull(draft.slug), + description: textOrNull(draft.description), + is_active: draft.is_active, + settings: {} + }; +} + +function isSluggedDirty(draft: SluggedDraft): boolean { + return draft.name.trim() !== "" || draft.slug.trim() !== "" || draft.description.trim() !== "" || !draft.is_active; +} + +function isRelationTypeDirty(draft: RelationTypeDraft): boolean { + return isSluggedDirty(draft) || Boolean(draft.structure_id || draft.source_unit_type_id || draft.target_unit_type_id) || !draft.is_hierarchical || draft.allow_cycles; +} + +function isUnitDirty(draft: UnitDraft): boolean { + return isSluggedDirty(draft) || Boolean(draft.unit_type_id || draft.parent_id); +} + +function isRelationDirty(draft: RelationDraft): boolean { + return Boolean(draft.structure_id || draft.relation_type_id || draft.source_unit_id || draft.target_unit_id) || !draft.is_active; +} + +function isFunctionTypeDirty(draft: FunctionTypeDraft): boolean { + return isSluggedDirty(draft) || Boolean(draft.organization_unit_type_id) || draft.delegable || draft.act_in_place_allowed; +} + +function isFunctionDirty(draft: FunctionDraft): boolean { + return isSluggedDirty(draft) || Boolean(draft.organization_unit_id || draft.function_type_id) || draft.delegable || draft.act_in_place_allowed; +} + +function isAssignmentDirty(draft: AssignmentDraft): boolean { + return Boolean( + draft.identity_id.trim() || + draft.account_id.trim() || + draft.function_id || + draft.applies_to_subunits || + draft.source.trim() !== "direct" || + draft.delegated_from_assignment_id || + draft.acting_for_account_id || + !draft.is_active + ); +} + +function mapById(items: T[]): Map { + return new Map(items.map((item) => [item.id, item])); +} + +function sectionGroupsFor(sections: ReadonlySet): ModuleSubnavGroup[] { + return SECTION_GROUPS.map((group) => ({ + ...group, + items: group.items.filter((item) => sections.has(item.id)) + })).filter((group) => group.items.length > 0); +} + +function apiErrorMessage(error: unknown): string { + if (error instanceof ApiError) return error.message; + if (error instanceof Error) return error.message; + return String(error); +} + +function optionalLabel(label: string | null | undefined): JSX.Element { + return label ? {label} : i18n:govoplan-organizations.none.334c4a4c; +} + +function activeStatus(active: boolean): JSX.Element { + return ; +} + +function ActiveToggleButton({ item, disabled, onToggle }: { item: ActiveItem; disabled: boolean; onToggle: () => void }) { + const label = item.is_active ? "i18n:govoplan-organizations.deactivate.46ab070d" : "i18n:govoplan-organizations.reactivate.58f2855d"; + return ( + + ); +} + +function SluggedFields({ + draft, + onChange, + disabled +}: { + draft: SluggedDraft; + onChange: (next: SluggedDraft) => void; + disabled: boolean; +}) { + return ( + <> + + onChange({ ...draft, name: event.target.value })} /> + + + onChange({ ...draft, slug: event.target.value })} /> + +
+ +