Initialize organizations module
This commit is contained in:
3
src/govoplan_organizations/__init__.py
Normal file
3
src/govoplan_organizations/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN organizations module."""
|
||||
|
||||
__version__ = "0.1.6"
|
||||
1
src/govoplan_organizations/backend/__init__.py
Normal file
1
src/govoplan_organizations/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend integration for the GovOPlaN organizations module."""
|
||||
1
src/govoplan_organizations/backend/db/__init__.py
Normal file
1
src/govoplan_organizations/backend/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Organizations database models."""
|
||||
80
src/govoplan_organizations/backend/db/models.py
Normal file
80
src/govoplan_organizations/backend/db/models.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class OrganizationUnit(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_units"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organizations_units_tenant_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
parent_id: Mapped[str | None] = mapped_column(ForeignKey("organizations_units.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class OrganizationFunction(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_functions"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_organizations_functions_tenant_unit_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class OrganizationFunctionAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "organizations_function_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"function_id",
|
||||
"organization_unit_id",
|
||||
name="uq_organizations_function_assignments_account_scope",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
identity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
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",
|
||||
"OrganizationUnit",
|
||||
"new_uuid",
|
||||
]
|
||||
146
src/govoplan_organizations/backend/directory.py
Normal file
146
src/govoplan_organizations/backend/directory.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from govoplan_core.core.organizations import (
|
||||
OrganizationDirectory,
|
||||
OrganizationFunctionAssignmentRef,
|
||||
OrganizationFunctionRef,
|
||||
OrganizationUnitRef,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
return "active" if active else "inactive"
|
||||
|
||||
|
||||
def _unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
||||
return OrganizationUnitRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
description=item.description,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_ref(item: OrganizationFunction) -> OrganizationFunctionRef:
|
||||
return OrganizationFunctionRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
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 _assignment_ref(item: OrganizationFunctionAssignment) -> OrganizationFunctionAssignmentRef:
|
||||
return OrganizationFunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.account_id,
|
||||
identity_id=item.identity_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 get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(OrganizationUnit, organization_unit_id)
|
||||
return _unit_ref(item) if item is not None else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
||||
with get_database().session() as session:
|
||||
rows = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_unit_ref(item) for item in rows)
|
||||
|
||||
def get_function(self, function_id: str) -> OrganizationFunctionRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(OrganizationFunction, function_id)
|
||||
return _function_ref(item) if item is not None else None
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> tuple[OrganizationFunctionRef, ...]:
|
||||
with get_database().session() as session:
|
||||
unit_ids = {organization_unit_id}
|
||||
if include_subunits:
|
||||
pending = [organization_unit_id]
|
||||
while pending:
|
||||
parent_id = pending.pop()
|
||||
child_ids = [
|
||||
row[0]
|
||||
for row in session.query(OrganizationUnit.id)
|
||||
.filter(OrganizationUnit.parent_id == parent_id)
|
||||
.all()
|
||||
]
|
||||
for child_id in child_ids:
|
||||
if child_id not in unit_ids:
|
||||
unit_ids.add(child_id)
|
||||
pending.append(child_id)
|
||||
rows = (
|
||||
session.query(OrganizationFunction)
|
||||
.filter(OrganizationFunction.organization_unit_id.in_(unit_ids))
|
||||
.order_by(OrganizationFunction.name.asc())
|
||||
.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, ...]:
|
||||
now = utc_now()
|
||||
with get_database().session() as session:
|
||||
query = (
|
||||
session.query(OrganizationFunctionAssignment)
|
||||
.join(OrganizationFunction, OrganizationFunction.id == OrganizationFunctionAssignment.function_id)
|
||||
.filter(
|
||||
OrganizationFunctionAssignment.account_id == account_id,
|
||||
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())
|
||||
53
src/govoplan_organizations/backend/manifest.py
Normal file
53
src/govoplan_organizations/backend/manifest.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401 - populate metadata
|
||||
|
||||
|
||||
def _organization_directory(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
|
||||
|
||||
return SqlOrganizationDirectory()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="organizations",
|
||||
name="Organizations",
|
||||
version="0.1.6",
|
||||
dependencies=("tenancy",),
|
||||
migration_spec=MigrationSpec(module_id="organizations", metadata=Base.metadata),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
organization_models.OrganizationUnit,
|
||||
organization_models.OrganizationFunction,
|
||||
organization_models.OrganizationFunctionAssignment,
|
||||
label="Organizations",
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY: _organization_directory,
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="organizations.model",
|
||||
title="Organization model",
|
||||
summary="Organizations owns units, hierarchy, functions, and function assignments. Access maps those facts to roles and rights.",
|
||||
body=(
|
||||
"Use organization functions to model responsibilities that outlive people. "
|
||||
"A function assignment can apply to one organization unit or to that unit and all subunits."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=25,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
1
src/govoplan_organizations/py.typed
Normal file
1
src/govoplan_organizations/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user