From 34133ba9cd64b86c3de82115d8d6fc4116191ba3 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 11:01:37 +0200 Subject: [PATCH] Initialize organizations module --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 ++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 ++ .gitea/ISSUE_TEMPLATE/task.md | 28 ++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 ++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 + .gitignore | 8 + AGENTS.md | 29 ++ README.md | 44 ++ docs/ORGANIZATION_MODEL.md | 36 ++ docs/gitea-labels.json | 416 ++++++++++++++++++ pyproject.toml | 24 + src/govoplan_organizations/__init__.py | 3 + .../backend/__init__.py | 1 + .../backend/db/__init__.py | 1 + .../backend/db/models.py | 80 ++++ .../backend/directory.py | 146 ++++++ .../backend/manifest.py | 53 +++ src/govoplan_organizations/py.typed | 1 + 20 files changed, 1005 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/ORGANIZATION_MODEL.md create mode 100644 docs/gitea-labels.json create mode 100644 pyproject.toml create mode 100644 src/govoplan_organizations/__init__.py create mode 100644 src/govoplan_organizations/backend/__init__.py create mode 100644 src/govoplan_organizations/backend/db/__init__.py create mode 100644 src/govoplan_organizations/backend/db/models.py create mode 100644 src/govoplan_organizations/backend/directory.py create mode 100644 src/govoplan_organizations/backend/manifest.py create mode 100644 src/govoplan_organizations/py.typed diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..83781a6 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/organizations +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..92e940f --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/organizations + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8db8279 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/organizations +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..b5779ae --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/organizations +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..eedcbeb --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/organizations +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7082bff --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +build/ +dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1efbbb8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,29 @@ +# GovOPlaN Organizations Codex Guide + +## Scope + +This repository owns the canonical GovOPlaN organizational model: tenant-local +organization units, hierarchy, functions, and account-held function +assignments. + +The module describes responsibility in the institution. It does not decide +which rights those responsibilities grant; `govoplan-access` maps organization +facts to roles, rights, permission decisions, and explainability. + +## Boundaries + +- Depend on kernel contracts from `govoplan-core`. +- Depend on `govoplan-tenancy` for tenant ownership. +- Do not import access, identity, postbox, workflow, or portal internals. +- Expose integration through manifests, capabilities, API routes, events, and + typed DTOs. +- Treat function assignment as organizational responsibility, not as a login + permission by itself. + +## Local Workflow + +Use Gitea issues as the canonical backlog and state log. The shared workflow is +installed under `.gitea/`, with labels in `docs/gitea-labels.json`. + +Focused verification is still driven from `/mnt/DATA/git/govoplan-core` for +cross-repository behavior. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c730690 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# GovOPlaN Organizations + +`govoplan-organizations` is the canonical organizational model module for +GovOPlaN. + +It owns organization units, organizational hierarchy, functions, and +account-held function assignments. These facts exist independently of +authentication and authorization. IDM can import them, Identity can link them +to people/accounts, and Access can project them into roles and rights. + +## Boundary + +Organizations owns: + +- tenant-local organization units +- parent/child organizational hierarchy +- functions in organization units +- whether a function may be delegated or used for acting in place +- account-held function assignments +- whether an assignment applies only to one unit or also to subunits + +Organizations does not own: + +- login accounts or identity lifecycle +- role and permission evaluation +- postboxes, workflows, portals, files, or cases +- external IDM connector internals + +## Module Contract + +The module registers the `organizations.directory` capability from +`govoplan_core.core.organizations`. + +Feature modules should consume the capability instead of importing +organization ORM models. + +## Development Install + +From the core checkout: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -e ../govoplan-organizations +``` diff --git a/docs/ORGANIZATION_MODEL.md b/docs/ORGANIZATION_MODEL.md new file mode 100644 index 0000000..af66914 --- /dev/null +++ b/docs/ORGANIZATION_MODEL.md @@ -0,0 +1,36 @@ +# Organization Model + +The organization model answers where responsibility lives. + +## Layers + +- Tenant: the administrative space. +- Organization unit: a department, office, committee, service desk, school, + institute, or other structural unit. +- Function: a named responsibility in an organization unit, such as clerk, + reviewer, approver, committee secretary, intake desk, or resource manager. +- Function assignment: an account holds a function in an organization unit, + optionally for that unit and all subunits. + +## Boundary With Identity + +Organization assignments refer to account and identity IDs, but this module +does not own login accounts or identity lifecycle. `govoplan-identity` owns the +identity/account directory. The organization module only records that an +account or identity currently holds an organizational function. + +## Boundary With Access + +A function assignment does not grant rights on its own. `govoplan-access` +maps organization facts to roles, rights, permission decisions, delegation +enforcement, and explain responses. + +This separation keeps the institution model stable even when authorization +policy changes. + +## Boundary With IDM + +`govoplan-idm` may import organization units, functions, and assignments from +external directories. It should write through organization capabilities or APIs +after preview, approval, and rollback planning. IDM is a synchronization layer, +not the canonical owner of the organization model. diff --git a/docs/gitea-labels.json b/docs/gitea-labels.json new file mode 100644 index 0000000..74dceb9 --- /dev/null +++ b/docs/gitea-labels.json @@ -0,0 +1,416 @@ +[ + { + "name": "type/bug", + "color": "d73a4a", + "description": "A reproducible defect, regression, or incorrect behavior.", + "exclusive": true + }, + { + "name": "type/feature", + "color": "0e8a16", + "description": "New user-visible behavior or platform capability.", + "exclusive": true + }, + { + "name": "type/task", + "color": "1d76db", + "description": "Implementation, maintenance, migration, or operational work.", + "exclusive": true + }, + { + "name": "type/debt", + "color": "fbca04", + "description": "Cleanup, refactoring, risk reduction, or deferred engineering work.", + "exclusive": true + }, + { + "name": "type/docs", + "color": "5319e7", + "description": "Documentation, process, or developer workflow work.", + "exclusive": true + }, + { + "name": "priority/p0", + "color": "b60205", + "description": "Immediate stop-the-line priority.", + "exclusive": true + }, + { + "name": "priority/p1", + "color": "d93f0b", + "description": "High priority for the next focused work window.", + "exclusive": true + }, + { + "name": "priority/p2", + "color": "fbca04", + "description": "Normal planned priority.", + "exclusive": true + }, + { + "name": "priority/p3", + "color": "c2e0c6", + "description": "Low priority or opportunistic cleanup.", + "exclusive": true + }, + { + "name": "status/triage", + "color": "d4c5f9", + "description": "Needs review, ownership, priority, or acceptance criteria.", + "exclusive": true + }, + { + "name": "status/ready", + "color": "0e8a16", + "description": "Ready for implementation.", + "exclusive": true + }, + { + "name": "status/in-progress", + "color": "0052cc", + "description": "Currently being worked.", + "exclusive": true + }, + { + "name": "status/blocked", + "color": "b60205", + "description": "Cannot progress without a decision, dependency, credential, or external change.", + "exclusive": true + }, + { + "name": "status/needs-info", + "color": "f9d0c4", + "description": "Needs clarifying input before implementation can proceed safely.", + "exclusive": true + }, + { + "name": "module/access", + "color": "0e8a16", + "description": "GovOPlaN access, identity, authentication, RBAC, and administration behavior.", + "exclusive": false + }, + { + "name": "module/addresses", + "color": "5319e7", + "description": "GovOPlaN Addresses module behavior or integration.", + "exclusive": false + }, + { + "name": "module/admin", + "color": "006b75", + "description": "GovOPlaN Admin module behavior or integration.", + "exclusive": false + }, + { + "name": "module/appointments", + "color": "d876e3", + "description": "GovOPlaN Appointments module behavior or integration.", + "exclusive": false + }, + { + "name": "module/audit", + "color": "0e8a16", + "description": "GovOPlaN Audit module behavior or integration.", + "exclusive": false + }, + { + "name": "module/calendar", + "color": "bfd4f2", + "description": "GovOPlaN Calendar module behavior or integration.", + "exclusive": false + }, + { + "name": "module/campaign", + "color": "d876e3", + "description": "GovOPlaN campaign module behavior or integration.", + "exclusive": false + }, + { + "name": "module/cases", + "color": "1d76db", + "description": "GovOPlaN Cases module behavior or integration.", + "exclusive": false + }, + { + "name": "module/connectors", + "color": "c2e0c6", + "description": "GovOPlaN Connectors module behavior or integration.", + "exclusive": false + }, + { + "name": "module/core", + "color": "0052cc", + "description": "GovOPlaN core runner, shared primitives, shell, or extension points.", + "exclusive": false + }, + { + "name": "module/dms", + "color": "c5def5", + "description": "GovOPlaN Dms module behavior or integration.", + "exclusive": false + }, + { + "name": "module/erp", + "color": "fef2c0", + "description": "GovOPlaN Erp module behavior or integration.", + "exclusive": false + }, + { + "name": "module/files", + "color": "006b75", + "description": "GovOPlaN files module behavior or integration.", + "exclusive": false + }, + { + "name": "module/fit-connect", + "color": "fbca04", + "description": "GovOPlaN Fit Connect module behavior or integration.", + "exclusive": false + }, + { + "name": "module/forms", + "color": "f9d0c4", + "description": "GovOPlaN Forms module behavior or integration.", + "exclusive": false + }, + { + "name": "module/identity-trust", + "color": "d4c5f9", + "description": "GovOPlaN Identity Trust module behavior or integration.", + "exclusive": false + }, + { + "name": "module/identity", + "color": "bfd4f2", + "description": "GovOPlaN Identity module behavior or integration.", + "exclusive": false + }, + { + "name": "module/idm", + "color": "0052cc", + "description": "GovOPlaN Idm module behavior or integration.", + "exclusive": false + }, + { + "name": "module/ledger", + "color": "5319e7", + "description": "GovOPlaN Ledger module behavior or integration.", + "exclusive": false + }, + { + "name": "module/mail", + "color": "5319e7", + "description": "GovOPlaN mail module behavior or integration.", + "exclusive": false + }, + { + "name": "module/notifications", + "color": "d876e3", + "description": "GovOPlaN Notifications module behavior or integration.", + "exclusive": false + }, + { + "name": "module/ops", + "color": "0e8a16", + "description": "GovOPlaN Ops module behavior or integration.", + "exclusive": false + }, + { + "name": "module/organizations", + "color": "bfdadc", + "description": "GovOPlaN Organizations module behavior or integration.", + "exclusive": false + }, + { + "name": "module/payments", + "color": "bfd4f2", + "description": "GovOPlaN Payments module behavior or integration.", + "exclusive": false + }, + { + "name": "module/policy", + "color": "d93f0b", + "description": "GovOPlaN Policy module behavior or integration.", + "exclusive": false + }, + { + "name": "module/portal", + "color": "1d76db", + "description": "GovOPlaN Portal module behavior or integration.", + "exclusive": false + }, + { + "name": "module/postbox", + "color": "d93f0b", + "description": "GovOPlaN Postbox module behavior or integration.", + "exclusive": false + }, + { + "name": "module/reporting", + "color": "c2e0c6", + "description": "GovOPlaN Reporting module behavior or integration.", + "exclusive": false + }, + { + "name": "module/search", + "color": "bfdadc", + "description": "GovOPlaN Search module behavior or integration.", + "exclusive": false + }, + { + "name": "module/scheduling", + "color": "d93f0b", + "description": "GovOPlaN Scheduling module behavior or integration.", + "exclusive": false + }, + { + "name": "module/tasks", + "color": "c5def5", + "description": "GovOPlaN Tasks module behavior or integration.", + "exclusive": false + }, + { + "name": "module/templates", + "color": "fef2c0", + "description": "GovOPlaN Templates module behavior or integration.", + "exclusive": false + }, + { + "name": "module/tenancy", + "color": "e4e669", + "description": "GovOPlaN Tenancy module behavior or integration.", + "exclusive": false + }, + { + "name": "module/web", + "color": "bfd4f2", + "description": "GovOPlaN public website, product page, or publication content.", + "exclusive": false + }, + { + "name": "module/workflow", + "color": "f9d0c4", + "description": "GovOPlaN Workflow module behavior or integration.", + "exclusive": false + }, + { + "name": "module/xoev", + "color": "d4c5f9", + "description": "GovOPlaN Xoev module behavior or integration.", + "exclusive": false + }, + { + "name": "module/xrechnung", + "color": "0052cc", + "description": "GovOPlaN Xrechnung module behavior or integration.", + "exclusive": false + }, + { + "name": "module/xta-osci", + "color": "5319e7", + "description": "GovOPlaN Xta Osci module behavior or integration.", + "exclusive": false + }, + { + "name": "area/auth", + "color": "bfd4f2", + "description": "Authentication, sessions, access bootstrap, or login behavior.", + "exclusive": false + }, + { + "name": "area/tenancy", + "color": "bfdadc", + "description": "Tenant boundaries, provisioning, or tenant-scoped data behavior.", + "exclusive": false + }, + { + "name": "area/rbac", + "color": "c5def5", + "description": "Permissions, roles, delegation, or authorization policy.", + "exclusive": false + }, + { + "name": "area/governance", + "color": "fef2c0", + "description": "Governance policy, audit, privacy, retention, or compliance behavior.", + "exclusive": false + }, + { + "name": "area/module-system", + "color": "bfe5bf", + "description": "Module discovery, manifests, capabilities, routing, or optional integrations.", + "exclusive": false + }, + { + "name": "area/migrations", + "color": "e4e669", + "description": "Alembic migrations, schema bootstrap, or persistence evolution.", + "exclusive": false + }, + { + "name": "area/webui", + "color": "1d76db", + "description": "Shared WebUI shell, frontend components, routing, or frontend tests.", + "exclusive": false + }, + { + "name": "area/api", + "color": "5319e7", + "description": "HTTP API contracts, routers, schemas, or API smoke behavior.", + "exclusive": false + }, + { + "name": "area/db", + "color": "0e8a16", + "description": "Database sessions, models, transactions, or persistence primitives.", + "exclusive": false + }, + { + "name": "area/devex", + "color": "bfd4f2", + "description": "Local developer workflow, scripts, tests, tooling, or release helpers.", + "exclusive": false + }, + { + "name": "area/release", + "color": "c2e0c6", + "description": "Versioning, release locks, tags, packaging, or dependency pins.", + "exclusive": false + }, + { + "name": "area/docs", + "color": "5319e7", + "description": "Durable documentation and project guidance.", + "exclusive": false + }, + { + "name": "area/marketing", + "color": "f9d0c4", + "description": "Public website, product messaging, publication copy, or legal page content.", + "exclusive": false + }, + { + "name": "source/todo-scan", + "color": "cccccc", + "description": "Imported from inline TODO/FIXME/HACK markers by the Gitea TODO importer.", + "exclusive": false + }, + { + "name": "source/backlog-import", + "color": "bfdadc", + "description": "Imported from markdown backlog, roadmap, plan, or TODO files.", + "exclusive": false + }, + { + "name": "codex/ready", + "color": "0e8a16", + "description": "Suitable for Codex to pick up with the existing issue context.", + "exclusive": false + }, + { + "name": "codex/needs-human", + "color": "f9d0c4", + "description": "Needs an explicit human decision before Codex should implement.", + "exclusive": false + } +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e31ec11 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-organizations" +version = "0.1.6" +description = "GovOPlaN organizational model module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.6", + "govoplan-tenancy>=0.1.6", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_organizations = ["py.typed"] + +[project.entry-points."govoplan.modules"] +organizations = "govoplan_organizations.backend.manifest:get_manifest" diff --git a/src/govoplan_organizations/__init__.py b/src/govoplan_organizations/__init__.py new file mode 100644 index 0000000..a94730b --- /dev/null +++ b/src/govoplan_organizations/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN organizations module.""" + +__version__ = "0.1.6" diff --git a/src/govoplan_organizations/backend/__init__.py b/src/govoplan_organizations/backend/__init__.py new file mode 100644 index 0000000..3352d0d --- /dev/null +++ b/src/govoplan_organizations/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for the GovOPlaN organizations module.""" diff --git a/src/govoplan_organizations/backend/db/__init__.py b/src/govoplan_organizations/backend/db/__init__.py new file mode 100644 index 0000000..96672f5 --- /dev/null +++ b/src/govoplan_organizations/backend/db/__init__.py @@ -0,0 +1 @@ +"""Organizations database models.""" diff --git a/src/govoplan_organizations/backend/db/models.py b/src/govoplan_organizations/backend/db/models.py new file mode 100644 index 0000000..14ad4a4 --- /dev/null +++ b/src/govoplan_organizations/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_organizations/backend/directory.py b/src/govoplan_organizations/backend/directory.py new file mode 100644 index 0000000..c54611f --- /dev/null +++ b/src/govoplan_organizations/backend/directory.py @@ -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()) diff --git a/src/govoplan_organizations/backend/manifest.py b/src/govoplan_organizations/backend/manifest.py new file mode 100644 index 0000000..42ad34d --- /dev/null +++ b/src/govoplan_organizations/backend/manifest.py @@ -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 diff --git a/src/govoplan_organizations/py.typed b/src/govoplan_organizations/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_organizations/py.typed @@ -0,0 +1 @@ +