Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:27 +02:00
parent 669647a986
commit 1f69e6b5a7
15 changed files with 485 additions and 722 deletions

View File

@@ -17,7 +17,6 @@ from govoplan_core.core.configuration_control import (
from govoplan_core.db.session import get_session
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationFunctionType,
OrganizationRelation,
OrganizationRelationType,
@@ -28,13 +27,10 @@ from govoplan_organizations.backend.db.models import (
)
from .schemas import (
FunctionAssignmentCreateRequest,
FunctionAssignmentUpdateRequest,
FunctionCreateRequest,
FunctionTypeCreateRequest,
FunctionTypeUpdateRequest,
FunctionUpdateRequest,
OrganizationFunctionAssignmentItem,
OrganizationFunctionItem,
OrganizationFunctionTypeItem,
OrganizationModelResponse,
@@ -69,7 +65,6 @@ ORG_READ_SCOPES = (
ORG_MODEL_WRITE_SCOPES = ("organizations:model:write",)
ORG_UNIT_WRITE_SCOPES = ("organizations:unit:write",)
ORG_FUNCTION_WRITE_SCOPES = ("organizations:function:write",)
ORG_ASSIGN_SCOPES = ("organizations:function:assign",)
ORG_SETTINGS_READ_SCOPES = ("organizations:settings:read", "organizations:model:read", "admin:settings:read")
ORG_SETTINGS_WRITE_SCOPES = ("organizations:settings:write", "admin:settings:write")
ORG_CHANGE_CONTROL_KEY = "organizations.model"
@@ -262,10 +257,6 @@ def _item_function(item: OrganizationFunction) -> OrganizationFunctionItem:
return OrganizationFunctionItem(**_row_fields(item))
def _item_assignment(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem:
return OrganizationFunctionAssignmentItem(**_row_fields(item))
def _row_fields(item: object) -> dict[str, Any]:
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
return {key: getattr(item, key) for key in keys}
@@ -352,7 +343,6 @@ def get_organization_model(
relations=[_item_relation(item) for item in session.query(OrganizationRelation).filter(OrganizationRelation.tenant_id == tenant_id).order_by(OrganizationRelation.created_at.asc()).all()],
function_types=[_item_function_type(item) for item in session.query(OrganizationFunctionType).filter(OrganizationFunctionType.tenant_id == tenant_id).order_by(OrganizationFunctionType.name.asc()).all()],
functions=[_item_function(item) for item in session.query(OrganizationFunction).filter(OrganizationFunction.tenant_id == tenant_id).order_by(OrganizationFunction.name.asc()).all()],
function_assignments=[_item_assignment(item) for item in session.query(OrganizationFunctionAssignment).filter(OrganizationFunctionAssignment.tenant_id == tenant_id).order_by(OrganizationFunctionAssignment.created_at.asc()).all()],
)
@@ -738,89 +728,6 @@ def update_function(
return result
@router.post("/function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED)
def create_function_assignment(
payload: FunctionAssignmentCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)),
) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal)
approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_assignment", operation="created", payload=payload)
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
item = OrganizationFunctionAssignment(
tenant_id=tenant_id,
identity_id=payload.identity_id,
account_id=payload.account_id,
function_id=function.id,
organization_unit_id=function.organization_unit_id,
applies_to_subunits=payload.applies_to_subunits,
source=payload.source,
delegated_from_assignment_id=payload.delegated_from_assignment_id,
acting_for_account_id=payload.acting_for_account_id,
valid_from=payload.valid_from,
valid_until=payload.valid_until,
is_active=payload.is_active,
settings=payload.settings,
)
if item.delegated_from_assignment_id is not None:
_get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
session.add(item)
saved = _commit(session, item)
result = _item_assignment(saved)
_record_organization_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=result.model_dump(mode="json"))
return result
@router.patch("/function-assignments/{item_id}", response_model=OrganizationFunctionAssignmentItem)
def update_function_assignment(
item_id: str,
payload: FunctionAssignmentUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*ORG_ASSIGN_SCOPES)),
) -> OrganizationFunctionAssignmentItem:
tenant_id = _tenant_id(principal)
item = _get_tenant_row(session, OrganizationFunctionAssignment, item_id, tenant_id, "Organization function assignment")
before = _row_fields(item)
approval, target, _value = _ensure_organization_change_allowed(session, principal, tenant_id=tenant_id, resource_type="function_assignment", operation="updated", payload=payload, resource_id=item_id)
if "function_id" in payload.model_fields_set:
if payload.function_id is None:
raise _invalid("Function is required.")
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
item.function_id = function.id
item.organization_unit_id = function.organization_unit_id
if "identity_id" in payload.model_fields_set and payload.identity_id is None:
raise _invalid("Identity is required.")
if "source" in payload.model_fields_set and payload.source is None:
raise _invalid("Assignment source is required.")
if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None:
raise _invalid("Subunit applicability cannot be empty.")
if "is_active" in payload.model_fields_set and payload.is_active is None:
raise _invalid("Active state cannot be empty.")
if "settings" in payload.model_fields_set and payload.settings is None:
raise _invalid("Settings cannot be empty.")
for field in (
"identity_id",
"account_id",
"applies_to_subunits",
"source",
"delegated_from_assignment_id",
"acting_for_account_id",
"valid_from",
"valid_until",
"is_active",
"settings",
):
if field in payload.model_fields_set:
setattr(item, field, getattr(payload, field))
if item.delegated_from_assignment_id == item.id:
raise _invalid("A function assignment cannot delegate from itself.")
if item.delegated_from_assignment_id is not None:
_get_tenant_row(session, OrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
result = _item_assignment(_commit(session, item))
_record_organization_change_applied(session, principal, approval=approval, target=target, before=before, after=result.model_dump(mode="json"))
return result
def _optional_function_type(session: Session, tenant_id: str, function_type_id: str | None) -> OrganizationFunctionType | None:
if function_type_id is None:
return None

View File

@@ -132,25 +132,6 @@ class OrganizationFunctionItem(BaseModel):
updated_at: datetime
class OrganizationFunctionAssignmentItem(BaseModel):
id: str
tenant_id: str
identity_id: str
account_id: str | None = None
function_id: str
organization_unit_id: str
applies_to_subunits: bool
source: str
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool
settings: dict[str, Any]
created_at: datetime
updated_at: datetime
class OrganizationModelResponse(BaseModel):
unit_types: list[OrganizationUnitTypeItem]
structures: list[OrganizationStructureItem]
@@ -159,7 +140,6 @@ class OrganizationModelResponse(BaseModel):
relations: list[OrganizationRelationItem]
function_types: list[OrganizationFunctionTypeItem]
functions: list[OrganizationFunctionItem]
function_assignments: list[OrganizationFunctionAssignmentItem]
class SluggedCreateRequest(BaseModel):
@@ -270,33 +250,3 @@ class FunctionUpdateRequest(SluggedUpdateRequest):
function_type_id: str | None = None
delegable: bool | None = None
act_in_place_allowed: bool | None = None
class FunctionAssignmentCreateRequest(BaseModel):
identity_id: str
function_id: str
account_id: str | None = None
applies_to_subunits: bool = False
source: str = "direct"
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool = True
settings: dict[str, Any] = Field(default_factory=dict)
change_request_id: str | None = None
class FunctionAssignmentUpdateRequest(BaseModel):
identity_id: str | None = None
function_id: str | None = None
account_id: str | None = None
applies_to_subunits: bool | None = None
source: str | None = None
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
is_active: bool | None = None
settings: dict[str, Any] | None = None
change_request_id: str | None = None

View File

@@ -150,37 +150,8 @@ class OrganizationFunction(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class OrganizationFunctionAssignment(Base, TimestampMixin):
__tablename__ = "organizations_function_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"identity_id",
"function_id",
"organization_unit_id",
name="uq_organizations_function_assignments_identity_scope",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True)
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = [
"OrganizationFunction",
"OrganizationFunctionAssignment",
"OrganizationFunctionType",
"OrganizationRelation",
"OrganizationRelationType",

View File

@@ -1,19 +1,13 @@
from __future__ import annotations
from sqlalchemy import or_
from govoplan_core.core.organizations import (
OrganizationDirectory,
OrganizationFunctionAssignmentRef,
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.db.session import get_database
from govoplan_core.security.time import utc_now
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationFunctionAssignment,
OrganizationUnit,
)
@@ -50,28 +44,7 @@ def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
)
def _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentRef:
return OrganizationFunctionAssignmentRef(
id=item.id,
tenant_id=item.tenant_id,
identity_id=item.identity_id,
account_id=item.account_id,
function_id=item.function_id,
organization_unit_id=item.organization_unit_id,
applies_to_subunits=item.applies_to_subunits,
source=item.source, # type: ignore[arg-type]
delegated_from_assignment_id=item.delegated_from_assignment_id,
acting_for_account_id=item.acting_for_account_id,
valid_from=item.valid_from,
valid_until=item.valid_until,
status=_status(item.is_active), # type: ignore[arg-type]
)
class SqlOrganizationDirectory(OrganizationDirectory):
def __init__(self, *, identity_directory: IdentityDirectory | None = None) -> None:
self._identity_directory = identity_directory
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id)
@@ -121,70 +94,3 @@ class SqlOrganizationDirectory(OrganizationDirectory):
.all()
)
return tuple(_function_ref(item) for item in rows)
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
with get_database().session() as session:
item = session.get(OrganizationFunctionAssignment, assignment_id)
return _assignment_ref(item) if item is not None else None
def function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
identity_ids: set[str] = set()
if self._identity_directory is not None:
identity = self._identity_directory.identity_for_account(account_id)
if identity is not None:
identity_ids.add(identity.id)
if not identity_ids:
return self._function_assignments_for_identity_ids(
identity_ids=(),
tenant_id=tenant_id,
fallback_account_id=account_id,
)
return self._function_assignments_for_identity_ids(
identity_ids=tuple(identity_ids),
tenant_id=tenant_id,
fallback_account_id=account_id,
)
def function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
return self._function_assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id)
def _function_assignments_for_identity_ids(
self,
*,
identity_ids: tuple[str, ...],
tenant_id: str | None = None,
fallback_account_id: str | None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
now = utc_now()
with get_database().session() as session:
identity_or_account_filter = OrganizationFunctionAssignment.identity_id.in_(identity_ids)
if fallback_account_id:
identity_or_account_filter = or_(
identity_or_account_filter,
OrganizationFunctionAssignment.account_id == fallback_account_id,
)
query = (
session.query(OrganizationFunctionAssignment)
.join(OrganizationFunction, OrganizationFunction.id == OrganizationFunctionAssignment.function_id)
.filter(
identity_or_account_filter,
OrganizationFunctionAssignment.is_active.is_(True),
OrganizationFunction.is_active.is_(True),
or_(OrganizationFunctionAssignment.valid_from.is_(None), OrganizationFunctionAssignment.valid_from <= now),
or_(OrganizationFunctionAssignment.valid_until.is_(None), OrganizationFunctionAssignment.valid_until > now),
)
.order_by(OrganizationFunctionAssignment.created_at.asc())
)
if tenant_id is not None:
query = query.filter(OrganizationFunctionAssignment.tenant_id == tenant_id)
return tuple(_assignment_ref(item) for item in query.all())

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationTopic,
@@ -50,22 +49,21 @@ PERMISSIONS = (
_permission("organizations:settings:write", "Manage organization settings", "Edit organization governance, audit, and retention settings."),
_permission("organizations:unit:read", "View organization units", "Read concrete organization units and relations."),
_permission("organizations:unit:write", "Manage organization units", "Create and edit concrete organization units and relations."),
_permission("organizations:function:read", "View organization functions", "Read function definitions and identity-held assignments."),
_permission("organizations:function:read", "View organization functions", "Read function definitions."),
_permission("organizations:function:write", "Manage organization functions", "Create and edit function definitions."),
_permission("organizations:function:assign", "Assign organization functions", "Assign identity-held functions in organization units."),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="organization_modeler",
name="Organization modeler",
description="Manage organization meta-model, concrete units, structures, functions, and assignments.",
description="Manage organization meta-model, concrete units, structures, and functions.",
permissions=tuple(permission.scope for permission in PERMISSIONS),
),
RoleTemplate(
slug="organization_viewer",
name="Organization viewer",
description="Read organization model, organization units, and function assignments.",
description="Read organization model, organization units, and functions.",
permissions=("organizations:model:read", "organizations:settings:read", "organizations:unit:read", "organizations:function:read"),
),
)
@@ -79,15 +77,10 @@ def _route_factory(context: ModuleContext):
def _organization_directory(context: ModuleContext) -> object:
identity_directory: IdentityDirectory | None = None
registry = context.registry
if hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if isinstance(capability, IdentityDirectory):
identity_directory = capability
del context
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
return SqlOrganizationDirectory(identity_directory=identity_directory)
return SqlOrganizationDirectory()
manifest = ModuleManifest(
@@ -95,7 +88,7 @@ manifest = ModuleManifest(
name="Organizations",
version="0.1.6",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("tenancy", "identity", "access", "audit", "policy"),
optional_dependencies=("tenancy", "access", "audit", "policy"),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_route_factory,
@@ -121,7 +114,6 @@ manifest = ModuleManifest(
organization_models.OrganizationUnit,
organization_models.OrganizationFunctionType,
organization_models.OrganizationFunction,
organization_models.OrganizationFunctionAssignment,
label="Organizations",
),
),
@@ -132,11 +124,11 @@ manifest = ModuleManifest(
DocumentationTopic(
id="organizations.model",
title="Organization model",
summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.",
summary="Organizations owns units, hierarchy, and functions. IDM links identities to functions, and Access maps accepted facts to roles and rights.",
body=(
"Use organization unit types, structures, and relation types to model how the institution describes itself. "
"A concrete organization unit can participate in several structures at the same time, such as an employer hierarchy and an academic structure. "
"Functions are held by identities in organization units. Accounts only exercise those functions through identity and access policy."
"Functions describe responsibilities in organization units. IDM links identities to those functions, and Access maps accepted facts to roles and rights."
),
layer="configured",
documentation_types=("admin", "user"),

View File

@@ -239,54 +239,6 @@ def _create_functions() -> None:
_create_index_if_missing(op.f(f"ix_organizations_functions_{column}"), "organizations_functions", [column])
def _create_function_assignments() -> None:
if "organizations_function_assignments" not in _tables():
op.create_table(
"organizations_function_assignments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False),
_json_column("settings"),
*_timestamp_columns(),
sa.ForeignKeyConstraint(["delegated_from_assignment_id"], ["organizations_function_assignments.id"], name=op.f("fk_organizations_function_assignments_delegated_from_assignment_id_organizations_function_assignments"), ondelete="SET NULL"),
sa.ForeignKeyConstraint(["function_id"], ["organizations_functions.id"], name=op.f("fk_organizations_function_assignments_function_id_organizations_functions"), ondelete="CASCADE"),
sa.ForeignKeyConstraint(["organization_unit_id"], ["organizations_units.id"], name=op.f("fk_organizations_function_assignments_organization_unit_id_organizations_units"), ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id", name=op.f("pk_organizations_function_assignments")),
sa.UniqueConstraint("tenant_id", "identity_id", "function_id", "organization_unit_id", name="uq_organizations_function_assignments_identity_scope"),
)
else:
columns = _columns("organizations_function_assignments")
if "identity_id" not in columns:
op.add_column("organizations_function_assignments", sa.Column("identity_id", sa.String(length=36), nullable=True))
op.execute(sa.text("UPDATE organizations_function_assignments SET identity_id = account_id WHERE identity_id IS NULL"))
for column in (
"account_id",
"acting_for_account_id",
"delegated_from_assignment_id",
"function_id",
"identity_id",
"organization_unit_id",
"tenant_id",
):
_create_index_if_missing(op.f(f"ix_organizations_function_assignments_{column}"), "organizations_function_assignments", [column])
_create_index_if_missing(
"uq_organizations_function_assignments_identity_scope",
"organizations_function_assignments",
["tenant_id", "identity_id", "function_id", "organization_unit_id"],
unique=True,
)
def upgrade() -> None:
_create_unit_types()
_create_tenant_settings()
@@ -296,12 +248,10 @@ def upgrade() -> None:
_create_relations()
_create_function_types()
_create_functions()
_create_function_assignments()
def downgrade() -> None:
for table_name, indexes in (
("organizations_function_assignments", ("uq_organizations_function_assignments_identity_scope",)),
("organizations_relations", ("ix_organizations_relations_structure_source", "ix_organizations_relations_structure_target")),
("organizations_relation_types", ("ix_organizations_relation_types_structure",)),
):