Compare commits
12 Commits
45cda1a33f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ebdffc0ec | |||
| 84ca4f39ae | |||
| 58857654e9 | |||
| 00212ea331 | |||
| 35aebe8759 | |||
| 28f799e426 | |||
| df91c70491 | |||
| 81e532fd54 | |||
| 025067eb87 | |||
| 6b0b2d2d0b | |||
| f09fdf2ef7 | |||
| c240778ad2 |
@@ -42,8 +42,13 @@ requirements, audit detail, and retention behavior.
|
|||||||
|
|
||||||
## Module Contract
|
## Module Contract
|
||||||
|
|
||||||
The module registers the `organizations.directory` capability from
|
The module registers two capabilities from
|
||||||
`govoplan_core.core.organizations`.
|
`govoplan_core.core.organizations`:
|
||||||
|
|
||||||
|
- `organizations.directory` for backward-compatible direct unit/function
|
||||||
|
lookup;
|
||||||
|
- `organizations.hierarchyDirectory` for typed, tenant-safe, explicitly
|
||||||
|
structure-scoped hierarchy and path resolution.
|
||||||
|
|
||||||
Feature modules should consume the capability instead of importing
|
Feature modules should consume the capability instead of importing
|
||||||
organization ORM models.
|
organization ORM models.
|
||||||
|
|||||||
@@ -66,6 +66,36 @@ The close-out condition is that Access role resolution works with canonical
|
|||||||
Organizations plus IDM installed and still works through projection fallback
|
Organizations plus IDM installed and still works through projection fallback
|
||||||
for transition deployments.
|
for transition deployments.
|
||||||
|
|
||||||
|
## Directory Contracts
|
||||||
|
|
||||||
|
`organizations.directory` remains the small compatibility contract for direct
|
||||||
|
unit and function lookup. It deliberately retains the legacy `parent_id`
|
||||||
|
projection needed by existing Access and IDM integrations.
|
||||||
|
|
||||||
|
`organizations.hierarchyDirectory` is the structure-aware contract for new
|
||||||
|
consumers. It provides:
|
||||||
|
|
||||||
|
- tenant-scoped unit-type and function-type references;
|
||||||
|
- function resolution by type and an explicit set of units;
|
||||||
|
- unit resolution by type, optionally bounded to one named structure;
|
||||||
|
- batched ancestor, descendant, and path resolution with a required structure,
|
||||||
|
optional relation-type filter, and bounded depth;
|
||||||
|
- the exact structure, relation type, and relation edge for every path step;
|
||||||
|
- explicit missing, inactive, unreachable, invalid-filter, cycle, and
|
||||||
|
depth-limit state.
|
||||||
|
|
||||||
|
An edge is traversed from `source_unit_id` to `target_unit_id` for descendant
|
||||||
|
lookups and in reverse for ancestor lookups. Consumers must select a structure;
|
||||||
|
the provider never treats `parent_id` or one arbitrary structure as the
|
||||||
|
institution's universal hierarchy.
|
||||||
|
|
||||||
|
Committed changes emit versioned `organizations.<resource>.<action>.v1`
|
||||||
|
platform events for units, functions, their types, structures, relation types,
|
||||||
|
and relation edges. The event payload contains schema version `1`, tenant and
|
||||||
|
resource references, status, changed fields, and routing-relevant IDs so
|
||||||
|
directory consumers can invalidate derived addresses without importing this
|
||||||
|
module's models.
|
||||||
|
|
||||||
## UI Boundary
|
## UI Boundary
|
||||||
|
|
||||||
The organization workspace at `/organizations` is the primary module UI for
|
The organization workspace at `/organizations` is the primary module UI for
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN organizations module."""
|
"""GovOPlaN organizations module."""
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.8"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
import re
|
import re
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -14,6 +16,17 @@ from govoplan_core.core.configuration_control import (
|
|||||||
ensure_configuration_change_allowed,
|
ensure_configuration_change_allowed,
|
||||||
record_configuration_change_applied,
|
record_configuration_change_applied,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION,
|
||||||
|
organization_lifecycle_event_type,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
EventObjectRef,
|
||||||
|
EventTenantRef,
|
||||||
|
PlatformEvent,
|
||||||
|
emit_platform_event,
|
||||||
|
)
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
@@ -55,6 +68,8 @@ from .schemas import (
|
|||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||||
|
ORGANIZATION_MODEL_COLLECTION_LIMIT = 5_000
|
||||||
|
ORGANIZATION_MODEL_TOTAL_LIMIT = 20_000
|
||||||
|
|
||||||
ORG_READ_SCOPES = (
|
ORG_READ_SCOPES = (
|
||||||
"organizations:model:read",
|
"organizations:model:read",
|
||||||
@@ -128,7 +143,29 @@ def _ensure_unique_slug(session: Session, model: type, tenant_id: str, slug: str
|
|||||||
|
|
||||||
|
|
||||||
def _commit(session: Session, item: ModelT) -> ModelT:
|
def _commit(session: Session, item: ModelT) -> ModelT:
|
||||||
|
lifecycle = _organization_lifecycle_change(item)
|
||||||
try:
|
try:
|
||||||
|
session.flush()
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=getattr(item, "tenant_id", None),
|
||||||
|
source_module="organizations",
|
||||||
|
resource_type=item.__class__.__name__,
|
||||||
|
resource_id=str(
|
||||||
|
getattr(
|
||||||
|
item,
|
||||||
|
"id",
|
||||||
|
getattr(item, "tenant_id", "system"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if lifecycle is not None:
|
||||||
|
_emit_organization_lifecycle_event(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
action=lifecycle[0],
|
||||||
|
changes=lifecycle[1],
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
@@ -137,6 +174,121 @@ def _commit(session: Session, item: ModelT) -> ModelT:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
_LIFECYCLE_RESOURCES = {
|
||||||
|
OrganizationUnitType: "unit_type",
|
||||||
|
OrganizationStructure: "structure",
|
||||||
|
OrganizationRelationType: "relation_type",
|
||||||
|
OrganizationUnit: "unit",
|
||||||
|
OrganizationRelation: "relation",
|
||||||
|
OrganizationFunctionType: "function_type",
|
||||||
|
OrganizationFunction: "function",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_lifecycle_change(
|
||||||
|
item: object,
|
||||||
|
) -> tuple[str, dict[str, dict[str, object | None]]] | None:
|
||||||
|
resource_type = _LIFECYCLE_RESOURCES.get(type(item))
|
||||||
|
if resource_type is None:
|
||||||
|
return None
|
||||||
|
state = sqlalchemy_inspect(item)
|
||||||
|
changes: dict[str, dict[str, object | None]] = {}
|
||||||
|
for attribute in state.mapper.column_attrs:
|
||||||
|
history = state.attrs[attribute.key].history
|
||||||
|
if not history.has_changes():
|
||||||
|
continue
|
||||||
|
changes[attribute.key] = {
|
||||||
|
"before": (
|
||||||
|
_organization_event_value(history.deleted[0])
|
||||||
|
if history.deleted
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"after": (
|
||||||
|
_organization_event_value(history.added[0])
|
||||||
|
if history.added
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if state.pending:
|
||||||
|
action = "created"
|
||||||
|
elif (
|
||||||
|
"is_active" in changes
|
||||||
|
and changes["is_active"]["after"] is False
|
||||||
|
):
|
||||||
|
action = "deactivated"
|
||||||
|
elif resource_type == "unit" and "parent_id" in changes:
|
||||||
|
action = "moved"
|
||||||
|
else:
|
||||||
|
action = "updated"
|
||||||
|
return action, changes
|
||||||
|
|
||||||
|
|
||||||
|
def _organization_event_value(value: object) -> object:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_organization_lifecycle_event(
|
||||||
|
session: Session,
|
||||||
|
item: object,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
changes: dict[str, dict[str, object | None]],
|
||||||
|
) -> None:
|
||||||
|
resource_type = _LIFECYCLE_RESOURCES[type(item)]
|
||||||
|
tenant_id = str(getattr(item, "tenant_id"))
|
||||||
|
item_id = str(getattr(item, "id"))
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"schema_version": ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": item_id,
|
||||||
|
"status": (
|
||||||
|
"active"
|
||||||
|
if bool(getattr(item, "is_active", True))
|
||||||
|
else "inactive"
|
||||||
|
),
|
||||||
|
"changed_fields": sorted(changes),
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
for field in (
|
||||||
|
"slug",
|
||||||
|
"unit_type_id",
|
||||||
|
"organization_unit_id",
|
||||||
|
"function_type_id",
|
||||||
|
"structure_id",
|
||||||
|
"relation_type_id",
|
||||||
|
"source_unit_id",
|
||||||
|
"target_unit_id",
|
||||||
|
):
|
||||||
|
value = getattr(item, field, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[field] = str(value)
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(
|
||||||
|
type=organization_lifecycle_event_type(
|
||||||
|
resource_type, # type: ignore[arg-type]
|
||||||
|
action, # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
module_id="organizations",
|
||||||
|
tenant=EventTenantRef(id=tenant_id),
|
||||||
|
subject=EventObjectRef(
|
||||||
|
type=f"organization_{resource_type}",
|
||||||
|
id=item_id,
|
||||||
|
label=str(getattr(item, "name", None) or "") or None,
|
||||||
|
),
|
||||||
|
resource=EventObjectRef(
|
||||||
|
type=f"organization_{resource_type}",
|
||||||
|
id=item_id,
|
||||||
|
),
|
||||||
|
payload=payload,
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _requires_organization_change_request(session: Session, tenant_id: str) -> bool:
|
def _requires_organization_change_request(session: Session, tenant_id: str) -> bool:
|
||||||
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
item = session.query(OrganizationTenantSettings).filter(OrganizationTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||||
return bool(item and item.require_model_change_requests)
|
return bool(item and item.require_model_change_requests)
|
||||||
@@ -335,15 +487,90 @@ def get_organization_model(
|
|||||||
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
principal: ApiPrincipal = Depends(require_any_scope(*ORG_READ_SCOPES)),
|
||||||
) -> OrganizationModelResponse:
|
) -> OrganizationModelResponse:
|
||||||
tenant_id = _tenant_id(principal)
|
tenant_id = _tenant_id(principal)
|
||||||
return OrganizationModelResponse(
|
unit_types = _bounded_organization_rows(
|
||||||
unit_types=[_item_unit_type(item) for item in session.query(OrganizationUnitType).filter(OrganizationUnitType.tenant_id == tenant_id).order_by(OrganizationUnitType.name.asc()).all()],
|
session.query(OrganizationUnitType)
|
||||||
structures=[_item_structure(item) for item in session.query(OrganizationStructure).filter(OrganizationStructure.tenant_id == tenant_id).order_by(OrganizationStructure.name.asc()).all()],
|
.filter(OrganizationUnitType.tenant_id == tenant_id)
|
||||||
relation_types=[_item_relation_type(item) for item in session.query(OrganizationRelationType).filter(OrganizationRelationType.tenant_id == tenant_id).order_by(OrganizationRelationType.name.asc()).all()],
|
.order_by(OrganizationUnitType.name.asc()),
|
||||||
units=[_item_unit(item) for item in session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant_id).order_by(OrganizationUnit.name.asc()).all()],
|
"unit types",
|
||||||
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()],
|
|
||||||
)
|
)
|
||||||
|
structures = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationStructure)
|
||||||
|
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationStructure.name.asc()),
|
||||||
|
"structures",
|
||||||
|
)
|
||||||
|
relation_types = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationRelationType)
|
||||||
|
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationRelationType.name.asc()),
|
||||||
|
"relation types",
|
||||||
|
)
|
||||||
|
units = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationUnit)
|
||||||
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationUnit.name.asc()),
|
||||||
|
"units",
|
||||||
|
)
|
||||||
|
relations = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationRelation)
|
||||||
|
.filter(OrganizationRelation.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationRelation.created_at.asc()),
|
||||||
|
"relations",
|
||||||
|
)
|
||||||
|
function_types = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationFunctionType)
|
||||||
|
.filter(OrganizationFunctionType.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationFunctionType.name.asc()),
|
||||||
|
"function types",
|
||||||
|
)
|
||||||
|
functions = _bounded_organization_rows(
|
||||||
|
session.query(OrganizationFunction)
|
||||||
|
.filter(OrganizationFunction.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationFunction.name.asc()),
|
||||||
|
"functions",
|
||||||
|
)
|
||||||
|
if sum(
|
||||||
|
len(items)
|
||||||
|
for items in (
|
||||||
|
unit_types,
|
||||||
|
structures,
|
||||||
|
relation_types,
|
||||||
|
units,
|
||||||
|
relations,
|
||||||
|
function_types,
|
||||||
|
functions,
|
||||||
|
)
|
||||||
|
) > ORGANIZATION_MODEL_TOTAL_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
"The organization model is too large for the aggregate "
|
||||||
|
"endpoint and cannot be returned as one response."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return OrganizationModelResponse(
|
||||||
|
unit_types=[_item_unit_type(item) for item in unit_types],
|
||||||
|
structures=[_item_structure(item) for item in structures],
|
||||||
|
relation_types=[_item_relation_type(item) for item in relation_types],
|
||||||
|
units=[_item_unit(item) for item in units],
|
||||||
|
relations=[_item_relation(item) for item in relations],
|
||||||
|
function_types=[_item_function_type(item) for item in function_types],
|
||||||
|
functions=[_item_function(item) for item in functions],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_organization_rows(query: Any, label: str) -> list[Any]:
|
||||||
|
rows = query.limit(ORGANIZATION_MODEL_COLLECTION_LIMIT + 1).all()
|
||||||
|
if len(rows) > ORGANIZATION_MODEL_COLLECTION_LIMIT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
f"The organization model has more than "
|
||||||
|
f"{ORGANIZATION_MODEL_COLLECTION_LIMIT} {label} and cannot "
|
||||||
|
"be returned as one response."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/unit-types", response_model=OrganizationUnitTypeItem, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -1,17 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Callable, Iterator, Sequence
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.organizations import (
|
from govoplan_core.core.organizations import (
|
||||||
OrganizationDirectory,
|
OrganizationDirectory,
|
||||||
OrganizationFunctionRef,
|
OrganizationFunctionRef,
|
||||||
|
OrganizationFunctionTypeRef,
|
||||||
|
OrganizationFunctionTypeResolution,
|
||||||
|
OrganizationHierarchyCatalogRef,
|
||||||
|
OrganizationHierarchyDirection,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
OrganizationHierarchyEdgeRef,
|
||||||
|
OrganizationHierarchyMatchRef,
|
||||||
|
OrganizationHierarchyPathResolution,
|
||||||
|
OrganizationHierarchyResolution,
|
||||||
|
OrganizationRelationTypeRef,
|
||||||
|
OrganizationResolutionStatus,
|
||||||
|
OrganizationStructureRef,
|
||||||
OrganizationUnitRef,
|
OrganizationUnitRef,
|
||||||
|
OrganizationUnitTypeRef,
|
||||||
|
OrganizationUnitTypeResolution,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_organizations.backend.db.models import (
|
from govoplan_organizations.backend.db.models import (
|
||||||
OrganizationFunction,
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
OrganizationUnit,
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MAX_DIRECTORY_BATCH = 500
|
||||||
|
MAX_HIERARCHY_DEPTH = 100
|
||||||
|
|
||||||
|
|
||||||
def _status(active: bool) -> str:
|
def _status(active: bool) -> str:
|
||||||
return "active" if active else "inactive"
|
return "active" if active else "inactive"
|
||||||
|
|
||||||
@@ -29,6 +61,17 @@ def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_type_ref(item: OrganizationUnitType) -> OrganizationUnitTypeRef:
|
||||||
|
return OrganizationUnitTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||||
return OrganizationFunctionRef(
|
return OrganizationFunctionRef(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -44,24 +87,143 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SqlOrganizationDirectory(OrganizationDirectory):
|
def _function_type_ref(
|
||||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
item: OrganizationFunctionType,
|
||||||
with get_database().session() as session:
|
) -> OrganizationFunctionTypeRef:
|
||||||
|
return OrganizationFunctionTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
organization_unit_type_id=item.organization_unit_type_id,
|
||||||
|
description=item.description,
|
||||||
|
delegable=item.delegable,
|
||||||
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _structure_ref(item: OrganizationStructure) -> OrganizationStructureRef:
|
||||||
|
return OrganizationStructureRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
structure_kind=item.structure_kind,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relation_type_ref(
|
||||||
|
item: OrganizationRelationType,
|
||||||
|
) -> OrganizationRelationTypeRef:
|
||||||
|
return OrganizationRelationTypeRef(
|
||||||
|
id=item.id,
|
||||||
|
tenant_id=item.tenant_id,
|
||||||
|
slug=item.slug,
|
||||||
|
name=item.name,
|
||||||
|
structure_id=item.structure_id,
|
||||||
|
source_unit_type_id=item.source_unit_type_id,
|
||||||
|
target_unit_type_id=item.target_unit_type_id,
|
||||||
|
is_hierarchical=item.is_hierarchical,
|
||||||
|
allow_cycles=item.allow_cycles,
|
||||||
|
description=item.description,
|
||||||
|
status=_status(item.is_active), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_ids(values: Sequence[str], *, label: str) -> tuple[str, ...]:
|
||||||
|
result = tuple(dict.fromkeys(str(value).strip() for value in values))
|
||||||
|
if any(not value for value in result):
|
||||||
|
raise ValueError(f"{label} must not contain empty IDs.")
|
||||||
|
if len(result) > MAX_DIRECTORY_BATCH:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} is limited to {MAX_DIRECTORY_BATCH} entries."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_direction(
|
||||||
|
value: OrganizationHierarchyDirection,
|
||||||
|
) -> OrganizationHierarchyDirection:
|
||||||
|
if value not in {"ancestors", "descendants"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Hierarchy direction must be ancestors or descendants."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_depth(value: int) -> int:
|
||||||
|
depth = int(value)
|
||||||
|
if depth < 1 or depth > MAX_HIERARCHY_DEPTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Hierarchy depth must be between 1 and {MAX_HIERARCHY_DEPTH}."
|
||||||
|
)
|
||||||
|
return depth
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _HierarchyGraph:
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
structure_id: str
|
||||||
|
structure: OrganizationStructureRef | None
|
||||||
|
relation_type_ids: tuple[str, ...]
|
||||||
|
units: dict[str, OrganizationUnitRef]
|
||||||
|
outgoing: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||||
|
incoming: dict[str, tuple[OrganizationHierarchyEdgeRef, ...]]
|
||||||
|
diagnostics: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
class SqlOrganizationDirectory(
|
||||||
|
OrganizationDirectory,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session_factory: Callable[[], Session] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _session(self) -> Iterator[Session]:
|
||||||
|
if self._session_factory is None:
|
||||||
|
with get_database().session() as session:
|
||||||
|
yield session
|
||||||
|
return
|
||||||
|
session = self._session_factory()
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def get_organization_unit(
|
||||||
|
self,
|
||||||
|
organization_unit_id: str,
|
||||||
|
) -> OrganizationUnitRef | None:
|
||||||
|
with self._session() as session:
|
||||||
item = session.get(OrganizationUnit, organization_unit_id)
|
item = session.get(OrganizationUnit, organization_unit_id)
|
||||||
return _unit_ref(item) if item is not None else None
|
return _unit_ref(item) if item is not None else None
|
||||||
|
|
||||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
def organization_units_for_tenant(
|
||||||
with get_database().session() as session:
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> tuple[OrganizationUnitRef, ...]:
|
||||||
|
with self._session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
session.query(OrganizationUnit)
|
session.query(OrganizationUnit)
|
||||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||||
.order_by(OrganizationUnit.name.asc())
|
.order_by(OrganizationUnit.name.asc(), OrganizationUnit.id)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_unit_ref(item) for item in rows)
|
return tuple(_unit_ref(item) for item in rows)
|
||||||
|
|
||||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
def get_function(
|
||||||
with get_database().session() as session:
|
self,
|
||||||
|
function_id: str,
|
||||||
|
) -> OrganizationFunctionRef | None:
|
||||||
|
with self._session() as session:
|
||||||
item = session.get(OrganizationFunction, function_id)
|
item = session.get(OrganizationFunction, function_id)
|
||||||
return _function_ref(item) if item is not None else None
|
return _function_ref(item) if item is not None else None
|
||||||
|
|
||||||
@@ -71,7 +233,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
*,
|
*,
|
||||||
include_subunits: bool = False,
|
include_subunits: bool = False,
|
||||||
) -> tuple[OrganizationFunctionRef, ...]:
|
) -> tuple[OrganizationFunctionRef, ...]:
|
||||||
with get_database().session() as session:
|
with self._session() as session:
|
||||||
|
unit = session.get(OrganizationUnit, organization_unit_id)
|
||||||
|
if unit is None:
|
||||||
|
return ()
|
||||||
unit_ids = {organization_unit_id}
|
unit_ids = {organization_unit_id}
|
||||||
if include_subunits:
|
if include_subunits:
|
||||||
pending = [organization_unit_id]
|
pending = [organization_unit_id]
|
||||||
@@ -80,7 +245,10 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
child_ids = [
|
child_ids = [
|
||||||
row[0]
|
row[0]
|
||||||
for row in session.query(OrganizationUnit.id)
|
for row in session.query(OrganizationUnit.id)
|
||||||
.filter(OrganizationUnit.parent_id == parent_id)
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == unit.tenant_id,
|
||||||
|
OrganizationUnit.parent_id == parent_id,
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
for child_id in child_ids:
|
for child_id in child_ids:
|
||||||
@@ -89,8 +257,688 @@ class SqlOrganizationDirectory(OrganizationDirectory):
|
|||||||
pending.append(child_id)
|
pending.append(child_id)
|
||||||
rows = (
|
rows = (
|
||||||
session.query(OrganizationFunction)
|
session.query(OrganizationFunction)
|
||||||
.filter(OrganizationFunction.organization_unit_id.in_(unit_ids))
|
.filter(
|
||||||
.order_by(OrganizationFunction.name.asc())
|
OrganizationFunction.tenant_id == unit.tenant_id,
|
||||||
|
OrganizationFunction.organization_unit_id.in_(unit_ids),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
OrganizationFunction.name.asc(),
|
||||||
|
OrganizationFunction.id,
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return tuple(_function_ref(item) for item in rows)
|
return tuple(_function_ref(item) for item in rows)
|
||||||
|
|
||||||
|
def get_unit_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
) -> OrganizationUnitTypeRef | None:
|
||||||
|
with self._session() as session:
|
||||||
|
item = session.get(OrganizationUnitType, unit_type_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return _unit_type_ref(item)
|
||||||
|
|
||||||
|
def hierarchy_catalog(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> OrganizationHierarchyCatalogRef:
|
||||||
|
with self._session() as session:
|
||||||
|
structures = (
|
||||||
|
session.query(OrganizationStructure)
|
||||||
|
.filter(OrganizationStructure.tenant_id == tenant_id)
|
||||||
|
.order_by(OrganizationStructure.name, OrganizationStructure.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
relation_types = (
|
||||||
|
session.query(OrganizationRelationType)
|
||||||
|
.filter(OrganizationRelationType.tenant_id == tenant_id)
|
||||||
|
.order_by(
|
||||||
|
OrganizationRelationType.structure_id,
|
||||||
|
OrganizationRelationType.name,
|
||||||
|
OrganizationRelationType.id,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return OrganizationHierarchyCatalogRef(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structures=tuple(_structure_ref(item) for item in structures),
|
||||||
|
relation_types=tuple(
|
||||||
|
_relation_type_ref(item) for item in relation_types
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_function_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
) -> OrganizationFunctionTypeRef | None:
|
||||||
|
with self._session() as session:
|
||||||
|
item = session.get(OrganizationFunctionType, function_type_id)
|
||||||
|
if item is None or item.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
return _function_type_ref(item)
|
||||||
|
|
||||||
|
def resolve_functions_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
*,
|
||||||
|
organization_unit_ids: Sequence[str] = (),
|
||||||
|
) -> OrganizationFunctionTypeResolution:
|
||||||
|
requested_ids = _unique_ids(
|
||||||
|
organization_unit_ids,
|
||||||
|
label="Organization unit scope",
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
function_type = session.get(
|
||||||
|
OrganizationFunctionType,
|
||||||
|
function_type_id,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
function_type is None
|
||||||
|
or function_type.tenant_id != tenant_id
|
||||||
|
):
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=requested_ids,
|
||||||
|
status="missing",
|
||||||
|
diagnostics=("function_type_missing",),
|
||||||
|
)
|
||||||
|
units: dict[str, OrganizationUnit] = {}
|
||||||
|
if requested_ids:
|
||||||
|
units = {
|
||||||
|
item.id: item
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(requested_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
query = session.query(OrganizationFunction).filter(
|
||||||
|
OrganizationFunction.tenant_id == tenant_id,
|
||||||
|
OrganizationFunction.function_type_id == function_type_id,
|
||||||
|
)
|
||||||
|
if requested_ids:
|
||||||
|
query = query.filter(
|
||||||
|
OrganizationFunction.organization_unit_id.in_(
|
||||||
|
tuple(units)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = query.order_by(
|
||||||
|
OrganizationFunction.organization_unit_id,
|
||||||
|
OrganizationFunction.name,
|
||||||
|
OrganizationFunction.id,
|
||||||
|
).all()
|
||||||
|
relevant_unit_ids = (
|
||||||
|
requested_ids
|
||||||
|
if requested_ids
|
||||||
|
else tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item.organization_unit_id for item in rows
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not requested_ids and relevant_unit_ids:
|
||||||
|
units = {
|
||||||
|
item.id: item
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(relevant_unit_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
missing = tuple(
|
||||||
|
item_id
|
||||||
|
for item_id in relevant_unit_ids
|
||||||
|
if item_id not in units
|
||||||
|
)
|
||||||
|
inactive = tuple(
|
||||||
|
item_id
|
||||||
|
for item_id in relevant_unit_ids
|
||||||
|
if item_id in units and not units[item_id].is_active
|
||||||
|
)
|
||||||
|
diagnostics = []
|
||||||
|
if missing:
|
||||||
|
diagnostics.append("organization_units_missing")
|
||||||
|
if inactive:
|
||||||
|
diagnostics.append("organization_units_inactive")
|
||||||
|
if not rows:
|
||||||
|
diagnostics.append("no_matching_functions")
|
||||||
|
return OrganizationFunctionTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
function_type_id=function_type_id,
|
||||||
|
requested_unit_ids=requested_ids,
|
||||||
|
status=(
|
||||||
|
"active" if function_type.is_active else "inactive"
|
||||||
|
),
|
||||||
|
function_type=_function_type_ref(function_type),
|
||||||
|
matches=tuple(_function_ref(item) for item in rows),
|
||||||
|
missing_unit_ids=missing,
|
||||||
|
inactive_unit_ids=inactive,
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_units_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
*,
|
||||||
|
structure_id: str | None = None,
|
||||||
|
root_unit_id: str | None = None,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "descendants",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> OrganizationUnitTypeResolution:
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
if bool(structure_id) != bool(root_unit_id):
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="invalid",
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
diagnostics=(
|
||||||
|
"structure_and_root_must_be_supplied_together",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
unit_type = session.get(OrganizationUnitType, unit_type_id)
|
||||||
|
if unit_type is None or unit_type.tenant_id != tenant_id:
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status="missing",
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
diagnostics=("unit_type_missing",),
|
||||||
|
)
|
||||||
|
rows = (
|
||||||
|
session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.unit_type_id == unit_type_id,
|
||||||
|
)
|
||||||
|
.order_by(OrganizationUnit.name, OrganizationUnit.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
status: OrganizationResolutionStatus = (
|
||||||
|
"active" if unit_type.is_active else "inactive"
|
||||||
|
)
|
||||||
|
if structure_id and root_unit_id:
|
||||||
|
hierarchy = self.resolve_hierarchy_relatives(
|
||||||
|
tenant_id,
|
||||||
|
(root_unit_id,),
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)[0]
|
||||||
|
scope_ids = {
|
||||||
|
root_unit_id,
|
||||||
|
*(match.unit.id for match in hierarchy.matches),
|
||||||
|
}
|
||||||
|
rows = [item for item in rows if item.id in scope_ids]
|
||||||
|
diagnostics = hierarchy.diagnostics
|
||||||
|
if hierarchy.status in {"missing", "invalid"}:
|
||||||
|
status = hierarchy.status
|
||||||
|
elif hierarchy.status == "inactive" and status == "active":
|
||||||
|
status = "inactive"
|
||||||
|
return OrganizationUnitTypeResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=unit_type_id,
|
||||||
|
status=status,
|
||||||
|
unit_type=_unit_type_ref(unit_type),
|
||||||
|
structure_id=structure_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
matches=tuple(_unit_ref(item) for item in rows),
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_relatives(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
organization_unit_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "ancestors",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> tuple[OrganizationHierarchyResolution, ...]:
|
||||||
|
root_ids = _unique_ids(
|
||||||
|
organization_unit_ids,
|
||||||
|
label="Organization hierarchy roots",
|
||||||
|
)
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
graph = self._hierarchy_graph(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
required_unit_ids=root_ids,
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
self._relative_resolution(
|
||||||
|
graph,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_id,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
for root_id in root_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_hierarchy_paths(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_pairs: Sequence[tuple[str, str]],
|
||||||
|
*,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: Sequence[str] = (),
|
||||||
|
direction: OrganizationHierarchyDirection = "descendants",
|
||||||
|
max_depth: int = 10,
|
||||||
|
) -> tuple[OrganizationHierarchyPathResolution, ...]:
|
||||||
|
if len(unit_pairs) > MAX_DIRECTORY_BATCH:
|
||||||
|
raise ValueError(
|
||||||
|
f"Organization path batches are limited to "
|
||||||
|
f"{MAX_DIRECTORY_BATCH} entries."
|
||||||
|
)
|
||||||
|
pairs = tuple(
|
||||||
|
(str(source).strip(), str(target).strip())
|
||||||
|
for source, target in unit_pairs
|
||||||
|
)
|
||||||
|
if any(not source or not target for source, target in pairs):
|
||||||
|
raise ValueError("Organization paths require source and target IDs.")
|
||||||
|
direction = _validated_direction(direction)
|
||||||
|
depth = _validated_depth(max_depth)
|
||||||
|
relation_ids = _unique_ids(
|
||||||
|
relation_type_ids,
|
||||||
|
label="Relation type filter",
|
||||||
|
)
|
||||||
|
required_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
item
|
||||||
|
for pair in pairs
|
||||||
|
for item in pair
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self._session() as session:
|
||||||
|
graph = self._hierarchy_graph(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=relation_ids,
|
||||||
|
required_unit_ids=required_ids,
|
||||||
|
)
|
||||||
|
cache: dict[str, OrganizationHierarchyResolution] = {}
|
||||||
|
results: list[OrganizationHierarchyPathResolution] = []
|
||||||
|
for source_id, target_id in pairs:
|
||||||
|
if source_id not in cache:
|
||||||
|
cache[source_id] = self._relative_resolution(
|
||||||
|
graph,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=source_id,
|
||||||
|
direction=direction,
|
||||||
|
max_depth=depth,
|
||||||
|
)
|
||||||
|
relative = cache[source_id]
|
||||||
|
target = graph.units.get(target_id)
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in relative.matches
|
||||||
|
if item.unit.id == target_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if source_id == target_id and relative.root is not None:
|
||||||
|
status: OrganizationResolutionStatus = (
|
||||||
|
"active"
|
||||||
|
if relative.root.status == "active"
|
||||||
|
else "inactive"
|
||||||
|
)
|
||||||
|
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
|
||||||
|
elif relative.root is None or target is None:
|
||||||
|
status = "missing"
|
||||||
|
path = ()
|
||||||
|
elif match is None:
|
||||||
|
status = (
|
||||||
|
"invalid"
|
||||||
|
if relative.status == "invalid"
|
||||||
|
else "unreachable"
|
||||||
|
)
|
||||||
|
path = ()
|
||||||
|
else:
|
||||||
|
status = (
|
||||||
|
"active"
|
||||||
|
if (
|
||||||
|
relative.root.status == "active"
|
||||||
|
and target.status == "active"
|
||||||
|
)
|
||||||
|
else "inactive"
|
||||||
|
)
|
||||||
|
path = match.path
|
||||||
|
results.append(
|
||||||
|
OrganizationHierarchyPathResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_unit_id=source_id,
|
||||||
|
target_unit_id=target_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=structure_id,
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=depth,
|
||||||
|
status=status,
|
||||||
|
source=relative.root,
|
||||||
|
target=target,
|
||||||
|
path=path,
|
||||||
|
cycle_detected=relative.cycle_detected,
|
||||||
|
depth_limited=relative.depth_limited,
|
||||||
|
diagnostics=relative.diagnostics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(results)
|
||||||
|
|
||||||
|
def _hierarchy_graph(
|
||||||
|
self,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
structure_id: str,
|
||||||
|
relation_type_ids: tuple[str, ...],
|
||||||
|
required_unit_ids: tuple[str, ...],
|
||||||
|
) -> _HierarchyGraph:
|
||||||
|
structure = session.get(OrganizationStructure, structure_id)
|
||||||
|
if structure is None or structure.tenant_id != tenant_id:
|
||||||
|
return _HierarchyGraph(
|
||||||
|
status="missing",
|
||||||
|
structure_id=structure_id,
|
||||||
|
structure=None,
|
||||||
|
relation_type_ids=relation_type_ids,
|
||||||
|
units={},
|
||||||
|
outgoing={},
|
||||||
|
incoming={},
|
||||||
|
diagnostics=("structure_missing",),
|
||||||
|
)
|
||||||
|
relation_query = session.query(OrganizationRelationType).filter(
|
||||||
|
OrganizationRelationType.tenant_id == tenant_id,
|
||||||
|
OrganizationRelationType.is_hierarchical.is_(True),
|
||||||
|
or_(
|
||||||
|
OrganizationRelationType.structure_id.is_(None),
|
||||||
|
OrganizationRelationType.structure_id == structure_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if relation_type_ids:
|
||||||
|
relation_query = relation_query.filter(
|
||||||
|
OrganizationRelationType.id.in_(relation_type_ids)
|
||||||
|
)
|
||||||
|
relation_types = relation_query.all()
|
||||||
|
relation_type_by_id = {
|
||||||
|
item.id: item
|
||||||
|
for item in relation_types
|
||||||
|
if item.is_active
|
||||||
|
}
|
||||||
|
diagnostics: list[str] = []
|
||||||
|
invalid_relation_filter = False
|
||||||
|
if relation_type_ids:
|
||||||
|
missing_relation_ids = tuple(
|
||||||
|
item
|
||||||
|
for item in relation_type_ids
|
||||||
|
if item not in {row.id for row in relation_types}
|
||||||
|
)
|
||||||
|
inactive_relation_ids = tuple(
|
||||||
|
item.id for item in relation_types if not item.is_active
|
||||||
|
)
|
||||||
|
if missing_relation_ids:
|
||||||
|
diagnostics.append("relation_types_missing_or_invalid")
|
||||||
|
invalid_relation_filter = True
|
||||||
|
if inactive_relation_ids:
|
||||||
|
diagnostics.append("relation_types_inactive")
|
||||||
|
invalid_relation_filter = True
|
||||||
|
selected_ids = tuple(sorted(relation_type_by_id))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
relations = []
|
||||||
|
if selected_ids:
|
||||||
|
relations = (
|
||||||
|
session.query(OrganizationRelation)
|
||||||
|
.filter(
|
||||||
|
OrganizationRelation.tenant_id == tenant_id,
|
||||||
|
OrganizationRelation.structure_id == structure_id,
|
||||||
|
OrganizationRelation.relation_type_id.in_(selected_ids),
|
||||||
|
OrganizationRelation.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(OrganizationRelation.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
relations = [
|
||||||
|
item
|
||||||
|
for item in relations
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
item.valid_from is None
|
||||||
|
or _utc_datetime(item.valid_from) <= now
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
item.valid_until is None
|
||||||
|
or _utc_datetime(item.valid_until) > now
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
unit_ids = {
|
||||||
|
*required_unit_ids,
|
||||||
|
*(item.source_unit_id for item in relations),
|
||||||
|
*(item.target_unit_id for item in relations),
|
||||||
|
}
|
||||||
|
units = {
|
||||||
|
item.id: _unit_ref(item)
|
||||||
|
for item in session.query(OrganizationUnit)
|
||||||
|
.filter(
|
||||||
|
OrganizationUnit.tenant_id == tenant_id,
|
||||||
|
OrganizationUnit.id.in_(unit_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
} if unit_ids else {}
|
||||||
|
structure_ref = _structure_ref(structure)
|
||||||
|
outgoing_lists: dict[
|
||||||
|
str,
|
||||||
|
list[OrganizationHierarchyEdgeRef],
|
||||||
|
] = {}
|
||||||
|
incoming_lists: dict[
|
||||||
|
str,
|
||||||
|
list[OrganizationHierarchyEdgeRef],
|
||||||
|
] = {}
|
||||||
|
for relation in relations:
|
||||||
|
relation_type = relation_type_by_id[relation.relation_type_id]
|
||||||
|
if (
|
||||||
|
relation.source_unit_id not in units
|
||||||
|
or relation.target_unit_id not in units
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
f"relation_unit_missing:{relation.id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
edge = OrganizationHierarchyEdgeRef(
|
||||||
|
id=relation.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
structure=structure_ref,
|
||||||
|
relation_type=_relation_type_ref(relation_type),
|
||||||
|
source_unit_id=relation.source_unit_id,
|
||||||
|
target_unit_id=relation.target_unit_id,
|
||||||
|
valid_from=relation.valid_from,
|
||||||
|
valid_until=relation.valid_until,
|
||||||
|
)
|
||||||
|
outgoing_lists.setdefault(edge.source_unit_id, []).append(edge)
|
||||||
|
incoming_lists.setdefault(edge.target_unit_id, []).append(edge)
|
||||||
|
return _HierarchyGraph(
|
||||||
|
status=(
|
||||||
|
"inactive"
|
||||||
|
if not structure.is_active
|
||||||
|
else "invalid"
|
||||||
|
if invalid_relation_filter
|
||||||
|
else "active"
|
||||||
|
),
|
||||||
|
structure_id=structure_id,
|
||||||
|
structure=structure_ref,
|
||||||
|
relation_type_ids=selected_ids,
|
||||||
|
units=units,
|
||||||
|
outgoing={
|
||||||
|
key: tuple(sorted(value, key=lambda item: item.id))
|
||||||
|
for key, value in outgoing_lists.items()
|
||||||
|
},
|
||||||
|
incoming={
|
||||||
|
key: tuple(sorted(value, key=lambda item: item.id))
|
||||||
|
for key, value in incoming_lists.items()
|
||||||
|
},
|
||||||
|
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _relative_resolution(
|
||||||
|
self,
|
||||||
|
graph: _HierarchyGraph,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
root_unit_id: str,
|
||||||
|
direction: OrganizationHierarchyDirection,
|
||||||
|
max_depth: int,
|
||||||
|
) -> OrganizationHierarchyResolution:
|
||||||
|
root = graph.units.get(root_unit_id)
|
||||||
|
if root is None:
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=(
|
||||||
|
graph.structure_id
|
||||||
|
),
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status="missing",
|
||||||
|
diagnostics=tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
(*graph.diagnostics, "root_unit_missing")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if graph.status in {"missing", "invalid"}:
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=(
|
||||||
|
graph.structure_id
|
||||||
|
),
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status=graph.status,
|
||||||
|
root=root,
|
||||||
|
diagnostics=graph.diagnostics,
|
||||||
|
)
|
||||||
|
adjacency = (
|
||||||
|
graph.incoming
|
||||||
|
if direction == "ancestors"
|
||||||
|
else graph.outgoing
|
||||||
|
)
|
||||||
|
pending = deque(
|
||||||
|
[(root_unit_id, 0, (), frozenset({root_unit_id}))]
|
||||||
|
)
|
||||||
|
best_depth = {root_unit_id: 0}
|
||||||
|
matches: dict[str, OrganizationHierarchyMatchRef] = {}
|
||||||
|
diagnostics = list(graph.diagnostics)
|
||||||
|
cycle_detected = False
|
||||||
|
depth_limited = False
|
||||||
|
while pending:
|
||||||
|
current_id, current_depth, path, path_units = pending.popleft()
|
||||||
|
edges = adjacency.get(current_id, ())
|
||||||
|
if current_depth >= max_depth:
|
||||||
|
if edges:
|
||||||
|
depth_limited = True
|
||||||
|
continue
|
||||||
|
for edge in edges:
|
||||||
|
next_id = (
|
||||||
|
edge.source_unit_id
|
||||||
|
if direction == "ancestors"
|
||||||
|
else edge.target_unit_id
|
||||||
|
)
|
||||||
|
if next_id in path_units:
|
||||||
|
cycle_detected = True
|
||||||
|
diagnostics.append(f"cycle_detected:{edge.id}")
|
||||||
|
continue
|
||||||
|
unit = graph.units.get(next_id)
|
||||||
|
if unit is None:
|
||||||
|
diagnostics.append(f"unit_missing:{next_id}")
|
||||||
|
continue
|
||||||
|
next_depth = current_depth + 1
|
||||||
|
previous_depth = best_depth.get(next_id)
|
||||||
|
if (
|
||||||
|
previous_depth is not None
|
||||||
|
and previous_depth <= next_depth
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
next_path = (*path, edge)
|
||||||
|
best_depth[next_id] = next_depth
|
||||||
|
matches[next_id] = OrganizationHierarchyMatchRef(
|
||||||
|
unit=unit,
|
||||||
|
depth=next_depth,
|
||||||
|
path=next_path,
|
||||||
|
)
|
||||||
|
pending.append(
|
||||||
|
(
|
||||||
|
next_id,
|
||||||
|
next_depth,
|
||||||
|
next_path,
|
||||||
|
path_units | {next_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if depth_limited:
|
||||||
|
diagnostics.append("maximum_depth_reached")
|
||||||
|
return OrganizationHierarchyResolution(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
root_unit_id=root_unit_id,
|
||||||
|
direction=direction,
|
||||||
|
structure_id=graph.structure_id,
|
||||||
|
relation_type_ids=graph.relation_type_ids,
|
||||||
|
max_depth=max_depth,
|
||||||
|
status=(
|
||||||
|
"active"
|
||||||
|
if root.status == "active" and graph.status == "active"
|
||||||
|
else "inactive"
|
||||||
|
),
|
||||||
|
root=root,
|
||||||
|
matches=tuple(
|
||||||
|
sorted(
|
||||||
|
matches.values(),
|
||||||
|
key=lambda item: (
|
||||||
|
item.depth,
|
||||||
|
item.unit.name.casefold(),
|
||||||
|
item.unit.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
cycle_detected=cycle_detected,
|
||||||
|
depth_limited=depth_limited,
|
||||||
|
diagnostics=tuple(dict.fromkeys(diagnostics)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_datetime(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["SqlOrganizationDirectory"]
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
ModuleContext,
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||||
|
|
||||||
@@ -89,6 +94,16 @@ manifest = ModuleManifest(
|
|||||||
version="0.1.8",
|
version="0.1.8",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
optional_dependencies=("tenancy", "access", "audit", "policy"),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="organizations.directory",
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="organizations.hierarchy_directory",
|
||||||
|
version="0.1.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
route_factory=_route_factory,
|
route_factory=_route_factory,
|
||||||
@@ -98,6 +113,15 @@ manifest = ModuleManifest(
|
|||||||
package_name="@govoplan/organizations-webui",
|
package_name="@govoplan/organizations-webui",
|
||||||
routes=(FrontendRoute(path="/organizations", component="OrganizationsPage", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
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),),
|
nav_items=(NavItem(path="/organizations", label="Organizations", icon="users", required_any=ORGANIZATIONS_READ_SCOPES, order=70),),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="organizations.admin.tenant",
|
||||||
|
module_id="organizations",
|
||||||
|
kind="section",
|
||||||
|
label="Organizations administration",
|
||||||
|
order=85,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id="organizations",
|
module_id="organizations",
|
||||||
@@ -119,6 +143,9 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (
|
||||||
|
_organization_directory
|
||||||
|
),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
|
|||||||
444
tests/test_hierarchy_directory.py
Normal file
444
tests/test_hierarchy_directory.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.events import EventBus, event_bus_context
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_organizations.backend.api.v1.routes import _commit
|
||||||
|
from govoplan_organizations.backend.db.models import (
|
||||||
|
OrganizationFunction,
|
||||||
|
OrganizationFunctionType,
|
||||||
|
OrganizationRelation,
|
||||||
|
OrganizationRelationType,
|
||||||
|
OrganizationStructure,
|
||||||
|
OrganizationUnit,
|
||||||
|
OrganizationUnitType,
|
||||||
|
)
|
||||||
|
from govoplan_organizations.backend.directory import (
|
||||||
|
SqlOrganizationDirectory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationHierarchyDirectoryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
OrganizationUnitType.__table__,
|
||||||
|
OrganizationStructure.__table__,
|
||||||
|
OrganizationRelationType.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationRelation.__table__,
|
||||||
|
OrganizationFunctionType.__table__,
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.Session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
self._seed()
|
||||||
|
self.directory = SqlOrganizationDirectory(
|
||||||
|
session_factory=self.Session,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
OrganizationFunction.__table__,
|
||||||
|
OrganizationFunctionType.__table__,
|
||||||
|
OrganizationRelation.__table__,
|
||||||
|
OrganizationUnit.__table__,
|
||||||
|
OrganizationRelationType.__table__,
|
||||||
|
OrganizationStructure.__table__,
|
||||||
|
OrganizationUnitType.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _seed(self) -> None:
|
||||||
|
self.unit_type = OrganizationUnitType(
|
||||||
|
id="type-department",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="department",
|
||||||
|
name="Department",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.function_type = OrganizationFunctionType(
|
||||||
|
id="type-intake",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="intake",
|
||||||
|
name="Intake",
|
||||||
|
organization_unit_type_id=self.unit_type.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.employer = OrganizationStructure(
|
||||||
|
id="structure-employer",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="employer",
|
||||||
|
name="Employer hierarchy",
|
||||||
|
structure_kind="hierarchy",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.project = OrganizationStructure(
|
||||||
|
id="structure-project",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="project",
|
||||||
|
name="Project hierarchy",
|
||||||
|
structure_kind="hierarchy",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.employer_parent = OrganizationRelationType(
|
||||||
|
id="relation-type-employer",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
slug="contains",
|
||||||
|
name="Contains",
|
||||||
|
is_hierarchical=True,
|
||||||
|
allow_cycles=False,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.project_parent = OrganizationRelationType(
|
||||||
|
id="relation-type-project",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.project.id,
|
||||||
|
slug="project-contains",
|
||||||
|
name="Contains",
|
||||||
|
is_hierarchical=True,
|
||||||
|
allow_cycles=False,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.root = self._unit("unit-root", "Root")
|
||||||
|
self.child = self._unit("unit-child", "Child")
|
||||||
|
self.grandchild = self._unit("unit-grandchild", "Grandchild")
|
||||||
|
self.project_root = self._unit("unit-project", "Project root")
|
||||||
|
self.other_tenant = self._unit(
|
||||||
|
"unit-other-tenant",
|
||||||
|
"Other tenant",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
)
|
||||||
|
self.employer_child = OrganizationRelation(
|
||||||
|
id="edge-employer-child",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
relation_type_id=self.employer_parent.id,
|
||||||
|
source_unit_id=self.root.id,
|
||||||
|
target_unit_id=self.child.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.employer_grandchild = OrganizationRelation(
|
||||||
|
id="edge-employer-grandchild",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
relation_type_id=self.employer_parent.id,
|
||||||
|
source_unit_id=self.child.id,
|
||||||
|
target_unit_id=self.grandchild.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.project_child = OrganizationRelation(
|
||||||
|
id="edge-project-child",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.project.id,
|
||||||
|
relation_type_id=self.project_parent.id,
|
||||||
|
source_unit_id=self.project_root.id,
|
||||||
|
target_unit_id=self.child.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.function = OrganizationFunction(
|
||||||
|
id="function-child-intake",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
function_type_id=self.function_type.id,
|
||||||
|
organization_unit_id=self.child.id,
|
||||||
|
slug="intake",
|
||||||
|
name="Child intake",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.unit_type,
|
||||||
|
self.function_type,
|
||||||
|
self.employer,
|
||||||
|
self.project,
|
||||||
|
self.employer_parent,
|
||||||
|
self.project_parent,
|
||||||
|
self.root,
|
||||||
|
self.child,
|
||||||
|
self.grandchild,
|
||||||
|
self.project_root,
|
||||||
|
self.other_tenant,
|
||||||
|
self.employer_child,
|
||||||
|
self.employer_grandchild,
|
||||||
|
self.project_child,
|
||||||
|
self.function,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def _unit(
|
||||||
|
self,
|
||||||
|
item_id: str,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
) -> OrganizationUnit:
|
||||||
|
return OrganizationUnit(
|
||||||
|
id=item_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
unit_type_id=(
|
||||||
|
self.unit_type.id if tenant_id == "tenant-1" else None
|
||||||
|
),
|
||||||
|
slug=item_id,
|
||||||
|
name=name,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parallel_structures_preserve_distinct_ancestor_provenance(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
employer = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.child.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
project = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.child.id,),
|
||||||
|
structure_id=self.project.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual([self.root.id], [item.unit.id for item in employer.matches])
|
||||||
|
self.assertEqual(
|
||||||
|
[self.project_root.id],
|
||||||
|
[item.unit.id for item in project.matches],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.employer.id,
|
||||||
|
employer.matches[0].path[0].structure.id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.project_parent.id,
|
||||||
|
project.matches[0].path[0].relation_type.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hierarchy_catalog_is_tenant_scoped_and_preserves_structure_links(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
catalog = self.directory.hierarchy_catalog("tenant-1")
|
||||||
|
empty = self.directory.hierarchy_catalog("tenant-2")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{self.employer.id, self.project.id},
|
||||||
|
{item.id for item in catalog.structures},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
(self.employer_parent.id, self.employer.id),
|
||||||
|
(self.project_parent.id, self.project.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
(item.id, item.structure_id)
|
||||||
|
for item in catalog.relation_types
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual((), empty.structures)
|
||||||
|
self.assertEqual((), empty.relation_types)
|
||||||
|
|
||||||
|
def test_bounded_paths_report_depth_and_cycles(self) -> None:
|
||||||
|
bounded = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.root.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="descendants",
|
||||||
|
max_depth=1,
|
||||||
|
)[0]
|
||||||
|
path = self.directory.resolve_hierarchy_paths(
|
||||||
|
"tenant-1",
|
||||||
|
((self.child.id, self.root.id),),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual([self.child.id], [item.unit.id for item in bounded.matches])
|
||||||
|
self.assertTrue(bounded.depth_limited)
|
||||||
|
self.assertEqual("active", path.status)
|
||||||
|
self.assertEqual(
|
||||||
|
[self.employer_child.id],
|
||||||
|
[edge.id for edge in path.path],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.employer_parent.allow_cycles = True
|
||||||
|
self.session.add(
|
||||||
|
OrganizationRelation(
|
||||||
|
id="edge-cycle",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
relation_type_id=self.employer_parent.id,
|
||||||
|
source_unit_id=self.grandchild.id,
|
||||||
|
target_unit_id=self.root.id,
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
cycle = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.root.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="descendants",
|
||||||
|
)[0]
|
||||||
|
self.assertTrue(cycle.cycle_detected)
|
||||||
|
self.assertTrue(
|
||||||
|
any(item.startswith("cycle_detected:") for item in cycle.diagnostics)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_function_and_unit_type_resolution_explains_missing_state(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
functions = self.directory.resolve_functions_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
self.function_type.id,
|
||||||
|
organization_unit_ids=(self.child.id, "missing-unit"),
|
||||||
|
)
|
||||||
|
missing_type = self.directory.resolve_functions_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
"missing-type",
|
||||||
|
organization_unit_ids=(self.child.id,),
|
||||||
|
)
|
||||||
|
units = self.directory.resolve_units_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
self.unit_type.id,
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
root_unit_id=self.root.id,
|
||||||
|
direction="descendants",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([self.function.id], [item.id for item in functions.matches])
|
||||||
|
self.assertEqual(("missing-unit",), functions.missing_unit_ids)
|
||||||
|
self.assertEqual("missing", missing_type.status)
|
||||||
|
self.assertEqual(
|
||||||
|
{self.root.id, self.child.id, self.grandchild.id},
|
||||||
|
{item.id for item in units.matches},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_moved_deactivated_and_cross_tenant_units_are_current(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.employer_child.source_unit_id = self.project_root.id
|
||||||
|
self.child.is_active = False
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
moved = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.child.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
isolated = self.directory.resolve_hierarchy_relatives(
|
||||||
|
"tenant-1",
|
||||||
|
(self.other_tenant.id,),
|
||||||
|
structure_id=self.employer.id,
|
||||||
|
direction="ancestors",
|
||||||
|
)[0]
|
||||||
|
functions = self.directory.resolve_functions_by_type(
|
||||||
|
"tenant-1",
|
||||||
|
self.function_type.id,
|
||||||
|
organization_unit_ids=(self.child.id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("inactive", moved.status)
|
||||||
|
self.assertEqual(
|
||||||
|
[self.project_root.id],
|
||||||
|
[item.unit.id for item in moved.matches],
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", isolated.status)
|
||||||
|
self.assertEqual((self.child.id,), functions.inactive_unit_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationLifecycleEventTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
self.Session = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_changes_emit_versioned_commit_coupled_events(self) -> None:
|
||||||
|
events = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", events.append)
|
||||||
|
with event_bus_context(bus):
|
||||||
|
unit_type = OrganizationUnitType(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="department",
|
||||||
|
name="Department",
|
||||||
|
is_active=True,
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
self.session.add(unit_type)
|
||||||
|
_commit(self.session, unit_type)
|
||||||
|
unit_type.name = "Office"
|
||||||
|
_commit(self.session, unit_type)
|
||||||
|
unit_type.is_active = False
|
||||||
|
_commit(self.session, unit_type)
|
||||||
|
|
||||||
|
organization_events = [
|
||||||
|
event
|
||||||
|
for event in events
|
||||||
|
if event.module_id == "organizations"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"organizations.unit_type.created.v1",
|
||||||
|
"organizations.unit_type.updated.v1",
|
||||||
|
"organizations.unit_type.deactivated.v1",
|
||||||
|
],
|
||||||
|
[event.type for event in organization_events],
|
||||||
|
)
|
||||||
|
self.assertTrue(unit_type.id)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
organization_events[0].payload["schema_version"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
unit_type.id,
|
||||||
|
organization_events[0].payload["resource_id"],
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"name",
|
||||||
|
organization_events[1].payload["changed_fields"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"inactive",
|
||||||
|
organization_events[2].payload["status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test:organizations-tree": "node scripts/test-organizations-tree-structure.mjs"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
@@ -14,12 +17,12 @@
|
|||||||
"./styles/organizations.css": "./src/styles/organizations.css"
|
"./styles/organizations.css": "./src/styles/organizations.css"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": ">=7.18.2 <8",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
},
|
},
|
||||||
|
|||||||
24
webui/scripts/test-organizations-tree-structure.mjs
Normal file
24
webui/scripts/test-organizations-tree-structure.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = readFileSync(
|
||||||
|
new URL("../src/features/organizations/OrganizationsPage.tsx", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const styles = readFileSync(
|
||||||
|
new URL("../src/styles/organizations.css", import.meta.url),
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(source.includes("ExplorerTree,"), "Organizations imports the central ExplorerTree");
|
||||||
|
assert(source.includes("<ExplorerTree"), "the organization hierarchy uses ExplorerTree");
|
||||||
|
assert(source.includes("collapsible={false}"), "the always-expanded hierarchy uses the non-collapsible contract");
|
||||||
|
assert(source.includes("renderNodeActions="), "per-unit controls use the sibling node-action slot");
|
||||||
|
assert(source.includes("<IconButton"), "per-unit controls use the central icon-only action primitive");
|
||||||
|
assert(!source.includes("renderUnitTreeNodes"), "the custom recursive renderer was removed");
|
||||||
|
assert(!source.includes("organizations-tree-row"), "the custom tree row was removed");
|
||||||
|
assert(!source.includes("organizations-tree-node"), "the custom tree node was removed");
|
||||||
|
assert(!styles.includes("organizations-tree-"), "Organizations no longer owns shared tree styling");
|
||||||
@@ -121,7 +121,7 @@ export default function OrganizationsAdminPanel({ settings, auth }: { settings:
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
<Card title="i18n:govoplan-organizations.audit_and_retention.3ba1d2fc">
|
||||||
<div className="organizations-form-grid">
|
<div className="admin-form-grid two-columns">
|
||||||
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
|
<FormField label="i18n:govoplan-organizations.audit_detail_level.7397355d">
|
||||||
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
<select value={draft.audit_detail_level} disabled={!canWrite || busy} onChange={(event) => setDraft({ ...draft, audit_detail_level: event.target.value as OrganizationAuditDetailLevel })}>
|
||||||
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
{AUDIT_DETAIL_LEVELS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
|
||||||
|
|||||||
@@ -8,20 +8,25 @@ import {
|
|||||||
DataGrid,
|
DataGrid,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
ExplorerTree,
|
||||||
FormField,
|
FormField,
|
||||||
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
ModuleSubnav,
|
ModuleSubnav,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
TableActionGroup,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
useEffectiveView,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
usePlatformUiCapabilities,
|
usePlatformUiCapabilities,
|
||||||
|
useViewSurfaces,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type ModuleSubnavGroup,
|
type ModuleSubnavGroup,
|
||||||
type OrganizationFunctionActionContribution,
|
|
||||||
type OrganizationFunctionActionsUiCapability
|
type OrganizationFunctionActionsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
@@ -294,15 +299,6 @@ function activeStatus(active: boolean): JSX.Element {
|
|||||||
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
|
return <StatusBadge status={active ? "success" : "inactive"} label={active ? "i18n:govoplan-organizations.active.a733b809" : "i18n:govoplan-organizations.inactive.09af574c"} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RowActions({ disabled, onEdit, extra }: { disabled: boolean; onEdit: () => void; extra?: JSX.Element[] }) {
|
|
||||||
return (
|
|
||||||
<div className="organizations-row-actions">
|
|
||||||
<AdminIconButton label="i18n:govoplan-organizations.edit.7dce1220" icon={<Edit3 size={16} aria-hidden="true" />} disabled={disabled} onClick={onEdit} />
|
|
||||||
{extra}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SluggedFields({
|
function SluggedFields({
|
||||||
draft,
|
draft,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -385,6 +381,8 @@ export default function OrganizationsPage({
|
|||||||
const [selectedUnitId, setSelectedUnitId] = useState("");
|
const [selectedUnitId, setSelectedUnitId] = useState("");
|
||||||
const [changeRequestId, setChangeRequestId] = useState("");
|
const [changeRequestId, setChangeRequestId] = useState("");
|
||||||
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
const functionActionCapabilities = usePlatformUiCapabilities<OrganizationFunctionActionsUiCapability>("organizations.functionActions");
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
|
const viewSurfaces = useViewSurfaces();
|
||||||
|
|
||||||
const canWriteModel = hasScope(auth, "organizations:model:write");
|
const canWriteModel = hasScope(auth, "organizations:model:write");
|
||||||
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
const canWriteUnits = hasScope(auth, "organizations:unit:write");
|
||||||
@@ -400,8 +398,11 @@ export default function OrganizationsPage({
|
|||||||
() => functionActionCapabilities
|
() => functionActionCapabilities
|
||||||
.flatMap((capability) => capability.actions)
|
.flatMap((capability) => capability.actions)
|
||||||
.filter((contribution) => contributionVisible(auth, contribution))
|
.filter((contribution) => contributionVisible(auth, contribution))
|
||||||
|
.filter((contribution) =>
|
||||||
|
isViewSurfaceVisible(effectiveView, contribution.surfaceId, viewSurfaces)
|
||||||
|
)
|
||||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||||
[auth, functionActionCapabilities]
|
[auth, effectiveView, functionActionCapabilities, viewSurfaces]
|
||||||
);
|
);
|
||||||
const unitsByParentId = useMemo(() => {
|
const unitsByParentId = useMemo(() => {
|
||||||
const mapped = new Map<string, OrganizationUnitItem[]>();
|
const mapped = new Map<string, OrganizationUnitItem[]>();
|
||||||
@@ -870,7 +871,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
|
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, sortable: true, filterable: true, value: (row) => row.slug },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
|
{ id: "description", header: "i18n:govoplan-organizations.description.55f8ebc8", minWidth: 220, value: (row) => row.description ?? "" },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editUnitType(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editUnitType(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
|
const structureColumns: DataGridColumn<OrganizationStructureItem>[] = [
|
||||||
@@ -878,7 +879,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
|
{ id: "kind", header: "i18n:govoplan-organizations.kind.794c9d9c", width: 140, sortable: true, filterable: true, value: (row) => row.structure_kind },
|
||||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editStructure(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editStructure(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
|
const relationTypeColumns: DataGridColumn<OrganizationRelationTypeItem>[] = [
|
||||||
@@ -888,7 +889,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
|
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 170, render: (row) => optionalLabel(unitTypeById.get(row.target_unit_type_id || "")?.name) },
|
||||||
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
|
{ id: "hierarchical", header: "i18n:govoplan-organizations.hierarchical.8964f313", width: 130, render: (row) => activeStatus(row.is_hierarchical) },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editRelationType(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editRelationType(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
|
const unitColumns: DataGridColumn<OrganizationUnitItem>[] = [
|
||||||
@@ -897,7 +898,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
|
{ id: "parent", header: "i18n:govoplan-organizations.parent_unit.1986c35e", minWidth: 180, render: (row) => optionalLabel(unitById.get(row.parent_id || "")?.name) },
|
||||||
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
{ id: "slug", header: "i18n:govoplan-organizations.slug.094da9b9", width: 160, value: (row) => row.slug },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteUnits || busy} onEdit={() => editUnit(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editUnit(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
|
const relationColumns: DataGridColumn<OrganizationRelationItem>[] = [
|
||||||
@@ -906,7 +907,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
|
{ id: "source", header: "i18n:govoplan-organizations.source_unit.2fbd8baa", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.source_unit_id)?.name) },
|
||||||
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
|
{ id: "target", header: "i18n:govoplan-organizations.target_unit.a1507d86", minWidth: 190, render: (row) => optionalLabel(unitById.get(row.target_unit_id)?.name) },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteUnits || busy} onEdit={() => editRelation(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteUnits || busy, onClick: () => editRelation(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
|
const functionTypeColumns: DataGridColumn<OrganizationFunctionTypeItem>[] = [
|
||||||
@@ -915,7 +916,7 @@ export default function OrganizationsPage({
|
|||||||
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
|
{ id: "delegable", header: "i18n:govoplan-organizations.delegable.b4f0137d", width: 120, render: (row) => activeStatus(row.delegable) },
|
||||||
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
|
{ id: "actInPlace", header: "i18n:govoplan-organizations.act_in_place.49b942bd", width: 140, render: (row) => activeStatus(row.act_in_place_allowed) },
|
||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{ id: "actions", header: "", width: 88, sticky: "end", render: (row) => <RowActions disabled={!canWriteModel || busy} onEdit={() => editFunctionType(row)} /> }
|
{ id: "actions", header: "i18n:govoplan-organizations.actions.c3cd636a", width: 72, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[{ id: "edit", label: "i18n:govoplan-organizations.edit.7dce1220", icon: <Edit3 size={16} aria-hidden="true" />, disabled: !canWriteModel || busy, onClick: () => editFunctionType(row) }]} /> }
|
||||||
];
|
];
|
||||||
|
|
||||||
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
|
const functionColumns: DataGridColumn<OrganizationFunctionItem>[] = [
|
||||||
@@ -926,20 +927,26 @@ export default function OrganizationsPage({
|
|||||||
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
{ id: "status", header: "i18n:govoplan-organizations.status.bae7d5be", width: 120, render: (row) => activeStatus(row.is_active) },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "",
|
header: "i18n:govoplan-organizations.actions.c3cd636a",
|
||||||
width: functionActionContributions.length ? 220 : 88,
|
width: 88 + (functionActionContributions.length * 40),
|
||||||
sticky: "end",
|
sticky: "end",
|
||||||
render: (row) => (
|
resizable: false,
|
||||||
<RowActions
|
align: "right",
|
||||||
disabled={!canWriteFunctions || busy}
|
render: (row) => <TableActionGroup actions={[
|
||||||
onEdit={() => editFunction(row)}
|
{
|
||||||
extra={functionActionContributions.map((contribution) => (
|
id: "edit",
|
||||||
<span className="organizations-contributed-action" key={contribution.id}>
|
label: "i18n:govoplan-organizations.edit.7dce1220",
|
||||||
{contribution.render({ settings, auth, function: row })}
|
icon: <Edit3 size={16} aria-hidden="true" />,
|
||||||
</span>
|
disabled: !canWriteFunctions || busy,
|
||||||
))}
|
onClick: () => editFunction(row)
|
||||||
/>
|
},
|
||||||
)
|
...functionActionContributions.map((contribution) => ({
|
||||||
|
id: contribution.id,
|
||||||
|
label: contribution.label,
|
||||||
|
icon: contribution.icon,
|
||||||
|
onClick: () => contribution.onClick({ settings, auth, function: row })
|
||||||
|
}))
|
||||||
|
]} />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1003,41 +1010,42 @@ export default function OrganizationsPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUnitTreeNodes(parentId = "", depth = 0, seen: ReadonlySet<string> = new Set()): JSX.Element[] {
|
|
||||||
return (unitsByParentId.get(parentId) ?? []).flatMap((unit) => {
|
|
||||||
if (seen.has(unit.id)) return [];
|
|
||||||
const nextSeen = new Set(seen);
|
|
||||||
nextSeen.add(unit.id);
|
|
||||||
return [
|
|
||||||
<div className="organizations-tree-row" key={unit.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`organizations-tree-node ${selectedUnitId === unit.id ? "active" : ""}`.trim()}
|
|
||||||
style={{ paddingLeft: `${10 + depth * 18}px` }}
|
|
||||||
onClick={() => setSelectedUnitId(unit.id)}
|
|
||||||
>
|
|
||||||
<strong>{unit.name}</strong>
|
|
||||||
<span>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</span>
|
|
||||||
</button>
|
|
||||||
<Button type="button" variant="ghost" disabled={!canWriteUnits || busy} onClick={() => addSubunit(unit)} title="i18n:govoplan-organizations.add_subunit.8256a8f7">
|
|
||||||
<Plus size={16} aria-hidden="true" /> i18n:govoplan-organizations.add_subunit.8256a8f7
|
|
||||||
</Button>
|
|
||||||
</div>,
|
|
||||||
...renderUnitTreeNodes(unit.id, depth + 1, nextSeen)
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUnitsSection() {
|
function renderUnitsSection() {
|
||||||
return (
|
return (
|
||||||
<div className="organizations-table-stack">
|
<div className="organizations-table-stack">
|
||||||
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
<Card title="i18n:govoplan-organizations.organization_tree.e5bfb195" actions={<AdminIconButton label="i18n:govoplan-organizations.add_root_unit.1ef8a9f5" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
||||||
<div className="organizations-tree-toolbar">
|
<div className="explorer-tree-toolbar">
|
||||||
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
{selectedUnitId && <Button type="button" variant="ghost" disabled={busy} onClick={() => setSelectedUnitId("")}>i18n:govoplan-organizations.none.334c4a4c</Button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="organizations-tree-list">
|
{model.units.length ? (
|
||||||
{model.units.length ? renderUnitTreeNodes() : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
<ExplorerTree
|
||||||
</div>
|
nodes={unitsByParentId.get("") ?? []}
|
||||||
|
getNodeId={(unit) => unit.id}
|
||||||
|
getNodeLabel={(unit) => unit.name}
|
||||||
|
getNodeChildren={(unit) => unitsByParentId.get(unit.id) ?? []}
|
||||||
|
activeId={selectedUnitId}
|
||||||
|
collapsible={false}
|
||||||
|
depth={0}
|
||||||
|
className="explorer-tree-scroll-region"
|
||||||
|
getNodeWrapStyle={(_unit, context) => ({ paddingLeft: `${Math.min(context.depth * 18, 72)}px` })}
|
||||||
|
onOpen={(unit) => setSelectedUnitId(unit.id)}
|
||||||
|
renderNodeContent={(unit) => (
|
||||||
|
<span className="explorer-tree-node-content">
|
||||||
|
<strong>{unit.name}</strong>
|
||||||
|
<small>{unitTypeById.get(unit.unit_type_id || "")?.name ?? "i18n:govoplan-organizations.no_unit_type.73e799f5"}</small>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
renderNodeActions={(unit) => (
|
||||||
|
<IconButton
|
||||||
|
label="i18n:govoplan-organizations.add_subunit.8256a8f7"
|
||||||
|
icon={<Plus size={16} aria-hidden="true" />}
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!canWriteUnits || busy}
|
||||||
|
onClick={() => addSubunit(unit)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : <p className="muted small-note">i18n:govoplan-organizations.no_units_found.eea2dd0c</p>}
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
<Card title="i18n:govoplan-organizations.units.e14d0d92" actions={<AdminIconButton label="i18n:govoplan-organizations.add_unit.8fa12fb1" icon={<Plus size={16} aria-hidden="true" />} variant="primary" disabled={!canWriteUnits || busy} onClick={() => openUnitCreate()} />}>
|
||||||
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
|
<DataGrid id="organizations-units" rows={model.units} columns={unitColumns} getRowKey={(row) => row.id} emptyText="i18n:govoplan-organizations.no_units_found.eea2dd0c" initialFit="container" />
|
||||||
@@ -1129,7 +1137,7 @@ export default function OrganizationsPage({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<form id={formId} className="organizations-form-grid" onSubmit={(event) => void submit(event)}>
|
<form id={formId} className="admin-form-grid two-columns" onSubmit={(event) => void submit(event)}>
|
||||||
{renderEditorFields()}
|
{renderEditorFields()}
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const organizationAdminSections: AdminSectionsUiCapability = {
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
id: "tenant-organization-settings",
|
id: "tenant-organization-settings",
|
||||||
|
surfaceId: "organizations.admin.tenant",
|
||||||
label: "i18n:govoplan-organizations.organizations.220edf64",
|
label: "i18n:govoplan-organizations.organizations.220edf64",
|
||||||
group: "TENANT",
|
group: "TENANT",
|
||||||
order: 85,
|
order: 85,
|
||||||
@@ -45,6 +46,15 @@ export const organizationsModule: PlatformWebModule = {
|
|||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["admin"],
|
optionalDependencies: ["admin"],
|
||||||
translations,
|
translations,
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "organizations.admin.tenant",
|
||||||
|
moduleId: "organizations",
|
||||||
|
kind: "section",
|
||||||
|
label: "Organizations administration",
|
||||||
|
order: 85
|
||||||
|
}
|
||||||
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{
|
{
|
||||||
to: "/organizations",
|
to: "/organizations",
|
||||||
|
|||||||
@@ -30,32 +30,6 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-grid .wide {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-form-actions,
|
|
||||||
.organizations-row-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-row-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-contributed-action {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-check-list {
|
.organizations-check-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -76,51 +50,6 @@
|
|||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-tree-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 4px;
|
|
||||||
max-height: 640px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-node {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-node:hover,
|
|
||||||
.organizations-tree-node.active {
|
|
||||||
border-color: var(--line);
|
|
||||||
background: var(--surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-tree-node span {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.organizations-field-note {
|
.organizations-field-note {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -147,8 +76,4 @@
|
|||||||
.organizations-workspace {
|
.organizations-workspace {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.organizations-form-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user