Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6970bf7457 | |||
| 47e106684d | |||
| 9e219bc4d3 | |||
| ea436a513f | |||
| e7c84e3227 | |||
| cf7afe9dda | |||
| ca8a8c5111 | |||
| f3b388fe7e | |||
| af3e0a055d | |||
| 51d4032b86 | |||
| 0beb9ffea9 | |||
| 9e6a6b5fdc | |||
| 48fb953b93 | |||
| 4bde0495f7 | |||
| a80caf7933 | |||
| 790790ab37 | |||
| 920e3c9834 | |||
| 13893c80cd | |||
| a192a2215f | |||
| e8fed6d25a | |||
| d9b5708df0 | |||
| 68328f3d8e | |||
| 53e947935a | |||
| 324c26da78 | |||
| 389f98e349 | |||
| ce9ef8d88f | |||
| 13bc3d3b4e | |||
| 3f5870281a | |||
| a46df85479 | |||
| c31581b1b9 | |||
| 26ae034153 | |||
| baa2143a26 | |||
| 8b1910b5b7 | |||
| d36bb94335 | |||
| 74034947c6 | |||
| c7183fe7f1 | |||
| 139a352c80 | |||
| 336c94137f | |||
| 93225b6487 | |||
| e11ea81008 | |||
| bc8afeb139 | |||
| f876345656 | |||
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 |
28
README.md
28
README.md
@@ -4,13 +4,6 @@
|
|||||||
**Repository type:** system (kernel).
|
**Repository type:** system (kernel).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=module-matrix.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=release-integration.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=dependency-audit.yml&actor=0&status=0)
|
|
||||||
[](https://git.add-ideas.de/add-ideas/govoplan/actions?workflow=security-audit.yml&actor=0&status=0)
|
|
||||||
|
|
||||||
# govoplan-core
|
|
||||||
|
|
||||||
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
|
GovOPlaN core is the platform runner and shared foundation. It owns the server entry point, database/session primitives, module discovery, migration orchestration, capability contracts, install/uninstall orchestration, and the shared WebUI shell. Platform and feature behavior is supplied by installed modules.
|
||||||
|
|
||||||
## Repository ownership
|
## Repository ownership
|
||||||
@@ -54,7 +47,7 @@ python3 -m venv .venv
|
|||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
@@ -74,6 +67,21 @@ ENABLED_MODULES=access,campaigns /mnt/DATA/git/govoplan/.venv/bin/python -m govo
|
|||||||
|
|
||||||
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
|
The runner loads the same `GovoplanServerConfig` as `govoplan_core.server.app:app`, builds the platform registry, and passes core plus enabled module source roots to uvicorn as reload directories. After reinstalling the editable package, the same command is also available as `govoplan-devserver`.
|
||||||
|
|
||||||
|
For focused backend work, keep the complete module graph active while watching
|
||||||
|
only the module being edited. Core/config sources and explicit `--reload-dir`
|
||||||
|
paths remain watched:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m govoplan_core.devserver \
|
||||||
|
--reload-module calendar \
|
||||||
|
--reload-module campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--reload-core-only` when no optional module source tree should trigger a
|
||||||
|
restart. Omitting both options preserves the broad default and watches every
|
||||||
|
enabled module. Startup, migration, and compatibility checks still run against
|
||||||
|
the complete enabled graph whenever the backend restarts.
|
||||||
|
|
||||||
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
|
The default development database is PostgreSQL at `postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev`. Store the password in `~/.pgpass`. To force the disposable SQLite fallback, run with `GOVOPLAN_DEV_DATABASE_BACKEND=sqlite`; that database lives below `runtime/`.
|
||||||
|
|
||||||
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
Local devserver runs do not require Redis. `CELERY_ENABLED` defaults to `false`, so campaign queue actions update database state without publishing Celery tasks. Use the synchronous send flow for local send tests, or set `CELERY_ENABLED=true` only when a Redis broker and worker are running.
|
||||||
@@ -143,6 +151,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
|||||||
|
|
||||||
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||||
|
|
||||||
|
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||||
|
asynchronous JavaScript budgets. See
|
||||||
|
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
## Module contract
|
## Module contract
|
||||||
|
|
||||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""add reusable core credential envelopes
|
||||||
|
|
||||||
|
Revision ID: c91f0a72be34
|
||||||
|
Revises: 0f1e2d3c4b5a
|
||||||
|
Create Date: 2026-07-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c91f0a72be34"
|
||||||
|
down_revision = "0f1e2d3c4b5a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_credential_envelopes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_scope",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "scope_type", "scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_active",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "is_active", "deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
for column in (
|
||||||
|
"tenant_id",
|
||||||
|
"scope_type",
|
||||||
|
"scope_id",
|
||||||
|
"credential_kind",
|
||||||
|
"is_active",
|
||||||
|
"created_by_user_id",
|
||||||
|
"updated_by_user_id",
|
||||||
|
"deleted_at",
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
op.f(f"ix_core_credential_envelopes_{column}"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
[column],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
op.drop_table("core_credential_envelopes")
|
||||||
@@ -5,9 +5,14 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
try:
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate optional access metadata
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
if exc.name != "govoplan_access":
|
||||||
|
raise
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
from govoplan_core.admin import models as core_admin_models # noqa: F401 - populate core admin metadata
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_core.server.default_config import get_server_config
|
from govoplan_core.server.default_config import get_server_config
|
||||||
|
|||||||
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
119
alembic/versions/c91f0a72be34_core_credential_envelopes.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""add reusable core credential envelopes
|
||||||
|
|
||||||
|
Revision ID: c91f0a72be34
|
||||||
|
Revises: 4f2a9c8e7b6d
|
||||||
|
Create Date: 2026-07-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "c91f0a72be34"
|
||||||
|
down_revision = "4f2a9c8e7b6d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
"core_credential_envelopes",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("scope_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("scope_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("credential_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("public_data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("secret_data_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("secret_keys", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_modules", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("allowed_server_refs", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("revision", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("updated_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("metadata", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tenant_id"],
|
||||||
|
["core_scopes.id"],
|
||||||
|
name=op.f("fk_core_credential_envelopes_tenant_id_core_scopes"),
|
||||||
|
ondelete="CASCADE",
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_core_credential_envelopes")),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_scope",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "scope_type", "scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_core_credential_envelopes_active",
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id", "is_active", "deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_tenant_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["tenant_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_scope_type"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["scope_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_scope_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["scope_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_credential_kind"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["credential_kind"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_is_active"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["is_active"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_created_by_user_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["created_by_user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_updated_by_user_id"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["updated_by_user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_core_credential_envelopes_deleted_at"),
|
||||||
|
"core_credential_envelopes",
|
||||||
|
["deleted_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if "core_credential_envelopes" in inspector.get_table_names():
|
||||||
|
op.drop_table("core_credential_envelopes")
|
||||||
59
docs/AUTOMATION_CONTRACTS.md
Normal file
59
docs/AUTOMATION_CONTRACTS.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Automation Contracts
|
||||||
|
|
||||||
|
Core defines provider-neutral automation contracts. It does not own domain
|
||||||
|
schedules, Workflow graphs, or Dataflow execution.
|
||||||
|
|
||||||
|
## Invocation Envelope
|
||||||
|
|
||||||
|
`AutomationInvocation` classifies a start as `manual`, `api`, `schedule`,
|
||||||
|
`event`, `workflow`, `dependency`, `retry`, or `backfill`. It carries opaque
|
||||||
|
trigger and delivery references, event identity, correlation and causation
|
||||||
|
IDs, scheduled time, requesting actor, and bounded metadata. Domain runs store
|
||||||
|
this envelope with their immutable definition revision.
|
||||||
|
|
||||||
|
## Current Authorization
|
||||||
|
|
||||||
|
An automated trigger must not persist a user session, bearer token, API key,
|
||||||
|
or a snapshot of all current permissions. It stores:
|
||||||
|
|
||||||
|
- tenant, account, and membership IDs;
|
||||||
|
- an opaque authorization reference;
|
||||||
|
- the minimum scopes required by the pinned definition and output target.
|
||||||
|
|
||||||
|
At delivery time the optional
|
||||||
|
`auth.automationPrincipalProvider` capability resolves current account,
|
||||||
|
membership, role, group, function, and delegation state. It intersects current
|
||||||
|
authorization with the stored grant. Missing, inactive, or reduced
|
||||||
|
authorization blocks the delivery before effects occur.
|
||||||
|
|
||||||
|
## Definition Governance
|
||||||
|
|
||||||
|
The optional `policy.definitionGovernance` capability evaluates `view`,
|
||||||
|
`edit`, `run`, `reuse`, `derive`, and `automate` for system, tenant, group, and
|
||||||
|
user definitions. A decision contains an ordered source path and effective
|
||||||
|
limits. Derived definitions pin their source revision and hash and retain
|
||||||
|
ancestor ceilings. Templates are reusable definitions and cannot run on their
|
||||||
|
own.
|
||||||
|
|
||||||
|
Without Policy, domain modules use a conservative tenant-local fallback:
|
||||||
|
local definitions remain viewable/editable and active complete flows may run;
|
||||||
|
inheritance, reuse, derivation, and automation are unavailable.
|
||||||
|
|
||||||
|
## Delivery Durability
|
||||||
|
|
||||||
|
Domain trigger implementations persist idempotent deliveries before running.
|
||||||
|
`emit_platform_event` binds event delivery to the producer's SQLAlchemy
|
||||||
|
transaction. When an enabled module provides `platform.eventOutbox`, the event
|
||||||
|
is stored in that transaction and a dispatcher may retry it across restarts and
|
||||||
|
workers. The Audit module provides the current SQL outbox implementation; the
|
||||||
|
Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
|
||||||
|
event-ingestion capability and the local event bus.
|
||||||
|
|
||||||
|
The outbox capability remains optional so reduced module combinations can
|
||||||
|
start. Without it, Core queues events on the SQLAlchemy transaction and
|
||||||
|
publishes them to the process-local bus only after the outer commit. A rollback,
|
||||||
|
including a nested savepoint rollback, discards the corresponding events. This
|
||||||
|
fallback is suitable for local or non-critical reactions, but it is not a
|
||||||
|
durable multi-worker automation source. Deployments that rely on event-triggered
|
||||||
|
work must enable the outbox provider and run the `events` worker queue and
|
||||||
|
periodic dispatcher.
|
||||||
69
docs/COMPATIBILITY_POLICY.md
Normal file
69
docs/COMPATIBILITY_POLICY.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# GovOPlaN Compatibility Policy
|
||||||
|
|
||||||
|
This document defines the compatibility window that release tooling, module
|
||||||
|
owners, migration authors, import/export providers, and API maintainers must
|
||||||
|
preserve. It is the source of truth for deciding whether compatibility code can
|
||||||
|
be removed.
|
||||||
|
|
||||||
|
## Database Upgrades
|
||||||
|
|
||||||
|
- A released installation from every tagged `0.1.x` version is a supported
|
||||||
|
database upgrade origin.
|
||||||
|
- The recorded public release-baseline ledger starts at `v0.1.7`; earlier
|
||||||
|
`0.1.x` tags predate production installations. If an earlier tagged database
|
||||||
|
is encountered, the release must provide or document a compatibility bridge
|
||||||
|
instead of silently treating the database as a fresh installation.
|
||||||
|
- Released migration revision IDs and recorded release heads are immutable.
|
||||||
|
- Each release must prove an upgrade from every still-supported recorded
|
||||||
|
baseline, as well as a fresh installation, before its tag is published.
|
||||||
|
- Migration-only reconciliation needed by an old database remains available for
|
||||||
|
at least one subsequent major release cycle after the corresponding runtime
|
||||||
|
compatibility path is removed.
|
||||||
|
|
||||||
|
The release-baseline format and commands are documented in
|
||||||
|
`RELEASE_DEPENDENCIES.md`.
|
||||||
|
|
||||||
|
## Configuration And Export Schemas
|
||||||
|
|
||||||
|
- Writers emit only the current schema version.
|
||||||
|
- Readers accept the current schema version and the previous two schema
|
||||||
|
versions.
|
||||||
|
- Older input is rejected with a diagnostic that identifies its version and the
|
||||||
|
required staged upgrade or conversion path.
|
||||||
|
- A module-owned configuration provider must version its input and output
|
||||||
|
schema explicitly. It must not infer an old schema from missing fields once a
|
||||||
|
versioned schema has shipped.
|
||||||
|
- Round-trip and upgrade tests must cover all three readable versions before a
|
||||||
|
schema change is released.
|
||||||
|
|
||||||
|
This window applies to configuration packages, module-owned exports, and other
|
||||||
|
portable GovOPlaN configuration artifacts. Domain interchange standards with
|
||||||
|
their own compatibility rules remain governed by the owning module.
|
||||||
|
|
||||||
|
## Runtime And API Aliases
|
||||||
|
|
||||||
|
- Compatibility aliases must emit an explicit deprecation diagnostic and point
|
||||||
|
to the supported replacement.
|
||||||
|
- New callers must use the canonical contract. In-tree callers may not add new
|
||||||
|
uses of a deprecated alias.
|
||||||
|
- Runtime imports, request fields, response fields, routes, and scope aliases
|
||||||
|
carried for the `0.1.x` split line are retired at `0.2`, with migration notes.
|
||||||
|
- An alias may be removed earlier only when it never shipped in a tag or when a
|
||||||
|
security fix requires removal. The release notes must state the exception.
|
||||||
|
- Database reconciliation code is not a runtime/API alias and follows the
|
||||||
|
longer database window above.
|
||||||
|
|
||||||
|
## Removal Checklist
|
||||||
|
|
||||||
|
Compatibility code can be removed only when all of the following are true:
|
||||||
|
|
||||||
|
1. The path is inventoried as a database bridge, portable-schema reader, or
|
||||||
|
runtime/API alias.
|
||||||
|
2. Its minimum retention window has elapsed.
|
||||||
|
3. In-tree callers and published module manifests use the replacement.
|
||||||
|
4. Upgrade, import, or API regression tests cover the retained window.
|
||||||
|
5. Diagnostics and migration notes identify any staged action operators must
|
||||||
|
take.
|
||||||
|
|
||||||
|
If one condition is not met, version-gate the compatibility path and record its
|
||||||
|
planned removal release instead of deleting it.
|
||||||
@@ -69,6 +69,11 @@ Required package metadata:
|
|||||||
- migration or transformation rules for older package versions
|
- migration or transformation rules for older package versions
|
||||||
- provenance, export source metadata, and signature metadata
|
- provenance, export source metadata, and signature metadata
|
||||||
|
|
||||||
|
Portable configuration schemas follow `COMPATIBILITY_POLICY.md`: providers
|
||||||
|
write only their current schema version and read that version plus the previous
|
||||||
|
two versions. Older input must produce a version-specific staged-upgrade
|
||||||
|
diagnostic.
|
||||||
|
|
||||||
Configuration fragments are interpreted only by the module that owns them. For
|
Configuration fragments are interpreted only by the module that owns them. For
|
||||||
example, workflow imports workflow definitions; forms imports form schemas;
|
example, workflow imports workflow definitions; forms imports form schemas;
|
||||||
mail imports mail templates and delivery defaults; payments imports payment
|
mail imports mail templates and delivery defaults; payments imports payment
|
||||||
|
|||||||
@@ -144,8 +144,12 @@ tools/checks/postgres-integration-check.py \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The integration check runs migrations and startup smoke checks across the
|
The integration check runs migrations and startup smoke checks across the
|
||||||
standard module permutations. `--reset-schema` is destructive and belongs only
|
standard module permutations. It first requires the retirement atomicity proof,
|
||||||
on throwaway databases.
|
using Files' real secret-owning provider and Audit's persistent recorder. That
|
||||||
|
proof uses only random, test-owned schemas and cleans them afterward; it does
|
||||||
|
not reset `public`. `--reset-schema` is destructive and belongs only on
|
||||||
|
throwaway databases. Do not pass `--skip-retirement-atomicity` when collecting
|
||||||
|
release evidence.
|
||||||
|
|
||||||
### Broker And Workers
|
### Broker And Workers
|
||||||
|
|
||||||
@@ -153,13 +157,15 @@ on throwaway databases.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
| `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. |
|
||||||
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
| `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. |
|
||||||
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,default` | Queue list expected by worker/process manager definitions. The Calendar queue drains durable external-calendar operations. |
|
| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules. |
|
||||||
|
| `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. |
|
||||||
|
| `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. |
|
||||||
|
|
||||||
Worker command:
|
Worker command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m celery -A govoplan_core.celery_app:celery worker \
|
python -m celery -A govoplan_core.celery_app:celery worker \
|
||||||
--queues send_email,append_sent,notifications,calendar,default \
|
--queues send_email,append_sent,notifications,calendar,dataflow,events,default \
|
||||||
--loglevel INFO
|
--loglevel INFO
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -205,9 +211,13 @@ prefer `FILE_STORAGE_*`.
|
|||||||
Interactive password login is enabled with fixed-window limits of 10 failures
|
Interactive password login is enabled with fixed-window limits of 10 failures
|
||||||
per normalized identity and 100 failures per direct client over 900 seconds.
|
per normalized identity and 100 failures per direct client over 900 seconds.
|
||||||
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
|
||||||
when Redis is reachable so replicas share state; a bounded process-local
|
when Redis is reachable so replicas share state. Production-like startup fails
|
||||||
fallback keeps development and Redis outages functional, with per-process
|
when throttling is enabled without `REDIS_URL`. Set
|
||||||
enforcement until Redis recovers.
|
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
|
||||||
|
single-process risk acceptance. A bounded process-local fallback keeps
|
||||||
|
development and temporary Redis outages functional, with per-process
|
||||||
|
enforcement until Redis recovers; monitor Redis because protection is weaker
|
||||||
|
during that fallback.
|
||||||
|
|
||||||
### Outbound Connector Egress
|
### Outbound Connector Egress
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ operator, and roadmap pages.
|
|||||||
| Topic | Canonical document | Notes |
|
| Topic | Canonical document | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
|
| Module architecture and kernel contracts | `MODULE_ARCHITECTURE.md` | Stable module contracts, API efficiency contracts, durable boundary decisions, lifecycle, and WebUI contribution rules. |
|
||||||
|
| Compatibility policy | `COMPATIBILITY_POLICY.md` | Supported database upgrade origins, portable-schema read/write windows, runtime/API alias retirement, and compatibility-code removal criteria. |
|
||||||
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
|
| RBAC and resource access | `ACCESS_RBAC_MODEL.md` | Current permission, role, API-key, and resource-access model. |
|
||||||
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
|
| Governance hierarchy | `GOVERNANCE_MODEL.md` | System, tenant, user/group, campaign policy inheritance and admin UI structure. |
|
||||||
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
|
| Policy decision DTOs and provenance | `POLICY_CONTRACTS.md` | Shared explain/provenance shape; module-specific policy docs should link here. |
|
||||||
@@ -25,6 +26,7 @@ operator, and roadmap pages.
|
|||||||
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
||||||
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
||||||
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
||||||
|
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
|
||||||
|
|
||||||
## Product And Module Planning
|
## Product And Module Planning
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
|
|||||||
|
|
||||||
## Production Transport Decision
|
## Production Transport Decision
|
||||||
|
|
||||||
The first production target is a **database outbox plus in-process immediate
|
The production transport is a **transactional database outbox plus retrying
|
||||||
dispatch**:
|
dispatcher**:
|
||||||
|
|
||||||
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
|
||||||
- Use `EventBus` as the in-process dispatch contract for same-process module
|
- Call `emit_platform_event(session, event)` to bind event delivery to the
|
||||||
reactions that are safe to run inline.
|
domain transaction.
|
||||||
|
- The optional `platform.eventOutbox` capability persists events atomically.
|
||||||
|
The Audit module provides the current SQL implementation.
|
||||||
|
- Without an outbox provider, Core publishes to `EventBus` only after the outer
|
||||||
|
transaction commits. This preserves reduced installations but is not durable
|
||||||
|
across process failure or multiple workers.
|
||||||
|
- Use `EventBus` as the in-process dispatch contract for module reactions
|
||||||
|
invoked by the outbox dispatcher or for non-critical fallback reactions.
|
||||||
- Use the shared `audit_event` / `audit_from_principal` helper for audited
|
- Use the shared `audit_event` / `audit_from_principal` helper for audited
|
||||||
module actions. The helper persists the audit row and immediately publishes a
|
module actions. The helper persists the audit row and transactionally emits a
|
||||||
governed `PlatformEvent` whose `type` is the audit action.
|
governed `PlatformEvent` whose `type` is the audit action.
|
||||||
- Use `record_change` for module delta feeds. It persists the change-sequence
|
- Use `record_change` for module delta feeds. It persists the change-sequence
|
||||||
row and immediately publishes a generic module change event such as
|
row and transactionally emits a generic module change event such as
|
||||||
`mail.profile.updated`.
|
`mail.profile.updated`.
|
||||||
- Persist durable integration/workflow events through a database outbox before
|
- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
|
||||||
acknowledging the state change that produced them.
|
`events` queue. The periodic schedule also retries pending rows.
|
||||||
- Drain the outbox through a small dispatcher process. The dispatcher may call
|
- The dispatcher invokes Dataflow event ingestion when that capability is
|
||||||
in-process handlers in the same deployment first, but its storage contract is
|
active, then publishes to the process-local bus.
|
||||||
database-backed.
|
|
||||||
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative
|
||||||
first event transport. A Celery dispatcher can consume the outbox later.
|
first event transport. PostgreSQL remains authoritative until dispatch is
|
||||||
|
recorded.
|
||||||
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
- Keep the dispatch implementation pluggable behind the `PlatformEvent`
|
||||||
envelope so a future message broker can be added without changing event
|
envelope so a future message broker can be added without changing event
|
||||||
producers.
|
producers.
|
||||||
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
|
|||||||
database transaction wherever possible. Handlers must be idempotent because the
|
database transaction wherever possible. Handlers must be idempotent because the
|
||||||
outbox dispatcher can retry after a crash or timeout.
|
outbox dispatcher can retry after a crash or timeout.
|
||||||
|
|
||||||
Recommended first outbox columns:
|
The current outbox stores:
|
||||||
|
|
||||||
- `event_id`, `event_type`, `module_id`
|
- `event_id`, `event_type`, `module_id`
|
||||||
- `correlation_id`, `causation_id`
|
- `correlation_id`, `causation_id`
|
||||||
- `payload`, `occurred_at`
|
- `classification`, serialized event `payload`
|
||||||
- `available_at`, `attempt_count`, `claimed_at`, `claim_token`
|
- `status`, `attempts`, `next_attempt_at`
|
||||||
- `processed_at`, `last_error`
|
- `dispatched_at`, `last_error`, timestamps
|
||||||
|
|
||||||
Inline `EventBus` handlers are allowed only for non-critical local reactions.
|
Handlers must be idempotent: a worker may complete an external effect and fail
|
||||||
Anything that must survive process failure, restart, package update, or worker
|
before marking its outbox row dispatched. Anything that must survive process
|
||||||
redeployment belongs in the outbox.
|
failure, restart, package update, or worker redeployment requires the outbox
|
||||||
|
provider and dispatcher.
|
||||||
|
|
||||||
## Trace IDs
|
## Trace IDs
|
||||||
|
|
||||||
|
|||||||
47
docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md
Normal file
47
docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# External References And Integration Maturity
|
||||||
|
|
||||||
|
GovOPlaN integrations use a shared external-reference contract instead of
|
||||||
|
storing connector-specific URLs and identifiers in every module.
|
||||||
|
|
||||||
|
An external reference identifies an object by:
|
||||||
|
|
||||||
|
- external system instance
|
||||||
|
- object type
|
||||||
|
- stable external object ID
|
||||||
|
- optional connector configuration
|
||||||
|
- canonical HTTP(S) URL without embedded credentials
|
||||||
|
- optional source version, ETag, observation time, and non-secret metadata
|
||||||
|
|
||||||
|
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
|
||||||
|
multiple references, but one reference must never silently change its identity.
|
||||||
|
Moving or escalating work creates a new object and an explicit relationship; it
|
||||||
|
does not rewrite either object's history.
|
||||||
|
|
||||||
|
## Integration Maturity
|
||||||
|
|
||||||
|
Maturity is cumulative:
|
||||||
|
|
||||||
|
1. `discover`: identify configured external systems and their health.
|
||||||
|
2. `link`: retain and open stable external references.
|
||||||
|
3. `search`: include authorized external objects in GovOPlaN search.
|
||||||
|
4. `read`: display authoritative external content.
|
||||||
|
5. `publish`: create or update external content from GovOPlaN.
|
||||||
|
6. `synchronize`: reconcile changes in both directions with conflict handling.
|
||||||
|
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
|
||||||
|
8. `replace`: provide the native operational capability without the external tool.
|
||||||
|
|
||||||
|
Connectors must declare and document the maturity they actually implement.
|
||||||
|
`synchronize` requires durable cursors, idempotency, provenance, conflict
|
||||||
|
handling, deletion semantics, and observable failures. A link-only connector
|
||||||
|
must not imply that GovOPlaN holds an authoritative copy.
|
||||||
|
|
||||||
|
## Domain Ownership
|
||||||
|
|
||||||
|
- Domain modules own native GovOPlaN objects and their authorization.
|
||||||
|
- Connectors own protocols, credentials, discovery, transport, and sync state.
|
||||||
|
- Search owns indexing and result aggregation, but source modules remain
|
||||||
|
responsible for authorization.
|
||||||
|
- Core owns only the stable DTOs and extension contracts.
|
||||||
|
|
||||||
|
The Python contract is
|
||||||
|
`govoplan_core.core.external_references.ExternalObjectReference`.
|
||||||
@@ -10,12 +10,12 @@ gates. Issues are the active backlog; this document is durable architecture
|
|||||||
planning context and should be mirrored to the Gitea wiki.
|
planning context and should be mirrored to the Gitea wiki.
|
||||||
|
|
||||||
The meta repository's
|
The meta repository's
|
||||||
[Connected Governance Platform Roadmap](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
[Connected Governance Platform Roadmap](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
describes the corresponding cross-product stakeholder visions, configurable
|
describes the corresponding cross-product stakeholder visions, configurable
|
||||||
service and operating configurations, connected outcome stories, and
|
service and operating configurations, connected outcome stories, and
|
||||||
capability horizons. The selected five-stage delivery sequence and its gates
|
capability horizons. The selected five-stage delivery sequence and its gates
|
||||||
are in the meta repository's
|
are in the meta repository's
|
||||||
[Reference Journey Program](https://git.add-ideas.de/add-ideas/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
[Reference Journey Program](https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
Those product documents are canonical; this Core roadmap remains their
|
Those product documents are canonical; this Core roadmap remains their
|
||||||
technical sequencing and module-routing companion.
|
technical sequencing and module-routing companion.
|
||||||
|
|
||||||
@@ -412,7 +412,7 @@ Goal: cover internal support and public issue reporting.
|
|||||||
|
|
||||||
Create or refine in this order:
|
Create or refine in this order:
|
||||||
|
|
||||||
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
|
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
|
||||||
location, evidence, and triage.
|
location, evidence, and triage.
|
||||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||||
escalation, and resolution evidence.
|
escalation, and resolution evidence.
|
||||||
@@ -625,48 +625,48 @@ repositories or to explicit missing-module decisions.
|
|||||||
|
|
||||||
| Idea | Owner | Tracking |
|
| Idea | Owner | Tracking |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Government operations backbone reference model | `govoplan-core` | `add-ideas/govoplan-core#213` |
|
| Government operations backbone reference model | `govoplan-core` | `GovOPlaN/govoplan-core#213` |
|
||||||
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `add-ideas/govoplan-core#214` |
|
| Permit-to-payment configuration package | `govoplan-core` plus participating modules | `GovOPlaN/govoplan-core#214` |
|
||||||
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `add-ideas/govoplan-core#218` |
|
| Fully UI-managed configuration with safety controls | `govoplan-admin`, `govoplan-core`, `govoplan-policy`, `govoplan-access`, `govoplan-audit` | `GovOPlaN/govoplan-core#218` |
|
||||||
| Access as a module | `govoplan-access` | `add-ideas/govoplan-access#7` |
|
| Access as a module | `govoplan-access` | `GovOPlaN/govoplan-access#7` |
|
||||||
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `add-ideas/govoplan-core#227` |
|
| Interface ethics and decision-consequence doctrine | `govoplan-core` plus all UI-owning modules | `GovOPlaN/govoplan-core#227` |
|
||||||
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `add-ideas/govoplan-workflow#1` |
|
| Action/effect automation layer | first `govoplan-workflow`; possible future `govoplan-automation` | `GovOPlaN/govoplan-workflow#1` |
|
||||||
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `add-ideas/govoplan-postbox#15`, `add-ideas/govoplan-identity-trust#1` |
|
| E2EE role/function postbox architecture | `govoplan-postbox`, `govoplan-identity-trust`, `govoplan-access`, `govoplan-policy`, `govoplan-audit` | `GovOPlaN/govoplan-postbox#15`, `GovOPlaN/govoplan-identity-trust#1` |
|
||||||
| Identity, account, function, role, right semantic model | `govoplan-access` | `add-ideas/govoplan-access#9` |
|
| Identity, account, function, role, right semantic model | `govoplan-access` | `GovOPlaN/govoplan-access#9` |
|
||||||
| Role-based service directory/catalog | `govoplan-portal` | `add-ideas/govoplan-portal#1` |
|
| Role-based service directory/catalog | `govoplan-portal` | `GovOPlaN/govoplan-portal#1` |
|
||||||
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `add-ideas/govoplan-tasks#1`, `add-ideas/govoplan-notifications#1` |
|
| Unified inbox across tasks, postbox, notifications, and portal | `govoplan-core` coordination plus owning modules | `GovOPlaN/govoplan-tasks#1`, `GovOPlaN/govoplan-notifications#1` |
|
||||||
| OpenProject API / project management connector | `govoplan-connectors` | `add-ideas/govoplan-connectors#1` |
|
| OpenProject API / project management connector | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#1` |
|
||||||
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `add-ideas/govoplan-core#196`, `add-ideas/govoplan-connectors#1` |
|
| Native project-management module decision | connector-first through `govoplan-connectors`; no native project module yet | `GovOPlaN/govoplan-core#196`, `GovOPlaN/govoplan-connectors#1` |
|
||||||
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `add-ideas/govoplan-core#197` |
|
| Datasources for databases, CSV, files, APIs | no repository yet; start with connectors/files/reporting and create `govoplan-datasources` only after the first package proves shared source-catalog ownership | `GovOPlaN/govoplan-core#197` |
|
||||||
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `add-ideas/govoplan-core#198` |
|
| Dataflow for pipelines, BI, publication | no repository yet; start with workflow/reporting/connectors and create `govoplan-dataflow` only after repeated pipeline/lineage contracts emerge | `GovOPlaN/govoplan-core#198` |
|
||||||
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `add-ideas/govoplan-core#216` |
|
| Monthly datasource and transformation workflows | first as configuration package across connectors, files, workflow, reporting, and templates | `GovOPlaN/govoplan-core#216` |
|
||||||
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-templates#1` |
|
| Templates for letters, emails, forms, reports | `govoplan-templates`, separate from reporting | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-templates#1` |
|
||||||
| Reporting and BI | `govoplan-reporting`, separate from templates | `add-ideas/govoplan-core#190`, `add-ideas/govoplan-reporting#1` |
|
| Reporting and BI | `govoplan-reporting`, separate from templates | `GovOPlaN/govoplan-core#190`, `GovOPlaN/govoplan-reporting#1` |
|
||||||
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `add-ideas/govoplan-files#15` |
|
| File connectors: Nextcloud, Seafile, SMB, NFS | `govoplan-files` | `GovOPlaN/govoplan-files#15` |
|
||||||
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `add-ideas/govoplan-core#191`, `add-ideas/govoplan-connectors#2` |
|
| Public-sector software integration catalogue | `govoplan-connectors` with core strategy index | `GovOPlaN/govoplan-core#191`, `GovOPlaN/govoplan-connectors#2` |
|
||||||
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `add-ideas/govoplan-core#215` |
|
| Public-sector integration landscape catalogue | `govoplan-connectors` with core tracking | `GovOPlaN/govoplan-core#215` |
|
||||||
| Cases module concept | `govoplan-cases` | `add-ideas/govoplan-core#174` |
|
| Cases module concept | `govoplan-cases` | `GovOPlaN/govoplan-core#174` |
|
||||||
| Workflow module concept | `govoplan-workflow` | `add-ideas/govoplan-core#175` |
|
| Workflow module concept | `govoplan-workflow` | `GovOPlaN/govoplan-core#175` |
|
||||||
| Connectors module concept | `govoplan-connectors` | `add-ideas/govoplan-core#176` |
|
| Connectors module concept | `govoplan-connectors` | `GovOPlaN/govoplan-core#176` |
|
||||||
| Adrema-style address and distribution-list management | `govoplan-addresses` | `add-ideas/govoplan-addresses#1` |
|
| Adrema-style address and distribution-list management | `govoplan-addresses` | `GovOPlaN/govoplan-addresses#1` |
|
||||||
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `add-ideas/govoplan-connectors#3`, `add-ideas/govoplan-core#198` |
|
| Consume sources and become a governed source | `govoplan-connectors` plus possible future `govoplan-dataflow` | `GovOPlaN/govoplan-connectors#3`, `GovOPlaN/govoplan-core#198` |
|
||||||
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `add-ideas/govoplan-connectors#6` |
|
| Governed connector configuration, dry-run, and simulation runtime | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#6` |
|
||||||
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-scheduling#1` |
|
| Terminfindung and meeting scheduling polls | `govoplan-scheduling`; calendar primitives remain in calendar | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-scheduling#1` |
|
||||||
| Terminplaner and calendar primitives | `govoplan-calendar` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-calendar#1` |
|
| Terminplaner and calendar primitives | `govoplan-calendar` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-calendar#1` |
|
||||||
| Terminbuchung appointment booking | `govoplan-appointments` | `add-ideas/govoplan-core#193`, `add-ideas/govoplan-appointments#1` |
|
| Terminbuchung appointment booking | `govoplan-appointments` | `GovOPlaN/govoplan-core#193`, `GovOPlaN/govoplan-appointments#1` |
|
||||||
| Collaborative documents | `govoplan-dms` | `add-ideas/govoplan-dms#1` |
|
| Collaborative documents | `govoplan-dms` | `GovOPlaN/govoplan-dms#1` |
|
||||||
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `add-ideas/govoplan-core#194`, `add-ideas/govoplan-forms#1` |
|
| Forms | `govoplan-forms` for definitions and `govoplan-forms-runtime` for submissions/runtime behavior | `GovOPlaN/govoplan-core#194`, `GovOPlaN/govoplan-forms#1` |
|
||||||
| RSS consume and emit | `govoplan-connectors` | `add-ideas/govoplan-connectors#4` |
|
| RSS consume and emit | `govoplan-connectors` | `GovOPlaN/govoplan-connectors#4` |
|
||||||
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `add-ideas/govoplan-idm#1` |
|
| LDAP, Active Directory, OpenDesk identity | `govoplan-idm` | `GovOPlaN/govoplan-idm#1` |
|
||||||
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `add-ideas/govoplan-core#195`, `add-ideas/govoplan-connectors#5` |
|
| OpenDesk stack integration map | integration profile across IDM/access, mail/calendar, files/DMS, and connectors; not a monolithic module | `GovOPlaN/govoplan-core#195`, `GovOPlaN/govoplan-connectors#5` |
|
||||||
| Open-Xchange mail/groupware | `govoplan-mail` | `add-ideas/govoplan-mail#5` |
|
| Open-Xchange mail/groupware | `govoplan-mail` | `GovOPlaN/govoplan-mail#5` |
|
||||||
| Open-Xchange calendar | `govoplan-calendar` | `add-ideas/govoplan-calendar#2` |
|
| Open-Xchange calendar | `govoplan-calendar` | `GovOPlaN/govoplan-calendar#2` |
|
||||||
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#217` |
|
| Scalability profiles and autoscaling readiness | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#217` |
|
||||||
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `add-ideas/govoplan-core#219` |
|
| Hardware sizing matrix and requirements calculator | `govoplan-ops`, `govoplan-core` | `GovOPlaN/govoplan-core#219` |
|
||||||
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `add-ideas/govoplan-core#220` |
|
| Collaboration suite integration strategy | `govoplan-connectors`, `govoplan-dms`, `govoplan-workflow`, `govoplan-tasks`, `govoplan-appointments`, `govoplan-calendar` | `GovOPlaN/govoplan-core#220` |
|
||||||
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#19` |
|
| Install/runtime configuration contract | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#19` |
|
||||||
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#26` |
|
| Installer/deployment operator flow | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#26` |
|
||||||
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `add-ideas/govoplan-core#28` |
|
| Production-like deployment documentation | `govoplan-core`, later `govoplan-ops` | `GovOPlaN/govoplan-core#28` |
|
||||||
|
|
||||||
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
Boundary rationale lives in `MODULE_ARCHITECTURE.md`. Current decisions:
|
||||||
|
|
||||||
@@ -705,7 +705,7 @@ Release composition and tag-only repository handling are documented in
|
|||||||
## Next Practical Work
|
## Next Practical Work
|
||||||
|
|
||||||
The active cross-product story is
|
The active cross-product story is
|
||||||
[`add-ideas/govoplan#14`](https://git.add-ideas.de/add-ideas/govoplan/issues/14).
|
[`GovOPlaN/govoplan#14`](https://git.add-ideas.de/GovOPlaN/govoplan/issues/14).
|
||||||
Module repositories own implementation issues; do not clone their state here.
|
Module repositories own implementation issues; do not clone their state here.
|
||||||
|
|
||||||
Immediate issue buckets:
|
Immediate issue buckets:
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ The compatibility/deprecation plan for the current split line is:
|
|||||||
- reject new cross-module imports that bypass manifests, capabilities, events,
|
- reject new cross-module imports that bypass manifests, capabilities, events,
|
||||||
or public module APIs
|
or public module APIs
|
||||||
|
|
||||||
|
The retention windows and removal checklist for database bridges,
|
||||||
|
configuration/export schemas, and runtime/API aliases are defined in
|
||||||
|
`COMPATIBILITY_POLICY.md`.
|
||||||
|
|
||||||
## Stable Kernel Contracts
|
## Stable Kernel Contracts
|
||||||
|
|
||||||
The following contracts are the baseline API that modules can rely on:
|
The following contracts are the baseline API that modules can rely on:
|
||||||
@@ -87,7 +91,8 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
- capability factory contract
|
- capability factory contract
|
||||||
- access DTO/protocol contracts in `govoplan_core.core.access`
|
- access DTO/protocol contracts in `govoplan_core.core.access`
|
||||||
- resource ACL provider contract
|
- resource ACL provider contract
|
||||||
- tenant summary provider contract
|
- bounded reference-option search provider contract
|
||||||
|
- single-tenant and optional batched tenant summary provider contracts
|
||||||
- tenant delete-veto provider contract
|
- tenant delete-veto provider contract
|
||||||
- WebUI module contribution contract
|
- WebUI module contribution contract
|
||||||
- navigation metadata contract
|
- navigation metadata contract
|
||||||
@@ -96,6 +101,15 @@ The following contracts are the baseline API that modules can rely on:
|
|||||||
|
|
||||||
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
Changes to these contracts must be versioned or accompanied by compatibility shims.
|
||||||
|
|
||||||
|
Tenant list pages prefer `tenant_summary_batch_providers`. A batch provider
|
||||||
|
receives the unique tenant IDs on the current page and returns count mappings
|
||||||
|
keyed by tenant ID. Missing tenant keys mean that the provider has no counts for
|
||||||
|
that tenant; provider errors remain visible. Modules that expose only the
|
||||||
|
single-tenant contract remain compatible through a per-tenant fallback.
|
||||||
|
Destructive tenant lifecycle planning deliberately continues to use the
|
||||||
|
single-tenant path so it invokes every registered provider for the target
|
||||||
|
tenant, independent of ordinary list-page projections.
|
||||||
|
|
||||||
This list is the Milestone A kernel-contract freeze baseline. New module work
|
This list is the Milestone A kernel-contract freeze baseline. New module work
|
||||||
may extend the kernel by adding explicit contracts, but existing contracts must
|
may extend the kernel by adding explicit contracts, but existing contracts must
|
||||||
remain source-compatible through the 0.1.x split line unless a migration shim
|
remain source-compatible through the 0.1.x split line unless a migration shim
|
||||||
@@ -333,6 +347,31 @@ full snapshot with `full: true`. A first-use `seq:0` watermark remains valid
|
|||||||
until such a floor exists, even if unrelated collections have advanced the
|
until such a floor exists, even if unrelated collections have advanced the
|
||||||
global sequence.
|
global sequence.
|
||||||
|
|
||||||
|
### Bounded Reference Selectors
|
||||||
|
|
||||||
|
Cross-module selectors use the module-neutral contract in
|
||||||
|
`govoplan_core.core.references`; consumers must not load an optional module's
|
||||||
|
complete directory and filter it in memory.
|
||||||
|
|
||||||
|
- Providers receive a normalized `ReferenceSearchRequest` with `kind`,
|
||||||
|
`tenant_id`, `query`, `selected_values`, `limit`, optional opaque `cursor`,
|
||||||
|
and policy context.
|
||||||
|
- Providers apply visibility and text filtering before materializing rows and
|
||||||
|
return `ReferenceSearchPage(options, next_cursor, has_more)`.
|
||||||
|
- A page contains at most the requested bounded search results. Already-selected
|
||||||
|
references are retained in addition to that bound so historical values remain
|
||||||
|
readable and removable even when they are inactive, deleted, or outside the
|
||||||
|
current search page.
|
||||||
|
- API consumers expose `next_cursor` and `has_more`. The current searchable
|
||||||
|
selector requests the first bounded page for each query; later load-more UI
|
||||||
|
can use the same cursor without changing the provider contract.
|
||||||
|
- `access.reference_options` supplies SQL-backed account, membership, and group
|
||||||
|
searches. When it is absent, Core degrades to the legacy Access directory or
|
||||||
|
to principal-only/unavailable references without importing Access.
|
||||||
|
- The shared WebUI `apiReferenceOptionProvider` resolves selected values in
|
||||||
|
chunks of at most 200, preventing a large existing selection from turning
|
||||||
|
into an unbounded request.
|
||||||
|
|
||||||
### Cursor/Keyset Pages
|
### Cursor/Keyset Pages
|
||||||
|
|
||||||
Offset pagination remains supported for compatibility and for first page loads,
|
Offset pagination remains supported for compatibility and for first page loads,
|
||||||
@@ -585,11 +624,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
|||||||
|
|
||||||
## WebUI Contract
|
## WebUI Contract
|
||||||
|
|
||||||
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
|
A WebUI module exports a `PlatformWebModule` from its package. The object
|
||||||
|
contributes local/fallback metadata and route render functions. The package
|
||||||
|
must ship `src/module.ts` with the default contribution export: Core's Vite
|
||||||
|
host imports that descriptor directly after the backend reports the module as
|
||||||
|
enabled. This keeps package-root re-exports from pulling page implementations
|
||||||
|
into the initial shell.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
export const filesModule: PlatformWebModule = {
|
export const filesModule: PlatformWebModule = {
|
||||||
id: "files",
|
id: "files",
|
||||||
label: "Files",
|
label: "Files",
|
||||||
@@ -604,6 +650,11 @@ export const filesModule: PlatformWebModule = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Route pages and substantial panels must use stable lazy imports. Core supplies
|
||||||
|
the shared loading and retryable error state around route rendering. The
|
||||||
|
initial static import closure and largest asynchronous chunk are enforced by
|
||||||
|
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
WebUI modules receive only the core route context:
|
WebUI modules receive only the core route context:
|
||||||
|
|
||||||
- `settings`
|
- `settings`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ consistent while each module still owns its domain rules.
|
|||||||
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
| RBAC/access policy | `govoplan-access` | access capabilities in `govoplan_core.core.access` | Permission decisions should use access capability contracts. Explain responses should adopt `PolicyDecision` when an API-level explanation is added. |
|
||||||
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
|
||||||
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
|
||||||
|
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
|
||||||
|
|
||||||
## Policy Decision
|
## Policy Decision
|
||||||
|
|
||||||
@@ -111,6 +112,20 @@ matching compatibility DTOs only for its legacy admin surface. Admin overview
|
|||||||
responses remain module-local because the same counters are exposed from
|
responses remain module-local because the same counters are exposed from
|
||||||
different menu contexts and are not yet a separately versioned platform API.
|
different menu contexts and are not yet a separately versioned platform API.
|
||||||
|
|
||||||
|
## Definition Governance
|
||||||
|
|
||||||
|
Dataflow and Workflow submit a `DefinitionGovernanceRequest` using only stable
|
||||||
|
scope, principal, status, definition-kind, and limit fields. Policy returns a
|
||||||
|
standard `PolicyDecision`. System definitions may be inherited as read-only;
|
||||||
|
group and user definitions are visible only in matching contexts. Templates
|
||||||
|
may be viewed and derived but never run or automated. A derived definition
|
||||||
|
passes its pinned ancestor limits back through the request context, and Policy
|
||||||
|
applies those limits as ceilings rather than defaults that can be broadened.
|
||||||
|
|
||||||
|
When the capability is absent, modules must not silently emulate cross-scope
|
||||||
|
inheritance. Their conservative fallback is limited to local tenant
|
||||||
|
definitions and disables reuse, derivation, and automation.
|
||||||
|
|
||||||
## Frontend Contract
|
## Frontend Contract
|
||||||
|
|
||||||
Policy UIs must:
|
Policy UIs must:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
|
|||||||
strategy index. The executable connector catalogue lives in
|
strategy index. The executable connector catalogue lives in
|
||||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
||||||
|
|
||||||
|
The canonical cumulative maturity model and external-object DTO are documented
|
||||||
|
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
|
||||||
|
|
||||||
## Strategy Labels
|
## Strategy Labels
|
||||||
|
|
||||||
Use one or more of these labels for every external system family:
|
Use one or more of these labels for every external system family:
|
||||||
|
|||||||
@@ -40,26 +40,26 @@ cd /mnt/DATA/git/govoplan
|
|||||||
Update those refs when cutting a release:
|
Update those refs when cutting a release:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
govoplan-tenancy git@git.add-ideas.de:add-ideas/govoplan-tenancy.git v0.1.8
|
govoplan-tenancy git@git.add-ideas.de:GovOPlaN/govoplan-tenancy.git v0.1.8
|
||||||
govoplan-organizations git@git.add-ideas.de:add-ideas/govoplan-organizations.git v0.1.8
|
govoplan-organizations git@git.add-ideas.de:GovOPlaN/govoplan-organizations.git v0.1.8
|
||||||
govoplan-identity git@git.add-ideas.de:add-ideas/govoplan-identity.git v0.1.8
|
govoplan-identity git@git.add-ideas.de:GovOPlaN/govoplan-identity.git v0.1.8
|
||||||
govoplan-idm git@git.add-ideas.de:add-ideas/govoplan-idm.git v0.1.8
|
govoplan-idm git@git.add-ideas.de:GovOPlaN/govoplan-idm.git v0.1.8
|
||||||
govoplan-access git@git.add-ideas.de:add-ideas/govoplan-access.git v0.1.8
|
govoplan-access git@git.add-ideas.de:GovOPlaN/govoplan-access.git v0.1.8
|
||||||
govoplan-admin git@git.add-ideas.de:add-ideas/govoplan-admin.git v0.1.8
|
govoplan-admin git@git.add-ideas.de:GovOPlaN/govoplan-admin.git v0.1.8
|
||||||
govoplan-policy git@git.add-ideas.de:add-ideas/govoplan-policy.git v0.1.8
|
govoplan-policy git@git.add-ideas.de:GovOPlaN/govoplan-policy.git v0.1.8
|
||||||
govoplan-audit git@git.add-ideas.de:add-ideas/govoplan-audit.git v0.1.8
|
govoplan-audit git@git.add-ideas.de:GovOPlaN/govoplan-audit.git v0.1.8
|
||||||
govoplan-dashboard git@git.add-ideas.de:add-ideas/govoplan-dashboard.git v0.1.8
|
govoplan-dashboard git@git.add-ideas.de:GovOPlaN/govoplan-dashboard.git v0.1.8
|
||||||
govoplan-addresses git@git.add-ideas.de:add-ideas/govoplan-addresses.git v0.1.8
|
govoplan-addresses git@git.add-ideas.de:GovOPlaN/govoplan-addresses.git v0.1.8
|
||||||
govoplan-files git@git.add-ideas.de:add-ideas/govoplan-files.git v0.1.8
|
govoplan-files git@git.add-ideas.de:GovOPlaN/govoplan-files.git v0.1.8
|
||||||
govoplan-mail git@git.add-ideas.de:add-ideas/govoplan-mail.git v0.1.8
|
govoplan-mail git@git.add-ideas.de:GovOPlaN/govoplan-mail.git v0.1.8
|
||||||
govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.8
|
govoplan-campaign git@git.add-ideas.de:GovOPlaN/govoplan-campaign.git v0.1.8
|
||||||
govoplan-calendar git@git.add-ideas.de:add-ideas/govoplan-calendar.git v0.1.8
|
govoplan-calendar git@git.add-ideas.de:GovOPlaN/govoplan-calendar.git v0.1.8
|
||||||
govoplan-poll git@git.add-ideas.de:add-ideas/govoplan-poll.git v0.1.8
|
govoplan-poll git@git.add-ideas.de:GovOPlaN/govoplan-poll.git v0.1.8
|
||||||
govoplan-scheduling git@git.add-ideas.de:add-ideas/govoplan-scheduling.git v0.1.8
|
govoplan-scheduling git@git.add-ideas.de:GovOPlaN/govoplan-scheduling.git v0.1.8
|
||||||
govoplan-notifications git@git.add-ideas.de:add-ideas/govoplan-notifications.git v0.1.8
|
govoplan-notifications git@git.add-ideas.de:GovOPlaN/govoplan-notifications.git v0.1.8
|
||||||
govoplan-evaluation git@git.add-ideas.de:add-ideas/govoplan-evaluation.git v0.1.8
|
govoplan-evaluation git@git.add-ideas.de:GovOPlaN/govoplan-evaluation.git v0.1.8
|
||||||
govoplan-docs git@git.add-ideas.de:add-ideas/govoplan-docs.git v0.1.8
|
govoplan-docs git@git.add-ideas.de:GovOPlaN/govoplan-docs.git v0.1.8
|
||||||
govoplan-ops git@git.add-ideas.de:add-ideas/govoplan-ops.git v0.1.8
|
govoplan-ops git@git.add-ideas.de:GovOPlaN/govoplan-ops.git v0.1.8
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebUI Packages
|
## WebUI Packages
|
||||||
@@ -788,10 +788,27 @@ tools/checks/postgres-integration-check.py \
|
|||||||
The script checks migrations and `/health` startup for core-only, files-only,
|
The script checks migrations and `/health` startup for core-only, files-only,
|
||||||
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
mail-only, campaign-only, campaign+files, campaign+mail, and full-product
|
||||||
module sets. `--reset-schema` is destructive and must only be used against a
|
module sets. `--reset-schema` is destructive and must only be used against a
|
||||||
throwaway database.
|
throwaway database. Before those permutations, the required Core proof runs in
|
||||||
|
random, test-owned schemas without modifying `public`. It exercises Files' real
|
||||||
|
credential-owning retirement provider and proves that credential scrubbing,
|
||||||
|
non-secret audit
|
||||||
|
insertion, and table retirement commit together; database-injected audit and
|
||||||
|
DDL failures roll the entire unit back. A 500 ms PostgreSQL `lock_timeout` and
|
||||||
|
captured backend process IDs also prove that each `DROP TABLE` uses the
|
||||||
|
installer Session connection instead of waiting through a second connection.
|
||||||
|
The meta check enables the release-gate flag so missing PostgreSQL configuration
|
||||||
|
or full-stack test packages are a hard failure; ordinary Core-only test discovery
|
||||||
|
skips this integration proof. Do not pass `--skip-retirement-atomicity` when
|
||||||
|
collecting release evidence.
|
||||||
|
|
||||||
## Migration Baselines
|
## Migration Baselines
|
||||||
|
|
||||||
|
Release migration support follows `COMPATIBILITY_POLICY.md`: every tagged
|
||||||
|
`0.1.x` installation is a supported upgrade origin, released revision IDs are
|
||||||
|
immutable, and migration-only reconciliation remains available for at least one
|
||||||
|
subsequent major release cycle after the matching runtime compatibility path is
|
||||||
|
removed.
|
||||||
|
|
||||||
Development migrations may be small and numerous while a feature is moving.
|
Development migrations may be small and numerous while a feature is moving.
|
||||||
GovOPlaN keeps those detailed migrations on an explicit development track and
|
GovOPlaN keeps those detailed migrations on an explicit development track and
|
||||||
publishes reviewed release shortcuts on the release track. Before a stable
|
publishes reviewed release shortcuts on the release track. Before a stable
|
||||||
@@ -881,7 +898,7 @@ before that baseline, so pre-v0.1.7 development revisions are not release
|
|||||||
upgrade targets. Future release-to-release changes must start from a recorded
|
upgrade targets. Future release-to-release changes must start from a recorded
|
||||||
release baseline and add a new release-track step-up instead of replacing prior
|
release baseline and add a new release-track step-up instead of replacing prior
|
||||||
release shortcuts. The tracking issue is
|
release shortcuts. The tracking issue is
|
||||||
`add-ideas/govoplan-core#223`.
|
`GovOPlaN/govoplan-core#223`.
|
||||||
|
|
||||||
## Related Operator Documents
|
## Related Operator Documents
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
|
||||||
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
|
||||||
become store keys. A configured Redis instance provides atomic counters shared
|
become store keys. A configured Redis instance provides atomic counters shared
|
||||||
across API workers; development, a missing Redis configuration, and temporary
|
across API workers. Development and temporary Redis outages use a bounded
|
||||||
Redis outages use a bounded process-local fallback. When Redis fails, local
|
process-local fallback. Production-like startup rejects an enabled login
|
||||||
attempts are still mirrored so losing the distributed store does not reset the
|
throttle without `REDIS_URL` unless
|
||||||
active worker's protection window.
|
`GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
|
||||||
|
single-process limitation. When Redis fails at runtime, local attempts are still
|
||||||
|
mirrored so losing the distributed store does not reset the active worker's
|
||||||
|
protection window.
|
||||||
|
|
||||||
Callers define one or more `ThrottleDimension` values with a controlled
|
Callers define one or more `ThrottleDimension` values with a controlled
|
||||||
namespace, a subject and a positive limit. They must call `check` before an
|
namespace, a subject and a positive limit. They must call `check` before an
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ binding design reference: future implementation should follow these decisions
|
|||||||
unless the decision is explicitly revised here and affected screens are updated
|
unless the decision is explicitly revised here and affected screens are updated
|
||||||
to match.
|
to match.
|
||||||
|
|
||||||
Active tracking issue: `add-ideas/govoplan-core#225`.
|
Active tracking issue: `GovOPlaN/govoplan-core#225`.
|
||||||
|
|
||||||
## Operating Rule
|
## Operating Rule
|
||||||
|
|
||||||
@@ -233,6 +233,15 @@ instead of reproducing their behavior.
|
|||||||
`disabledReason` for row state; omit an action only when that action does not
|
`disabledReason` for row state; omit an action only when that action does not
|
||||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||||
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
row. `DataGridEmptyAction` does this for the standard add/move/remove layout.
|
||||||
|
- A paginated `DataGrid` has exactly one query owner. Client mode receives the
|
||||||
|
complete logical row set and applies filtering and sorting before slicing a
|
||||||
|
page. Server mode receives only the loaded page, requires `onQueryChange`,
|
||||||
|
and the backend applies every emitted filter/sort before pagination while
|
||||||
|
returning `totalRows` for the filtered result. Server list filters declare
|
||||||
|
their complete option domain instead of deriving it from the loaded page.
|
||||||
|
External filter affordances such as summary-count shortcuts update the
|
||||||
|
grid's `query` contract; the grid header controls and backend query therefore
|
||||||
|
always display and execute the same filter state.
|
||||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||||
|
|
||||||
|
|||||||
71
docs/WEBUI_BUNDLE_BUDGETS.md
Normal file
71
docs/WEBUI_BUNDLE_BUDGETS.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# WebUI Loading And Bundle Budgets
|
||||||
|
|
||||||
|
The Core WebUI host owns the loading boundary for installed module packages.
|
||||||
|
Vite discovers configured packages at build time, but emits an asynchronous
|
||||||
|
loader for each package's `src/module.ts` contribution descriptor. At runtime,
|
||||||
|
Core imports only descriptors whose backend manifests are enabled and identify
|
||||||
|
the matching `frontend.package_name`.
|
||||||
|
|
||||||
|
The direct descriptor entry is intentional. A package root may re-export pages
|
||||||
|
for consumers; importing that barrel as module wiring can cause those pages to
|
||||||
|
be evaluated before navigation. Route pages and substantial panels should use
|
||||||
|
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
|
||||||
|
|
||||||
|
## Enforced Budgets
|
||||||
|
|
||||||
|
`webui/bundle-budget.json` contains the production limits:
|
||||||
|
|
||||||
|
| Measurement | Raw limit | Gzip limit |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
|
||||||
|
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
|
||||||
|
|
||||||
|
`npm run build` writes a Vite manifest, measures the entry and its recursive
|
||||||
|
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
|
||||||
|
is exceeded. `npm run test:module-permutations` applies the same gate to every
|
||||||
|
permutation and records the collected results in
|
||||||
|
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
|
||||||
|
to the step summary.
|
||||||
|
|
||||||
|
Budgets are limits, not targets. A change that approaches a limit should add a
|
||||||
|
new lazy boundary or remove unnecessary entry code instead of raising the
|
||||||
|
limit without measurement and review.
|
||||||
|
|
||||||
|
## 2026-07-30 Baseline
|
||||||
|
|
||||||
|
Measurements use the same full-product source tree and Node 22 runtime. The
|
||||||
|
post-change build additionally includes the Search module in the default and
|
||||||
|
full-product sets.
|
||||||
|
|
||||||
|
| Initial-load measurement | Before | After | Reduction |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| JavaScript assets in initial static closure | 1 | 1 | 0% |
|
||||||
|
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
|
||||||
|
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
|
||||||
|
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
|
||||||
|
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
|
||||||
|
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
|
||||||
|
|
||||||
|
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
|
||||||
|
source 30 times with a randomized source marker. It is useful for a controlled
|
||||||
|
before/after comparison, but is not enforced in CI because absolute timings
|
||||||
|
vary across runners. Transfer budgets use deterministic raw and gzip byte
|
||||||
|
counts.
|
||||||
|
|
||||||
|
The first budgeted full-product build reported:
|
||||||
|
|
||||||
|
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
|
||||||
|
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
|
npm run build
|
||||||
|
npm run check:bundle-budget
|
||||||
|
npm run test:module-permutations
|
||||||
|
```
|
||||||
|
|
||||||
|
The build gate also catches accidental eager imports: a page pulled into the
|
||||||
|
entry closure consumes the initial budget, while an oversized page or module
|
||||||
|
descriptor consumes the asynchronous chunk budget.
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
"description": "Portal form, case workflow, task creation, mail notification, payment setup, and access roles for a simple application process.",
|
||||||
"publisher": "ADD ideas",
|
"publisher": "ADD ideas",
|
||||||
"category": "workflow",
|
"category": "workflow",
|
||||||
"artifact_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
"artifact_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-configuration-packages.git@application-handling-basic-v0.1.0",
|
||||||
"artifact_sha256": "<sha256>",
|
"artifact_sha256": "<sha256>",
|
||||||
"required_modules": [
|
"required_modules": [
|
||||||
{ "module_id": "portal", "version": ">=0.1.0" },
|
{ "module_id": "portal", "version": ">=0.1.0" },
|
||||||
|
|||||||
@@ -291,6 +291,378 @@
|
|||||||
"release": "0.1.11",
|
"release": "0.1.11",
|
||||||
"squash_policy": "reviewed-manual",
|
"squash_policy": "reviewed-manual",
|
||||||
"track": "release"
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T07:06:21Z",
|
||||||
|
"release": "0.1.12",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T08:39:02Z",
|
||||||
|
"release": "0.1.13",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revision": "608192abcdef"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revision": "6e7f8a9b0c1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revision": "6f7a8b9c0d1e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revision": "8f9a0b1c2d3e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revision": "a7b8c9d0e1f3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revision": "af1b2c3d4e5f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revision": "c9d4e7f1a2b3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revision": "d8b3e2c1f4a5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revision": "e1f2a4b5c6d"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"owner_heads": [
|
||||||
|
{
|
||||||
|
"owner": "govoplan-access",
|
||||||
|
"revisions": [
|
||||||
|
"4a5b6c7d8e9f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-addresses",
|
||||||
|
"revisions": [
|
||||||
|
"e1f2a4b5c6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-calendar",
|
||||||
|
"revisions": [
|
||||||
|
"af1b2c3d4e5f"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-campaign",
|
||||||
|
"revisions": [
|
||||||
|
"d8b3e2c1f4a5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-core",
|
||||||
|
"revisions": [
|
||||||
|
"4f2a9c8e7b6d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-files",
|
||||||
|
"revisions": [
|
||||||
|
"a7b8c9d0e1f3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-identity",
|
||||||
|
"revisions": [
|
||||||
|
"5c6d7e8f9a10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-idm",
|
||||||
|
"revisions": [
|
||||||
|
"8f9a0b1c2d3e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-mail",
|
||||||
|
"revisions": [
|
||||||
|
"608192abcdef"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-notifications",
|
||||||
|
"revisions": [
|
||||||
|
"6f7a8b9c0d1e"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-organizations",
|
||||||
|
"revisions": [
|
||||||
|
"6d7e8f9a0b1c"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-poll",
|
||||||
|
"revisions": [
|
||||||
|
"6e7f8a9b0c1d"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"owner": "govoplan-scheduling",
|
||||||
|
"revisions": [
|
||||||
|
"c9d4e7f1a2b3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"recorded_at": "2026-07-22T18:15:01Z",
|
||||||
|
"release": "0.1.14",
|
||||||
|
"squash_policy": "reviewed-manual",
|
||||||
|
"track": "release"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version": 1
|
"version": 1
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"python_ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
"migration_safety": "forward_only",
|
"migration_safety": "forward_only",
|
||||||
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
"migration_after": ["access"],
|
"migration_after": ["access"],
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git@v0.1.4",
|
"ref": "govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-files-webui-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-files-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -78,9 +78,9 @@
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-mail",
|
"python_package": "govoplan-mail",
|
||||||
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"python_ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/mail-webui",
|
"webui_package": "@govoplan/mail-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||||
"provides_interfaces": [
|
"provides_interfaces": [
|
||||||
{
|
{
|
||||||
"name": "mail.campaign_delivery",
|
"name": "mail.campaign_delivery",
|
||||||
@@ -97,14 +97,14 @@
|
|||||||
],
|
],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git@v0.1.4",
|
"ref": "govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-0.1.4.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.4"
|
"git_ref": "refs/tags/v0.1.4"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.4",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-mail-webui-0.1.4.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-mail-webui-0.1.4.intoto.jsonl",
|
||||||
@@ -121,19 +121,19 @@
|
|||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-dashboard",
|
"python_package": "govoplan-dashboard",
|
||||||
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
"python_ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||||
"webui_package": "@govoplan/dashboard-webui",
|
"webui_package": "@govoplan/dashboard-webui",
|
||||||
"webui_ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"webui_ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
"python": {
|
"python": {
|
||||||
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git@v0.1.6",
|
"ref": "govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.6",
|
||||||
"sha256": "<sha256 of the resolved Python artifact>",
|
"sha256": "<sha256 of the resolved Python artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-0.1.6.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-0.1.6.intoto.jsonl",
|
||||||
"git_ref": "refs/tags/v0.1.6"
|
"git_ref": "refs/tags/v0.1.6"
|
||||||
},
|
},
|
||||||
"webui": {
|
"webui": {
|
||||||
"ref": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.6",
|
"ref": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.6",
|
||||||
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
"sha256": "<sha256 of the resolved WebUI package artifact>",
|
||||||
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
"sbom_url": "https://govoplan.add-ideas.de/sbom/govoplan-dashboard-webui-0.1.6.spdx.json",
|
||||||
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
"provenance_url": "https://govoplan.add-ideas.de/provenance/govoplan-dashboard-webui-0.1.6.intoto.jsonl",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.11"
|
version = "0.1.14"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -27,6 +27,12 @@ where = ["src"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
govoplan_core = ["py.typed"]
|
govoplan_core = ["py.typed"]
|
||||||
|
|
||||||
|
[tool.setuptools.data-files]
|
||||||
|
"govoplan_core_runtime" = ["alembic.ini"]
|
||||||
|
"govoplan_core_runtime/alembic" = ["alembic/env.py", "alembic/script.py.mako"]
|
||||||
|
"govoplan_core_runtime/alembic/versions" = ["alembic/versions/*.py"]
|
||||||
|
"govoplan_core_runtime/alembic/dev_versions" = ["alembic/dev_versions/*.py"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
govoplan-config = "govoplan_core.commands.config:main"
|
govoplan-config = "govoplan_core.commands.config:main"
|
||||||
govoplan-devserver = "govoplan_core.devserver:main"
|
govoplan-devserver = "govoplan_core.devserver:main"
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ class DeltaCollectionResponse(BaseModel):
|
|||||||
full: bool = False
|
full: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionResponse(BaseModel):
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
kind: str | None = None
|
||||||
|
availability: Literal["available", "inactive", "unavailable"] = "available"
|
||||||
|
disabled: bool = False
|
||||||
|
source_module: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionListResponse(BaseModel):
|
||||||
|
options: list[ReferenceOptionResponse] = Field(default_factory=list)
|
||||||
|
provider_available: bool = True
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from govoplan_core.core.events import (
|
|||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
current_event_trace,
|
current_event_trace,
|
||||||
normalize_trace_id,
|
normalize_trace_id,
|
||||||
publish_platform_event,
|
emit_platform_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.runtime import get_registry
|
from govoplan_core.core.runtime import get_registry
|
||||||
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
|
||||||
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _publish_audit_platform_event(item: AuditRecordRef) -> None:
|
def _publish_audit_platform_event(
|
||||||
|
session: Session,
|
||||||
|
item: AuditRecordRef,
|
||||||
|
) -> None:
|
||||||
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
|
||||||
publish_platform_event(
|
emit_platform_event(
|
||||||
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
type=item.action,
|
type=item.action,
|
||||||
module_id=_module_id_for_audit_action(item.action),
|
module_id=_module_id_for_audit_action(item.action),
|
||||||
@@ -252,7 +256,7 @@ def audit_event(
|
|||||||
actor_id=user_id or api_key_id,
|
actor_id=user_id or api_key_id,
|
||||||
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
|
||||||
)
|
)
|
||||||
_publish_audit_platform_event(item)
|
_publish_audit_platform_event(session, item)
|
||||||
if commit:
|
if commit:
|
||||||
session.commit()
|
session.commit()
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
|||||||
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
|
||||||
registry = _registry_from_request(request)
|
registry = _registry_from_request(request)
|
||||||
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available")
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
|
||||||
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
|
||||||
if not isinstance(capability, ApiPrincipalProvider):
|
if not isinstance(capability, ApiPrincipalProvider):
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")
|
||||||
|
|||||||
@@ -1,12 +1,35 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_DELIVERY_TASKS, CampaignDeliveryTaskProvider
|
||||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_OUTBOX, CalendarOutboxProvider
|
||||||
|
from govoplan_core.core.dataflows import (
|
||||||
|
CAPABILITY_DATAFLOW_RUN_WORKER,
|
||||||
|
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
|
||||||
|
DataflowRunWorker,
|
||||||
|
DataflowTriggerDispatcher,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.events import (
|
||||||
|
CAPABILITY_PLATFORM_EVENT_OUTBOX,
|
||||||
|
DurableEventConsumer,
|
||||||
|
PlatformEvent,
|
||||||
|
PlatformEventOutbox,
|
||||||
|
publish_platform_event,
|
||||||
|
)
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleContext
|
from govoplan_core.core.modules import ModuleContext
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
|
||||||
|
from govoplan_core.core.postbox import (
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
|
PostboxRoutingProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.workflows import (
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||||
|
WorkflowRuntimeWorker,
|
||||||
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import configure_runtime
|
from govoplan_core.core.runtime import configure_runtime
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
@@ -29,6 +52,13 @@ celery.conf.update(
|
|||||||
"govoplan.notifications.deliver": {"queue": "notifications"},
|
"govoplan.notifications.deliver": {"queue": "notifications"},
|
||||||
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
"govoplan.notifications.deliver_pending": {"queue": "notifications"},
|
||||||
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
|
||||||
|
"govoplan.dataflow.dispatch_runs": {"queue": "dataflow"},
|
||||||
|
"govoplan.dataflow.purge_runs": {"queue": "dataflow"},
|
||||||
|
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
|
||||||
|
"govoplan.workflow.reconcile": {"queue": "workflow"},
|
||||||
|
"govoplan.postbox.dispatch_routes": {"queue": "postbox"},
|
||||||
|
"govoplan.events.dispatch_outbox": {"queue": "events"},
|
||||||
|
"govoplan.events.purge_outbox": {"queue": "events"},
|
||||||
},
|
},
|
||||||
worker_prefetch_multiplier=1,
|
worker_prefetch_multiplier=1,
|
||||||
task_acks_late=True,
|
task_acks_late=True,
|
||||||
@@ -39,6 +69,41 @@ celery.conf.update(
|
|||||||
"schedule": 60.0,
|
"schedule": 60.0,
|
||||||
"args": (None, 100),
|
"args": (None, 100),
|
||||||
},
|
},
|
||||||
|
"dataflow-triggers-every-minute": {
|
||||||
|
"task": "govoplan.dataflow.dispatch_triggers",
|
||||||
|
"schedule": 60.0,
|
||||||
|
"args": (100,),
|
||||||
|
},
|
||||||
|
"dataflow-runs-every-five-seconds": {
|
||||||
|
"task": "govoplan.dataflow.dispatch_runs",
|
||||||
|
"schedule": 5.0,
|
||||||
|
"args": (10,),
|
||||||
|
},
|
||||||
|
"dataflow-run-retention-daily": {
|
||||||
|
"task": "govoplan.dataflow.purge_runs",
|
||||||
|
"schedule": 24 * 60 * 60.0,
|
||||||
|
"args": (500,),
|
||||||
|
},
|
||||||
|
"workflow-reconcile-every-five-seconds": {
|
||||||
|
"task": "govoplan.workflow.reconcile",
|
||||||
|
"schedule": 5.0,
|
||||||
|
"args": (50,),
|
||||||
|
},
|
||||||
|
"postbox-routes-every-minute": {
|
||||||
|
"task": "govoplan.postbox.dispatch_routes",
|
||||||
|
"schedule": 60.0,
|
||||||
|
"args": (None, 50),
|
||||||
|
},
|
||||||
|
"platform-events-every-ten-seconds": {
|
||||||
|
"task": "govoplan.events.dispatch_outbox",
|
||||||
|
"schedule": 10.0,
|
||||||
|
"args": (100,),
|
||||||
|
},
|
||||||
|
"platform-event-retention-daily": {
|
||||||
|
"task": "govoplan.events.purge_outbox",
|
||||||
|
"schedule": 24 * 60 * 60.0,
|
||||||
|
"args": (500,),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +151,70 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _dataflow_trigger_dispatcher(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> DataflowTriggerDispatcher | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER
|
||||||
|
)
|
||||||
|
if not isinstance(capability, DataflowTriggerDispatcher):
|
||||||
|
raise RuntimeError("Dataflow trigger dispatcher capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _dataflow_run_worker(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> DataflowRunWorker | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_DATAFLOW_RUN_WORKER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_DATAFLOW_RUN_WORKER)
|
||||||
|
if not isinstance(capability, DataflowRunWorker):
|
||||||
|
raise RuntimeError("Dataflow run worker capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_runtime_worker(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> WorkflowRuntimeWorker | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER
|
||||||
|
)
|
||||||
|
if not isinstance(capability, WorkflowRuntimeWorker):
|
||||||
|
raise RuntimeError("Workflow runtime worker capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _postbox_routing_provider(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> PostboxRoutingProvider | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_POSTBOX_ROUTING):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_POSTBOX_ROUTING)
|
||||||
|
if not isinstance(capability, PostboxRoutingProvider):
|
||||||
|
raise RuntimeError("Postbox routing capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_event_outbox(
|
||||||
|
registry: PlatformRegistry | None = None,
|
||||||
|
) -> PlatformEventOutbox | None:
|
||||||
|
registry = registry or _platform_registry()
|
||||||
|
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
|
||||||
|
return None
|
||||||
|
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||||
|
if not isinstance(capability, PlatformEventOutbox):
|
||||||
|
raise RuntimeError("Platform event outbox capability is invalid")
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
|
||||||
def send_email(self, job_id: str):
|
def send_email(self, job_id: str):
|
||||||
"""Send one explicitly queued campaign job.
|
"""Send one explicitly queued campaign job.
|
||||||
@@ -149,3 +278,223 @@ def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50
|
|||||||
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
result = dict(provider.dispatch_due(session, tenant_id=tenant_id, limit=limit))
|
||||||
session.commit()
|
session.commit()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.dataflow.dispatch_triggers",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_dataflow_triggers(self, limit: int = 100):
|
||||||
|
"""Drain durable Dataflow trigger deliveries and due schedules."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _dataflow_trigger_dispatcher()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"queued": 0,
|
||||||
|
"processed": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
result = dict(provider.dispatch_due(session, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.dataflow.dispatch_runs",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_dataflow_runs(self, limit: int = 10):
|
||||||
|
"""Claim and execute durable Dataflow runs outside the API process."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _dataflow_run_worker()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"claimed": 0,
|
||||||
|
"succeeded": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
}
|
||||||
|
result = dict(
|
||||||
|
provider.dispatch_pending(
|
||||||
|
session,
|
||||||
|
limit=limit,
|
||||||
|
worker_id=getattr(self.request, "hostname", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.dataflow.purge_runs",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def purge_dataflow_runs(self, limit: int = 500):
|
||||||
|
"""Apply Dataflow evidence-retention policy without deleting run records."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _dataflow_run_worker()
|
||||||
|
if provider is None:
|
||||||
|
return {"purged": 0}
|
||||||
|
result = dict(provider.purge_expired(session, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.workflow.reconcile",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def reconcile_workflow_instances(self, limit: int = 50):
|
||||||
|
"""Resume asynchronous Workflow steps from durable provider state."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _workflow_runtime_worker()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"inspected": 0,
|
||||||
|
"advanced": 0,
|
||||||
|
"waiting": 0,
|
||||||
|
"failed": 0,
|
||||||
|
}
|
||||||
|
result = dict(provider.reconcile_pending(session, limit=limit))
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.postbox.dispatch_routes",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_postbox_routes(
|
||||||
|
self,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
):
|
||||||
|
"""Deliver due Postbox vacancy escalations from durable route rows."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
provider = _postbox_routing_provider()
|
||||||
|
if provider is None:
|
||||||
|
return {
|
||||||
|
"selected": 0,
|
||||||
|
"delivered": 0,
|
||||||
|
"vacant": 0,
|
||||||
|
"rescheduled": 0,
|
||||||
|
"cancelled": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"route_ids": [],
|
||||||
|
}
|
||||||
|
result = dict(
|
||||||
|
provider.dispatch_due_routes(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.events.dispatch_outbox",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def dispatch_platform_events(self, limit: int = 100):
|
||||||
|
"""Deliver committed platform events through persistent consumer ledgers."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
registry = _platform_registry()
|
||||||
|
outbox = _platform_event_outbox(registry)
|
||||||
|
if outbox is None:
|
||||||
|
return {
|
||||||
|
"selected": 0,
|
||||||
|
"delivered": 0,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
"dispatched": 0,
|
||||||
|
"observer_failed": 0,
|
||||||
|
}
|
||||||
|
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
|
||||||
|
consumers = ()
|
||||||
|
if dataflow_dispatcher is not None:
|
||||||
|
def deliver_to_dataflow(
|
||||||
|
event: PlatformEvent,
|
||||||
|
_delivery_key: str,
|
||||||
|
) -> None:
|
||||||
|
dataflow_dispatcher.ingest_event(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
consumers = (
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="dataflow.event-triggers.v1",
|
||||||
|
event_types=frozenset({"*"}),
|
||||||
|
classifications=frozenset({"public", "internal"}),
|
||||||
|
handler=deliver_to_dataflow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = dict(
|
||||||
|
outbox.dispatch_pending(
|
||||||
|
session,
|
||||||
|
consumers=consumers,
|
||||||
|
observer=publish_platform_event,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
name="govoplan.events.purge_outbox",
|
||||||
|
bind=True,
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def purge_platform_events(self, limit: int = 500):
|
||||||
|
"""Remove old terminal event envelopes while retaining quarantine evidence."""
|
||||||
|
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
outbox = _platform_event_outbox()
|
||||||
|
if outbox is None:
|
||||||
|
return {"deleted": 0}
|
||||||
|
before = datetime.now(timezone.utc) - timedelta(
|
||||||
|
days=settings.platform_event_outbox_terminal_retention_days
|
||||||
|
)
|
||||||
|
result = dict(
|
||||||
|
outbox.purge_terminal(
|
||||||
|
session,
|
||||||
|
before=before,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
|||||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||||
|
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
|
||||||
|
"auth.automationPrincipalProvider"
|
||||||
|
)
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||||
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
CAPABILITY_AUDIT_RETENTION,
|
CAPABILITY_AUDIT_RETENTION,
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
@@ -57,12 +61,19 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
|||||||
AUTH_CAPABILITY_NAMES = frozenset(
|
AUTH_CAPABILITY_NAMES = frozenset(
|
||||||
{
|
{
|
||||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
|
||||||
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
|
||||||
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
|
||||||
|
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
|
||||||
|
}
|
||||||
|
|
||||||
AuthMethod = Literal["session", "api_key", "service_account"]
|
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||||
AccessSubjectKind = Literal[
|
AccessSubjectKind = Literal[
|
||||||
"identity",
|
"identity",
|
||||||
|
|||||||
183
src/govoplan_core/core/automation.py
Normal file
183
src/govoplan_core/core/automation.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
)
|
||||||
|
|
||||||
|
AutomationInvocationKind = Literal[
|
||||||
|
"manual",
|
||||||
|
"api",
|
||||||
|
"schedule",
|
||||||
|
"event",
|
||||||
|
"workflow",
|
||||||
|
"dependency",
|
||||||
|
"retry",
|
||||||
|
"backfill",
|
||||||
|
]
|
||||||
|
AutomationSubjectKind = Literal["delegated_user", "service_account"]
|
||||||
|
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AutomationInvocation:
|
||||||
|
kind: AutomationInvocationKind = "manual"
|
||||||
|
trigger_ref: str | None = None
|
||||||
|
delivery_ref: str | None = None
|
||||||
|
event_id: str | None = None
|
||||||
|
event_type: str | None = None
|
||||||
|
correlation_id: str | None = None
|
||||||
|
causation_id: str | None = None
|
||||||
|
scheduled_for: datetime | None = None
|
||||||
|
requested_by: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AutomationPrincipalRequest:
|
||||||
|
tenant_id: str
|
||||||
|
authorization_ref: str
|
||||||
|
grant_scopes: tuple[str, ...]
|
||||||
|
account_id: str | None = None
|
||||||
|
membership_id: str | None = None
|
||||||
|
service_account_id: str | None = None
|
||||||
|
subject_kind: AutomationSubjectKind = "delegated_user"
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
contract_version: str = AUTOMATION_PRINCIPAL_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if (
|
||||||
|
self.contract_version
|
||||||
|
!= AUTOMATION_PRINCIPAL_CONTRACT_VERSION
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported automation-principal contract version"
|
||||||
|
)
|
||||||
|
if not self.tenant_id.strip():
|
||||||
|
raise ValueError("Automation tenant id is required")
|
||||||
|
if not self.authorization_ref.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Automation authorization artifact reference is required"
|
||||||
|
)
|
||||||
|
if self.subject_kind == "delegated_user":
|
||||||
|
if (
|
||||||
|
not self.account_id
|
||||||
|
or not self.membership_id
|
||||||
|
or self.service_account_id is not None
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Delegated-user automation requires account and "
|
||||||
|
"membership references only"
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
not self.service_account_id
|
||||||
|
or self.account_id is not None
|
||||||
|
or self.membership_id is not None
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Service-account automation requires only a service-account "
|
||||||
|
"reference"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
not scope.strip()
|
||||||
|
for scope in self.grant_scopes
|
||||||
|
):
|
||||||
|
raise ValueError("Automation grant scopes must not be empty")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delegated_user(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
membership_id: str,
|
||||||
|
authorization_ref: str,
|
||||||
|
grant_scopes: tuple[str, ...],
|
||||||
|
context: Mapping[str, object] | None = None,
|
||||||
|
) -> AutomationPrincipalRequest:
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=membership_id,
|
||||||
|
authorization_ref=authorization_ref,
|
||||||
|
grant_scopes=grant_scopes,
|
||||||
|
context=context or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def service_account(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
service_account_id: str,
|
||||||
|
authorization_ref: str,
|
||||||
|
grant_scopes: tuple[str, ...],
|
||||||
|
context: Mapping[str, object] | None = None,
|
||||||
|
) -> AutomationPrincipalRequest:
|
||||||
|
return cls(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
service_account_id=service_account_id,
|
||||||
|
subject_kind="service_account",
|
||||||
|
authorization_ref=authorization_ref,
|
||||||
|
grant_scopes=grant_scopes,
|
||||||
|
context=context or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AutomationPrincipalResolution:
|
||||||
|
allowed: bool
|
||||||
|
principal: object | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
granted_scopes: tuple[str, ...] = ()
|
||||||
|
missing_scopes: tuple[str, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class AutomationPrincipalProvider(Protocol):
|
||||||
|
def resolve_automation_principal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def automation_principal_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> AutomationPrincipalProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, AutomationPrincipalProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
||||||
|
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
|
||||||
|
"AutomationInvocation",
|
||||||
|
"AutomationInvocationKind",
|
||||||
|
"AutomationPrincipalProvider",
|
||||||
|
"AutomationPrincipalRequest",
|
||||||
|
"AutomationPrincipalResolution",
|
||||||
|
"AutomationSubjectKind",
|
||||||
|
"automation_principal_provider",
|
||||||
|
]
|
||||||
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
|
|||||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
|
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
|
||||||
from govoplan_core.db.base import Base, utcnow
|
from govoplan_core.db.base import Base, utcnow
|
||||||
|
|
||||||
WATERMARK_PREFIX = "seq:"
|
WATERMARK_PREFIX = "seq:"
|
||||||
@@ -96,13 +96,14 @@ def record_change(
|
|||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
_publish_change_event(entry)
|
_publish_change_event(session, entry)
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
|
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
|
||||||
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
||||||
publish_platform_event(
|
emit_platform_event(
|
||||||
|
session,
|
||||||
PlatformEvent(
|
PlatformEvent(
|
||||||
type=event_type,
|
type=event_type,
|
||||||
module_id=entry.module_id,
|
module_id=entry.module_id,
|
||||||
|
|||||||
215
src/govoplan_core/core/dataflows.py
Normal file
215
src/govoplan_core/core/dataflows.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.automation import AutomationInvocation
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
|
||||||
|
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
|
||||||
|
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunError(ValueError):
|
||||||
|
"""Stable base error for module-neutral Dataflow run operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunNotFoundError(DataflowRunError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunConflictError(DataflowRunError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunUnavailableError(DataflowRunError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowPublicationTarget:
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = None
|
||||||
|
set_current: bool = True
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowRunRequest:
|
||||||
|
pipeline_ref: str
|
||||||
|
revision: int
|
||||||
|
idempotency_key: str
|
||||||
|
row_limit: int = 500
|
||||||
|
execution_backend: str = "auto"
|
||||||
|
environment: str = "development"
|
||||||
|
max_attempts: int = 3
|
||||||
|
retention_days: int = 30
|
||||||
|
publication: DataflowPublicationTarget | None = None
|
||||||
|
invocation: AutomationInvocation = field(
|
||||||
|
default_factory=AutomationInvocation
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DataflowRunDescriptor:
|
||||||
|
ref: str
|
||||||
|
pipeline_ref: str
|
||||||
|
revision: int
|
||||||
|
status: str
|
||||||
|
definition_hash: str
|
||||||
|
executor_version: str
|
||||||
|
input_row_count: int = 0
|
||||||
|
output_row_count: int = 0
|
||||||
|
output_publication_ref: str | None = None
|
||||||
|
output_datasource_ref: str | None = None
|
||||||
|
output_materialization_ref: str | None = None
|
||||||
|
invocation_kind: str = "manual"
|
||||||
|
trigger_ref: str | None = None
|
||||||
|
delivery_ref: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
started_at: datetime | None = None
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowRunLifecycleProvider(Protocol):
|
||||||
|
def start_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DataflowRunRequest,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRunDescriptor | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def cancel_run(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
run_ref: str,
|
||||||
|
) -> DataflowRunDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowTriggerDispatcher(Protocol):
|
||||||
|
def dispatch_due(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def ingest_event(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event: PlatformEvent,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DataflowRunWorker(Protocol):
|
||||||
|
"""Durable worker boundary for queued Dataflow execution.
|
||||||
|
|
||||||
|
Implementations own claim transaction boundaries so a lease is committed
|
||||||
|
before potentially long-running execution starts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dispatch_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
worker_id: str | None = None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def purge_expired(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_run_lifecycle(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowRunLifecycleProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_LIFECYCLE)
|
||||||
|
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_run_worker(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowRunWorker | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_RUN_WORKER)
|
||||||
|
return capability if isinstance(capability, DataflowRunWorker) else None
|
||||||
|
|
||||||
|
|
||||||
|
def dataflow_trigger_dispatcher(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DataflowTriggerDispatcher | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, DataflowTriggerDispatcher)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
|
||||||
|
"CAPABILITY_DATAFLOW_RUN_WORKER",
|
||||||
|
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
|
||||||
|
"DataflowPublicationTarget",
|
||||||
|
"DataflowRunConflictError",
|
||||||
|
"DataflowRunDescriptor",
|
||||||
|
"DataflowRunError",
|
||||||
|
"DataflowRunLifecycleProvider",
|
||||||
|
"DataflowRunNotFoundError",
|
||||||
|
"DataflowRunRequest",
|
||||||
|
"DataflowRunUnavailableError",
|
||||||
|
"DataflowRunWorker",
|
||||||
|
"DataflowTriggerDispatcher",
|
||||||
|
"dataflow_run_lifecycle",
|
||||||
|
"dataflow_run_worker",
|
||||||
|
"dataflow_trigger_dispatcher",
|
||||||
|
]
|
||||||
438
src/govoplan_core/core/datasources.py
Normal file
438
src/govoplan_core/core/datasources.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
|
||||||
|
|
||||||
|
DatasourceMode = Literal["live", "cached", "static"]
|
||||||
|
DatasourceKind = Literal[
|
||||||
|
"upload",
|
||||||
|
"database",
|
||||||
|
"http",
|
||||||
|
"rest",
|
||||||
|
"directory",
|
||||||
|
"file",
|
||||||
|
"feed",
|
||||||
|
"custom",
|
||||||
|
]
|
||||||
|
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
|
||||||
|
DatasourceConsistency = Literal["current", "live", "frozen"]
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral datasource operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceNotFoundError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceAccessError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceValidationError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceUnavailableError(DatasourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceField:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceDescriptor:
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
status: str = "active"
|
||||||
|
description: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
provider_ref: str | None = None
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
current_materialization_ref: str | None = None
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceMaterialization:
|
||||||
|
ref: str
|
||||||
|
datasource_ref: str
|
||||||
|
revision: int
|
||||||
|
state: str
|
||||||
|
fingerprint: str
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
frozen_at: datetime | None = None
|
||||||
|
frozen_label: str | None = None
|
||||||
|
source_timestamp: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceStage:
|
||||||
|
ref: str
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
state: str
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
fingerprint: str = ""
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
validation: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
promoted_at: datetime | None = None
|
||||||
|
promoted_materialization_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceReadRequest:
|
||||||
|
datasource_ref: str
|
||||||
|
materialization_ref: str | None = None
|
||||||
|
consistency: DatasourceConsistency = "current"
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceReadResult:
|
||||||
|
datasource: DatasourceDescriptor
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
materialization: DatasourceMaterialization | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceStageInput:
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
mode: DatasourceMode
|
||||||
|
shape: DatasourceShape
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
description: str | None = None
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
provider_ref: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourcePublicationRequest:
|
||||||
|
producer_module: str
|
||||||
|
producer_run_ref: str
|
||||||
|
idempotency_key: str
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
target_datasource_ref: str | None = None
|
||||||
|
name: str | None = None
|
||||||
|
source_name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
freeze: bool = False
|
||||||
|
frozen_label: str | None = None
|
||||||
|
set_current: bool = True
|
||||||
|
source_timestamp: datetime | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourcePublicationResult:
|
||||||
|
ref: str
|
||||||
|
status: str
|
||||||
|
datasource: DatasourceDescriptor
|
||||||
|
materialization: DatasourceMaterialization
|
||||||
|
replayed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOrigin:
|
||||||
|
ref: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
kind: DatasourceKind
|
||||||
|
shape: DatasourceShape
|
||||||
|
supported_modes: tuple[DatasourceMode, ...]
|
||||||
|
provider: str
|
||||||
|
description: str | None = None
|
||||||
|
schema: tuple[DatasourceField, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOriginReadRequest:
|
||||||
|
origin_ref: str
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DatasourceOriginReadResult:
|
||||||
|
origin: DatasourceOrigin
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceCatalogueProvider(Protocol):
|
||||||
|
def list_datasources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceDescriptor]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> DatasourceDescriptor | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceReadRequest,
|
||||||
|
) -> DatasourceReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def list_materializations(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> Sequence[DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceLifecycleProvider(Protocol):
|
||||||
|
def list_stages(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceStage]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def create_stage(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
stage: DatasourceStageInput,
|
||||||
|
) -> DatasourceStage:
|
||||||
|
...
|
||||||
|
|
||||||
|
def promote_stage(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
stage_ref: str,
|
||||||
|
freeze: bool = False,
|
||||||
|
frozen_label: str | None = None,
|
||||||
|
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def register_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
name: str,
|
||||||
|
source_name: str,
|
||||||
|
mode: DatasourceMode,
|
||||||
|
description: str | None = None,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
def refresh_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> tuple[DatasourceDescriptor, DatasourceMaterialization]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def freeze_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
label: str | None = None,
|
||||||
|
) -> DatasourceMaterialization:
|
||||||
|
...
|
||||||
|
|
||||||
|
def retire_datasource(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
datasource_ref: str,
|
||||||
|
) -> DatasourceDescriptor:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourcePublicationProvider(Protocol):
|
||||||
|
def publish_rows(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourcePublicationRequest,
|
||||||
|
) -> DatasourcePublicationResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DatasourceOriginProvider(Protocol):
|
||||||
|
def list_origins(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DatasourceOrigin]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
origin_ref: str,
|
||||||
|
) -> DatasourceOrigin | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_origin(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: DatasourceOriginReadRequest,
|
||||||
|
) -> DatasourceOriginReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_CATALOGUE)
|
||||||
|
return capability if isinstance(capability, DatasourceCatalogueProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
|
||||||
|
return capability if isinstance(capability, DatasourceLifecycleProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_publication(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DatasourcePublicationProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_PUBLICATION)
|
||||||
|
return capability if isinstance(capability, DatasourcePublicationProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def datasource_origins(registry: object | None) -> DatasourceOriginProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_DATASOURCE_ORIGINS)
|
||||||
|
return capability if isinstance(capability, DatasourceOriginProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_DATASOURCE_CATALOGUE",
|
||||||
|
"CAPABILITY_DATASOURCE_LIFECYCLE",
|
||||||
|
"CAPABILITY_DATASOURCE_ORIGINS",
|
||||||
|
"DatasourceAccessError",
|
||||||
|
"DatasourceCatalogueProvider",
|
||||||
|
"DatasourceConsistency",
|
||||||
|
"DatasourceDescriptor",
|
||||||
|
"DatasourceError",
|
||||||
|
"DatasourceField",
|
||||||
|
"DatasourceKind",
|
||||||
|
"DatasourceLifecycleProvider",
|
||||||
|
"DatasourceMaterialization",
|
||||||
|
"DatasourceMode",
|
||||||
|
"DatasourceNotFoundError",
|
||||||
|
"DatasourceOrigin",
|
||||||
|
"DatasourceOriginProvider",
|
||||||
|
"DatasourceOriginReadRequest",
|
||||||
|
"DatasourceOriginReadResult",
|
||||||
|
"DatasourceReadRequest",
|
||||||
|
"DatasourceReadResult",
|
||||||
|
"DatasourceShape",
|
||||||
|
"DatasourceStage",
|
||||||
|
"DatasourceStageInput",
|
||||||
|
"DatasourceUnavailableError",
|
||||||
|
"DatasourceValidationError",
|
||||||
|
"datasource_catalogue",
|
||||||
|
"datasource_lifecycle",
|
||||||
|
"datasource_origins",
|
||||||
|
]
|
||||||
455
src/govoplan_core/core/definition_graphs.py
Normal file
455
src/govoplan_core/core/definition_graphs.py
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionPort:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
required: bool = True
|
||||||
|
multiple: bool = False
|
||||||
|
minimum_connections: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionConfigField:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
kind: str
|
||||||
|
required: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
options: tuple[tuple[str, str], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNodeType:
|
||||||
|
type: str
|
||||||
|
category: str
|
||||||
|
label: str
|
||||||
|
description: str
|
||||||
|
icon: str
|
||||||
|
input_ports: tuple[DefinitionPort, ...] = ()
|
||||||
|
output_ports: tuple[DefinitionPort, ...] = (
|
||||||
|
DefinitionPort(id="output", label="Output"),
|
||||||
|
)
|
||||||
|
config_fields: tuple[DefinitionConfigField, ...] = ()
|
||||||
|
default_config: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNode:
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionEdge:
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
source_port: str = "output"
|
||||||
|
target_port: str = "input"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionNodeCountConstraint:
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
minimum: int = 0
|
||||||
|
maximum: int | None = None
|
||||||
|
node_types: tuple[str, ...] = ()
|
||||||
|
type_prefixes: tuple[str, ...] = ()
|
||||||
|
categories: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def matches(self, node: DefinitionNode, node_type: DefinitionNodeType | None) -> bool:
|
||||||
|
return (
|
||||||
|
node.type in self.node_types
|
||||||
|
or any(node.type.startswith(prefix) for prefix in self.type_prefixes)
|
||||||
|
or node_type is not None
|
||||||
|
and node_type.category in self.categories
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGraphConstraints:
|
||||||
|
max_nodes: int = 100
|
||||||
|
max_edges: int = 200
|
||||||
|
allow_cycles: bool = False
|
||||||
|
require_connected: bool = True
|
||||||
|
node_counts: tuple[DefinitionNodeCountConstraint, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGraphLibrary:
|
||||||
|
id: str
|
||||||
|
version: str
|
||||||
|
category_labels: Mapping[str, str]
|
||||||
|
node_types: tuple[DefinitionNodeType, ...]
|
||||||
|
constraints: DefinitionGraphConstraints = field(default_factory=DefinitionGraphConstraints)
|
||||||
|
|
||||||
|
def node_type(self, type_id: str) -> DefinitionNodeType | None:
|
||||||
|
return next((item for item in self.node_types if item.type == type_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionDiagnostic:
|
||||||
|
severity: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
node_id: str | None = None
|
||||||
|
field: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_definition_graph(
|
||||||
|
library: DefinitionGraphLibrary,
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> tuple[DefinitionDiagnostic, ...]:
|
||||||
|
if not nodes:
|
||||||
|
return (_error("graph.empty", "Add nodes before saving the definition."),)
|
||||||
|
constraints = library.constraints
|
||||||
|
diagnostics = _graph_size_diagnostics(
|
||||||
|
node_count=len(nodes),
|
||||||
|
edge_count=len(edges),
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
node_by_id = {node.id: node for node in nodes}
|
||||||
|
definitions: dict[str, DefinitionNodeType] = {}
|
||||||
|
for item in library.node_types:
|
||||||
|
definitions.setdefault(item.type, item)
|
||||||
|
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
|
||||||
|
incoming, undirected, topology_edges, edge_diagnostics = (
|
||||||
|
_validate_definition_edges(
|
||||||
|
edges=edges,
|
||||||
|
node_by_id=node_by_id,
|
||||||
|
definitions=definitions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(edge_diagnostics)
|
||||||
|
diagnostics.extend(
|
||||||
|
_node_port_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
definitions=definitions,
|
||||||
|
incoming=incoming,
|
||||||
|
library_id=library.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(
|
||||||
|
_node_count_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
definitions=definitions,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diagnostics.extend(
|
||||||
|
_topology_diagnostics(
|
||||||
|
nodes=nodes,
|
||||||
|
node_by_id=node_by_id,
|
||||||
|
topology_edges=topology_edges,
|
||||||
|
undirected=undirected,
|
||||||
|
constraints=constraints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def _graph_size_diagnostics(
|
||||||
|
*,
|
||||||
|
node_count: int,
|
||||||
|
edge_count: int,
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
if node_count > constraints.max_nodes:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"graph.node_limit",
|
||||||
|
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if edge_count > constraints.max_edges:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"graph.edge_limit",
|
||||||
|
f"Definitions are limited to {constraints.max_edges:,} edges.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _identifier_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
if len({node.id for node in nodes}) != len(nodes):
|
||||||
|
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
|
||||||
|
if len({edge.id for edge in edges}) != len(edges):
|
||||||
|
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_definition_edges(
|
||||||
|
*,
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, list[DefinitionEdge]],
|
||||||
|
dict[str, set[str]],
|
||||||
|
list[DefinitionEdge],
|
||||||
|
list[DefinitionDiagnostic],
|
||||||
|
]:
|
||||||
|
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
|
||||||
|
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
|
||||||
|
topology_edges: list[DefinitionEdge] = []
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for edge in edges:
|
||||||
|
structural_error = _edge_structure_diagnostic(edge, node_by_id)
|
||||||
|
if structural_error is not None:
|
||||||
|
diagnostics.append(structural_error)
|
||||||
|
continue
|
||||||
|
topology_edges.append(edge)
|
||||||
|
port_error = _edge_port_diagnostic(
|
||||||
|
edge,
|
||||||
|
source_definition=definitions.get(node_by_id[edge.source].type),
|
||||||
|
target_definition=definitions.get(node_by_id[edge.target].type),
|
||||||
|
)
|
||||||
|
if port_error is not None:
|
||||||
|
diagnostics.append(port_error)
|
||||||
|
continue
|
||||||
|
incoming[edge.target].append(edge)
|
||||||
|
undirected[edge.source].add(edge.target)
|
||||||
|
undirected[edge.target].add(edge.source)
|
||||||
|
return incoming, undirected, topology_edges, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_structure_diagnostic(
|
||||||
|
edge: DefinitionEdge,
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
) -> DefinitionDiagnostic | None:
|
||||||
|
if edge.source not in node_by_id:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_source",
|
||||||
|
f"Edge {edge.id!r} references an unknown source node.",
|
||||||
|
)
|
||||||
|
if edge.target not in node_by_id:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_target",
|
||||||
|
f"Edge {edge.id!r} references an unknown target node.",
|
||||||
|
)
|
||||||
|
if edge.source == edge.target:
|
||||||
|
return _error(
|
||||||
|
"edge.self_reference",
|
||||||
|
"A node cannot connect to itself.",
|
||||||
|
node_id=edge.source,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_port_diagnostic(
|
||||||
|
edge: DefinitionEdge,
|
||||||
|
*,
|
||||||
|
source_definition: DefinitionNodeType | None,
|
||||||
|
target_definition: DefinitionNodeType | None,
|
||||||
|
) -> DefinitionDiagnostic | None:
|
||||||
|
if source_definition is not None and edge.source_port not in {
|
||||||
|
port.id for port in source_definition.output_ports
|
||||||
|
}:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_source_port",
|
||||||
|
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||||
|
node_id=edge.source,
|
||||||
|
)
|
||||||
|
if target_definition is not None and edge.target_port not in {
|
||||||
|
port.id for port in target_definition.input_ports
|
||||||
|
}:
|
||||||
|
return _error(
|
||||||
|
"edge.unknown_target_port",
|
||||||
|
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||||
|
node_id=edge.target,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _node_port_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
incoming: Mapping[str, Sequence[DefinitionEdge]],
|
||||||
|
library_id: str,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for node in nodes:
|
||||||
|
definition = definitions.get(node.type)
|
||||||
|
if definition is None:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.unsupported_type",
|
||||||
|
f"Node type {node.type!r} is not in the {library_id!r} library.",
|
||||||
|
node_id=node.id,
|
||||||
|
field="type",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for port in definition.input_ports:
|
||||||
|
connections = [
|
||||||
|
edge for edge in incoming.get(node.id, ()) if edge.target_port == port.id
|
||||||
|
]
|
||||||
|
minimum = port.minimum_connections if port.required else 0
|
||||||
|
if len(connections) < minimum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.input_required",
|
||||||
|
(
|
||||||
|
f"{definition.label} requires {minimum} "
|
||||||
|
f"{port.label.lower()} connection"
|
||||||
|
f"{'' if minimum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not port.multiple and len(connections) > 1:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
"node.input_multiple",
|
||||||
|
f"{port.label} accepts only one connection.",
|
||||||
|
node_id=node.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _node_count_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
definitions: Mapping[str, DefinitionNodeType],
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
for count_constraint in constraints.node_counts:
|
||||||
|
count = sum(
|
||||||
|
count_constraint.matches(node, definitions.get(node.type))
|
||||||
|
for node in nodes
|
||||||
|
)
|
||||||
|
if count < count_constraint.minimum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
count_constraint.code,
|
||||||
|
(
|
||||||
|
f"A definition needs at least {count_constraint.minimum} "
|
||||||
|
f"{count_constraint.label} node"
|
||||||
|
f"{'' if count_constraint.minimum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if count_constraint.maximum is not None and count > count_constraint.maximum:
|
||||||
|
diagnostics.append(
|
||||||
|
_error(
|
||||||
|
count_constraint.code,
|
||||||
|
(
|
||||||
|
f"A definition allows at most {count_constraint.maximum} "
|
||||||
|
f"{count_constraint.label} node"
|
||||||
|
f"{'' if count_constraint.maximum == 1 else 's'}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _topology_diagnostics(
|
||||||
|
*,
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
node_by_id: Mapping[str, DefinitionNode],
|
||||||
|
topology_edges: Sequence[DefinitionEdge],
|
||||||
|
undirected: Mapping[str, set[str]],
|
||||||
|
constraints: DefinitionGraphConstraints,
|
||||||
|
) -> list[DefinitionDiagnostic]:
|
||||||
|
diagnostics: list[DefinitionDiagnostic] = []
|
||||||
|
_, cyclic = definition_topological_order(nodes, topology_edges)
|
||||||
|
if cyclic and not constraints.allow_cycles:
|
||||||
|
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
|
||||||
|
if constraints.require_connected and len(node_by_id) > 1:
|
||||||
|
connected = _connected_nodes(next(iter(node_by_id)), undirected)
|
||||||
|
if len(connected) != len(node_by_id):
|
||||||
|
diagnostics.append(
|
||||||
|
_error("graph.disconnected", "Every node must belong to one connected definition.")
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def definition_topological_order(
|
||||||
|
nodes: Sequence[DefinitionNode],
|
||||||
|
edges: Sequence[DefinitionEdge],
|
||||||
|
) -> tuple[tuple[str, ...], bool]:
|
||||||
|
node_ids = [node.id for node in nodes]
|
||||||
|
incoming_count = {node_id: 0 for node_id in node_ids}
|
||||||
|
outgoing: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
|
||||||
|
for edge in edges:
|
||||||
|
if (
|
||||||
|
edge.source in outgoing
|
||||||
|
and edge.target in incoming_count
|
||||||
|
and edge.source != edge.target
|
||||||
|
):
|
||||||
|
outgoing[edge.source].append(edge.target)
|
||||||
|
incoming_count[edge.target] += 1
|
||||||
|
ready = deque(node_id for node_id in node_ids if incoming_count[node_id] == 0)
|
||||||
|
ordered: list[str] = []
|
||||||
|
while ready:
|
||||||
|
node_id = ready.popleft()
|
||||||
|
ordered.append(node_id)
|
||||||
|
for target in outgoing[node_id]:
|
||||||
|
incoming_count[target] -= 1
|
||||||
|
if incoming_count[target] == 0:
|
||||||
|
ready.append(target)
|
||||||
|
return tuple(ordered), len(ordered) != len(node_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _connected_nodes(start: str, adjacency: Mapping[str, set[str]]) -> set[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
pending = [start]
|
||||||
|
while pending:
|
||||||
|
node_id = pending.pop()
|
||||||
|
if node_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(node_id)
|
||||||
|
pending.extend(adjacency.get(node_id, ()))
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
def _error(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
node_id: str | None = None,
|
||||||
|
field: str | None = None,
|
||||||
|
) -> DefinitionDiagnostic:
|
||||||
|
return DefinitionDiagnostic(
|
||||||
|
severity="error",
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
node_id=node_id,
|
||||||
|
field=field,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DefinitionConfigField",
|
||||||
|
"DefinitionDiagnostic",
|
||||||
|
"DefinitionEdge",
|
||||||
|
"DefinitionGraphConstraints",
|
||||||
|
"DefinitionGraphLibrary",
|
||||||
|
"DefinitionNode",
|
||||||
|
"DefinitionNodeCountConstraint",
|
||||||
|
"DefinitionNodeType",
|
||||||
|
"DefinitionPort",
|
||||||
|
"definition_topological_order",
|
||||||
|
"validate_definition_graph",
|
||||||
|
]
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import re
|
import re
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, Protocol, runtime_checkable
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import event as sqlalchemy_event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||||
|
_CONSUMER_ID_RE = re.compile(r"^[a-z][a-z0-9_.:-]{0,127}$")
|
||||||
|
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
|
||||||
|
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
|
||||||
|
|
||||||
|
|
||||||
def new_event_id() -> str:
|
def new_event_id() -> str:
|
||||||
@@ -100,6 +106,105 @@ class PlatformEvent:
|
|||||||
|
|
||||||
|
|
||||||
EventHandler = Callable[[PlatformEvent], None]
|
EventHandler = Callable[[PlatformEvent], None]
|
||||||
|
DurableEventHandler = Callable[[PlatformEvent, str], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DurableEventConsumer:
|
||||||
|
"""Allowlisted durable consumer with an explicit disclosure boundary."""
|
||||||
|
|
||||||
|
consumer_id: str
|
||||||
|
handler: DurableEventHandler
|
||||||
|
event_types: frozenset[str] = field(
|
||||||
|
default_factory=lambda: frozenset({"*"})
|
||||||
|
)
|
||||||
|
classifications: frozenset[EventClassification] = field(
|
||||||
|
default_factory=lambda: frozenset({"public", "internal"})
|
||||||
|
)
|
||||||
|
policy_decision_ref: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not _CONSUMER_ID_RE.fullmatch(self.consumer_id):
|
||||||
|
raise ValueError("Durable event consumer id is invalid")
|
||||||
|
if not self.event_types or any(
|
||||||
|
item != "*" and not _TRACE_ID_RE.fullmatch(item)
|
||||||
|
for item in self.event_types
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Durable event consumers require valid event-type allowlists"
|
||||||
|
)
|
||||||
|
invalid_classifications = set(self.classifications) - {
|
||||||
|
"public",
|
||||||
|
"internal",
|
||||||
|
"confidential",
|
||||||
|
"restricted",
|
||||||
|
}
|
||||||
|
if not self.classifications or invalid_classifications:
|
||||||
|
raise ValueError(
|
||||||
|
"Durable event consumer classifications are invalid"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
self.classifications & {"confidential", "restricted"}
|
||||||
|
and not normalize_trace_id(self.policy_decision_ref)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Confidential or restricted event subscriptions require "
|
||||||
|
"an explicit policy decision reference"
|
||||||
|
)
|
||||||
|
|
||||||
|
def accepts(self, event: PlatformEvent) -> bool:
|
||||||
|
return (
|
||||||
|
event.classification in self.classifications
|
||||||
|
and (
|
||||||
|
"*" in self.event_types
|
||||||
|
or event.type in self.event_types
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def delivery_key(self, event: PlatformEvent) -> str:
|
||||||
|
return f"{event.event_id}:{self.consumer_id}"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PlatformEventOutbox(Protocol):
|
||||||
|
def enqueue(self, session: object, event: PlatformEvent) -> object:
|
||||||
|
...
|
||||||
|
|
||||||
|
def dispatch_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
consumers: Sequence[DurableEventConsumer] = (),
|
||||||
|
observer: EventHandler | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Mapping[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def replay_delivery(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
event_id: str,
|
||||||
|
consumer_id: str,
|
||||||
|
operator_id: str,
|
||||||
|
reason: str,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def purge_terminal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
before: datetime,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> Mapping[str, int]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delivery_metrics(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def current_event_trace() -> EventTrace | None:
|
def current_event_trace() -> EventTrace | None:
|
||||||
@@ -189,5 +294,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
|
|||||||
platform_event_bus().publish(event)
|
platform_event_bus().publish(event)
|
||||||
|
|
||||||
|
|
||||||
|
def platform_event_outbox(
|
||||||
|
registry: object | None = None,
|
||||||
|
) -> PlatformEventOutbox | None:
|
||||||
|
if registry is None:
|
||||||
|
from govoplan_core.core.runtime import get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||||
|
return capability if isinstance(capability, PlatformEventOutbox) else None
|
||||||
|
|
||||||
|
|
||||||
|
def emit_platform_event(
|
||||||
|
session: Session,
|
||||||
|
event: PlatformEvent,
|
||||||
|
*,
|
||||||
|
registry: object | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist an event with its transaction or publish it after commit.
|
||||||
|
|
||||||
|
The durable outbox is optional so reduced module combinations remain
|
||||||
|
usable. Without it, the event is kept on the SQLAlchemy session and only
|
||||||
|
delivered to the process-local bus after the outer transaction commits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
traced = ensure_event_trace(event)
|
||||||
|
outbox = platform_event_outbox(registry)
|
||||||
|
if outbox is not None:
|
||||||
|
outbox.enqueue(session, traced)
|
||||||
|
return
|
||||||
|
transaction = (
|
||||||
|
session.get_nested_transaction()
|
||||||
|
or session.get_transaction()
|
||||||
|
or session.begin()
|
||||||
|
)
|
||||||
|
pending_by_transaction = session.info.setdefault(
|
||||||
|
_PENDING_EVENTS_KEY,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
pending_by_transaction.setdefault(transaction, []).append(
|
||||||
|
(platform_event_bus(), traced)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@sqlalchemy_event.listens_for(Session, "after_commit")
|
||||||
|
def _publish_committed_events(session: Session) -> None:
|
||||||
|
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||||
|
if transaction is None:
|
||||||
|
return
|
||||||
|
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||||
|
if not isinstance(pending_by_transaction, dict):
|
||||||
|
return
|
||||||
|
pending = pending_by_transaction.pop(transaction, ())
|
||||||
|
parent = transaction.parent
|
||||||
|
if parent is not None:
|
||||||
|
pending_by_transaction.setdefault(parent, []).extend(pending)
|
||||||
|
else:
|
||||||
|
for bus, event in pending:
|
||||||
|
bus.publish(event)
|
||||||
|
if not pending_by_transaction:
|
||||||
|
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
@sqlalchemy_event.listens_for(Session, "after_rollback")
|
||||||
|
def _discard_rolled_back_events(session: Session) -> None:
|
||||||
|
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||||
|
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||||
|
if transaction is None or not isinstance(pending_by_transaction, dict):
|
||||||
|
return
|
||||||
|
pending_by_transaction.pop(transaction, None)
|
||||||
|
if transaction.parent is None or not pending_by_transaction:
|
||||||
|
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
return {key: item for key, item in value.items() if item is not None}
|
return {key: item for key, item in value.items() if item is not None}
|
||||||
|
|||||||
137
src/govoplan_core/core/external_references.py
Normal file
137
src/govoplan_core/core/external_references.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
IntegrationMaturity = Literal[
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
]
|
||||||
|
|
||||||
|
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
|
||||||
|
"discover",
|
||||||
|
"link",
|
||||||
|
"search",
|
||||||
|
"read",
|
||||||
|
"publish",
|
||||||
|
"synchronize",
|
||||||
|
"migrate",
|
||||||
|
"replace",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalReferenceValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExternalObjectReference:
|
||||||
|
"""Stable identity and provenance for an object owned by another system."""
|
||||||
|
|
||||||
|
system: str
|
||||||
|
object_type: str
|
||||||
|
object_id: str
|
||||||
|
maturity: IntegrationMaturity = "link"
|
||||||
|
connector_id: str | None = None
|
||||||
|
canonical_url: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
etag: str | None = None
|
||||||
|
observed_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in ("system", "object_type", "object_id"):
|
||||||
|
value = str(getattr(self, field_name) or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"External reference {field_name} is required."
|
||||||
|
)
|
||||||
|
if len(value) > 255:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"External reference {field_name} is limited to 255 characters."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, field_name, value)
|
||||||
|
if self.maturity not in INTEGRATION_MATURITY_ORDER:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported integration maturity: {self.maturity!r}."
|
||||||
|
)
|
||||||
|
if self.connector_id is not None:
|
||||||
|
connector_id = self.connector_id.strip()
|
||||||
|
if not connector_id:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference connector_id cannot be blank."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "connector_id", connector_id)
|
||||||
|
if self.canonical_url is not None:
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"canonical_url",
|
||||||
|
_validated_reference_url(self.canonical_url),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_key(self) -> str:
|
||||||
|
return f"{self.system}:{self.object_type}:{self.object_id}"
|
||||||
|
|
||||||
|
def supports(self, maturity: IntegrationMaturity) -> bool:
|
||||||
|
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
|
||||||
|
maturity
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"system": self.system,
|
||||||
|
"object_type": self.object_type,
|
||||||
|
"object_id": self.object_id,
|
||||||
|
"maturity": self.maturity,
|
||||||
|
"connector_id": self.connector_id,
|
||||||
|
"canonical_url": self.canonical_url,
|
||||||
|
"version": self.version,
|
||||||
|
"etag": self.etag,
|
||||||
|
"observed_at": (
|
||||||
|
self.observed_at.isoformat() if self.observed_at is not None else None
|
||||||
|
),
|
||||||
|
"metadata": dict(self.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
|
||||||
|
try:
|
||||||
|
return INTEGRATION_MATURITY_ORDER.index(maturity)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
f"Unsupported integration maturity: {maturity!r}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_reference_url(value: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
parsed = urlsplit(normalized)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference URLs must use HTTP or HTTPS."
|
||||||
|
)
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ExternalReferenceValidationError(
|
||||||
|
"External reference URLs must not contain credentials."
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExternalObjectReference",
|
||||||
|
"ExternalReferenceValidationError",
|
||||||
|
"INTEGRATION_MATURITY_ORDER",
|
||||||
|
"IntegrationMaturity",
|
||||||
|
"integration_maturity_rank",
|
||||||
|
]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
|
|||||||
|
|
||||||
IDM_MODULE_ID = "idm"
|
IDM_MODULE_ID = "idm"
|
||||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||||
|
|
||||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||||
@@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef:
|
|||||||
status: IdmStatus = "active"
|
status: IdmStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationFunctionIncumbencyRef:
|
||||||
|
tenant_id: str
|
||||||
|
function_id: str
|
||||||
|
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
|
||||||
|
function_active: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vacant(self) -> bool:
|
||||||
|
return not self.assignments
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class IdmDirectory(Protocol):
|
class IdmDirectory(Protocol):
|
||||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||||
@@ -40,6 +53,7 @@ class IdmDirectory(Protocol):
|
|||||||
identity_id: str,
|
identity_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -48,5 +62,47 @@ class IdmDirectory(Protocol):
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
*,
|
*,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def organization_function_assignments_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def organization_function_assignments_for_accounts(
|
||||||
|
self,
|
||||||
|
account_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IdmFunctionAssignmentDirectory(Protocol):
|
||||||
|
"""Reverse lookup for effective incumbency and vacancy decisions."""
|
||||||
|
|
||||||
|
def organization_function_assignments_for_function(
|
||||||
|
self,
|
||||||
|
function_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def organization_function_incumbencies(
|
||||||
|
self,
|
||||||
|
function_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
||||||
|
...
|
||||||
|
|||||||
@@ -338,8 +338,10 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud
|
|||||||
|
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||||
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
# Deployment-wide connector egress policy. Enable private networks only when
|
# Deployment-wide connector egress policy. Enable private networks only when
|
||||||
@@ -404,8 +406,10 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
|||||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||||
REDIS_URL=redis://127.0.0.1:56379/0
|
REDIS_URL=redis://127.0.0.1:56379/0
|
||||||
CELERY_ENABLED=true
|
CELERY_ENABLED=true
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8
|
||||||
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||||
|
|
||||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ class ModuleLifecycleManager:
|
|||||||
def mounted_module_ids(self) -> tuple[str, ...]:
|
def mounted_module_ids(self) -> tuple[str, ...]:
|
||||||
return tuple(sorted(self._mounted_modules))
|
return tuple(sorted(self._mounted_modules))
|
||||||
|
|
||||||
|
def live_apply_enabled(self) -> bool:
|
||||||
|
configured = getattr(
|
||||||
|
self.settings,
|
||||||
|
"module_live_apply_enabled",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if configured is not None:
|
||||||
|
return bool(configured)
|
||||||
|
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
|
||||||
|
return app_env in {"dev", "development", "local", "test", "testing"}
|
||||||
|
|
||||||
def apply_enabled_modules(
|
def apply_enabled_modules(
|
||||||
self,
|
self,
|
||||||
requested_enabled: Sequence[str],
|
requested_enabled: Sequence[str],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||||
|
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
|
||||||
from govoplan_core.core.discovery import iter_module_entry_points
|
from govoplan_core.core.discovery import iter_module_entry_points
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
|
|||||||
|
|
||||||
MODULE_SETTINGS_KEY = "module_management"
|
MODULE_SETTINGS_KEY = "module_management"
|
||||||
INSTALL_PLAN_KEY = "install_plan"
|
INSTALL_PLAN_KEY = "install_plan"
|
||||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
|
||||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
PROTECTED_MODULES = ("admin",)
|
||||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||||
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
|
|||||||
if desired is not None:
|
if desired is not None:
|
||||||
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
||||||
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
||||||
|
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
|
||||||
if "admin" in fallback:
|
if "admin" in fallback:
|
||||||
candidates.append("admin")
|
candidates.append("admin")
|
||||||
return tuple(dict.fromkeys(candidates))
|
return tuple(dict.fromkeys(candidates))
|
||||||
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
|
|||||||
*,
|
*,
|
||||||
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
||||||
) -> ModuleStatePlan:
|
) -> ModuleStatePlan:
|
||||||
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
|
requested = _normalized_module_ids(requested_enabled)
|
||||||
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
|
requested.update(_normalized_module_ids(protected_modules))
|
||||||
requested.update(protected)
|
|
||||||
missing = sorted(module_id for module_id in requested if module_id not in available)
|
missing = sorted(module_id for module_id in requested if module_id not in available)
|
||||||
if missing:
|
if missing:
|
||||||
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
||||||
|
|
||||||
added_dependencies: set[str] = set()
|
added_dependencies = _expand_enabled_module_closure(requested, available)
|
||||||
visiting: set[str] = set()
|
ordered = _order_enabled_modules(requested, available, added_dependencies)
|
||||||
visited: set[str] = set()
|
|
||||||
ordered: list[str] = []
|
|
||||||
|
|
||||||
def visit(module_id: str) -> None:
|
|
||||||
if module_id in visited:
|
|
||||||
return
|
|
||||||
if module_id in visiting:
|
|
||||||
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
|
|
||||||
manifest = available.get(module_id)
|
|
||||||
if manifest is None:
|
|
||||||
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
|
|
||||||
visiting.add(module_id)
|
|
||||||
for dependency_id in manifest.dependencies:
|
|
||||||
if dependency_id not in available:
|
|
||||||
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
|
|
||||||
if dependency_id not in requested:
|
|
||||||
added_dependencies.add(dependency_id)
|
|
||||||
requested.add(dependency_id)
|
|
||||||
visit(dependency_id)
|
|
||||||
visiting.remove(module_id)
|
|
||||||
visited.add(module_id)
|
|
||||||
ordered.append(module_id)
|
|
||||||
|
|
||||||
for module_id in sorted(requested):
|
|
||||||
visit(module_id)
|
|
||||||
return ModuleStatePlan(
|
return ModuleStatePlan(
|
||||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
enabled_modules=tuple(dict.fromkeys(ordered)),
|
||||||
added_dependencies=tuple(sorted(added_dependencies)),
|
added_dependencies=tuple(sorted(added_dependencies)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
|
||||||
|
return {str(item).strip() for item in values if str(item).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_enabled_module_closure(
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
) -> set[str]:
|
||||||
|
added: set[str] = set()
|
||||||
|
while True:
|
||||||
|
dependencies = {
|
||||||
|
dependency
|
||||||
|
for module_id in requested
|
||||||
|
for dependency in available[module_id].dependencies
|
||||||
|
}
|
||||||
|
_require_installed_modules(
|
||||||
|
dependencies,
|
||||||
|
available,
|
||||||
|
message="Required modules are not installed: ",
|
||||||
|
)
|
||||||
|
dependency_additions = dependencies - requested
|
||||||
|
added.update(dependency_additions)
|
||||||
|
requested.update(dependency_additions)
|
||||||
|
|
||||||
|
providers = _missing_capability_providers(requested, available)
|
||||||
|
_require_installed_modules(
|
||||||
|
providers,
|
||||||
|
available,
|
||||||
|
message="Required capability providers are not installed: ",
|
||||||
|
)
|
||||||
|
additions = providers - requested
|
||||||
|
if not additions:
|
||||||
|
return added
|
||||||
|
added.update(additions)
|
||||||
|
requested.update(additions)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_capability_providers(
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
) -> set[str]:
|
||||||
|
provided = {
|
||||||
|
capability
|
||||||
|
for module_id in requested
|
||||||
|
for capability in available[module_id].capability_factories
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider_id
|
||||||
|
for module_id in requested
|
||||||
|
for capability in available[module_id].required_capabilities
|
||||||
|
if capability not in provided
|
||||||
|
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
|
||||||
|
if provider_id is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_installed_modules(
|
||||||
|
module_ids: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
missing = sorted(module_id for module_id in module_ids if module_id not in available)
|
||||||
|
if missing:
|
||||||
|
raise ModuleManagementError(message + ", ".join(missing))
|
||||||
|
|
||||||
|
|
||||||
|
def _order_enabled_modules(
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
added_dependencies: set[str],
|
||||||
|
) -> list[str]:
|
||||||
|
visiting: set[str] = set()
|
||||||
|
visited: set[str] = set()
|
||||||
|
ordered: list[str] = []
|
||||||
|
for module_id in sorted(requested):
|
||||||
|
_visit_enabled_module(
|
||||||
|
module_id,
|
||||||
|
requested=requested,
|
||||||
|
available=available,
|
||||||
|
added_dependencies=added_dependencies,
|
||||||
|
visiting=visiting,
|
||||||
|
visited=visited,
|
||||||
|
ordered=ordered,
|
||||||
|
)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _visit_enabled_module(
|
||||||
|
module_id: str,
|
||||||
|
*,
|
||||||
|
requested: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
added_dependencies: set[str],
|
||||||
|
visiting: set[str],
|
||||||
|
visited: set[str],
|
||||||
|
ordered: list[str],
|
||||||
|
) -> None:
|
||||||
|
if module_id in visited:
|
||||||
|
return
|
||||||
|
if module_id in visiting:
|
||||||
|
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
|
||||||
|
manifest = available.get(module_id)
|
||||||
|
if manifest is None:
|
||||||
|
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
|
||||||
|
visiting.add(module_id)
|
||||||
|
for dependency_id in manifest.dependencies:
|
||||||
|
if dependency_id not in available:
|
||||||
|
raise ModuleManagementError(
|
||||||
|
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
|
||||||
|
)
|
||||||
|
if dependency_id not in requested:
|
||||||
|
added_dependencies.add(dependency_id)
|
||||||
|
requested.add(dependency_id)
|
||||||
|
_visit_enabled_module(
|
||||||
|
dependency_id,
|
||||||
|
requested=requested,
|
||||||
|
available=available,
|
||||||
|
added_dependencies=added_dependencies,
|
||||||
|
visiting=visiting,
|
||||||
|
visited=visited,
|
||||||
|
ordered=ordered,
|
||||||
|
)
|
||||||
|
visiting.remove(module_id)
|
||||||
|
visited.add(module_id)
|
||||||
|
ordered.append(module_id)
|
||||||
|
|
||||||
|
|
||||||
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
||||||
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
||||||
for manifest in available.values():
|
for manifest in available.values():
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||||
|
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchProviderRegistration,
|
||||||
|
SearchSourceProviderRegistration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
@@ -57,6 +63,7 @@ class NavItem:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +75,7 @@ class FrontendRoute:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -92,6 +100,7 @@ class FrontendModule:
|
|||||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -301,6 +310,10 @@ class ResourceAclProvider(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||||
|
TenantSummaryBatchProvider = Callable[
|
||||||
|
[object, Sequence[str]],
|
||||||
|
Mapping[str, Mapping[str, int]],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -348,9 +361,12 @@ class ModuleManifest:
|
|||||||
frontend: FrontendModule | None = None
|
frontend: FrontendModule | None = None
|
||||||
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
resource_acl_providers: tuple[ResourceAclProvider, ...] = ()
|
||||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||||
|
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||||
|
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||||
|
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||||
on_activate: LifecycleHook | None = None
|
on_activate: LifecycleHook | None = None
|
||||||
on_deactivate: LifecycleHook | None = None
|
on_deactivate: LifecycleHook | None = None
|
||||||
|
|||||||
@@ -2,13 +2,57 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
ORGANIZATIONS_MODULE_ID = "organizations"
|
ORGANIZATIONS_MODULE_ID = "organizations"
|
||||||
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY = (
|
||||||
|
"organizations.hierarchyDirectory"
|
||||||
|
)
|
||||||
|
|
||||||
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
||||||
|
OrganizationResolutionStatus = Literal[
|
||||||
|
"active",
|
||||||
|
"inactive",
|
||||||
|
"missing",
|
||||||
|
"unreachable",
|
||||||
|
"invalid",
|
||||||
|
]
|
||||||
|
OrganizationHierarchyDirection = Literal["ancestors", "descendants"]
|
||||||
|
OrganizationLifecycleResource = Literal[
|
||||||
|
"unit_type",
|
||||||
|
"structure",
|
||||||
|
"relation_type",
|
||||||
|
"unit",
|
||||||
|
"relation",
|
||||||
|
"function_type",
|
||||||
|
"function",
|
||||||
|
]
|
||||||
|
OrganizationLifecycleAction = Literal[
|
||||||
|
"created",
|
||||||
|
"updated",
|
||||||
|
"moved",
|
||||||
|
"deactivated",
|
||||||
|
]
|
||||||
|
ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION = 1
|
||||||
|
ORGANIZATION_LIFECYCLE_RESOURCES: tuple[
|
||||||
|
OrganizationLifecycleResource,
|
||||||
|
...,
|
||||||
|
] = (
|
||||||
|
"unit_type",
|
||||||
|
"structure",
|
||||||
|
"relation_type",
|
||||||
|
"unit",
|
||||||
|
"relation",
|
||||||
|
"function_type",
|
||||||
|
"function",
|
||||||
|
)
|
||||||
|
ORGANIZATION_LIFECYCLE_ACTIONS: tuple[
|
||||||
|
OrganizationLifecycleAction,
|
||||||
|
...,
|
||||||
|
] = ("created", "updated", "moved", "deactivated")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -37,6 +81,141 @@ class OrganizationFunctionRef:
|
|||||||
status: OrganizationStatus = "active"
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationUnitTypeRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationFunctionTypeRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
organization_unit_type_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
delegable: bool = False
|
||||||
|
act_in_place_allowed: bool = False
|
||||||
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationStructureRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
structure_kind: str
|
||||||
|
description: str | None = None
|
||||||
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationRelationTypeRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
structure_id: str | None = None
|
||||||
|
source_unit_type_id: str | None = None
|
||||||
|
target_unit_type_id: str | None = None
|
||||||
|
is_hierarchical: bool = True
|
||||||
|
allow_cycles: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationHierarchyCatalogRef:
|
||||||
|
tenant_id: str
|
||||||
|
structures: tuple[OrganizationStructureRef, ...] = ()
|
||||||
|
relation_types: tuple[OrganizationRelationTypeRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationHierarchyEdgeRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
structure: OrganizationStructureRef
|
||||||
|
relation_type: OrganizationRelationTypeRef
|
||||||
|
source_unit_id: str
|
||||||
|
target_unit_id: str
|
||||||
|
valid_from: datetime | None = None
|
||||||
|
valid_until: datetime | None = None
|
||||||
|
status: OrganizationStatus = "active"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationHierarchyMatchRef:
|
||||||
|
unit: OrganizationUnitRef
|
||||||
|
depth: int
|
||||||
|
path: tuple[OrganizationHierarchyEdgeRef, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationHierarchyResolution:
|
||||||
|
tenant_id: str
|
||||||
|
root_unit_id: str
|
||||||
|
direction: OrganizationHierarchyDirection
|
||||||
|
structure_id: str
|
||||||
|
relation_type_ids: tuple[str, ...]
|
||||||
|
max_depth: int
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
root: OrganizationUnitRef | None = None
|
||||||
|
matches: tuple[OrganizationHierarchyMatchRef, ...] = ()
|
||||||
|
cycle_detected: bool = False
|
||||||
|
depth_limited: bool = False
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationHierarchyPathResolution:
|
||||||
|
tenant_id: str
|
||||||
|
source_unit_id: str
|
||||||
|
target_unit_id: str
|
||||||
|
direction: OrganizationHierarchyDirection
|
||||||
|
structure_id: str
|
||||||
|
relation_type_ids: tuple[str, ...]
|
||||||
|
max_depth: int
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
source: OrganizationUnitRef | None = None
|
||||||
|
target: OrganizationUnitRef | None = None
|
||||||
|
path: tuple[OrganizationHierarchyEdgeRef, ...] = ()
|
||||||
|
cycle_detected: bool = False
|
||||||
|
depth_limited: bool = False
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationFunctionTypeResolution:
|
||||||
|
tenant_id: str
|
||||||
|
function_type_id: str
|
||||||
|
requested_unit_ids: tuple[str, ...]
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
function_type: OrganizationFunctionTypeRef | None = None
|
||||||
|
matches: tuple[OrganizationFunctionRef, ...] = ()
|
||||||
|
missing_unit_ids: tuple[str, ...] = ()
|
||||||
|
inactive_unit_ids: tuple[str, ...] = ()
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OrganizationUnitTypeResolution:
|
||||||
|
tenant_id: str
|
||||||
|
unit_type_id: str
|
||||||
|
status: OrganizationResolutionStatus
|
||||||
|
unit_type: OrganizationUnitTypeRef | None = None
|
||||||
|
structure_id: str | None = None
|
||||||
|
root_unit_id: str | None = None
|
||||||
|
matches: tuple[OrganizationUnitRef, ...] = ()
|
||||||
|
diagnostics: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class OrganizationDirectory(Protocol):
|
class OrganizationDirectory(Protocol):
|
||||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||||
@@ -55,3 +234,156 @@ class OrganizationDirectory(Protocol):
|
|||||||
include_subunits: bool = False,
|
include_subunits: bool = False,
|
||||||
) -> Sequence[OrganizationFunctionRef]:
|
) -> Sequence[OrganizationFunctionRef]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OrganizationHierarchyDirectory(Protocol):
|
||||||
|
def hierarchy_catalog(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> OrganizationHierarchyCatalogRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_unit_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
unit_type_id: str,
|
||||||
|
) -> OrganizationUnitTypeRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_function_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
) -> OrganizationFunctionTypeRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_functions_by_type(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
function_type_id: str,
|
||||||
|
*,
|
||||||
|
organization_unit_ids: Sequence[str] = (),
|
||||||
|
) -> OrganizationFunctionTypeResolution:
|
||||||
|
...
|
||||||
|
|
||||||
|
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:
|
||||||
|
...
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> Sequence[OrganizationHierarchyResolution]:
|
||||||
|
...
|
||||||
|
|
||||||
|
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,
|
||||||
|
) -> Sequence[OrganizationHierarchyPathResolution]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def organization_directory(
|
||||||
|
registry: object | None,
|
||||||
|
) -> OrganizationDirectory | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||||
|
return capability if isinstance(capability, OrganizationDirectory) else None
|
||||||
|
|
||||||
|
|
||||||
|
def organization_hierarchy_directory(
|
||||||
|
registry: object | None,
|
||||||
|
) -> OrganizationHierarchyDirectory | None:
|
||||||
|
capability = _capability(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, OrganizationHierarchyDirectory)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def organization_lifecycle_event_type(
|
||||||
|
resource: OrganizationLifecycleResource,
|
||||||
|
action: OrganizationLifecycleAction,
|
||||||
|
) -> str:
|
||||||
|
if resource not in ORGANIZATION_LIFECYCLE_RESOURCES:
|
||||||
|
raise ValueError("Unsupported organization lifecycle resource.")
|
||||||
|
if action not in ORGANIZATION_LIFECYCLE_ACTIONS:
|
||||||
|
raise ValueError("Unsupported organization lifecycle action.")
|
||||||
|
return f"organizations.{resource}.{action}.v1"
|
||||||
|
|
||||||
|
|
||||||
|
ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES = frozenset(
|
||||||
|
organization_lifecycle_event_type(resource, action)
|
||||||
|
for resource in ORGANIZATION_LIFECYCLE_RESOURCES
|
||||||
|
for action in ORGANIZATION_LIFECYCLE_ACTIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ORGANIZATION_DIRECTORY",
|
||||||
|
"CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY",
|
||||||
|
"ORGANIZATION_DIRECTORY_INVALIDATION_EVENT_TYPES",
|
||||||
|
"ORGANIZATION_LIFECYCLE_ACTIONS",
|
||||||
|
"ORGANIZATION_LIFECYCLE_EVENT_SCHEMA_VERSION",
|
||||||
|
"ORGANIZATION_LIFECYCLE_RESOURCES",
|
||||||
|
"ORGANIZATIONS_MODULE_ID",
|
||||||
|
"OrganizationDirectory",
|
||||||
|
"OrganizationFunctionRef",
|
||||||
|
"OrganizationFunctionTypeRef",
|
||||||
|
"OrganizationFunctionTypeResolution",
|
||||||
|
"OrganizationHierarchyDirection",
|
||||||
|
"OrganizationHierarchyCatalogRef",
|
||||||
|
"OrganizationHierarchyDirectory",
|
||||||
|
"OrganizationHierarchyEdgeRef",
|
||||||
|
"OrganizationHierarchyMatchRef",
|
||||||
|
"OrganizationHierarchyPathResolution",
|
||||||
|
"OrganizationHierarchyResolution",
|
||||||
|
"OrganizationLifecycleAction",
|
||||||
|
"OrganizationLifecycleResource",
|
||||||
|
"OrganizationRelationTypeRef",
|
||||||
|
"OrganizationResolutionStatus",
|
||||||
|
"OrganizationStatus",
|
||||||
|
"OrganizationStructureRef",
|
||||||
|
"OrganizationUnitRef",
|
||||||
|
"OrganizationUnitTypeRef",
|
||||||
|
"OrganizationUnitTypeResolution",
|
||||||
|
"organization_directory",
|
||||||
|
"organization_hierarchy_directory",
|
||||||
|
"organization_lifecycle_event_type",
|
||||||
|
]
|
||||||
|
|||||||
@@ -4,11 +4,24 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import quote, unquote
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||||
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
|
||||||
|
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||||
|
DefinitionKind = Literal["flow", "template"]
|
||||||
|
DefinitionGovernanceAction = Literal[
|
||||||
|
"view",
|
||||||
|
"edit",
|
||||||
|
"run",
|
||||||
|
"reuse",
|
||||||
|
"derive",
|
||||||
|
"automate",
|
||||||
|
]
|
||||||
|
|
||||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||||
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
|
||||||
|
|
||||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||||
|
|
||||||
@@ -128,6 +141,63 @@ class PolicyDecision:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionScopeRef:
|
||||||
|
scope_type: DefinitionScopeType
|
||||||
|
scope_id: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> str:
|
||||||
|
return policy_source_path(self.scope_type, self.scope_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DefinitionGovernanceRequest:
|
||||||
|
module_id: str
|
||||||
|
definition_ref: str
|
||||||
|
tenant_id: str
|
||||||
|
definition_scope: DefinitionScopeRef
|
||||||
|
target_scope: DefinitionScopeRef
|
||||||
|
definition_kind: DefinitionKind
|
||||||
|
action: DefinitionGovernanceAction
|
||||||
|
actor: PrincipalRef
|
||||||
|
status: str = "draft"
|
||||||
|
inherit_to_lower_scopes: bool = False
|
||||||
|
allow_run: bool = True
|
||||||
|
allow_reuse: bool = False
|
||||||
|
allow_automation: bool = False
|
||||||
|
context: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class DefinitionGovernancePolicy(Protocol):
|
||||||
|
def resolve_definition_action(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
request: DefinitionGovernanceRequest,
|
||||||
|
) -> PolicyDecision:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def definition_governance_policy(
|
||||||
|
registry: object | None,
|
||||||
|
) -> DefinitionGovernancePolicy | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, DefinitionGovernancePolicy)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SchedulingParticipantPrivacyRequest:
|
class SchedulingParticipantPrivacyRequest:
|
||||||
"""Context for resolving what one Scheduling participant may see.
|
"""Context for resolving what one Scheduling participant may see.
|
||||||
|
|||||||
410
src/govoplan_core/core/postbox.py
Normal file
410
src/govoplan_core/core/postbox.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
POSTBOX_MODULE_ID = "postbox"
|
||||||
|
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
|
||||||
|
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
|
||||||
|
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
|
||||||
|
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
|
||||||
|
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
|
||||||
|
CAPABILITY_POSTBOX_ROUTING = "postbox.routing"
|
||||||
|
|
||||||
|
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
|
||||||
|
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxActorRef:
|
||||||
|
account_id: str
|
||||||
|
identity_id: str | None = None
|
||||||
|
selected_assignment_id: str | None = None
|
||||||
|
acting_for_account_id: str | None = None
|
||||||
|
authorized_actions: frozenset[PostboxAction] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxTargetRef:
|
||||||
|
postbox_id: str | None = None
|
||||||
|
address_key: str | None = None
|
||||||
|
template_id: str | None = None
|
||||||
|
organization_unit_id: str | None = None
|
||||||
|
function_id: str | None = None
|
||||||
|
context_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryTemplateRef:
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
published_revision_id: str
|
||||||
|
function_type_id: str | None
|
||||||
|
scope_kind: str
|
||||||
|
scope_id: str | None
|
||||||
|
classification: str
|
||||||
|
allow_vacant_delivery: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxOrganizationFunctionTargetRef:
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
function_type_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxOrganizationUnitTargetRef:
|
||||||
|
id: str
|
||||||
|
slug: str
|
||||||
|
name: str
|
||||||
|
unit_type_id: str | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryCatalogRef:
|
||||||
|
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
|
||||||
|
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
|
||||||
|
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxAccessDecisionRef:
|
||||||
|
allowed: bool
|
||||||
|
action: PostboxAction
|
||||||
|
postbox_id: str
|
||||||
|
reason_code: str
|
||||||
|
explanation: str
|
||||||
|
organization_unit_id: str | None = None
|
||||||
|
function_id: str | None = None
|
||||||
|
assignment_ids: tuple[str, ...] = ()
|
||||||
|
assignment_sources: tuple[str, ...] = ()
|
||||||
|
selected_assignment_id: str | None = None
|
||||||
|
holder_count: int = 0
|
||||||
|
vacant: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDirectoryEntryRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
address: str
|
||||||
|
address_key: str
|
||||||
|
name: str
|
||||||
|
status: str
|
||||||
|
classification: str
|
||||||
|
organization_unit_id: str | None = None
|
||||||
|
organization_unit_name: str | None = None
|
||||||
|
function_id: str | None = None
|
||||||
|
function_name: str | None = None
|
||||||
|
context_key: str | None = None
|
||||||
|
template_revision_id: str | None = None
|
||||||
|
holder_count: int = 0
|
||||||
|
vacant: bool = True
|
||||||
|
access: PostboxAccessDecisionRef | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxParticipantRef:
|
||||||
|
kind: str
|
||||||
|
reference_type: str
|
||||||
|
reference_id: str | None = None
|
||||||
|
label: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxAttachmentRef:
|
||||||
|
reference_type: str
|
||||||
|
reference_id: str
|
||||||
|
name: str | None = None
|
||||||
|
media_type: str | None = None
|
||||||
|
size_bytes: int | None = None
|
||||||
|
digest: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxMessageRef:
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
postbox_id: str
|
||||||
|
subject: str
|
||||||
|
body_text: str | None
|
||||||
|
status: str
|
||||||
|
classification: str
|
||||||
|
sender_label: str | None
|
||||||
|
delivered_at: datetime
|
||||||
|
read_at: datetime | None = None
|
||||||
|
acknowledged_at: datetime | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
withdrawn_at: datetime | None = None
|
||||||
|
producer_module: str | None = None
|
||||||
|
producer_resource_type: str | None = None
|
||||||
|
producer_resource_id: str | None = None
|
||||||
|
encryption_profile: str = "plaintext_v1"
|
||||||
|
key_epoch: int = 1
|
||||||
|
ciphertext_ref: str | None = None
|
||||||
|
signed_manifest_ref: str | None = None
|
||||||
|
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||||
|
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryRequest:
|
||||||
|
tenant_id: str
|
||||||
|
target: PostboxTargetRef
|
||||||
|
producer_module: str
|
||||||
|
producer_resource_type: str
|
||||||
|
producer_resource_id: str | None
|
||||||
|
idempotency_key: str
|
||||||
|
subject: str
|
||||||
|
body_text: str | None = None
|
||||||
|
sender_label: str | None = None
|
||||||
|
classification: str = "internal"
|
||||||
|
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||||
|
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PostboxDeliveryResult:
|
||||||
|
delivery_id: str
|
||||||
|
postbox_id: str
|
||||||
|
message_id: str
|
||||||
|
address: str
|
||||||
|
status: str
|
||||||
|
vacant: bool
|
||||||
|
holder_count: int
|
||||||
|
duplicate: bool = False
|
||||||
|
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxDeliveryRejected(RuntimeError):
|
||||||
|
"""A delivery was rejected before the provider accepted any effect."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
temporary: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.temporary = temporary
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxDeliveryOutcomeUnknown(RuntimeError):
|
||||||
|
"""The provider may have accepted an effect and must not be bypassed."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxDirectoryProvider(Protocol):
|
||||||
|
def list_visible_postboxes(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
) -> Sequence[PostboxDirectoryEntryRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def resolve_postbox(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
target: PostboxTargetRef,
|
||||||
|
materialize: bool = False,
|
||||||
|
) -> PostboxDirectoryEntryRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delivery_catalog(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> PostboxDeliveryCatalogRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxAccessProvider(Protocol):
|
||||||
|
def explain_access(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
postbox_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
action: PostboxAction,
|
||||||
|
) -> PostboxAccessDecisionRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxMessagesProvider(Protocol):
|
||||||
|
def list_messages(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
postbox_ids: Sequence[str],
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
query: str | None = None,
|
||||||
|
state: PostboxMessageListState = "all",
|
||||||
|
) -> Sequence[PostboxMessageRef]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
message_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
) -> PostboxMessageRef | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def mark_message(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
message_id: str,
|
||||||
|
actor: PostboxActorRef,
|
||||||
|
state: Literal["read", "acknowledged"],
|
||||||
|
) -> PostboxMessageRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxDeliveryProvider(Protocol):
|
||||||
|
def deliver(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
request: PostboxDeliveryRequest,
|
||||||
|
) -> PostboxDeliveryResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxEvidenceProvider(Protocol):
|
||||||
|
def link_evidence(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
message_id: str,
|
||||||
|
attachment: PostboxAttachmentRef,
|
||||||
|
) -> PostboxMessageRef:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PostboxRoutingProvider(Protocol):
|
||||||
|
def dispatch_due_routes(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def _postbox_provider(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
capability_name: str,
|
||||||
|
provider_type: type,
|
||||||
|
) -> object | None:
|
||||||
|
if registry is None or not hasattr(registry, "has_capability"):
|
||||||
|
return None
|
||||||
|
if not registry.has_capability(capability_name):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(capability_name)
|
||||||
|
return capability if isinstance(capability, provider_type) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_directory_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxDirectoryProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
|
provider_type=PostboxDirectoryProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxDirectoryProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_access_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxAccessProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_ACCESS,
|
||||||
|
provider_type=PostboxAccessProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxAccessProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_messages_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxMessagesProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_MESSAGES,
|
||||||
|
provider_type=PostboxMessagesProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxMessagesProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_delivery_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxDeliveryProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_DELIVERY,
|
||||||
|
provider_type=PostboxDeliveryProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxDeliveryProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_evidence_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxEvidenceProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
|
provider_type=PostboxEvidenceProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxEvidenceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def postbox_routing_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> PostboxRoutingProvider | None:
|
||||||
|
provider = _postbox_provider(
|
||||||
|
registry,
|
||||||
|
capability_name=CAPABILITY_POSTBOX_ROUTING,
|
||||||
|
provider_type=PostboxRoutingProvider,
|
||||||
|
)
|
||||||
|
return provider if isinstance(provider, PostboxRoutingProvider) else None
|
||||||
111
src/govoplan_core/core/principal_cache.py
Normal file
111
src/govoplan_core/core/principal_cache.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
ChangeSequenceEntry,
|
||||||
|
retained_sequence_floor,
|
||||||
|
record_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTH_PRINCIPAL_REVISION_COLLECTION = "core.auth-principal-revisions"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthPrincipalRevision:
|
||||||
|
system: int
|
||||||
|
tenant: int
|
||||||
|
|
||||||
|
|
||||||
|
def auth_principal_revision(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
) -> AuthPrincipalRevision:
|
||||||
|
"""Return the durable global and tenant authorization revision.
|
||||||
|
|
||||||
|
The sequence rows make invalidation visible across processes. Retention
|
||||||
|
floors keep revisions monotonic if old change rows are pruned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
session.query(
|
||||||
|
ChangeSequenceEntry.tenant_id,
|
||||||
|
func.max(ChangeSequenceEntry.id),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
ChangeSequenceEntry.module_id == "core",
|
||||||
|
ChangeSequenceEntry.collection == AUTH_PRINCIPAL_REVISION_COLLECTION,
|
||||||
|
or_(
|
||||||
|
ChangeSequenceEntry.tenant_id.is_(None),
|
||||||
|
ChangeSequenceEntry.tenant_id == tenant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.group_by(ChangeSequenceEntry.tenant_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
revisions = {row_tenant_id: int(sequence_id or 0) for row_tenant_id, sequence_id in rows}
|
||||||
|
system = max(
|
||||||
|
revisions.get(None, 0),
|
||||||
|
retained_sequence_floor(
|
||||||
|
session,
|
||||||
|
tenant_id=None,
|
||||||
|
module_id="core",
|
||||||
|
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tenant = 0
|
||||||
|
if tenant_id is not None:
|
||||||
|
tenant = max(
|
||||||
|
revisions.get(tenant_id, 0),
|
||||||
|
retained_sequence_floor(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id="core",
|
||||||
|
collections=(AUTH_PRINCIPAL_REVISION_COLLECTION,),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return AuthPrincipalRevision(system=system, tenant=tenant)
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_auth_principals(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
source_module: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
actor_type: str | None = None,
|
||||||
|
actor_id: str | None = None,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> ChangeSequenceEntry:
|
||||||
|
"""Advance the authorization revision in the caller's transaction."""
|
||||||
|
|
||||||
|
return record_change(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
module_id="core",
|
||||||
|
collection=AUTH_PRINCIPAL_REVISION_COLLECTION,
|
||||||
|
resource_type="auth_principal_revision",
|
||||||
|
resource_id=tenant_id or "system",
|
||||||
|
operation="invalidated",
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_id=actor_id,
|
||||||
|
payload={
|
||||||
|
"source_module": source_module,
|
||||||
|
"resource_type": resource_type,
|
||||||
|
"resource_id": resource_id,
|
||||||
|
**({"reason": reason} if reason else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AUTH_PRINCIPAL_REVISION_COLLECTION",
|
||||||
|
"AuthPrincipalRevision",
|
||||||
|
"auth_principal_revision",
|
||||||
|
"invalidate_auth_principals",
|
||||||
|
]
|
||||||
499
src/govoplan_core/core/references.py
Normal file
499
src/govoplan_core/core/references.py
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_DIRECTORY,
|
||||||
|
AccessDirectory,
|
||||||
|
GroupRef,
|
||||||
|
UserRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS = "access.reference_options"
|
||||||
|
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceOption:
|
||||||
|
"""A stable identifier paired with presentation-only directory metadata."""
|
||||||
|
|
||||||
|
value: str
|
||||||
|
label: str
|
||||||
|
description: str | None = None
|
||||||
|
kind: str | None = None
|
||||||
|
availability: ReferenceAvailability = "available"
|
||||||
|
disabled: bool = False
|
||||||
|
source_module: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"value": self.value,
|
||||||
|
"label": self.label,
|
||||||
|
"description": self.description,
|
||||||
|
"kind": self.kind,
|
||||||
|
"availability": self.availability,
|
||||||
|
"disabled": self.disabled,
|
||||||
|
"source_module": self.source_module,
|
||||||
|
"provenance": dict(self.provenance),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceSearchRequest:
|
||||||
|
kind: str
|
||||||
|
tenant_id: str | None
|
||||||
|
query: str = ""
|
||||||
|
selected_values: tuple[str, ...] = ()
|
||||||
|
limit: int = 50
|
||||||
|
cursor: str | None = None
|
||||||
|
context: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReferenceSearchPage:
|
||||||
|
options: tuple[ReferenceOption, ...] = ()
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ReferenceOptionProvider(Protocol):
|
||||||
|
"""Optional module-neutral provider for typed reference selectors."""
|
||||||
|
|
||||||
|
def search_reference_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ReferenceSearchRequest,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def reference_option_provider(
|
||||||
|
registry: object | None,
|
||||||
|
capability_name: str,
|
||||||
|
) -> ReferenceOptionProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(capability_name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(capability_name)
|
||||||
|
return provider if isinstance(provider, ReferenceOptionProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def access_scope_reference_options(
|
||||||
|
registry: object | None,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
query: str = "",
|
||||||
|
selected_values: Sequence[str] = (),
|
||||||
|
limit: int = 50,
|
||||||
|
administrative: bool = False,
|
||||||
|
session: object | None = None,
|
||||||
|
reference_kind: str | None = None,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
"""Return canonical user-account or group references for definition scopes."""
|
||||||
|
|
||||||
|
return access_scope_reference_page(
|
||||||
|
registry,
|
||||||
|
principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected_values,
|
||||||
|
limit=limit,
|
||||||
|
administrative=administrative,
|
||||||
|
session=session,
|
||||||
|
reference_kind=reference_kind,
|
||||||
|
).options
|
||||||
|
|
||||||
|
|
||||||
|
def access_scope_reference_page(
|
||||||
|
registry: object | None,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
query: str = "",
|
||||||
|
selected_values: Sequence[str] = (),
|
||||||
|
limit: int = 50,
|
||||||
|
cursor: str | None = None,
|
||||||
|
administrative: bool = False,
|
||||||
|
session: object | None = None,
|
||||||
|
reference_kind: str | None = None,
|
||||||
|
) -> ReferenceSearchPage:
|
||||||
|
"""Search bounded Access references while retaining selected values."""
|
||||||
|
|
||||||
|
clean_type = str(scope_type or "").strip().casefold()
|
||||||
|
if clean_type not in {"user", "group"}:
|
||||||
|
raise ValueError("Scope reference type must be user or group.")
|
||||||
|
clean_kind = str(reference_kind or clean_type).strip().casefold()
|
||||||
|
if clean_kind not in (
|
||||||
|
{"user", "membership"} if clean_type == "user" else {"group"}
|
||||||
|
):
|
||||||
|
raise ValueError("Scope reference kind is incompatible with the scope type.")
|
||||||
|
normalized_limit = max(1, min(int(limit), 200))
|
||||||
|
selected = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(value).strip() for value in selected_values if str(value).strip()
|
||||||
|
)
|
||||||
|
)[:200]
|
||||||
|
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||||
|
provider = reference_option_provider(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
)
|
||||||
|
if provider is not None:
|
||||||
|
if not tenant_id:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(
|
||||||
|
_unavailable_option(value, kind=clean_kind)
|
||||||
|
for value in selected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page = provider.search_reference_options(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=ReferenceSearchRequest(
|
||||||
|
kind=clean_kind,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
query=str(query or "").strip().casefold(),
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
cursor=str(cursor).strip() if cursor else None,
|
||||||
|
context={"administrative": administrative},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_bounded_page_options(
|
||||||
|
page.options,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
kind=clean_kind,
|
||||||
|
),
|
||||||
|
next_cursor=page.next_cursor,
|
||||||
|
has_more=page.has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
directory = _access_directory(registry)
|
||||||
|
if directory is None:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_fallback_scope_options(
|
||||||
|
principal,
|
||||||
|
scope_type=clean_type,
|
||||||
|
reference_kind=clean_kind,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tenant_id:
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=tuple(
|
||||||
|
_unavailable_option(value, kind=clean_kind) for value in selected
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if clean_type == "user":
|
||||||
|
candidates = _user_options(
|
||||||
|
directory.users_for_tenant(tenant_id),
|
||||||
|
principal=principal,
|
||||||
|
administrative=administrative,
|
||||||
|
value_kind=clean_kind,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
candidates = _group_options(
|
||||||
|
directory.groups_for_tenant(tenant_id),
|
||||||
|
principal=principal,
|
||||||
|
administrative=administrative,
|
||||||
|
)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=_filter_and_retain_options(
|
||||||
|
candidates,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected,
|
||||||
|
limit=normalized_limit,
|
||||||
|
kind=clean_kind,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def access_scope_reference_provider_available(registry: object | None) -> bool:
|
||||||
|
return (
|
||||||
|
reference_option_provider(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
or _access_directory(registry) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_access_scope_reference(
|
||||||
|
registry: object | None,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
preserve_existing: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Validate new directory-backed scope references while preserving history."""
|
||||||
|
|
||||||
|
clean_type = str(scope_type or "").strip().casefold()
|
||||||
|
clean_id = str(scope_id or "").strip() or None
|
||||||
|
if clean_type not in {"user", "group"}:
|
||||||
|
return clean_id
|
||||||
|
if not clean_id:
|
||||||
|
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
|
||||||
|
if clean_id == preserve_existing:
|
||||||
|
return clean_id
|
||||||
|
|
||||||
|
directory = _access_directory(registry)
|
||||||
|
if directory is None:
|
||||||
|
return clean_id
|
||||||
|
if not tenant_id:
|
||||||
|
raise ValueError("Directory-backed scopes require an active tenant.")
|
||||||
|
|
||||||
|
if clean_type == "group":
|
||||||
|
group = directory.get_group(clean_id)
|
||||||
|
if group is None or group.tenant_id != tenant_id:
|
||||||
|
raise ValueError("The selected group is not available in the active tenant.")
|
||||||
|
if group.status != "active":
|
||||||
|
raise ValueError("Inactive groups cannot be selected for new definitions.")
|
||||||
|
return group.id
|
||||||
|
|
||||||
|
matching_user = next(
|
||||||
|
(
|
||||||
|
user
|
||||||
|
for user in directory.users_for_tenant(tenant_id)
|
||||||
|
if user.account_id == clean_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if matching_user is None:
|
||||||
|
raise ValueError("The selected user account is not available in the active tenant.")
|
||||||
|
if matching_user.status != "active":
|
||||||
|
raise ValueError("Inactive users cannot be selected for new definitions.")
|
||||||
|
account = directory.get_account(matching_user.account_id)
|
||||||
|
if account is not None and account.status != "active":
|
||||||
|
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
|
||||||
|
return matching_user.account_id
|
||||||
|
|
||||||
|
|
||||||
|
def _access_directory(registry: object | None) -> AccessDirectory | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||||
|
return provider if isinstance(provider, AccessDirectory) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _user_options(
|
||||||
|
users: Sequence[UserRef],
|
||||||
|
*,
|
||||||
|
principal: object,
|
||||||
|
administrative: bool,
|
||||||
|
value_kind: str = "user",
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
principal_account_id = str(getattr(principal, "account_id", "") or "")
|
||||||
|
options: dict[str, ReferenceOption] = {}
|
||||||
|
for user in users:
|
||||||
|
if not administrative and user.account_id != principal_account_id:
|
||||||
|
continue
|
||||||
|
inactive = user.status != "active"
|
||||||
|
label = user.display_name or user.email or user.account_id
|
||||||
|
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
|
||||||
|
value = user.id if value_kind == "membership" else user.account_id
|
||||||
|
options[value] = ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=label,
|
||||||
|
description=" · ".join(detail_parts) or None,
|
||||||
|
kind=value_kind,
|
||||||
|
availability="inactive" if inactive else "available",
|
||||||
|
disabled=inactive,
|
||||||
|
source_module="access",
|
||||||
|
provenance={
|
||||||
|
"membership_id": user.id,
|
||||||
|
"tenant_id": user.tenant_id,
|
||||||
|
"account_id": user.account_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return tuple(options.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _group_options(
|
||||||
|
groups: Sequence[GroupRef],
|
||||||
|
*,
|
||||||
|
principal: object,
|
||||||
|
administrative: bool,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
permitted = {
|
||||||
|
str(value)
|
||||||
|
for value in getattr(principal, "group_ids", ())
|
||||||
|
if str(value)
|
||||||
|
}
|
||||||
|
return tuple(
|
||||||
|
ReferenceOption(
|
||||||
|
value=group.id,
|
||||||
|
label=group.name or group.id,
|
||||||
|
description="Inactive" if group.status != "active" else None,
|
||||||
|
kind="group",
|
||||||
|
availability=(
|
||||||
|
"inactive" if group.status != "active" else "available"
|
||||||
|
),
|
||||||
|
disabled=group.status != "active",
|
||||||
|
source_module="access",
|
||||||
|
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
if administrative or group.id in permitted
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_and_retain_options(
|
||||||
|
options: Sequence[ReferenceOption],
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
selected_values: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
by_value = {option.value: option for option in options}
|
||||||
|
needle = str(query or "").strip().casefold()
|
||||||
|
matches = [
|
||||||
|
option
|
||||||
|
for option in options
|
||||||
|
if not needle
|
||||||
|
or needle
|
||||||
|
in " ".join(
|
||||||
|
(
|
||||||
|
option.value,
|
||||||
|
option.label,
|
||||||
|
option.description or "",
|
||||||
|
)
|
||||||
|
).casefold()
|
||||||
|
][:limit]
|
||||||
|
returned = {option.value for option in matches}
|
||||||
|
for value in selected_values:
|
||||||
|
if value in returned:
|
||||||
|
continue
|
||||||
|
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
|
||||||
|
returned.add(value)
|
||||||
|
return tuple(matches)
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_page_options(
|
||||||
|
options: Sequence[ReferenceOption],
|
||||||
|
*,
|
||||||
|
selected_values: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
kind: str,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
by_value = {option.value: option for option in options}
|
||||||
|
selected = set(selected_values)
|
||||||
|
bounded = [
|
||||||
|
option for option in options if option.value not in selected
|
||||||
|
][:limit]
|
||||||
|
returned = {option.value for option in bounded}
|
||||||
|
for value in selected_values:
|
||||||
|
if value in returned:
|
||||||
|
continue
|
||||||
|
bounded.append(by_value.get(value) or _unavailable_option(value, kind=kind))
|
||||||
|
returned.add(value)
|
||||||
|
return tuple(bounded)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_scope_options(
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
reference_kind: str | None = None,
|
||||||
|
query: str,
|
||||||
|
selected_values: Sequence[str],
|
||||||
|
limit: int,
|
||||||
|
) -> tuple[ReferenceOption, ...]:
|
||||||
|
candidates: list[ReferenceOption] = []
|
||||||
|
if scope_type == "user":
|
||||||
|
kind = str(reference_kind or "user")
|
||||||
|
value = str(
|
||||||
|
(
|
||||||
|
getattr(principal, "membership_id", "")
|
||||||
|
if kind == "membership"
|
||||||
|
else getattr(principal, "account_id", "")
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if value:
|
||||||
|
candidates.append(
|
||||||
|
ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=(
|
||||||
|
str(getattr(principal, "display_name", "") or "")
|
||||||
|
or str(getattr(principal, "email", "") or "")
|
||||||
|
or value
|
||||||
|
),
|
||||||
|
description="Current user · Access directory unavailable",
|
||||||
|
kind=kind,
|
||||||
|
source_module="core",
|
||||||
|
provenance={"fallback": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
candidates.extend(
|
||||||
|
ReferenceOption(
|
||||||
|
value=str(group_id),
|
||||||
|
label=str(group_id),
|
||||||
|
description="Permitted group · Access directory unavailable",
|
||||||
|
kind="group",
|
||||||
|
source_module="core",
|
||||||
|
provenance={"fallback": True},
|
||||||
|
)
|
||||||
|
for group_id in getattr(principal, "group_ids", ())
|
||||||
|
if str(group_id)
|
||||||
|
)
|
||||||
|
return _filter_and_retain_options(
|
||||||
|
candidates,
|
||||||
|
query=query,
|
||||||
|
selected_values=selected_values,
|
||||||
|
limit=limit,
|
||||||
|
kind=scope_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
|
||||||
|
return ReferenceOption(
|
||||||
|
value=value,
|
||||||
|
label=f"Unavailable {kind}",
|
||||||
|
description=value,
|
||||||
|
kind=kind,
|
||||||
|
availability="unavailable",
|
||||||
|
disabled=True,
|
||||||
|
source_module=None,
|
||||||
|
provenance={"retained_reference": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_ACCESS_REFERENCE_OPTIONS",
|
||||||
|
"ReferenceAvailability",
|
||||||
|
"ReferenceOption",
|
||||||
|
"ReferenceOptionProvider",
|
||||||
|
"ReferenceSearchPage",
|
||||||
|
"ReferenceSearchRequest",
|
||||||
|
"access_scope_reference_page",
|
||||||
|
"access_scope_reference_options",
|
||||||
|
"access_scope_reference_provider_available",
|
||||||
|
"reference_option_provider",
|
||||||
|
"validate_access_scope_reference",
|
||||||
|
]
|
||||||
@@ -21,10 +21,24 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||||
|
TenantSummaryBatchProvider,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
RegisteredSearchProvider,
|
||||||
|
RegisteredSearchSourceProvider,
|
||||||
|
SearchProvider,
|
||||||
|
SearchSourceProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import (
|
||||||
|
ViewSurface,
|
||||||
|
module_view_surface_id,
|
||||||
|
navigation_view_surface_id,
|
||||||
|
route_view_surface_id,
|
||||||
|
validate_view_surface_id,
|
||||||
|
)
|
||||||
|
|
||||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||||
@@ -49,10 +63,17 @@ class PlatformRegistry:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._manifests: dict[str, ModuleManifest] = {}
|
self._manifests: dict[str, ModuleManifest] = {}
|
||||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||||
|
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||||
self._capabilities: dict[str, object] = {}
|
self._capabilities: dict[str, object] = {}
|
||||||
self._capability_context: ModuleContext | None = None
|
self._capability_context: ModuleContext | None = None
|
||||||
|
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||||
|
self._search_providers: dict[str, SearchProvider] = {}
|
||||||
|
self._search_source_registrations: list[
|
||||||
|
RegisteredSearchSourceProvider
|
||||||
|
] = []
|
||||||
|
self._search_sources: dict[str, SearchSourceProvider] = {}
|
||||||
|
|
||||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||||
if manifest.id in self._manifests:
|
if manifest.id in self._manifests:
|
||||||
@@ -60,11 +81,27 @@ class PlatformRegistry:
|
|||||||
self._manifests[manifest.id] = manifest
|
self._manifests[manifest.id] = manifest
|
||||||
for provider in manifest.tenant_summary_providers:
|
for provider in manifest.tenant_summary_providers:
|
||||||
self.register_tenant_summary_provider(manifest.id, provider)
|
self.register_tenant_summary_provider(manifest.id, provider)
|
||||||
|
for provider in manifest.tenant_summary_batch_providers:
|
||||||
|
self.register_tenant_summary_batch_provider(manifest.id, provider)
|
||||||
for resource_type, providers in manifest.delete_veto_providers.items():
|
for resource_type, providers in manifest.delete_veto_providers.items():
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
self.register_delete_veto(manifest.id, resource_type, provider)
|
||||||
for name, factory in manifest.capability_factories.items():
|
for name, factory in manifest.capability_factories.items():
|
||||||
self.register_capability_factory(manifest.id, name, factory)
|
self.register_capability_factory(manifest.id, name, factory)
|
||||||
|
for registration in manifest.search_providers:
|
||||||
|
self._search_provider_registrations.append(
|
||||||
|
RegisteredSearchProvider(
|
||||||
|
module_id=manifest.id,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for registration in manifest.search_sources:
|
||||||
|
self._search_source_registrations.append(
|
||||||
|
RegisteredSearchSourceProvider(
|
||||||
|
module_id=manifest.id,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
)
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||||
@@ -77,12 +114,23 @@ class PlatformRegistry:
|
|||||||
|
|
||||||
self._manifests = dict(replacement._manifests)
|
self._manifests = dict(replacement._manifests)
|
||||||
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
|
self._tenant_summary_providers = dict(replacement._tenant_summary_providers)
|
||||||
|
self._tenant_summary_batch_providers = dict(
|
||||||
|
replacement._tenant_summary_batch_providers
|
||||||
|
)
|
||||||
self._delete_veto_providers = defaultdict(list, {
|
self._delete_veto_providers = defaultdict(list, {
|
||||||
resource_type: list(providers)
|
resource_type: list(providers)
|
||||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||||
})
|
})
|
||||||
self._capability_factories = dict(replacement._capability_factories)
|
self._capability_factories = dict(replacement._capability_factories)
|
||||||
|
self._search_provider_registrations = list(
|
||||||
|
replacement._search_provider_registrations
|
||||||
|
)
|
||||||
|
self._search_source_registrations = list(
|
||||||
|
replacement._search_source_registrations
|
||||||
|
)
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
|
self._search_providers.clear()
|
||||||
|
self._search_sources.clear()
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
def get(self, module_id: str) -> ModuleManifest | None:
|
def get(self, module_id: str) -> ModuleManifest | None:
|
||||||
@@ -113,12 +161,21 @@ class PlatformRegistry:
|
|||||||
def nav_items(self) -> tuple[NavItem, ...]:
|
def nav_items(self) -> tuple[NavItem, ...]:
|
||||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
||||||
|
|
||||||
|
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||||
|
return tuple(
|
||||||
|
surface
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for surface in manifest_view_surfaces(manifest)
|
||||||
|
)
|
||||||
|
|
||||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||||
|
|
||||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||||
self._capability_context = context
|
self._capability_context = context
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
|
self._search_providers.clear()
|
||||||
|
self._search_sources.clear()
|
||||||
|
|
||||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||||
if name in self._capability_factories:
|
if name in self._capability_factories:
|
||||||
@@ -146,12 +203,95 @@ class PlatformRegistry:
|
|||||||
raise RegistryError(f"Required capability is not available: {name}")
|
raise RegistryError(f"Required capability is not available: {name}")
|
||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
def search_provider_registrations(
|
||||||
|
self,
|
||||||
|
) -> tuple[RegisteredSearchProvider, ...]:
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
self._search_provider_registrations,
|
||||||
|
key=lambda item: (
|
||||||
|
item.registration.order,
|
||||||
|
item.module_id,
|
||||||
|
item.registration.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_providers(
|
||||||
|
self,
|
||||||
|
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
|
||||||
|
if self._capability_context is None:
|
||||||
|
if self._search_provider_registrations:
|
||||||
|
raise RegistryError("Search provider context is not configured.")
|
||||||
|
return ()
|
||||||
|
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
|
||||||
|
for registered in self.search_provider_registrations():
|
||||||
|
key = f"{registered.module_id}:{registered.registration.id}"
|
||||||
|
provider = self._search_providers.get(key)
|
||||||
|
if provider is None:
|
||||||
|
provider = registered.registration.create(self._capability_context)
|
||||||
|
self._search_providers[key] = provider
|
||||||
|
providers.append((registered, provider))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
def search_source_registrations(
|
||||||
|
self,
|
||||||
|
) -> tuple[RegisteredSearchSourceProvider, ...]:
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
self._search_source_registrations,
|
||||||
|
key=lambda item: (
|
||||||
|
item.registration.order,
|
||||||
|
item.module_id,
|
||||||
|
item.registration.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def search_sources(
|
||||||
|
self,
|
||||||
|
) -> tuple[
|
||||||
|
tuple[RegisteredSearchSourceProvider, SearchSourceProvider],
|
||||||
|
...,
|
||||||
|
]:
|
||||||
|
if self._capability_context is None:
|
||||||
|
if self._search_source_registrations:
|
||||||
|
raise RegistryError(
|
||||||
|
"Search source context is not configured."
|
||||||
|
)
|
||||||
|
return ()
|
||||||
|
providers: list[
|
||||||
|
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||||
|
] = []
|
||||||
|
for registered in self.search_source_registrations():
|
||||||
|
key = f"{registered.module_id}:{registered.registration.id}"
|
||||||
|
provider = self._search_sources.get(key)
|
||||||
|
if provider is None:
|
||||||
|
provider = registered.registration.create(
|
||||||
|
self._capability_context
|
||||||
|
)
|
||||||
|
self._search_sources[key] = provider
|
||||||
|
providers.append((registered, provider))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||||
self._tenant_summary_providers[module_id] = provider
|
self._tenant_summary_providers[module_id] = provider
|
||||||
|
|
||||||
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
||||||
return dict(self._tenant_summary_providers)
|
return dict(self._tenant_summary_providers)
|
||||||
|
|
||||||
|
def register_tenant_summary_batch_provider(
|
||||||
|
self,
|
||||||
|
module_id: str,
|
||||||
|
provider: TenantSummaryBatchProvider,
|
||||||
|
) -> None:
|
||||||
|
self._tenant_summary_batch_providers[module_id] = provider
|
||||||
|
|
||||||
|
def tenant_summary_batch_providers(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, TenantSummaryBatchProvider]:
|
||||||
|
return dict(self._tenant_summary_batch_providers)
|
||||||
|
|
||||||
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
||||||
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
@@ -230,6 +370,53 @@ class PlatformRegistry:
|
|||||||
return (self._manifests[module_id] for module_id in ordered)
|
return (self._manifests[module_id] for module_id in ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return ()
|
||||||
|
root_id = module_view_surface_id(manifest.id)
|
||||||
|
surfaces = [
|
||||||
|
ViewSurface(
|
||||||
|
id=root_id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="module",
|
||||||
|
label=manifest.name,
|
||||||
|
order=min((item.order for item in frontend.nav_items), default=100),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
surfaces.extend(
|
||||||
|
ViewSurface(
|
||||||
|
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="navigation",
|
||||||
|
label=item.label,
|
||||||
|
parent_id=root_id,
|
||||||
|
order=item.order,
|
||||||
|
)
|
||||||
|
for item in frontend.nav_items
|
||||||
|
)
|
||||||
|
surfaces.extend(
|
||||||
|
ViewSurface(
|
||||||
|
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="route",
|
||||||
|
label=route.component,
|
||||||
|
parent_id=root_id,
|
||||||
|
description=route.path,
|
||||||
|
order=route.order,
|
||||||
|
)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes)
|
||||||
|
)
|
||||||
|
surfaces.extend(
|
||||||
|
replace(
|
||||||
|
surface,
|
||||||
|
parent_id=surface.parent_id or root_id,
|
||||||
|
)
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
)
|
||||||
|
return tuple(surfaces)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_delete_veto_result(
|
def _normalize_delete_veto_result(
|
||||||
result: object,
|
result: object,
|
||||||
*,
|
*,
|
||||||
@@ -401,6 +588,32 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
|||||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||||
|
provider_ids: set[str] = set()
|
||||||
|
for registration in manifest.search_providers:
|
||||||
|
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} search provider id must be namespaced: "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
if registration.id in provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate search provider "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
provider_ids.add(registration.id)
|
||||||
|
source_ids: set[str] = set()
|
||||||
|
for registration in manifest.search_sources:
|
||||||
|
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} search source id must be "
|
||||||
|
f"namespaced: {registration.id!r}"
|
||||||
|
)
|
||||||
|
if registration.id in source_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate search source "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
source_ids.add(registration.id)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||||
@@ -438,8 +651,117 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
|
if route.surface_id is not None:
|
||||||
|
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
if item.surface_id is not None:
|
||||||
|
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||||
|
_validate_view_surfaces(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return
|
||||||
|
root_id = module_view_surface_id(manifest.id)
|
||||||
|
known_ids = {root_id}
|
||||||
|
_validate_view_surface_id(manifest.id, root_id)
|
||||||
|
for item in frontend.nav_items:
|
||||||
|
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
||||||
|
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
|
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||||
|
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
_validate_declared_view_surface(manifest.id, surface)
|
||||||
|
_register_view_surface_id(manifest.id, surface.id, known_ids)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
_validate_view_surface_definition(
|
||||||
|
manifest.id,
|
||||||
|
surface,
|
||||||
|
root_id=root_id,
|
||||||
|
known_ids=known_ids,
|
||||||
|
)
|
||||||
|
parent_by_id = {
|
||||||
|
surface.id: surface.parent_id or root_id
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
}
|
||||||
|
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_view_surface_id(
|
||||||
|
module_id: str,
|
||||||
|
surface_id: str,
|
||||||
|
known_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
_validate_view_surface_id(module_id, surface_id)
|
||||||
|
if surface_id in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
|
||||||
|
)
|
||||||
|
known_ids.add(surface_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_declared_view_surface(
|
||||||
|
module_id: str,
|
||||||
|
surface: ViewSurface,
|
||||||
|
) -> None:
|
||||||
|
if surface.module_id != module_id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||||
|
f"not module {module_id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_definition(
|
||||||
|
module_id: str,
|
||||||
|
surface: ViewSurface,
|
||||||
|
*,
|
||||||
|
root_id: str,
|
||||||
|
known_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
parent_id = surface.parent_id or root_id
|
||||||
|
if parent_id not in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface {surface.id!r} in module {module_id!r} "
|
||||||
|
f"references unknown parent {parent_id!r}"
|
||||||
|
)
|
||||||
|
if surface.required and not surface.default_visible:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Required view surface {surface.id!r} in module {module_id!r} "
|
||||||
|
"must be visible by default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_hierarchy(
|
||||||
|
module_id: str,
|
||||||
|
parent_by_id: Mapping[str, str],
|
||||||
|
) -> None:
|
||||||
|
for surface_id in parent_by_id:
|
||||||
|
path: set[str] = set()
|
||||||
|
current_id: str | None = surface_id
|
||||||
|
while current_id in parent_by_id:
|
||||||
|
if current_id in path:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface hierarchy in module {module_id!r} "
|
||||||
|
f"contains a cycle at {current_id!r}"
|
||||||
|
)
|
||||||
|
path.add(current_id)
|
||||||
|
current_id = parent_by_id[current_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||||
|
if not validate_view_surface_id(surface_id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
|
||||||
|
)
|
||||||
|
if not surface_id.startswith(f"{module_id}."):
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface id for module {module_id!r} must start with "
|
||||||
|
f"{module_id!r} followed by '.': {surface_id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||||
|
|||||||
383
src/govoplan_core/core/sanctions.py
Normal file
383
src/govoplan_core/core/sanctions.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
SANCTIONS_SNAPSHOT_CONTRACT_VERSION = "1"
|
||||||
|
SANCTIONS_SCREENING_CONTRACT_VERSION = "1"
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS = (
|
||||||
|
"connectors.sanctionsSnapshotProvider"
|
||||||
|
)
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING = (
|
||||||
|
"riskCompliance.sanctionsScreeningProvider"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsSnapshotReference:
|
||||||
|
ref: str
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
publisher: str
|
||||||
|
jurisdiction: str
|
||||||
|
list_type: str
|
||||||
|
source_id: str
|
||||||
|
source_version: str
|
||||||
|
publication_at: datetime | None
|
||||||
|
effective_at: datetime | None
|
||||||
|
acquired_at: datetime
|
||||||
|
content_type: str
|
||||||
|
byte_count: int
|
||||||
|
sha256: str
|
||||||
|
parser_version: str
|
||||||
|
raw_evidence_ref: str
|
||||||
|
connector_run_id: str
|
||||||
|
signature_evidence: Mapping[str, object] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
licence_notes: str | None = None
|
||||||
|
trust_notes: str | None = None
|
||||||
|
transport_evidence: Mapping[str, object] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SNAPSHOT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SNAPSHOT_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions snapshot contract version."
|
||||||
|
)
|
||||||
|
required = (
|
||||||
|
self.ref,
|
||||||
|
self.tenant_id,
|
||||||
|
self.provider_id,
|
||||||
|
self.publisher,
|
||||||
|
self.jurisdiction,
|
||||||
|
self.list_type,
|
||||||
|
self.source_id,
|
||||||
|
self.source_version,
|
||||||
|
self.content_type,
|
||||||
|
self.sha256,
|
||||||
|
self.parser_version,
|
||||||
|
self.raw_evidence_ref,
|
||||||
|
self.connector_run_id,
|
||||||
|
)
|
||||||
|
if not all(value.strip() for value in required):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot references require complete provenance."
|
||||||
|
)
|
||||||
|
if self.byte_count < 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot evidence must not be empty."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
len(self.sha256) != 64
|
||||||
|
or any(character not in "0123456789abcdef" for character in self.sha256)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot SHA-256 evidence is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsSnapshotPayload:
|
||||||
|
snapshot: SanctionsSnapshotReference
|
||||||
|
content: bytes
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.content) != self.snapshot.byte_count:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot content length does not match metadata."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningSubject:
|
||||||
|
subject_type: Literal["person", "entity"]
|
||||||
|
primary_name: str | None = None
|
||||||
|
subject_ref: str | None = None
|
||||||
|
aliases: tuple[str, ...] = ()
|
||||||
|
identifiers: tuple[Mapping[str, str], ...] = ()
|
||||||
|
dates: tuple[str, ...] = ()
|
||||||
|
addresses: tuple[Mapping[str, str], ...] = ()
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.subject_type not in {"person", "entity"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening subjects must be a person or entity."
|
||||||
|
)
|
||||||
|
if not str(self.primary_name or "").strip() and not any(
|
||||||
|
str(value.get("value") or "").strip()
|
||||||
|
for value in self.identifiers
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions screening subject needs a name or identifier."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningPolicy:
|
||||||
|
fuzzy_threshold: float = 0.88
|
||||||
|
max_snapshot_age_days: int = 7
|
||||||
|
max_candidates: int = 100
|
||||||
|
failure_policy: Literal["block", "review", "degraded"] = "block"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 0.8 <= self.fuzzy_threshold <= 1:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening fuzzy threshold must be between 0.8 and 1."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_snapshot_age_days <= 365:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions snapshot age must be between 1 and 365 days."
|
||||||
|
)
|
||||||
|
if not 1 <= self.max_candidates <= 100:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening candidate limit must be between 1 and 100."
|
||||||
|
)
|
||||||
|
if self.failure_policy not in {"block", "review", "degraded"}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening failure policy is not supported."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningRequest:
|
||||||
|
list_snapshot_id: str
|
||||||
|
idempotency_key: str
|
||||||
|
subject: SanctionsScreeningSubject
|
||||||
|
policy: SanctionsScreeningPolicy = field(
|
||||||
|
default_factory=SanctionsScreeningPolicy
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening contract version."
|
||||||
|
)
|
||||||
|
if not self.list_snapshot_id.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions list snapshot is required for screening."
|
||||||
|
)
|
||||||
|
if not self.idempotency_key.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"A sanctions screening idempotency key is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningEvidence:
|
||||||
|
ref: str
|
||||||
|
run_id: str
|
||||||
|
outcome: str
|
||||||
|
candidate_count: int
|
||||||
|
list_snapshot_id: str
|
||||||
|
source_version: str
|
||||||
|
subject_fingerprint: str
|
||||||
|
matcher_version: str
|
||||||
|
normalization_version: str
|
||||||
|
policy_version: str
|
||||||
|
completed_at: datetime | None
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening evidence version."
|
||||||
|
)
|
||||||
|
if self.ref != f"risk-screening:{self.run_id}":
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening evidence reference is invalid."
|
||||||
|
)
|
||||||
|
if self.candidate_count < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening candidate count cannot be negative."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningFreshnessRequest:
|
||||||
|
evidence_ref: str
|
||||||
|
current_subject: SanctionsScreeningSubject | None = None
|
||||||
|
expected_list_snapshot_id: str | None = None
|
||||||
|
policy: SanctionsScreeningPolicy = field(
|
||||||
|
default_factory=SanctionsScreeningPolicy
|
||||||
|
)
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening freshness contract version."
|
||||||
|
)
|
||||||
|
if not self.evidence_ref.startswith("risk-screening:"):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening evidence reference is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningFreshness:
|
||||||
|
evidence: SanctionsScreeningEvidence
|
||||||
|
fresh: bool
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
checked_at: datetime
|
||||||
|
current_list_snapshot_id: str | None
|
||||||
|
gate_decision: Literal["allow", "block", "review", "degraded"]
|
||||||
|
gate_reasons: tuple[str, ...]
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening freshness version."
|
||||||
|
)
|
||||||
|
if self.fresh != (not self.reasons):
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening freshness reasons are inconsistent."
|
||||||
|
)
|
||||||
|
if self.gate_decision not in {
|
||||||
|
"allow",
|
||||||
|
"block",
|
||||||
|
"review",
|
||||||
|
"degraded",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening gate decision is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SanctionsScreeningResult:
|
||||||
|
evidence: SanctionsScreeningEvidence
|
||||||
|
freshness: SanctionsScreeningFreshness
|
||||||
|
created: bool
|
||||||
|
contract_version: str = SANCTIONS_SCREENING_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.contract_version != SANCTIONS_SCREENING_CONTRACT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
"Unsupported sanctions screening result version."
|
||||||
|
)
|
||||||
|
if self.evidence != self.freshness.evidence:
|
||||||
|
raise ValueError(
|
||||||
|
"Sanctions screening result evidence is inconsistent."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SanctionsSnapshotProvider(Protocol):
|
||||||
|
def list_snapshots(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[SanctionsSnapshotReference]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_ref: str,
|
||||||
|
) -> SanctionsSnapshotReference | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot_ref: str,
|
||||||
|
) -> SanctionsSnapshotPayload:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SanctionsScreeningProvider(Protocol):
|
||||||
|
def request_screening(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
request: SanctionsScreeningRequest,
|
||||||
|
) -> SanctionsScreeningResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
def check_freshness(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
request: SanctionsScreeningFreshnessRequest,
|
||||||
|
) -> SanctionsScreeningFreshness:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def sanctions_snapshot_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SanctionsSnapshotProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider
|
||||||
|
if isinstance(provider, SanctionsSnapshotProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sanctions_screening_provider(
|
||||||
|
registry: object | None,
|
||||||
|
) -> SanctionsScreeningProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
provider
|
||||||
|
if isinstance(provider, SanctionsScreeningProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS",
|
||||||
|
"CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING",
|
||||||
|
"SANCTIONS_SCREENING_CONTRACT_VERSION",
|
||||||
|
"SANCTIONS_SNAPSHOT_CONTRACT_VERSION",
|
||||||
|
"SanctionsScreeningEvidence",
|
||||||
|
"SanctionsScreeningFreshness",
|
||||||
|
"SanctionsScreeningFreshnessRequest",
|
||||||
|
"SanctionsScreeningPolicy",
|
||||||
|
"SanctionsScreeningProvider",
|
||||||
|
"SanctionsScreeningRequest",
|
||||||
|
"SanctionsScreeningResult",
|
||||||
|
"SanctionsScreeningSubject",
|
||||||
|
"SanctionsSnapshotPayload",
|
||||||
|
"SanctionsSnapshotProvider",
|
||||||
|
"SanctionsSnapshotReference",
|
||||||
|
"sanctions_screening_provider",
|
||||||
|
"sanctions_snapshot_provider",
|
||||||
|
]
|
||||||
473
src/govoplan_core/core/search.py
Normal file
473
src/govoplan_core/core/search.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.external_references import ExternalObjectReference
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
|
|
||||||
|
SearchContextKind = Literal["global", "module", "resource"]
|
||||||
|
SearchVisibility = Literal["tenant", "restricted"]
|
||||||
|
SearchIndexChangeKind = Literal["upsert", "delete"]
|
||||||
|
SEARCH_SOURCE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchQuery:
|
||||||
|
text: str
|
||||||
|
tenant_id: str
|
||||||
|
module_ids: tuple[str, ...] = ()
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
context_kind: SearchContextKind = "global"
|
||||||
|
context_id: str | None = None
|
||||||
|
limit: int = 25
|
||||||
|
offset: int = 0
|
||||||
|
cursor: str | None = None
|
||||||
|
language: str = "simple"
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
normalized_text = self.text.strip()
|
||||||
|
if len(normalized_text) > 500:
|
||||||
|
raise ValueError("Search text is limited to 500 characters.")
|
||||||
|
if not self.tenant_id.strip():
|
||||||
|
raise ValueError("Search queries require a tenant.")
|
||||||
|
if not 1 <= self.limit <= 200:
|
||||||
|
raise ValueError("Search result limits must be between 1 and 200.")
|
||||||
|
if self.offset < 0:
|
||||||
|
raise ValueError("Search offsets cannot be negative.")
|
||||||
|
if self.cursor and self.offset:
|
||||||
|
raise ValueError(
|
||||||
|
"Search cursor and offset pagination cannot be combined."
|
||||||
|
)
|
||||||
|
if not self.language.replace("_", "").isalnum():
|
||||||
|
raise ValueError("Search language configuration is invalid.")
|
||||||
|
if len(self.module_ids) > 50 or len(self.resource_types) > 50:
|
||||||
|
raise ValueError(
|
||||||
|
"Search queries support at most 50 module and type filters."
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "text", normalized_text)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResult:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
summary: str | None = None
|
||||||
|
score: float = 0.0
|
||||||
|
highlights: tuple[str, ...] = ()
|
||||||
|
breadcrumbs: tuple[str, ...] = ()
|
||||||
|
external_reference: ExternalObjectReference | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
source_revision: str | None = None
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResourceReference:
|
||||||
|
tenant_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
for field_name in (
|
||||||
|
"tenant_id",
|
||||||
|
"module_id",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
):
|
||||||
|
if not str(getattr(self, field_name) or "").strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"Search resource reference {field_name} is required."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> str:
|
||||||
|
values = (
|
||||||
|
self.tenant_id,
|
||||||
|
self.module_id,
|
||||||
|
self.resource_type,
|
||||||
|
self.resource_id,
|
||||||
|
)
|
||||||
|
return "|".join(
|
||||||
|
f"{len(value)}:{value}"
|
||||||
|
for value in values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchResourceType:
|
||||||
|
provider_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
label: str
|
||||||
|
index_version: int = 1
|
||||||
|
language: str = "simple"
|
||||||
|
requires_authorization_recheck: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
self.provider_id,
|
||||||
|
self.module_id,
|
||||||
|
self.resource_type,
|
||||||
|
self.label,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search resource type identifiers and label are required."
|
||||||
|
)
|
||||||
|
if self.index_version < 1:
|
||||||
|
raise ValueError("Search index versions start at one.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchDocument:
|
||||||
|
tenant_id: str
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
summary: str | None = None
|
||||||
|
body: str | None = None
|
||||||
|
keywords: tuple[str, ...] = ()
|
||||||
|
visibility: SearchVisibility = "restricted"
|
||||||
|
acl_tokens: tuple[str, ...] = ()
|
||||||
|
external_reference: ExternalObjectReference | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
provider_id: str | None = None
|
||||||
|
source_revision: str = "1"
|
||||||
|
change_cursor: str | None = None
|
||||||
|
source_updated_at: datetime | None = None
|
||||||
|
language: str = "simple"
|
||||||
|
index_version: int = 1
|
||||||
|
requires_authorization_recheck: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
required = {
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"resource_type": self.resource_type,
|
||||||
|
"resource_id": self.resource_id,
|
||||||
|
"title": self.title,
|
||||||
|
"url": self.url,
|
||||||
|
}
|
||||||
|
for field_name, value in required.items():
|
||||||
|
if not str(value or "").strip():
|
||||||
|
raise ValueError(f"Search document {field_name} is required.")
|
||||||
|
limits = {
|
||||||
|
"module_id": 100,
|
||||||
|
"resource_type": 100,
|
||||||
|
"resource_id": 255,
|
||||||
|
"title": 500,
|
||||||
|
"url": 1500,
|
||||||
|
"summary": 4_000,
|
||||||
|
"body": 200_000,
|
||||||
|
"source_revision": 255,
|
||||||
|
"change_cursor": 500,
|
||||||
|
"language": 32,
|
||||||
|
}
|
||||||
|
for field_name, limit in limits.items():
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None and len(str(value)) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Search document {field_name} is limited to "
|
||||||
|
f"{limit} characters."
|
||||||
|
)
|
||||||
|
if len(self.keywords) > 100 or any(
|
||||||
|
len(keyword) > 200
|
||||||
|
for keyword in self.keywords
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents support at most 100 bounded keywords."
|
||||||
|
)
|
||||||
|
if len(self.acl_tokens) > 500 or any(
|
||||||
|
len(token) > 500
|
||||||
|
for token in self.acl_tokens
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents support at most 500 bounded ACL tokens."
|
||||||
|
)
|
||||||
|
if self.visibility == "restricted" and not self.acl_tokens:
|
||||||
|
raise ValueError(
|
||||||
|
"Restricted search documents require at least one ACL token."
|
||||||
|
)
|
||||||
|
if not self.source_revision.strip():
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents require a stable source revision."
|
||||||
|
)
|
||||||
|
if self.index_version < 1:
|
||||||
|
raise ValueError("Search document index versions start at one.")
|
||||||
|
if (
|
||||||
|
self.requires_authorization_recheck
|
||||||
|
and not str(self.provider_id or "").strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search documents requiring authorization rechecks must "
|
||||||
|
"name their source provider."
|
||||||
|
)
|
||||||
|
if self.provider_id is not None and len(self.provider_id) > 200:
|
||||||
|
raise ValueError(
|
||||||
|
"Search document provider_id is limited to 200 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reference(self) -> SearchResourceReference:
|
||||||
|
return SearchResourceReference(
|
||||||
|
tenant_id=self.tenant_id,
|
||||||
|
module_id=self.module_id,
|
||||||
|
resource_type=self.resource_type,
|
||||||
|
resource_id=self.resource_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchAuthorizationRequest:
|
||||||
|
reference: SearchResourceReference
|
||||||
|
source_revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchBackfillRequest:
|
||||||
|
tenant_id: str
|
||||||
|
provider_id: str
|
||||||
|
resource_type: str
|
||||||
|
rebuild_id: str
|
||||||
|
cursor: str | None = None
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not 1 <= self.limit <= 500:
|
||||||
|
raise ValueError(
|
||||||
|
"Search backfill page size must be between 1 and 500."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchBackfillPage:
|
||||||
|
documents: tuple[SearchDocument, ...]
|
||||||
|
next_cursor: str | None
|
||||||
|
complete: bool
|
||||||
|
high_watermark: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if len(self.documents) > 500:
|
||||||
|
raise ValueError(
|
||||||
|
"Search backfill providers returned more than 500 documents."
|
||||||
|
)
|
||||||
|
if not self.complete and not self.next_cursor:
|
||||||
|
raise ValueError(
|
||||||
|
"Incomplete search backfill pages require a next cursor."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchIndexChange:
|
||||||
|
change_id: str
|
||||||
|
provider_id: str
|
||||||
|
kind: SearchIndexChangeKind
|
||||||
|
reference: SearchResourceReference
|
||||||
|
source_revision: str
|
||||||
|
cursor: str
|
||||||
|
document: SearchDocument | None = None
|
||||||
|
occurred_at: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not all(
|
||||||
|
value.strip()
|
||||||
|
for value in (
|
||||||
|
self.change_id,
|
||||||
|
self.provider_id,
|
||||||
|
self.source_revision,
|
||||||
|
self.cursor,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search index changes require stable identity and revision."
|
||||||
|
)
|
||||||
|
if self.kind == "upsert":
|
||||||
|
if (
|
||||||
|
self.document is None
|
||||||
|
or self.document.reference != self.reference
|
||||||
|
or self.document.provider_id != self.provider_id
|
||||||
|
or self.document.source_revision
|
||||||
|
!= self.source_revision
|
||||||
|
or self.document.change_cursor != self.cursor
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Search upserts require a matching document."
|
||||||
|
)
|
||||||
|
elif self.document is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"Search deletes must not contain a document."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchProvider(Protocol):
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: SearchQuery,
|
||||||
|
) -> Sequence[SearchResult]:
|
||||||
|
"""Return only results the principal may currently read."""
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchIndexWriter(Protocol):
|
||||||
|
def upsert_document(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
document: SearchDocument,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_document(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
module_id: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: str,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
def enqueue_change(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
change: SearchIndexChange,
|
||||||
|
) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class SearchSourceProvider(Protocol):
|
||||||
|
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def backfill(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: SearchBackfillRequest,
|
||||||
|
) -> SearchBackfillPage:
|
||||||
|
...
|
||||||
|
|
||||||
|
def authorize(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
requests: Sequence[SearchAuthorizationRequest],
|
||||||
|
) -> Mapping[str, bool]:
|
||||||
|
"""Return an explicit decision for every requested reference key."""
|
||||||
|
|
||||||
|
|
||||||
|
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
||||||
|
SearchSourceProviderFactory = Callable[
|
||||||
|
[ModuleContext],
|
||||||
|
SearchSourceProvider,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: SearchProviderFactory
|
||||||
|
resource_types: tuple[str, ...] = ()
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> SearchProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, SearchProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Search provider {self.id!r} does not implement SearchProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredSearchProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: SearchProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SearchSourceProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: SearchSourceProviderFactory
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> SearchSourceProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, SearchSourceProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Search source {self.id!r} does not implement "
|
||||||
|
"SearchSourceProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredSearchSourceProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: SearchSourceProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
|
||||||
|
|
||||||
|
|
||||||
|
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||||
|
return writer if isinstance(writer, SearchIndexWriter) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_SEARCH_INDEX_WRITER",
|
||||||
|
"SEARCH_SOURCE_CONTRACT_VERSION",
|
||||||
|
"RegisteredSearchProvider",
|
||||||
|
"RegisteredSearchSourceProvider",
|
||||||
|
"SearchAuthorizationRequest",
|
||||||
|
"SearchBackfillPage",
|
||||||
|
"SearchBackfillRequest",
|
||||||
|
"SearchContextKind",
|
||||||
|
"SearchDocument",
|
||||||
|
"SearchIndexChange",
|
||||||
|
"SearchIndexChangeKind",
|
||||||
|
"SearchIndexWriter",
|
||||||
|
"SearchProvider",
|
||||||
|
"SearchProviderFactory",
|
||||||
|
"SearchProviderRegistration",
|
||||||
|
"SearchQuery",
|
||||||
|
"SearchResourceReference",
|
||||||
|
"SearchResourceType",
|
||||||
|
"SearchResult",
|
||||||
|
"SearchSourceProvider",
|
||||||
|
"SearchSourceProviderFactory",
|
||||||
|
"SearchSourceProviderRegistration",
|
||||||
|
"SearchVisibility",
|
||||||
|
"search_index_writer",
|
||||||
|
]
|
||||||
264
src/govoplan_core/core/tabular_sources.py
Normal file
264
src/govoplan_core/core/tabular_sources.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SOURCES = "connectors.tabularSources"
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER = "connectors.tabularSnapshotWriter"
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceError(ValueError):
|
||||||
|
"""Stable base error for provider-neutral tabular source operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceNotFoundError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceAccessError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceValidationError(TabularSourceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tabular_csv(
|
||||||
|
csv_text: str,
|
||||||
|
*,
|
||||||
|
delimiter: str = ",",
|
||||||
|
max_rows: int = 10_000,
|
||||||
|
) -> tuple[Mapping[str, object], ...]:
|
||||||
|
"""Parse a bounded CSV document into JSON-compatible tabular rows."""
|
||||||
|
|
||||||
|
if len(delimiter) != 1:
|
||||||
|
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||||
|
try:
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||||
|
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for row in reader:
|
||||||
|
_validate_csv_row_shape(row)
|
||||||
|
if _csv_row_is_empty(row, original_headers):
|
||||||
|
continue
|
||||||
|
if len(rows) >= max_rows:
|
||||||
|
raise TabularSourceValidationError(
|
||||||
|
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||||
|
)
|
||||||
|
rows.append(_csv_row(row, original_headers, normalized_headers))
|
||||||
|
return tuple(rows)
|
||||||
|
except csv.Error as exc:
|
||||||
|
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_headers(
|
||||||
|
fieldnames: Sequence[str | None] | None,
|
||||||
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
if not fieldnames:
|
||||||
|
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||||
|
original = tuple(str(name or "") for name in fieldnames)
|
||||||
|
normalized = tuple(
|
||||||
|
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
|
||||||
|
for position, name in enumerate(original)
|
||||||
|
)
|
||||||
|
if any(not name for name in normalized):
|
||||||
|
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||||
|
if len(set(normalized)) != len(normalized):
|
||||||
|
raise TabularSourceValidationError("CSV column names must be unique.")
|
||||||
|
return original, normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
|
||||||
|
extras = row.get(None)
|
||||||
|
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
|
||||||
|
raise TabularSourceValidationError(
|
||||||
|
"A CSV row contains more values than the header defines."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row_is_empty(
|
||||||
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
|
headers: Sequence[str],
|
||||||
|
) -> bool:
|
||||||
|
return all(
|
||||||
|
value is None or isinstance(value, list) or not value.strip()
|
||||||
|
for value in (row.get(header) for header in headers)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_row(
|
||||||
|
row: Mapping[str | None, str | list[str] | None],
|
||||||
|
original_headers: Sequence[str],
|
||||||
|
normalized_headers: Sequence[str],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
normalized: _csv_scalar(value if isinstance(value, str) else None)
|
||||||
|
for original, normalized in zip(
|
||||||
|
original_headers,
|
||||||
|
normalized_headers,
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
for value in (row.get(original),)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _csv_scalar(value: str | None) -> object:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
lowered = text.casefold()
|
||||||
|
if lowered in {"true", "false"}:
|
||||||
|
return lowered == "true"
|
||||||
|
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)", text):
|
||||||
|
return int(text)
|
||||||
|
if re.fullmatch(r"-?(?:0|[1-9][0-9]*)\.[0-9]+", text):
|
||||||
|
return float(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularColumn:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularSource:
|
||||||
|
"""Opaque, policy-filtered source reference exposed to consuming modules."""
|
||||||
|
|
||||||
|
ref: str
|
||||||
|
provider: str
|
||||||
|
source_name: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
schema: tuple[TabularColumn, ...] = ()
|
||||||
|
schema_version: str = "1"
|
||||||
|
fingerprint: str = ""
|
||||||
|
row_count: int | None = None
|
||||||
|
byte_count: int | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
capabilities: tuple[str, ...] = ("read",)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularReadRequest:
|
||||||
|
source_ref: str
|
||||||
|
limit: int = 250
|
||||||
|
offset: int = 0
|
||||||
|
columns: tuple[str, ...] = ()
|
||||||
|
expected_fingerprint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularReadResult:
|
||||||
|
source: TabularSource
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
total_rows: int
|
||||||
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TabularSnapshotInput:
|
||||||
|
name: str
|
||||||
|
source_name: str
|
||||||
|
rows: tuple[Mapping[str, object], ...]
|
||||||
|
description: str | None = None
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TabularSourceProvider(Protocol):
|
||||||
|
"""Principal-aware catalogue and bounded reader for tabular sources."""
|
||||||
|
|
||||||
|
def list_sources(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[TabularSource]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
source_ref: str,
|
||||||
|
) -> TabularSource | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def read_source(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: TabularReadRequest,
|
||||||
|
) -> TabularReadResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TabularSnapshotWriter(Protocol):
|
||||||
|
"""Optional capability for importing bounded static tabular snapshots."""
|
||||||
|
|
||||||
|
def create_snapshot(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
snapshot: TabularSnapshotInput,
|
||||||
|
) -> TabularSource:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def tabular_source_provider(registry: object | None) -> TabularSourceProvider | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SOURCES)
|
||||||
|
return capability if isinstance(capability, TabularSourceProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
def tabular_snapshot_writer(registry: object | None) -> TabularSnapshotWriter | None:
|
||||||
|
capability = _capability(registry, CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER)
|
||||||
|
return capability if isinstance(capability, TabularSnapshotWriter) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER",
|
||||||
|
"CAPABILITY_CONNECTORS_TABULAR_SOURCES",
|
||||||
|
"TabularColumn",
|
||||||
|
"TabularReadRequest",
|
||||||
|
"TabularReadResult",
|
||||||
|
"TabularSnapshotInput",
|
||||||
|
"TabularSnapshotWriter",
|
||||||
|
"TabularSource",
|
||||||
|
"TabularSourceAccessError",
|
||||||
|
"TabularSourceError",
|
||||||
|
"TabularSourceNotFoundError",
|
||||||
|
"TabularSourceProvider",
|
||||||
|
"TabularSourceValidationError",
|
||||||
|
"parse_tabular_csv",
|
||||||
|
"tabular_snapshot_writer",
|
||||||
|
"tabular_source_provider",
|
||||||
|
]
|
||||||
95
src/govoplan_core/core/views.py
Normal file
95
src/govoplan_core/core/views.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS_MODULE_ID = "views"
|
||||||
|
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
|
||||||
|
VIEW_SURFACE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
ViewSurfaceKind = Literal[
|
||||||
|
"module",
|
||||||
|
"navigation",
|
||||||
|
"route",
|
||||||
|
"section",
|
||||||
|
"action",
|
||||||
|
"selector",
|
||||||
|
]
|
||||||
|
|
||||||
|
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||||
|
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ViewSurface:
|
||||||
|
id: str
|
||||||
|
module_id: str
|
||||||
|
kind: ViewSurfaceKind
|
||||||
|
label: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
order: int = 100
|
||||||
|
default_visible: bool = True
|
||||||
|
required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectiveView:
|
||||||
|
view_id: str | None
|
||||||
|
revision_id: str | None
|
||||||
|
name: str | None
|
||||||
|
visible_surface_ids: frozenset[str]
|
||||||
|
locked: bool = False
|
||||||
|
provenance: tuple[dict[str, object], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ViewResolver(Protocol):
|
||||||
|
def resolve_effective_view(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
workflow_view_id: str | None = None,
|
||||||
|
) -> EffectiveView: ...
|
||||||
|
|
||||||
|
|
||||||
|
def module_view_surface_id(module_id: str) -> str:
|
||||||
|
return f"{module_id}.module"
|
||||||
|
|
||||||
|
|
||||||
|
def navigation_view_surface_id(module_id: str, path: str) -> str:
|
||||||
|
return f"{module_id}.nav.{_surface_slug(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def route_view_surface_id(module_id: str, path: str) -> str:
|
||||||
|
return f"{module_id}.route.{_surface_slug(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_view_surface_id(value: str) -> bool:
|
||||||
|
return bool(_SURFACE_ID_RE.fullmatch(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_slug(value: str) -> str:
|
||||||
|
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
|
||||||
|
return normalized or "root"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_VIEWS_RESOLVER",
|
||||||
|
"EffectiveView",
|
||||||
|
"VIEWS_MODULE_ID",
|
||||||
|
"VIEW_SURFACE_CONTRACT_VERSION",
|
||||||
|
"ViewResolver",
|
||||||
|
"ViewSurface",
|
||||||
|
"ViewSurfaceKind",
|
||||||
|
"module_view_surface_id",
|
||||||
|
"navigation_view_surface_id",
|
||||||
|
"route_view_surface_id",
|
||||||
|
"validate_view_surface_id",
|
||||||
|
]
|
||||||
44
src/govoplan_core/core/workflows.py
Normal file
44
src/govoplan_core/core/workflows.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER = "workflow.runtimeWorker"
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WorkflowRuntimeWorker(Protocol):
|
||||||
|
def reconcile_pending(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_runtime_worker(
|
||||||
|
registry: object | None,
|
||||||
|
) -> WorkflowRuntimeWorker | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
capability = registry.capability(CAPABILITY_WORKFLOW_RUNTIME_WORKER)
|
||||||
|
return (
|
||||||
|
capability
|
||||||
|
if isinstance(capability, WorkflowRuntimeWorker)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_WORKFLOW_RUNTIME_WORKER",
|
||||||
|
"WorkflowRuntimeWorker",
|
||||||
|
"workflow_runtime_worker",
|
||||||
|
]
|
||||||
@@ -32,6 +32,7 @@ def create_all_tables() -> None:
|
|||||||
# model metadata with the shared SQLAlchemy base before create_all runs.
|
# model metadata with the shared SQLAlchemy base before create_all runs.
|
||||||
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
from govoplan_core.admin import models as core_admin_models # noqa: F401
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401
|
||||||
|
|
||||||
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
raw_enabled_modules = load_startup_enabled_modules(settings.enabled_modules)
|
||||||
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
candidate_modules = startup_candidate_module_ids(settings.enabled_modules, raw_enabled_modules)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
import sysconfig
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
@@ -17,6 +18,7 @@ from sqlalchemy import create_engine, inspect, text
|
|||||||
|
|
||||||
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
from govoplan_core.core.migrations import MigrationMetadataPlan, migration_metadata_plan
|
||||||
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
from govoplan_core.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||||
|
from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 - populate core metadata
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
|
||||||
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
from govoplan_core.core.modules import ModuleMigrationTaskContext, ModuleMigrationTaskResult
|
||||||
@@ -462,11 +464,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
|
|||||||
|
|
||||||
def _repo_root() -> Path:
|
def _repo_root() -> Path:
|
||||||
packaged_root = Path(__file__).resolve().parents[3]
|
packaged_root = Path(__file__).resolve().parents[3]
|
||||||
|
installed_runtime_root = Path(sysconfig.get_path("data")) / "govoplan_core_runtime"
|
||||||
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
configured = os.environ.get("GOVOPLAN_CORE_SOURCE_ROOT")
|
||||||
candidates = [
|
candidates = [
|
||||||
Path(configured).expanduser() if configured else None,
|
Path(configured).expanduser() if configured else None,
|
||||||
Path.cwd(),
|
Path.cwd(),
|
||||||
packaged_root,
|
packaged_root,
|
||||||
|
installed_runtime_root,
|
||||||
]
|
]
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Generator, Mapping
|
from collections.abc import Generator, Mapping
|
||||||
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from govoplan_core.db.query_metrics import instrument_engine
|
from govoplan_core.db.query_metrics import instrument_engine
|
||||||
|
|
||||||
|
|
||||||
|
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
|
||||||
|
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
|
||||||
|
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
|
||||||
|
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
|
||||||
|
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def default_connect_args(database_url: str) -> dict[str, Any]:
|
def default_connect_args(database_url: str) -> dict[str, Any]:
|
||||||
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||||
|
|
||||||
@@ -24,9 +33,42 @@ def create_database_engine(
|
|||||||
merged_connect_args = dict(default_connect_args(database_url))
|
merged_connect_args = dict(default_connect_args(database_url))
|
||||||
if connect_args:
|
if connect_args:
|
||||||
merged_connect_args.update(connect_args)
|
merged_connect_args.update(connect_args)
|
||||||
|
if not database_url.startswith("sqlite"):
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_size",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"max_overflow",
|
||||||
|
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_timeout",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
|
||||||
|
)
|
||||||
|
kwargs.setdefault(
|
||||||
|
"pool_recycle",
|
||||||
|
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
|
||||||
|
)
|
||||||
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_setting(name: str, default: int) -> int:
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"{name} must be an integer") from exc
|
||||||
|
minimum, maximum = _POOL_SETTING_BOUNDS[name]
|
||||||
|
if not minimum <= value <= maximum:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be between {minimum} and {maximum}",
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DatabaseHandle:
|
class DatabaseHandle:
|
||||||
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class DevserverState:
|
|||||||
config: GovoplanServerConfig
|
config: GovoplanServerConfig
|
||||||
registry: PlatformRegistry
|
registry: PlatformRegistry
|
||||||
reload_dirs: list[str]
|
reload_dirs: list[str]
|
||||||
|
reload_module_ids: tuple[str, ...] | None
|
||||||
|
|
||||||
|
|
||||||
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
def _config_module_runtime_root(config_path: str | None) -> Path | None:
|
||||||
@@ -190,6 +191,9 @@ def _manifest_source_roots(manifest: ModuleManifest) -> tuple[Path, ...]:
|
|||||||
for provider in manifest.tenant_summary_providers:
|
for provider in manifest.tenant_summary_providers:
|
||||||
roots.extend(_source_roots_for_object(provider))
|
roots.extend(_source_roots_for_object(provider))
|
||||||
|
|
||||||
|
for provider in manifest.tenant_summary_batch_providers:
|
||||||
|
roots.extend(_source_roots_for_object(provider))
|
||||||
|
|
||||||
for providers in manifest.delete_veto_providers.values():
|
for providers in manifest.delete_veto_providers.values():
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
roots.extend(_source_roots_for_object(provider))
|
roots.extend(_source_roots_for_object(provider))
|
||||||
@@ -237,20 +241,44 @@ def build_reload_dirs(
|
|||||||
config_path: str | None = None,
|
config_path: str | None = None,
|
||||||
registry: PlatformRegistry | None = None,
|
registry: PlatformRegistry | None = None,
|
||||||
extra_dirs: Sequence[str] = (),
|
extra_dirs: Sequence[str] = (),
|
||||||
|
module_ids: Sequence[str] | None = None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
active_registry = registry or build_platform_registry(config.enabled_modules, manifest_factories=config.manifest_factories)
|
||||||
manifests = active_registry.manifests()
|
manifests = active_registry.manifests()
|
||||||
enabled_module_ids = {manifest.id for manifest in manifests}
|
enabled_module_ids = {manifest.id for manifest in manifests}
|
||||||
|
selected_module_ids = (
|
||||||
|
enabled_module_ids
|
||||||
|
if module_ids is None
|
||||||
|
else {module_id.strip() for module_id in module_ids if module_id.strip()}
|
||||||
|
)
|
||||||
|
unknown_module_ids = selected_module_ids - enabled_module_ids
|
||||||
|
if unknown_module_ids:
|
||||||
|
raise SystemExit(
|
||||||
|
"Reload modules are not enabled: "
|
||||||
|
+ ", ".join(sorted(unknown_module_ids))
|
||||||
|
)
|
||||||
|
|
||||||
roots: list[Path | str] = []
|
roots: list[Path | str] = []
|
||||||
roots.extend(_config_source_roots(config_path))
|
roots.extend(_config_source_roots(config_path))
|
||||||
|
|
||||||
for factory in config.manifest_factories:
|
for factory in config.manifest_factories:
|
||||||
|
try:
|
||||||
|
manifest = factory()
|
||||||
|
except TypeError:
|
||||||
|
manifest = None
|
||||||
|
if (
|
||||||
|
module_ids is not None
|
||||||
|
and isinstance(manifest, ModuleManifest)
|
||||||
|
and manifest.id not in selected_module_ids
|
||||||
|
):
|
||||||
|
continue
|
||||||
roots.extend(_source_roots_for_object(factory))
|
roots.extend(_source_roots_for_object(factory))
|
||||||
|
|
||||||
roots.extend(_entry_point_source_roots(enabled_module_ids))
|
roots.extend(_entry_point_source_roots(selected_module_ids))
|
||||||
|
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
|
if manifest.id not in selected_module_ids:
|
||||||
|
continue
|
||||||
roots.extend(_manifest_source_roots(manifest))
|
roots.extend(_manifest_source_roots(manifest))
|
||||||
|
|
||||||
roots.extend(extra_dirs)
|
roots.extend(extra_dirs)
|
||||||
@@ -265,11 +293,32 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|||||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
|
parser.add_argument("--port", type=int, default=8000, help="Port to bind. Default: 8000.")
|
||||||
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
parser.add_argument("--no-reload", action="store_true", help="Disable uvicorn reload.")
|
||||||
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
parser.add_argument("--reload-dir", action="append", default=[], help="Additional directory to watch. May be passed multiple times.")
|
||||||
|
reload_scope = parser.add_mutually_exclusive_group()
|
||||||
|
reload_scope.add_argument(
|
||||||
|
"--reload-module",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="MODULE_ID",
|
||||||
|
help=(
|
||||||
|
"Watch only this enabled module in addition to core/config sources. "
|
||||||
|
"May be passed multiple times."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
reload_scope.add_argument(
|
||||||
|
"--reload-core-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Watch core/config sources but no optional module source trees.",
|
||||||
|
)
|
||||||
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
parser.add_argument("--smoke", action="store_true", help="Prepare runtime paths, run app startup, print effective paths, and exit without uvicorn.")
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[str] = ()) -> DevserverState:
|
def prepare_devserver(
|
||||||
|
config_path: str | None,
|
||||||
|
*,
|
||||||
|
extra_reload_dirs: Sequence[str] = (),
|
||||||
|
reload_module_ids: Sequence[str] | None = None,
|
||||||
|
) -> DevserverState:
|
||||||
runtime_root = apply_runtime_defaults(config_path)
|
runtime_root = apply_runtime_defaults(config_path)
|
||||||
database_url = os.getenv("DATABASE_URL", "")
|
database_url = os.getenv("DATABASE_URL", "")
|
||||||
validate_sqlite_database_url(database_url)
|
validate_sqlite_database_url(database_url)
|
||||||
@@ -291,7 +340,13 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
|
|||||||
)
|
)
|
||||||
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
|
enabled_modules = load_startup_enabled_modules(config.enabled_modules, available=available_modules)
|
||||||
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
|
registry = build_platform_registry(enabled_modules, manifest_factories=config.manifest_factories)
|
||||||
reload_dirs = build_reload_dirs(config, config_path=config_path, registry=registry, extra_dirs=extra_reload_dirs)
|
reload_dirs = build_reload_dirs(
|
||||||
|
config,
|
||||||
|
config_path=config_path,
|
||||||
|
registry=registry,
|
||||||
|
extra_dirs=extra_reload_dirs,
|
||||||
|
module_ids=reload_module_ids,
|
||||||
|
)
|
||||||
return DevserverState(
|
return DevserverState(
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
runtime_root=runtime_root,
|
runtime_root=runtime_root,
|
||||||
@@ -300,6 +355,11 @@ def prepare_devserver(config_path: str | None, *, extra_reload_dirs: Sequence[st
|
|||||||
config=config,
|
config=config,
|
||||||
registry=registry,
|
registry=registry,
|
||||||
reload_dirs=reload_dirs,
|
reload_dirs=reload_dirs,
|
||||||
|
reload_module_ids=(
|
||||||
|
None
|
||||||
|
if reload_module_ids is None
|
||||||
|
else tuple(sorted(set(reload_module_ids)))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -320,6 +380,15 @@ def print_devserver_summary(state: DevserverState, *, app: str, no_reload: bool)
|
|||||||
if no_reload:
|
if no_reload:
|
||||||
print("Reload: disabled")
|
print("Reload: disabled")
|
||||||
else:
|
else:
|
||||||
|
if state.reload_module_ids is None:
|
||||||
|
print("Reload scope: core/config plus all enabled modules")
|
||||||
|
elif state.reload_module_ids:
|
||||||
|
print(
|
||||||
|
"Reload scope: core/config plus "
|
||||||
|
+ ", ".join(state.reload_module_ids)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Reload scope: core/config only")
|
||||||
print("Reload dirs:")
|
print("Reload dirs:")
|
||||||
for directory in state.reload_dirs:
|
for directory in state.reload_dirs:
|
||||||
print(f" - {directory}")
|
print(f" - {directory}")
|
||||||
@@ -353,7 +422,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
os.environ["GOVOPLAN_SERVER_CONFIG"] = args.config
|
||||||
|
|
||||||
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
config_path = args.config or os.getenv("GOVOPLAN_SERVER_CONFIG")
|
||||||
state = prepare_devserver(config_path, extra_reload_dirs=args.reload_dir)
|
reload_module_ids: Sequence[str] | None = args.reload_module
|
||||||
|
if args.reload_core_only:
|
||||||
|
reload_module_ids = ()
|
||||||
|
state = prepare_devserver(
|
||||||
|
config_path,
|
||||||
|
extra_reload_dirs=args.reload_dir,
|
||||||
|
reload_module_ids=reload_module_ids,
|
||||||
|
)
|
||||||
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
print_devserver_summary(state, app=args.app, no_reload=args.no_reload)
|
||||||
|
|
||||||
if args.smoke:
|
if args.smoke:
|
||||||
|
|||||||
563
src/govoplan_core/security/credential_envelopes.py
Normal file
563
src/govoplan_core/security/credential_envelopes.py
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Index, JSON, String, Text, select
|
||||||
|
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||||
|
|
||||||
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
from govoplan_core.core.change_sequence import record_change
|
||||||
|
from govoplan_core.db.base import Base, TimestampMixin, utcnow
|
||||||
|
from govoplan_core.security.redaction import is_sensitive_key, redact_secret_values
|
||||||
|
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
|
||||||
|
CREDENTIAL_SCOPE_TYPES = frozenset({"system", "tenant", "group", "user", "campaign"})
|
||||||
|
CREDENTIAL_KINDS = frozenset(
|
||||||
|
{
|
||||||
|
"username_password",
|
||||||
|
"token",
|
||||||
|
"oauth2",
|
||||||
|
"api_key",
|
||||||
|
"client_secret",
|
||||||
|
"aws",
|
||||||
|
"custom",
|
||||||
|
"external_secret",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_SECRET_PUBLIC_DATA_KEYS = frozenset(
|
||||||
|
{
|
||||||
|
"access_token",
|
||||||
|
"api_key",
|
||||||
|
"authorization",
|
||||||
|
"bearer_token",
|
||||||
|
"client_secret",
|
||||||
|
"password",
|
||||||
|
"passphrase",
|
||||||
|
"private_key",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
"secret_access_key",
|
||||||
|
"session_token",
|
||||||
|
"token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelope(Base, TimestampMixin):
|
||||||
|
__tablename__ = "core_credential_envelopes"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_core_credential_envelopes_scope", "tenant_id", "scope_type", "scope_id"),
|
||||||
|
Index("ix_core_credential_envelopes_active", "tenant_id", "is_active", "deleted_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
scope_type: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant", index=True)
|
||||||
|
scope_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
credential_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
public_data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
secret_data_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
secret_keys: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
allowed_modules: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
allowed_server_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
revision: Mapped[str] = mapped_column(String(36), default=lambda: str(uuid.uuid4()), nullable=False)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
updated_by_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CredentialAccessContext:
|
||||||
|
tenant_id: str | None
|
||||||
|
user_id: str | None = None
|
||||||
|
group_ids: frozenset[str] = frozenset()
|
||||||
|
target_scope_type: str = "tenant"
|
||||||
|
target_scope_id: str | None = None
|
||||||
|
module_id: str | None = None
|
||||||
|
server_ref: str | None = None
|
||||||
|
administrative: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ResolvedCredentialEnvelope:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
credential_kind: str
|
||||||
|
public_data: Mapping[str, Any]
|
||||||
|
secret_data: Mapping[str, Any]
|
||||||
|
revision: str
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_credential_scope(
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> tuple[str | None, str, str | None]:
|
||||||
|
normalized_type = str(scope_type or "tenant").strip().casefold()
|
||||||
|
if normalized_type not in CREDENTIAL_SCOPE_TYPES:
|
||||||
|
raise CredentialEnvelopeError(f"Unsupported credential scope: {scope_type!r}")
|
||||||
|
normalized_id = _optional_text(scope_id)
|
||||||
|
if normalized_type == "system":
|
||||||
|
return None, "system", None
|
||||||
|
normalized_tenant = _required_text(tenant_id, "Credential tenant_id is required outside system scope")
|
||||||
|
if normalized_type == "tenant":
|
||||||
|
return normalized_tenant, "tenant", normalized_id or normalized_tenant
|
||||||
|
if not normalized_id:
|
||||||
|
raise CredentialEnvelopeError(f"{normalized_type.capitalize()} credentials require scope_id")
|
||||||
|
return normalized_tenant, normalized_type, normalized_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
name: str,
|
||||||
|
credential_kind: str,
|
||||||
|
public_data: Mapping[str, Any] | None = None,
|
||||||
|
secret_data: Mapping[str, Any] | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
allowed_modules: list[str] | tuple[str, ...] = (),
|
||||||
|
allowed_server_refs: list[str] | tuple[str, ...] = (),
|
||||||
|
inherit_to_lower_scopes: bool = False,
|
||||||
|
is_active: bool = True,
|
||||||
|
user_id: str | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
row_tenant_id, row_scope_type, row_scope_id = normalize_credential_scope(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
clean_kind = _normalize_kind(credential_kind)
|
||||||
|
clean_public = _safe_public_mapping(public_data)
|
||||||
|
clean_secret = _json_mapping(secret_data)
|
||||||
|
row = CredentialEnvelope(
|
||||||
|
tenant_id=row_tenant_id,
|
||||||
|
scope_type=row_scope_type,
|
||||||
|
scope_id=row_scope_id,
|
||||||
|
name=_required_text(name, "Credential name is required"),
|
||||||
|
description=_optional_text(description),
|
||||||
|
credential_kind=clean_kind,
|
||||||
|
public_data=clean_public,
|
||||||
|
secret_data_encrypted=_encrypt_secret_mapping(clean_secret),
|
||||||
|
secret_keys=sorted(clean_secret),
|
||||||
|
allowed_modules=_normalized_values(allowed_modules),
|
||||||
|
allowed_server_refs=_normalized_values(allowed_server_refs),
|
||||||
|
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
|
||||||
|
is_active=bool(is_active),
|
||||||
|
created_by_user_id=_optional_text(user_id),
|
||||||
|
updated_by_user_id=_optional_text(user_id),
|
||||||
|
metadata_=_json_mapping(metadata) or None,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
_record_credential_change(session, row=row, operation="created", user_id=user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def update_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
row: CredentialEnvelope,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
description_supplied: bool = False,
|
||||||
|
credential_kind: str | None = None,
|
||||||
|
public_data: Mapping[str, Any] | None = None,
|
||||||
|
secret_data: Mapping[str, Any] | None = None,
|
||||||
|
clear_secret: bool = False,
|
||||||
|
allowed_modules: list[str] | tuple[str, ...] | None = None,
|
||||||
|
allowed_server_refs: list[str] | tuple[str, ...] | None = None,
|
||||||
|
inherit_to_lower_scopes: bool | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
metadata: Mapping[str, Any] | None = None,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
if row.deleted_at is not None:
|
||||||
|
raise CredentialEnvelopeError("Deleted credentials cannot be updated")
|
||||||
|
if name is not None:
|
||||||
|
row.name = _required_text(name, "Credential name is required")
|
||||||
|
if description_supplied or description is not None:
|
||||||
|
row.description = _optional_text(description)
|
||||||
|
if credential_kind is not None:
|
||||||
|
clean_kind = _normalize_kind(credential_kind)
|
||||||
|
if clean_kind != row.credential_kind and secret_data is None and not clear_secret:
|
||||||
|
raise CredentialEnvelopeError(
|
||||||
|
"Changing credential kind requires replacing or clearing its secret"
|
||||||
|
)
|
||||||
|
row.credential_kind = clean_kind
|
||||||
|
if public_data is not None:
|
||||||
|
row.public_data = _safe_public_mapping(public_data)
|
||||||
|
if secret_data is not None:
|
||||||
|
clean_secret = _json_mapping(secret_data)
|
||||||
|
row.secret_data_encrypted = _encrypt_secret_mapping(clean_secret)
|
||||||
|
row.secret_keys = sorted(clean_secret)
|
||||||
|
elif clear_secret:
|
||||||
|
row.secret_data_encrypted = None
|
||||||
|
row.secret_keys = []
|
||||||
|
if allowed_modules is not None:
|
||||||
|
row.allowed_modules = _normalized_values(allowed_modules)
|
||||||
|
if allowed_server_refs is not None:
|
||||||
|
row.allowed_server_refs = _normalized_values(allowed_server_refs)
|
||||||
|
if inherit_to_lower_scopes is not None:
|
||||||
|
row.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
|
||||||
|
if is_active is not None:
|
||||||
|
row.is_active = bool(is_active)
|
||||||
|
if metadata is not None:
|
||||||
|
row.metadata_ = _json_mapping(metadata) or None
|
||||||
|
row.updated_by_user_id = _optional_text(user_id)
|
||||||
|
row.revision = str(uuid.uuid4())
|
||||||
|
session.flush()
|
||||||
|
_record_credential_change(session, row=row, operation="updated", user_id=user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def retire_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
row: CredentialEnvelope,
|
||||||
|
*,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
if row.deleted_at is not None:
|
||||||
|
return row
|
||||||
|
row.secret_data_encrypted = None
|
||||||
|
row.secret_keys = []
|
||||||
|
row.is_active = False
|
||||||
|
row.deleted_at = utcnow()
|
||||||
|
row.updated_by_user_id = _optional_text(user_id)
|
||||||
|
row.revision = str(uuid.uuid4())
|
||||||
|
session.flush()
|
||||||
|
_record_credential_change(session, row=row, operation="deleted", user_id=user_id)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
context: CredentialAccessContext,
|
||||||
|
require_active: bool = True,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
statement = select(CredentialEnvelope).where(CredentialEnvelope.id == _required_text(credential_id, "Credential id is required"))
|
||||||
|
if for_update:
|
||||||
|
statement = statement.with_for_update()
|
||||||
|
row = session.execute(statement).scalar_one_or_none()
|
||||||
|
if row is None or row.deleted_at is not None or not credential_visible_to_context(row, context):
|
||||||
|
raise CredentialEnvelopeError("Credential envelope not found")
|
||||||
|
if require_active and not row.is_active:
|
||||||
|
raise CredentialEnvelopeError("Credential envelope is inactive")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def list_credential_envelopes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
context: CredentialAccessContext,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
) -> list[CredentialEnvelope]:
|
||||||
|
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
|
||||||
|
if context.tenant_id is None:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(
|
||||||
|
(CredentialEnvelope.tenant_id == context.tenant_id)
|
||||||
|
| (CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
)
|
||||||
|
if not include_inactive:
|
||||||
|
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
||||||
|
rows = session.execute(statement.order_by(CredentialEnvelope.name, CredentialEnvelope.id)).scalars()
|
||||||
|
return [row for row in rows if credential_visible_to_context(row, context)]
|
||||||
|
|
||||||
|
|
||||||
|
def list_managed_credential_envelopes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
) -> list[CredentialEnvelope]:
|
||||||
|
"""List envelopes for an administrative surface without applying use-site limits."""
|
||||||
|
|
||||||
|
statement = select(CredentialEnvelope).where(CredentialEnvelope.deleted_at.is_(None))
|
||||||
|
if tenant_id is None:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
|
||||||
|
if not include_inactive:
|
||||||
|
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
statement.order_by(
|
||||||
|
CredentialEnvelope.scope_type,
|
||||||
|
CredentialEnvelope.name,
|
||||||
|
CredentialEnvelope.id,
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_managed_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
tenant_id: str | None,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
statement = select(CredentialEnvelope).where(
|
||||||
|
CredentialEnvelope.id
|
||||||
|
== _required_text(credential_id, "Credential id is required"),
|
||||||
|
CredentialEnvelope.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if tenant_id is None:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id.is_(None))
|
||||||
|
else:
|
||||||
|
statement = statement.where(CredentialEnvelope.tenant_id == tenant_id)
|
||||||
|
if for_update:
|
||||||
|
statement = statement.with_for_update()
|
||||||
|
row = session.execute(statement).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise CredentialEnvelopeError("Credential envelope not found")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def credential_visible_to_context(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
|
||||||
|
if row.deleted_at is not None:
|
||||||
|
return False
|
||||||
|
if not _scope_visible(row, context):
|
||||||
|
return False
|
||||||
|
if row.allowed_modules and (not context.module_id or context.module_id not in row.allowed_modules):
|
||||||
|
return False
|
||||||
|
if row.allowed_server_refs and (not context.server_ref or context.server_ref not in row.allowed_server_refs):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_credential_envelope(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
credential_id: str,
|
||||||
|
context: CredentialAccessContext,
|
||||||
|
) -> ResolvedCredentialEnvelope:
|
||||||
|
row = get_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id=credential_id,
|
||||||
|
context=context,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
|
return ResolvedCredentialEnvelope(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
credential_kind=row.credential_kind,
|
||||||
|
public_data=dict(row.public_data or {}),
|
||||||
|
secret_data=_decrypt_secret_mapping(row.secret_data_encrypted),
|
||||||
|
revision=row.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def credential_envelope_summary(row: CredentialEnvelope) -> dict[str, Any]:
|
||||||
|
public_data = redact_secret_values(dict(row.public_data or {}))
|
||||||
|
return {
|
||||||
|
"id": row.id,
|
||||||
|
"tenant_id": row.tenant_id,
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"name": row.name,
|
||||||
|
"description": row.description,
|
||||||
|
"credential_kind": row.credential_kind,
|
||||||
|
"public_data": public_data,
|
||||||
|
"secret_keys": sorted(str(key) for key in (row.secret_keys or [])),
|
||||||
|
"secret_configured": bool(row.secret_data_encrypted),
|
||||||
|
"allowed_modules": sorted(str(item) for item in (row.allowed_modules or [])),
|
||||||
|
"allowed_server_refs": sorted(str(item) for item in (row.allowed_server_refs or [])),
|
||||||
|
"inherit_to_lower_scopes": bool(row.inherit_to_lower_scopes),
|
||||||
|
"is_active": bool(row.is_active),
|
||||||
|
"revision": row.revision,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
"updated_at": row.updated_at,
|
||||||
|
"deleted_at": row.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_visible(row: CredentialEnvelope, context: CredentialAccessContext) -> bool:
|
||||||
|
target_type = str(context.target_scope_type or "tenant").strip().casefold()
|
||||||
|
target_id = _optional_text(context.target_scope_id)
|
||||||
|
if context.administrative:
|
||||||
|
return row.scope_type == "system" or row.tenant_id == context.tenant_id
|
||||||
|
if row.scope_type == "system":
|
||||||
|
return target_type == "system" or bool(row.inherit_to_lower_scopes)
|
||||||
|
if row.tenant_id != context.tenant_id:
|
||||||
|
return False
|
||||||
|
if row.scope_type == "tenant":
|
||||||
|
if target_type == "tenant":
|
||||||
|
return row.scope_id in {None, context.tenant_id, target_id}
|
||||||
|
return bool(row.inherit_to_lower_scopes)
|
||||||
|
if row.scope_type == "user":
|
||||||
|
return row.scope_id == context.user_id or (target_type == "user" and row.scope_id == target_id)
|
||||||
|
if row.scope_type == "group":
|
||||||
|
exact = row.scope_id in context.group_ids or (target_type == "group" and row.scope_id == target_id)
|
||||||
|
return exact and (target_type == "group" or bool(row.inherit_to_lower_scopes))
|
||||||
|
if row.scope_type == "campaign":
|
||||||
|
return target_type == "campaign" and row.scope_id == target_id
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _record_credential_change(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
row: CredentialEnvelope,
|
||||||
|
operation: str,
|
||||||
|
user_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
scope = "system" if row.scope_type == "system" else "tenant"
|
||||||
|
details = {
|
||||||
|
"scope_type": row.scope_type,
|
||||||
|
"scope_id": row.scope_id,
|
||||||
|
"credential_kind": row.credential_kind,
|
||||||
|
"allowed_modules": list(row.allowed_modules or []),
|
||||||
|
"allowed_server_refs": list(row.allowed_server_refs or []),
|
||||||
|
"secret_configured": bool(row.secret_data_encrypted),
|
||||||
|
}
|
||||||
|
record_change(
|
||||||
|
session,
|
||||||
|
module_id="core",
|
||||||
|
collection="core.security.credentials",
|
||||||
|
resource_type="credential_envelope",
|
||||||
|
resource_id=row.id,
|
||||||
|
operation=operation,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
actor_type="user" if user_id else None,
|
||||||
|
actor_id=user_id,
|
||||||
|
payload={"scope_type": row.scope_type, "credential_kind": row.credential_kind},
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
action=f"credential.{operation}",
|
||||||
|
scope=scope,
|
||||||
|
user_id=user_id,
|
||||||
|
object_type="credential_envelope",
|
||||||
|
object_id=row.id,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_secret_mapping(value: Mapping[str, Any]) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
return encrypt_secret(json.dumps(dict(value), separators=(",", ":"), sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_secret_mapping(value: str | None) -> dict[str, Any]:
|
||||||
|
decrypted = decrypt_secret(value)
|
||||||
|
if not decrypted:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(decrypted)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise CredentialEnvelopeError("Stored credential payload is invalid") from exc
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise CredentialEnvelopeError("Stored credential payload is invalid")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_kind(value: str) -> str:
|
||||||
|
normalized = _required_text(value, "Credential kind is required").casefold()
|
||||||
|
if normalized not in CREDENTIAL_KINDS:
|
||||||
|
raise CredentialEnvelopeError(f"Unsupported credential kind: {value!r}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_values(values: list[str] | tuple[str, ...]) -> list[str]:
|
||||||
|
return sorted({_required_text(value, "Credential policy values cannot be blank") for value in values})
|
||||||
|
|
||||||
|
|
||||||
|
def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
return dict(value or {})
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_public_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||||
|
payload = _json_mapping(value)
|
||||||
|
unsafe_path = _secret_public_data_path(payload)
|
||||||
|
if unsafe_path:
|
||||||
|
raise CredentialEnvelopeError(
|
||||||
|
f"Credential public_data must not contain secret field {unsafe_path!r}"
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_public_data_path(value: object, path: str = "public_data") -> str | None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for key, item in value.items():
|
||||||
|
item_path = f"{path}.{key}"
|
||||||
|
if _is_secret_public_key(key):
|
||||||
|
return item_path
|
||||||
|
nested_path = _secret_public_data_path(item, item_path)
|
||||||
|
if nested_path:
|
||||||
|
return nested_path
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
nested_path = _secret_public_data_path(item, f"{path}[{index}]")
|
||||||
|
if nested_path:
|
||||||
|
return nested_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secret_public_key(key: object) -> bool:
|
||||||
|
clean_key = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", str(key).strip())
|
||||||
|
clean_key = re.sub(r"[^A-Za-z0-9]+", "_", clean_key).strip("_").casefold()
|
||||||
|
if clean_key.endswith(("_id", "_ref", "_reference", "_type")):
|
||||||
|
return False
|
||||||
|
return clean_key in _SECRET_PUBLIC_DATA_KEYS or is_sensitive_key(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(value: object | None, message: str) -> str:
|
||||||
|
clean = _optional_text(value)
|
||||||
|
if not clean:
|
||||||
|
raise CredentialEnvelopeError(message)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = str(value).strip()
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CREDENTIAL_KINDS",
|
||||||
|
"CREDENTIAL_SCOPE_TYPES",
|
||||||
|
"CredentialAccessContext",
|
||||||
|
"CredentialEnvelope",
|
||||||
|
"CredentialEnvelopeError",
|
||||||
|
"ResolvedCredentialEnvelope",
|
||||||
|
"create_credential_envelope",
|
||||||
|
"credential_envelope_summary",
|
||||||
|
"credential_visible_to_context",
|
||||||
|
"get_credential_envelope",
|
||||||
|
"get_managed_credential_envelope",
|
||||||
|
"list_credential_envelopes",
|
||||||
|
"list_managed_credential_envelopes",
|
||||||
|
"normalize_credential_scope",
|
||||||
|
"resolve_credential_envelope",
|
||||||
|
"retire_credential_envelope",
|
||||||
|
"update_credential_envelope",
|
||||||
|
]
|
||||||
@@ -8,6 +8,7 @@ from govoplan_core.db.session import configure_database
|
|||||||
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||||
from govoplan_core.server.fastapi import create_govoplan_app
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
from govoplan_core.server.credentials import router as credential_router
|
||||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import validate_no_route_collisions
|
from govoplan_core.server.route_validation import validate_no_route_collisions
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ def _server_api_router(server_config: GovoplanServerConfig, registry) -> APIRout
|
|||||||
for router in server_config.base_routers:
|
for router in server_config.base_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
api_router.include_router(create_platform_router(settings=server_config.settings))
|
api_router.include_router(create_platform_router(settings=server_config.settings))
|
||||||
|
api_router.include_router(credential_router)
|
||||||
for router in server_config.post_module_routers:
|
for router in server_config.post_module_routers:
|
||||||
api_router.include_router(router)
|
api_router.include_router(router)
|
||||||
for contribution in server_config.extra_routers:
|
for contribution in server_config.extra_routers:
|
||||||
|
|||||||
380
src/govoplan_core/server/credentials.py
Normal file
380
src/govoplan_core/server/credentials.py
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel, Field, SecretStr
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.security.credential_envelopes import (
|
||||||
|
CredentialEnvelope,
|
||||||
|
CredentialEnvelopeError,
|
||||||
|
create_credential_envelope,
|
||||||
|
credential_envelope_summary,
|
||||||
|
get_managed_credential_envelope,
|
||||||
|
list_managed_credential_envelopes,
|
||||||
|
normalize_credential_scope,
|
||||||
|
retire_credential_envelope,
|
||||||
|
update_credential_envelope,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CredentialScopeType = Literal["system", "tenant", "group", "user", "campaign"]
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str | None = None
|
||||||
|
scope_type: CredentialScopeType
|
||||||
|
scope_id: str | None = None
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
credential_kind: str
|
||||||
|
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secret_keys: list[str] = Field(default_factory=list)
|
||||||
|
secret_configured: bool = False
|
||||||
|
allowed_modules: list[str] = Field(default_factory=list)
|
||||||
|
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||||
|
inherit_to_lower_scopes: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
revision: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
deleted_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeListResponse(BaseModel):
|
||||||
|
credentials: list[CredentialEnvelopeResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeCreateRequest(BaseModel):
|
||||||
|
scope_type: CredentialScopeType = "tenant"
|
||||||
|
scope_id: str | None = Field(default=None, max_length=255)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
credential_kind: str = Field(default="username_password", max_length=40)
|
||||||
|
public_data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secret_data: dict[str, SecretStr] = Field(default_factory=dict)
|
||||||
|
allowed_modules: list[str] = Field(default_factory=list)
|
||||||
|
allowed_server_refs: list[str] = Field(default_factory=list)
|
||||||
|
inherit_to_lower_scopes: bool = False
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
credential_kind: str | None = Field(default=None, max_length=40)
|
||||||
|
public_data: dict[str, Any] | None = None
|
||||||
|
secret_data: dict[str, SecretStr] | None = None
|
||||||
|
clear_secret: bool = False
|
||||||
|
allowed_modules: list[str] | None = None
|
||||||
|
allowed_server_refs: list[str] | None = None
|
||||||
|
inherit_to_lower_scopes: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/credentials", tags=["credentials"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CredentialEnvelopeListResponse)
|
||||||
|
def list_credentials(
|
||||||
|
scope_type: CredentialScopeType = Query(default="tenant"),
|
||||||
|
scope_id: str | None = Query(default=None),
|
||||||
|
include_inactive: bool = Query(default=False),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CredentialEnvelopeListResponse:
|
||||||
|
tenant_id, normalized_scope_type, normalized_scope_id = _target_scope(
|
||||||
|
principal,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
write=False,
|
||||||
|
)
|
||||||
|
rows = list_managed_credential_envelopes(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
)
|
||||||
|
return CredentialEnvelopeListResponse(
|
||||||
|
credentials=[
|
||||||
|
_response(row)
|
||||||
|
for row in rows
|
||||||
|
if row.scope_type == normalized_scope_type
|
||||||
|
and row.scope_id == normalized_scope_id
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=CredentialEnvelopeResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_credential(
|
||||||
|
payload: CredentialEnvelopeCreateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CredentialEnvelopeResponse:
|
||||||
|
tenant_id, scope_type, scope_id = _target_scope(
|
||||||
|
principal,
|
||||||
|
scope_type=payload.scope_type,
|
||||||
|
scope_id=payload.scope_id,
|
||||||
|
write=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
row = create_credential_envelope(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
credential_kind=payload.credential_kind,
|
||||||
|
public_data=payload.public_data,
|
||||||
|
secret_data=_secret_values(payload.secret_data),
|
||||||
|
allowed_modules=payload.allowed_modules,
|
||||||
|
allowed_server_refs=payload.allowed_server_refs,
|
||||||
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
user_id=principal.membership_id,
|
||||||
|
metadata={"created_by_module": "core"},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return _response(row)
|
||||||
|
except CredentialEnvelopeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _credential_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
||||||
|
def update_credential(
|
||||||
|
credential_id: str,
|
||||||
|
payload: CredentialEnvelopeUpdateRequest,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CredentialEnvelopeResponse:
|
||||||
|
try:
|
||||||
|
row = _managed_row_for_write(session, principal, credential_id)
|
||||||
|
row = update_credential_envelope(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
description_supplied="description" in payload.model_fields_set,
|
||||||
|
credential_kind=payload.credential_kind,
|
||||||
|
public_data=payload.public_data,
|
||||||
|
secret_data=(
|
||||||
|
_secret_values(payload.secret_data)
|
||||||
|
if payload.secret_data is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
clear_secret=payload.clear_secret,
|
||||||
|
allowed_modules=payload.allowed_modules,
|
||||||
|
allowed_server_refs=payload.allowed_server_refs,
|
||||||
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||||
|
is_active=payload.is_active,
|
||||||
|
user_id=principal.membership_id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(row)
|
||||||
|
return _response(row)
|
||||||
|
except CredentialEnvelopeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _credential_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{credential_id}", response_model=CredentialEnvelopeResponse)
|
||||||
|
def delete_credential(
|
||||||
|
credential_id: str,
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> CredentialEnvelopeResponse:
|
||||||
|
try:
|
||||||
|
row = _managed_row_for_write(session, principal, credential_id)
|
||||||
|
retire_credential_envelope(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
user_id=principal.membership_id,
|
||||||
|
)
|
||||||
|
response = _response(row)
|
||||||
|
session.commit()
|
||||||
|
return response
|
||||||
|
except CredentialEnvelopeError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _credential_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_row_for_write(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
credential_id: str,
|
||||||
|
) -> CredentialEnvelope:
|
||||||
|
system_row = None
|
||||||
|
if _can_manage_system_credentials(principal):
|
||||||
|
try:
|
||||||
|
system_row = get_managed_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id=credential_id,
|
||||||
|
tenant_id=None,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
except CredentialEnvelopeError:
|
||||||
|
pass
|
||||||
|
row = system_row or get_managed_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id=credential_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
_target_scope(
|
||||||
|
principal,
|
||||||
|
scope_type=row.scope_type,
|
||||||
|
scope_id=row.scope_id,
|
||||||
|
write=True,
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _target_scope(
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
write: bool,
|
||||||
|
) -> tuple[str | None, str, str | None]:
|
||||||
|
requested_type = str(scope_type or "tenant").strip().casefold()
|
||||||
|
requested_id = str(scope_id).strip() if scope_id else None
|
||||||
|
if requested_type == "system":
|
||||||
|
if not (
|
||||||
|
_can_manage_system_credentials(principal)
|
||||||
|
if write
|
||||||
|
else _can_read_system_credentials(principal)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="System credential permission is required.",
|
||||||
|
)
|
||||||
|
return _normalize_target_scope(
|
||||||
|
tenant_id=None,
|
||||||
|
scope_type="system",
|
||||||
|
scope_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if requested_type == "user" and requested_id == principal.membership_id:
|
||||||
|
own_scope = (
|
||||||
|
has_scope(principal, "access:credential:manage_own")
|
||||||
|
or has_scope(principal, "mail:secret:manage_own")
|
||||||
|
)
|
||||||
|
if own_scope:
|
||||||
|
return _normalize_target_scope(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=requested_type,
|
||||||
|
scope_id=requested_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed = (
|
||||||
|
_can_manage_tenant_credentials(principal)
|
||||||
|
if write
|
||||||
|
else _can_read_tenant_credentials(principal)
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Tenant credential permission is required.",
|
||||||
|
)
|
||||||
|
return _normalize_target_scope(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
scope_type=requested_type,
|
||||||
|
scope_id=requested_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_target_scope(
|
||||||
|
*,
|
||||||
|
tenant_id: str | None,
|
||||||
|
scope_type: str,
|
||||||
|
scope_id: str | None,
|
||||||
|
) -> tuple[str | None, str, str | None]:
|
||||||
|
try:
|
||||||
|
return normalize_credential_scope(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
except CredentialEnvelopeError as exc:
|
||||||
|
raise _credential_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_system_credentials(principal: ApiPrincipal) -> bool:
|
||||||
|
return any(
|
||||||
|
has_scope(principal, scope)
|
||||||
|
for scope in (
|
||||||
|
"access:system_credential:read",
|
||||||
|
"access:system_setting:read",
|
||||||
|
"system:settings:read",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_manage_system_credentials(principal: ApiPrincipal) -> bool:
|
||||||
|
return any(
|
||||||
|
has_scope(principal, scope)
|
||||||
|
for scope in (
|
||||||
|
"access:system_credential:write",
|
||||||
|
"access:system_setting:write",
|
||||||
|
"system:settings:write",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_read_tenant_credentials(principal: ApiPrincipal) -> bool:
|
||||||
|
return any(
|
||||||
|
has_scope(principal, scope)
|
||||||
|
for scope in (
|
||||||
|
"access:credential:read",
|
||||||
|
"access:setting:read",
|
||||||
|
"admin:settings:read",
|
||||||
|
"mail_servers:read",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _can_manage_tenant_credentials(principal: ApiPrincipal) -> bool:
|
||||||
|
return any(
|
||||||
|
has_scope(principal, scope)
|
||||||
|
for scope in (
|
||||||
|
"access:credential:write",
|
||||||
|
"access:setting:write",
|
||||||
|
"admin:settings:write",
|
||||||
|
"mail_servers:manage_credentials",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_values(values: dict[str, SecretStr]) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
str(key): value.get_secret_value()
|
||||||
|
for key, value in values.items()
|
||||||
|
if str(key).strip() and value.get_secret_value()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _response(row: CredentialEnvelope) -> CredentialEnvelopeResponse:
|
||||||
|
return CredentialEnvelopeResponse.model_validate(
|
||||||
|
credential_envelope_summary(row)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_error(exc: CredentialEnvelopeError) -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -72,8 +72,19 @@ def _validate_production_startup() -> None:
|
|||||||
"off",
|
"off",
|
||||||
}
|
}
|
||||||
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
|
if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
|
||||||
|
allow_process_local = os.getenv(
|
||||||
|
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
|
||||||
|
"false",
|
||||||
|
).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
if not allow_process_local:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Production login throttling requires REDIS_URL. "
|
||||||
|
"Set GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true only "
|
||||||
|
"for an explicitly single-process deployment."
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Redis is not configured; login throttling is process-local and will not coordinate horizontally"
|
"Redis is not configured; login throttling is explicitly limited "
|
||||||
|
"to this process"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||||
|
from govoplan_core.core.views import (
|
||||||
|
VIEW_SURFACE_CONTRACT_VERSION,
|
||||||
|
ViewSurface,
|
||||||
|
navigation_view_surface_id,
|
||||||
|
route_view_surface_id,
|
||||||
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
from govoplan_core.i18n import system_i18n_payload
|
||||||
|
|
||||||
@@ -19,7 +26,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
|||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"path": item.path,
|
"path": item.path,
|
||||||
"label": item.label,
|
"label": item.label,
|
||||||
@@ -28,16 +35,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
|||||||
"required_all": list(item.required_all),
|
"required_all": list(item.required_all),
|
||||||
"required_any": list(item.required_any),
|
"required_any": list(item.required_any),
|
||||||
"order": item.order,
|
"order": item.order,
|
||||||
|
"surface_id": (
|
||||||
|
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||||
|
if module_id
|
||||||
|
else item.surface_id
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"path": route.path,
|
"path": route.path,
|
||||||
"component": route.component,
|
"component": route.component,
|
||||||
"required_all": list(route.required_all),
|
"required_all": list(route.required_all),
|
||||||
"required_any": list(route.required_any),
|
"required_any": list(route.required_any),
|
||||||
"order": route.order,
|
"order": route.order,
|
||||||
|
"surface_id": (
|
||||||
|
route.surface_id or route_view_surface_id(module_id, route.path)
|
||||||
|
if module_id
|
||||||
|
else route.surface_id
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +66,29 @@ def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, obje
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": surface.id,
|
||||||
|
"module_id": surface.module_id,
|
||||||
|
"kind": surface.kind,
|
||||||
|
"label": surface.label,
|
||||||
|
"parent_id": surface.parent_id,
|
||||||
|
"description": surface.description,
|
||||||
|
"order": surface.order,
|
||||||
|
"default_visible": surface.default_visible,
|
||||||
|
"required": surface.required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
_view_surface_payload(surface)
|
||||||
|
for surface in manifest_view_surfaces(manifest)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||||
|
frontend = manifest.frontend
|
||||||
if frontend is None:
|
if frontend is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -60,12 +99,14 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
|||||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
|
||||||
"public_routes": [
|
"public_routes": [
|
||||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
],
|
],
|
||||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
||||||
|
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
||||||
|
"view_surfaces": _frontend_view_surfaces(manifest),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -113,7 +154,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/modules")
|
@router.get("/modules")
|
||||||
def modules(request: Request):
|
def modules(
|
||||||
|
request: Request,
|
||||||
|
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
return {
|
return {
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -125,8 +169,8 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
"optional_dependencies": list(manifest.optional_dependencies),
|
"optional_dependencies": list(manifest.optional_dependencies),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||||
"frontend": _frontend_payload(manifest.frontend),
|
"frontend": _frontend_payload(manifest),
|
||||||
}
|
}
|
||||||
for manifest in registry.manifests()
|
for manifest in registry.manifests()
|
||||||
]
|
]
|
||||||
@@ -150,7 +194,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/permissions")
|
@router.get("/permissions")
|
||||||
def permissions(request: Request):
|
def permissions(
|
||||||
|
request: Request,
|
||||||
|
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
return {
|
return {
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -170,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/navigation")
|
@router.get("/navigation")
|
||||||
def navigation(request: Request):
|
def navigation(
|
||||||
|
request: Request,
|
||||||
|
_principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
return {
|
return {
|
||||||
"items": [_nav_item_payload(item) for item in registry.nav_items()]
|
"items": [_nav_item_payload(item) for item in registry.nav_items()]
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Iterable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
DEFAULT_CAPABILITY_PROVIDERS,
|
||||||
|
)
|
||||||
from govoplan_core.core.discovery import discover_module_manifests
|
from govoplan_core.core.discovery import discover_module_manifests
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
@@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = {
|
|||||||
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
|
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
items = [item.strip() for item in value.split(",")]
|
items = [item.strip() for item in value.split(",")]
|
||||||
@@ -57,22 +59,68 @@ def available_module_manifests(
|
|||||||
return manifests
|
return manifests
|
||||||
|
|
||||||
|
|
||||||
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
|
def build_platform_registry(
|
||||||
|
enabled_modules: str | Iterable[str],
|
||||||
|
*,
|
||||||
|
manifest_factories: Sequence[ManifestFactory] = (),
|
||||||
|
) -> PlatformRegistry:
|
||||||
requested = parse_enabled_modules(enabled_modules)
|
requested = parse_enabled_modules(enabled_modules)
|
||||||
if "access" not in requested:
|
available = _resolve_required_manifests(
|
||||||
requested.insert(0, "access")
|
requested,
|
||||||
|
manifest_factories=manifest_factories,
|
||||||
available = available_module_manifests(manifest_factories, enabled_modules=requested)
|
)
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
for module_id in requested:
|
for module_id, manifest in available.items():
|
||||||
manifest = available.get(module_id)
|
|
||||||
if manifest is None:
|
|
||||||
raise RegistryError(f"Enabled module is not available: {module_id}")
|
|
||||||
registry.register(manifest)
|
registry.register(manifest)
|
||||||
registry.validate()
|
registry.validate()
|
||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_required_manifests(
|
||||||
|
requested: Sequence[str],
|
||||||
|
*,
|
||||||
|
manifest_factories: Sequence[ManifestFactory],
|
||||||
|
) -> dict[str, ModuleManifest]:
|
||||||
|
manifests: dict[str, ModuleManifest] = {}
|
||||||
|
pending = list(dict.fromkeys(requested))
|
||||||
|
|
||||||
|
while pending:
|
||||||
|
module_ids = [module_id for module_id in pending if module_id not in manifests]
|
||||||
|
pending = []
|
||||||
|
if not module_ids:
|
||||||
|
break
|
||||||
|
|
||||||
|
available = available_module_manifests(
|
||||||
|
manifest_factories,
|
||||||
|
enabled_modules=module_ids,
|
||||||
|
)
|
||||||
|
for module_id in module_ids:
|
||||||
|
manifest = available.get(module_id)
|
||||||
|
if manifest is None:
|
||||||
|
raise RegistryError(f"Enabled module is not available: {module_id}")
|
||||||
|
manifests[module_id] = manifest
|
||||||
|
|
||||||
|
provided_capabilities = {
|
||||||
|
capability
|
||||||
|
for manifest in manifests.values()
|
||||||
|
for capability in manifest.capability_factories
|
||||||
|
}
|
||||||
|
for manifest in tuple(manifests.values()):
|
||||||
|
pending.extend(
|
||||||
|
dependency_id
|
||||||
|
for dependency_id in manifest.dependencies
|
||||||
|
if dependency_id not in manifests
|
||||||
|
)
|
||||||
|
for capability in manifest.required_capabilities:
|
||||||
|
if capability in provided_capabilities:
|
||||||
|
continue
|
||||||
|
provider_id = DEFAULT_CAPABILITY_PROVIDERS.get(capability)
|
||||||
|
if provider_id is not None and provider_id not in manifests:
|
||||||
|
pending.append(provider_id)
|
||||||
|
|
||||||
|
return manifests
|
||||||
|
|
||||||
|
|
||||||
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
def _load_builtin_manifest(module_id: str) -> ModuleManifest:
|
||||||
target = _BUILTIN_MANIFESTS[module_id]
|
target = _BUILTIN_MANIFESTS[module_id]
|
||||||
module_name, function_name = validate_object_path(target)
|
module_name, function_name = validate_object_path(target)
|
||||||
|
|||||||
@@ -6,18 +6,54 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
model_config = SettingsConfigDict(env_file=None, extra="ignore")
|
||||||
|
|
||||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||||
|
module_live_apply_enabled: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
|
||||||
|
)
|
||||||
|
|
||||||
database_url: str = Field(
|
database_url: str = Field(
|
||||||
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
|
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
|
||||||
alias="DATABASE_URL",
|
alias="DATABASE_URL",
|
||||||
)
|
)
|
||||||
|
database_pool_size: int = Field(
|
||||||
|
default=5,
|
||||||
|
alias="GOVOPLAN_DB_POOL_SIZE",
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
)
|
||||||
|
database_max_overflow: int = Field(
|
||||||
|
default=10,
|
||||||
|
alias="GOVOPLAN_DB_MAX_OVERFLOW",
|
||||||
|
ge=0,
|
||||||
|
le=200,
|
||||||
|
)
|
||||||
|
database_pool_timeout_seconds: int = Field(
|
||||||
|
default=30,
|
||||||
|
alias="GOVOPLAN_DB_POOL_TIMEOUT_SECONDS",
|
||||||
|
ge=1,
|
||||||
|
le=300,
|
||||||
|
)
|
||||||
|
database_pool_recycle_seconds: int = Field(
|
||||||
|
default=1800,
|
||||||
|
alias="GOVOPLAN_DB_POOL_RECYCLE_SECONDS",
|
||||||
|
ge=0,
|
||||||
|
le=86_400,
|
||||||
|
)
|
||||||
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
|
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
|
||||||
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
|
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
|
||||||
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
|
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
|
||||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,notifications,docs,ops", alias="ENABLED_MODULES")
|
enabled_modules: str = Field(
|
||||||
|
default=(
|
||||||
|
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
||||||
|
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
||||||
|
"datasources,dataflow,workflow,views,search,risk_compliance,"
|
||||||
|
"postbox,notifications,docs,ops"
|
||||||
|
),
|
||||||
|
alias="ENABLED_MODULES",
|
||||||
|
)
|
||||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||||
@@ -47,6 +83,33 @@ class Settings(BaseSettings):
|
|||||||
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
|
||||||
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
|
||||||
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
|
||||||
|
auth_activity_touch_interval_seconds: int = Field(
|
||||||
|
default=5 * 60,
|
||||||
|
ge=0,
|
||||||
|
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
|
||||||
|
)
|
||||||
|
auth_principal_cache_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
alias="AUTH_PRINCIPAL_CACHE_ENABLED",
|
||||||
|
)
|
||||||
|
auth_principal_cache_session_ttl_seconds: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=0,
|
||||||
|
le=300,
|
||||||
|
alias="AUTH_PRINCIPAL_CACHE_SESSION_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
auth_principal_cache_api_key_ttl_seconds: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=0,
|
||||||
|
le=300,
|
||||||
|
alias="AUTH_PRINCIPAL_CACHE_API_KEY_TTL_SECONDS",
|
||||||
|
)
|
||||||
|
auth_principal_cache_max_entries: int = Field(
|
||||||
|
default=2048,
|
||||||
|
ge=1,
|
||||||
|
le=100_000,
|
||||||
|
alias="AUTH_PRINCIPAL_CACHE_MAX_ENTRIES",
|
||||||
|
)
|
||||||
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
|
||||||
auth_login_throttle_identity_limit: int = Field(
|
auth_login_throttle_identity_limit: int = Field(
|
||||||
default=10,
|
default=10,
|
||||||
@@ -70,12 +133,29 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64")
|
||||||
celery_queues: str = Field(default="send_email,append_sent,notifications,calendar,default", alias="CELERY_QUEUES")
|
celery_queues: str = Field(
|
||||||
|
default=(
|
||||||
|
"send_email,append_sent,notifications,calendar,"
|
||||||
|
"dataflow,events,default"
|
||||||
|
),
|
||||||
|
alias="CELERY_QUEUES",
|
||||||
|
)
|
||||||
calendar_outbox_terminal_retention_days: int = Field(
|
calendar_outbox_terminal_retention_days: int = Field(
|
||||||
default=90,
|
default=90,
|
||||||
ge=0,
|
ge=0,
|
||||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||||
)
|
)
|
||||||
|
platform_event_outbox_max_attempts: int = Field(
|
||||||
|
default=8,
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
alias="PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS",
|
||||||
|
)
|
||||||
|
platform_event_outbox_terminal_retention_days: int = Field(
|
||||||
|
default=90,
|
||||||
|
ge=0,
|
||||||
|
alias="PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||||
|
)
|
||||||
scheduling_cancellation_notice_days: int = Field(
|
scheduling_cancellation_notice_days: int = Field(
|
||||||
default=30,
|
default=30,
|
||||||
ge=1,
|
ge=1,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -18,6 +20,16 @@ class EffectiveTenantGovernance:
|
|||||||
allow_api_keys: bool
|
allow_api_keys: bool
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class _AccessTenantCountsBatchProvider(Protocol):
|
||||||
|
def tenant_counts_many(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def _narrowing_bool(system_allows: bool, tenant_override: bool | None) -> bool:
|
def _narrowing_bool(system_allows: bool, tenant_override: bool | None) -> bool:
|
||||||
if not system_allows:
|
if not system_allows:
|
||||||
return False
|
return False
|
||||||
@@ -46,12 +58,24 @@ def assert_tenant_governance_override_allowed(session: Session, *, field: str, v
|
|||||||
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
|
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
|
||||||
|
|
||||||
|
|
||||||
def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]:
|
def _tenant_module_counts(
|
||||||
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
*,
|
||||||
|
module_ids: tuple[str, ...] | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
if registry is None or not hasattr(registry, "tenant_summary_providers"):
|
if registry is None or not hasattr(registry, "tenant_summary_providers"):
|
||||||
return {}
|
return {}
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for provider in registry.tenant_summary_providers().values():
|
providers = registry.tenant_summary_providers()
|
||||||
|
if module_ids is not None:
|
||||||
|
providers = {
|
||||||
|
module_id: provider
|
||||||
|
for module_id, provider in providers.items()
|
||||||
|
if module_id in module_ids
|
||||||
|
}
|
||||||
|
for provider in providers.values():
|
||||||
provided = provider(session, tenant_id)
|
provided = provider(session, tenant_id)
|
||||||
counts.update({str(key): int(value) for key, value in provided.items()})
|
counts.update({str(key): int(value) for key, value in provided.items()})
|
||||||
return counts
|
return counts
|
||||||
@@ -67,17 +91,130 @@ def _access_administration() -> AccessAdministration | None:
|
|||||||
return capability
|
return capability
|
||||||
|
|
||||||
|
|
||||||
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]:
|
def tenant_counts(
|
||||||
module_counts = _tenant_module_counts(session, tenant_id)
|
session: Session,
|
||||||
|
tenant_id: str,
|
||||||
|
*,
|
||||||
|
module_ids: tuple[str, ...] | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
module_counts = _tenant_module_counts(
|
||||||
|
session,
|
||||||
|
tenant_id,
|
||||||
|
module_ids=module_ids,
|
||||||
|
)
|
||||||
access_administration = _access_administration()
|
access_administration = _access_administration()
|
||||||
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
|
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
**module_counts,
|
||||||
"users": int(access_counts.get("users", 0)),
|
"users": int(access_counts.get("users", 0)),
|
||||||
"active_users": int(access_counts.get("active_users", 0)),
|
"active_users": int(access_counts.get("active_users", 0)),
|
||||||
"groups": int(access_counts.get("groups", 0)),
|
"groups": int(access_counts.get("groups", 0)),
|
||||||
"campaigns": module_counts.get("campaigns", 0),
|
"campaigns": int(module_counts.get("campaigns", 0)),
|
||||||
"files": module_counts.get("files", 0),
|
"files": int(module_counts.get("files", 0)),
|
||||||
"api_keys": int(access_counts.get("api_keys", 0)),
|
"api_keys": int(access_counts.get("api_keys", 0)),
|
||||||
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
|
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_counts_many(
|
||||||
|
session: Session,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
*,
|
||||||
|
module_ids: tuple[str, ...] | None = None,
|
||||||
|
) -> dict[str, dict[str, int]]:
|
||||||
|
"""Collect list-page summaries while preserving legacy provider support."""
|
||||||
|
|
||||||
|
normalized_ids = tuple(
|
||||||
|
dict.fromkeys(
|
||||||
|
str(tenant_id).strip()
|
||||||
|
for tenant_id in tenant_ids
|
||||||
|
if str(tenant_id).strip()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts_by_tenant: dict[str, dict[str, int]] = {
|
||||||
|
tenant_id: {} for tenant_id in normalized_ids
|
||||||
|
}
|
||||||
|
if not normalized_ids:
|
||||||
|
return counts_by_tenant
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is not None and hasattr(registry, "tenant_summary_providers"):
|
||||||
|
providers = dict(registry.tenant_summary_providers())
|
||||||
|
if module_ids is not None:
|
||||||
|
providers = {
|
||||||
|
module_id: provider
|
||||||
|
for module_id, provider in providers.items()
|
||||||
|
if module_id in module_ids
|
||||||
|
}
|
||||||
|
batch_providers = (
|
||||||
|
dict(registry.tenant_summary_batch_providers())
|
||||||
|
if hasattr(registry, "tenant_summary_batch_providers")
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
for module_id, provider in providers.items():
|
||||||
|
batch_provider = batch_providers.get(module_id)
|
||||||
|
if batch_provider is None:
|
||||||
|
provided_by_tenant = {
|
||||||
|
tenant_id: provider(session, tenant_id)
|
||||||
|
for tenant_id in normalized_ids
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
provided_by_tenant = batch_provider(session, normalized_ids)
|
||||||
|
_merge_tenant_counts(
|
||||||
|
counts_by_tenant,
|
||||||
|
provided_by_tenant,
|
||||||
|
tenant_ids=normalized_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
access_administration = _access_administration()
|
||||||
|
if isinstance(access_administration, _AccessTenantCountsBatchProvider):
|
||||||
|
access_counts = access_administration.tenant_counts_many(
|
||||||
|
session,
|
||||||
|
normalized_ids,
|
||||||
|
)
|
||||||
|
elif access_administration is not None:
|
||||||
|
access_counts = {
|
||||||
|
tenant_id: access_administration.tenant_counts(session, tenant_id)
|
||||||
|
for tenant_id in normalized_ids
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
access_counts = {}
|
||||||
|
_merge_tenant_counts(
|
||||||
|
counts_by_tenant,
|
||||||
|
access_counts,
|
||||||
|
tenant_ids=normalized_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenant_id: _normalized_tenant_counts(counts_by_tenant[tenant_id])
|
||||||
|
for tenant_id in normalized_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_tenant_counts(
|
||||||
|
target: dict[str, dict[str, int]],
|
||||||
|
provided_by_tenant: Mapping[str, Mapping[str, int]],
|
||||||
|
*,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> None:
|
||||||
|
for tenant_id in tenant_ids:
|
||||||
|
provided = provided_by_tenant.get(tenant_id, {})
|
||||||
|
target[tenant_id].update(
|
||||||
|
{str(key): int(value) for key, value in provided.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_tenant_counts(counts: Mapping[str, int]) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
**{str(key): int(value) for key, value in counts.items()},
|
||||||
|
"users": int(counts.get("users", 0)),
|
||||||
|
"active_users": int(counts.get("active_users", 0)),
|
||||||
|
"groups": int(counts.get("groups", 0)),
|
||||||
|
"campaigns": int(counts.get("campaigns", 0)),
|
||||||
|
"files": int(counts.get("files", 0)),
|
||||||
|
"api_keys": int(counts.get("api_keys", 0)),
|
||||||
|
"active_api_keys": int(
|
||||||
|
counts.get("active_api_keys", counts.get("api_keys", 0))
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from govoplan_core.core.access import (
|
|||||||
CAPABILITY_AUDIT_RECORDER,
|
CAPABILITY_AUDIT_RECORDER,
|
||||||
CAPABILITY_AUDIT_RETENTION,
|
CAPABILITY_AUDIT_RETENTION,
|
||||||
CAPABILITY_AUDIT_SINK,
|
CAPABILITY_AUDIT_SINK,
|
||||||
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||||
AccessDecision,
|
AccessDecision,
|
||||||
AccessAdministration,
|
AccessAdministration,
|
||||||
@@ -1082,16 +1083,62 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
def get_organization_function_assignment(self, requested_assignment_id: str):
|
def get_organization_function_assignment(self, requested_assignment_id: str):
|
||||||
return assignment if requested_assignment_id == assignment_id else None
|
return assignment if requested_assignment_id == assignment_id else None
|
||||||
|
|
||||||
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
|
def organization_function_assignments_for_identity(
|
||||||
|
self,
|
||||||
|
requested_identity_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
del effective_at
|
||||||
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
|
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
|
||||||
return (assignment,)
|
return (assignment,)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
|
def organization_function_assignments_for_account(
|
||||||
|
self,
|
||||||
|
requested_account_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
del effective_at
|
||||||
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
|
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
|
||||||
return (assignment,)
|
return (assignment,)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
def organization_function_assignments_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
item_id: self.organization_function_assignments_for_identity(
|
||||||
|
item_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
for item_id in identity_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
def organization_function_assignments_for_accounts(
|
||||||
|
self,
|
||||||
|
account_ids,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
item_id: self.organization_function_assignments_for_account(
|
||||||
|
item_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
for item_id in account_ids
|
||||||
|
}
|
||||||
|
|
||||||
class FakeOrganizationDirectory(CoreOrganizationDirectory):
|
class FakeOrganizationDirectory(CoreOrganizationDirectory):
|
||||||
def get_organization_unit(self, requested_organization_unit_id: str):
|
def get_organization_unit(self, requested_organization_unit_id: str):
|
||||||
if requested_organization_unit_id != organization_unit_id:
|
if requested_organization_unit_id != organization_unit_id:
|
||||||
@@ -1317,7 +1364,7 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
clear_runtime()
|
clear_runtime()
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
|
||||||
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
def test_api_principal_dependency_uses_auth_provider_capability(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
||||||
try:
|
try:
|
||||||
account_id = "account-capability"
|
account_id = "account-capability"
|
||||||
@@ -1346,6 +1393,31 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
|
||||||
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
|
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
|
||||||
|
|
||||||
|
class CapabilityApiPrincipalProvider:
|
||||||
|
def resolve_api_principal(
|
||||||
|
self,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
authorization=None,
|
||||||
|
x_api_key=None,
|
||||||
|
):
|
||||||
|
del authorization, x_api_key
|
||||||
|
principal_ref = CapabilityPrincipalResolver().resolve_request(
|
||||||
|
request,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
account = session.get(Account, principal_ref.account_id)
|
||||||
|
user = session.get(User, principal_ref.membership_id)
|
||||||
|
assert account is not None
|
||||||
|
assert user is not None
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=principal_ref,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
permission_evaluator=CapabilityPermissionEvaluator(),
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@router.get("/capability-principal")
|
@router.get("/capability-principal")
|
||||||
@@ -1365,6 +1437,7 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
|
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
|
||||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
|
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
|
||||||
|
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: lambda context: CapabilityApiPrincipalProvider(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1372,7 +1445,7 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
title="access contract test",
|
title="access contract test",
|
||||||
version="test",
|
version="test",
|
||||||
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
|
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
|
||||||
enabled_modules=(),
|
enabled_modules=(ACCESS_MODULE_ID,),
|
||||||
manifest_factories=(access_manifest,),
|
manifest_factories=(access_manifest,),
|
||||||
base_routers=(router,),
|
base_routers=(router,),
|
||||||
post_module_routers=(),
|
post_module_routers=(),
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ engine = _database.engine
|
|||||||
SessionLocal = _database.SessionLocal
|
SessionLocal = _database.SessionLocal
|
||||||
|
|
||||||
from govoplan_core.server.app import app
|
from govoplan_core.server.app import app
|
||||||
|
from govoplan_campaign.backend.sending.execution import SNAPSHOT_VERSION
|
||||||
|
|
||||||
|
|
||||||
class ApiSmokeTests(unittest.TestCase):
|
class ApiSmokeTests(unittest.TestCase):
|
||||||
@@ -764,7 +765,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
assert version is not None
|
assert version is not None
|
||||||
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
|
||||||
snapshot = version.execution_snapshot or {}
|
snapshot = version.execution_snapshot or {}
|
||||||
self.assertEqual(snapshot["snapshot_version"], "5")
|
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
|
||||||
self.assertEqual(snapshot["mail_profile_id"], profile_id)
|
self.assertEqual(snapshot["mail_profile_id"], profile_id)
|
||||||
self.assertNotIn("smtp", snapshot)
|
self.assertNotIn("smtp", snapshot)
|
||||||
self.assertNotIn("imap", snapshot)
|
self.assertNotIn("imap", snapshot)
|
||||||
@@ -774,8 +775,10 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
|
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
|
||||||
self.assertEqual(deleted.status_code, 200, deleted.text)
|
self.assertEqual(deleted.status_code, 200, deleted.text)
|
||||||
self.assertFalse(deleted.json()["is_active"])
|
self.assertFalse(deleted.json()["is_active"])
|
||||||
self.assertFalse(deleted.json()["smtp_password_configured"])
|
# Reusable hierarchy credentials outlive a deactivated profile. The
|
||||||
self.assertFalse(deleted.json()["imap_password_configured"])
|
# profile's retired legacy secret columns are still scrubbed below.
|
||||||
|
self.assertTrue(deleted.json()["smtp_password_configured"])
|
||||||
|
self.assertTrue(deleted.json()["imap_password_configured"])
|
||||||
|
|
||||||
from govoplan_audit.backend.db.models import AuditLog
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
|
|
||||||
@@ -864,7 +867,10 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(version)
|
self.assertIsNotNone(version)
|
||||||
assert version is not None
|
assert version is not None
|
||||||
snapshot_payload = version.execution_snapshot or {}
|
snapshot_payload = version.execution_snapshot or {}
|
||||||
self.assertEqual(snapshot_payload["snapshot_version"], "5")
|
self.assertEqual(
|
||||||
|
snapshot_payload["snapshot_version"],
|
||||||
|
SNAPSHOT_VERSION,
|
||||||
|
)
|
||||||
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
|
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
|
||||||
self.assertNotIn("smtp", snapshot_payload)
|
self.assertNotIn("smtp", snapshot_payload)
|
||||||
self.assertNotIn("imap", snapshot_payload)
|
self.assertNotIn("imap", snapshot_payload)
|
||||||
@@ -2945,7 +2951,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
delta_payload["jobs"][0]["last_error"],
|
delta_payload["jobs"][0]["last_error"],
|
||||||
"SMTP delivery outcome requires operator reconciliation.",
|
"Delivery outcome requires operator reconciliation.",
|
||||||
)
|
)
|
||||||
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
|
||||||
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
|
||||||
@@ -4172,7 +4178,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
|
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
|
||||||
from govoplan_mail.backend.db.models import MailServerProfile
|
from govoplan_mail.backend.db.models import MailServerEndpoint, MailServerProfile
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
version = session.get(CampaignVersion, version_id)
|
version = session.get(CampaignVersion, version_id)
|
||||||
@@ -4180,7 +4186,7 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
assert version is not None
|
assert version is not None
|
||||||
snapshot = version.execution_snapshot
|
snapshot = version.execution_snapshot
|
||||||
self.assertIsInstance(snapshot, dict)
|
self.assertIsInstance(snapshot, dict)
|
||||||
self.assertEqual(snapshot["snapshot_version"], "5")
|
self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
|
||||||
self.assertEqual(snapshot["job_count"], 1)
|
self.assertEqual(snapshot["job_count"], 1)
|
||||||
self.assertEqual(snapshot["queueable_job_count"], 1)
|
self.assertEqual(snapshot["queueable_job_count"], 1)
|
||||||
self.assertTrue(snapshot["job_manifest_sha256"])
|
self.assertTrue(snapshot["job_manifest_sha256"])
|
||||||
@@ -4195,9 +4201,17 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
|
profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
|
||||||
self.assertIsNotNone(profile)
|
self.assertIsNotNone(profile)
|
||||||
assert profile is not None
|
assert profile is not None
|
||||||
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"}
|
smtp_server_id = snapshot.get("smtp_server_id")
|
||||||
profile.smtp_transport_revision = "manually-rotated-transport-revision"
|
self.assertTrue(smtp_server_id)
|
||||||
session.add(profile)
|
smtp_server = session.get(MailServerEndpoint, smtp_server_id)
|
||||||
|
self.assertIsNotNone(smtp_server)
|
||||||
|
assert smtp_server is not None
|
||||||
|
smtp_server.config = {
|
||||||
|
**smtp_server.config,
|
||||||
|
"host": "smtp.example.invalid",
|
||||||
|
}
|
||||||
|
smtp_server.transport_revision = "manually-rotated-transport-revision"
|
||||||
|
session.add(smtp_server)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
sent = self.client.post(
|
sent = self.client.post(
|
||||||
@@ -4211,18 +4225,17 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"enqueue_imap_task": False,
|
"enqueue_imap_task": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(sent.status_code, 200, sent.text)
|
self.assertEqual(sent.status_code, 422, sent.text)
|
||||||
self.assertEqual(sent.json()["result"]["sent_count"], 0)
|
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
|
||||||
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
|
||||||
self.assertIn("changed after this campaign was built", sent.json()["result"]["results"][0]["message"])
|
|
||||||
|
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
self.assertEqual(job.send_status, "failed_permanent")
|
self.assertEqual(job.queue_status, "draft")
|
||||||
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
|
self.assertEqual(job.send_status, "not_queued")
|
||||||
self.assertIn("changed after this campaign was built", attempt.error_message)
|
self.assertEqual(
|
||||||
self.assertNotIn("smtp.example.invalid", attempt.error_message)
|
session.query(SendAttempt).filter(SendAttempt.job_id == job.id).count(),
|
||||||
self.assertIsNone(attempt.smtp_response)
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
def test_send_now_sends_exact_generated_eml_bytes(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
@@ -4286,11 +4299,15 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
"enqueue_imap_task": False,
|
"enqueue_imap_task": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(sent.status_code, 200, sent.text)
|
self.assertEqual(sent.status_code, 422, sent.text)
|
||||||
self.assertEqual(sent.json()["result"]["failed_count"], 1)
|
self.assertIn("Synchronous preflight stopped before contacting SMTP", sent.json()["detail"])
|
||||||
self.assertIn("Generated EML", sent.json()["result"]["results"][0]["message"])
|
|
||||||
self.assertEqual(list_records(kind="smtp"), [])
|
self.assertEqual(list_records(kind="smtp"), [])
|
||||||
|
|
||||||
|
with SessionLocal() as session:
|
||||||
|
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
|
||||||
|
self.assertEqual(job.queue_status, "draft")
|
||||||
|
self.assertEqual(job.send_status, "not_queued")
|
||||||
|
|
||||||
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
def test_partial_smtp_recipient_refusal_is_recorded_without_retrying_accepted_delivery(self) -> None:
|
||||||
headers, _ = self._login()
|
headers, _ = self._login()
|
||||||
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
from govoplan_mail.backend.dev.mock_mailbox import set_failures
|
||||||
@@ -4349,7 +4366,8 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
self.assertEqual(sent.status_code, 200, sent.text)
|
self.assertEqual(sent.status_code, 200, sent.text)
|
||||||
result = sent.json()["result"]
|
result = sent.json()["result"]
|
||||||
self.assertEqual(result["sent_count"], 1, sent.text)
|
self.assertEqual(result["sent_count"], 1, sent.text)
|
||||||
self.assertIn("refused recipients", result["results"][0]["message"])
|
self.assertEqual(result["results"][0]["status"], "smtp_accepted")
|
||||||
|
self.assertNotIn("message", result["results"][0])
|
||||||
finally:
|
finally:
|
||||||
set_failures(smtp_reject_recipients_containing=None)
|
set_failures(smtp_reject_recipients_containing=None)
|
||||||
|
|
||||||
|
|||||||
96
tests/test_automation_contract.py
Normal file
96
tests/test_automation_contract.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.automation import (
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
|
AutomationInvocation,
|
||||||
|
AutomationPrincipalProvider,
|
||||||
|
AutomationPrincipalRequest,
|
||||||
|
AutomationPrincipalResolution,
|
||||||
|
automation_principal_provider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def resolve_automation_principal(self, session, *, request):
|
||||||
|
del session
|
||||||
|
return AutomationPrincipalResolution(
|
||||||
|
allowed=True,
|
||||||
|
principal={"account_id": request.account_id},
|
||||||
|
granted_scopes=request.grant_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationContractTests(unittest.TestCase):
|
||||||
|
def test_principal_request_has_explicit_subject_contracts(self) -> None:
|
||||||
|
service = AutomationPrincipalRequest.service_account(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
service_account_id="service-1",
|
||||||
|
authorization_ref="trigger:1",
|
||||||
|
grant_scopes=("dataflow:pipeline:run",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("service_account", service.subject_kind)
|
||||||
|
self.assertEqual("service-1", service.service_account_id)
|
||||||
|
self.assertIsNone(service.account_id)
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"Delegated-user automation",
|
||||||
|
):
|
||||||
|
AutomationPrincipalRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
authorization_ref="trigger:1",
|
||||||
|
grant_scopes=("dataflow:pipeline:run",),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invocation_records_stable_trigger_provenance(self) -> None:
|
||||||
|
invocation = AutomationInvocation(
|
||||||
|
kind="event",
|
||||||
|
trigger_ref="dataflow-trigger:1",
|
||||||
|
event_id="event-1",
|
||||||
|
event_type="files.uploaded",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("event", invocation.kind)
|
||||||
|
self.assertEqual("event-1", invocation.event_id)
|
||||||
|
|
||||||
|
def test_principal_provider_is_runtime_resolved(self) -> None:
|
||||||
|
provider = _Provider()
|
||||||
|
self.assertIsInstance(provider, AutomationPrincipalProvider)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="automation_contract_test",
|
||||||
|
name="Automation contract test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
|
||||||
|
lambda context: provider
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(provider, automation_principal_provider(registry))
|
||||||
|
result = provider.resolve_automation_principal(
|
||||||
|
object(),
|
||||||
|
request=AutomationPrincipalRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
authorization_ref="trigger:1",
|
||||||
|
grant_scopes=("dataflow:pipeline:run",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(result.allowed)
|
||||||
|
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import (
|
|||||||
sequence_watermark_is_expired,
|
sequence_watermark_is_expired,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
|
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
|
||||||
|
from govoplan_core.core.runtime import (
|
||||||
|
clear_runtime,
|
||||||
|
configure_runtime,
|
||||||
|
get_runtime_context,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
class ChangeSequenceTests(unittest.TestCase):
|
class ChangeSequenceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self._runtime_context = get_runtime_context()
|
||||||
|
clear_runtime()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
if self._runtime_context is not None:
|
||||||
|
configure_runtime(self._runtime_context)
|
||||||
|
else:
|
||||||
|
clear_runtime()
|
||||||
|
|
||||||
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
|
def test_records_changes_and_returns_entries_after_watermark(self) -> None:
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
SessionLocal = sessionmaker(bind=engine)
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
@@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase):
|
|||||||
actor_id="user-1",
|
actor_id="user-1",
|
||||||
payload={"scope_type": "tenant"},
|
payload={"scope_type": "tenant"},
|
||||||
)
|
)
|
||||||
|
self.assertEqual([], seen)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
|
self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
|
||||||
event = seen[1]
|
event = seen[1]
|
||||||
@@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_record_change_discards_event_when_transaction_rolls_back(self) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
seen: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", seen.append)
|
||||||
|
with SessionLocal() as session, event_bus_context(bus):
|
||||||
|
record_change(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="files",
|
||||||
|
collection="files.assets",
|
||||||
|
resource_type="file",
|
||||||
|
resource_id="file-1",
|
||||||
|
operation="created",
|
||||||
|
)
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
self.assertEqual([], seen)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
|
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
|
||||||
engine = create_engine("sqlite:///:memory:")
|
engine = create_engine("sqlite:///:memory:")
|
||||||
SessionLocal = sessionmaker(bind=engine)
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Response
|
|||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from govoplan_core.auth import get_api_principal
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.server.fastapi import create_govoplan_app
|
from govoplan_core.server.fastapi import create_govoplan_app
|
||||||
from govoplan_core.server.platform import create_platform_router
|
from govoplan_core.server.platform import create_platform_router
|
||||||
@@ -85,6 +86,7 @@ class ConditionalRequestTests(unittest.TestCase):
|
|||||||
registry=PlatformRegistry(),
|
registry=PlatformRegistry(),
|
||||||
api_router=api_router,
|
api_router=api_router,
|
||||||
)
|
)
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: object()
|
||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
for path in (
|
for path in (
|
||||||
|
|||||||
@@ -8,15 +8,19 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
from govoplan_core.audit.logging import audit_event, audit_operation_context
|
||||||
from govoplan_core.core.events import (
|
from govoplan_core.core.events import (
|
||||||
|
DurableEventConsumer,
|
||||||
EventActorRef,
|
EventActorRef,
|
||||||
EventBus,
|
EventBus,
|
||||||
EventObjectRef,
|
EventObjectRef,
|
||||||
EventTenantRef,
|
EventTenantRef,
|
||||||
PlatformEvent,
|
PlatformEvent,
|
||||||
current_event_trace,
|
current_event_trace,
|
||||||
|
emit_platform_event,
|
||||||
event_bus_context,
|
event_bus_context,
|
||||||
event_context,
|
event_context,
|
||||||
normalize_trace_id,
|
normalize_trace_id,
|
||||||
@@ -31,13 +35,96 @@ from tests.db_isolation import temporary_database
|
|||||||
|
|
||||||
|
|
||||||
def _configure_audit_runtime() -> None:
|
def _configure_audit_runtime() -> None:
|
||||||
registry = build_platform_registry(("audit",))
|
registry = build_platform_registry(("access", "audit"))
|
||||||
context = ModuleContext(registry=registry, settings=object())
|
context = ModuleContext(registry=registry, settings=object())
|
||||||
registry.configure_capability_context(context)
|
registry.configure_capability_context(context)
|
||||||
configure_runtime(context)
|
configure_runtime(context)
|
||||||
|
|
||||||
|
|
||||||
class CoreEventTests(unittest.TestCase):
|
class CoreEventTests(unittest.TestCase):
|
||||||
|
def test_durable_consumer_requires_policy_for_classified_events(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "policy decision"):
|
||||||
|
DurableEventConsumer(
|
||||||
|
consumer_id="workflow.triggers.v1",
|
||||||
|
classifications=frozenset({"restricted"}),
|
||||||
|
handler=lambda _event, _delivery_key: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
consumer = DurableEventConsumer(
|
||||||
|
consumer_id="workflow.triggers.v1",
|
||||||
|
event_types=frozenset({"case.changed"}),
|
||||||
|
classifications=frozenset(
|
||||||
|
{"internal", "confidential"}
|
||||||
|
),
|
||||||
|
policy_decision_ref="policy:decision:1",
|
||||||
|
handler=lambda _event, _delivery_key: None,
|
||||||
|
)
|
||||||
|
accepted = PlatformEvent(
|
||||||
|
type="case.changed",
|
||||||
|
module_id="cases",
|
||||||
|
classification="confidential",
|
||||||
|
event_id="event-1",
|
||||||
|
)
|
||||||
|
rejected = PlatformEvent(
|
||||||
|
type="case.deleted",
|
||||||
|
module_id="cases",
|
||||||
|
classification="confidential",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(consumer.accepts(accepted))
|
||||||
|
self.assertFalse(consumer.accepts(rejected))
|
||||||
|
self.assertEqual(
|
||||||
|
"event-1:workflow.triggers.v1",
|
||||||
|
consumer.delivery_key(accepted),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
seen: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", seen.append)
|
||||||
|
try:
|
||||||
|
with Session(engine) as session, event_bus_context(bus):
|
||||||
|
with session.begin():
|
||||||
|
with session.begin_nested():
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(type="nested.created", module_id="core"),
|
||||||
|
)
|
||||||
|
self.assertEqual([], seen)
|
||||||
|
self.assertEqual(
|
||||||
|
["nested.created"],
|
||||||
|
[event.type for event in seen],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_fallback_event_discards_only_rolled_back_savepoint(self) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
seen: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("*", seen.append)
|
||||||
|
try:
|
||||||
|
with Session(engine) as session, event_bus_context(bus):
|
||||||
|
with session.begin():
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(type="outer.created", module_id="core"),
|
||||||
|
)
|
||||||
|
savepoint = session.begin_nested()
|
||||||
|
emit_platform_event(
|
||||||
|
session,
|
||||||
|
PlatformEvent(type="nested.created", module_id="core"),
|
||||||
|
)
|
||||||
|
savepoint.rollback()
|
||||||
|
self.assertEqual([], seen)
|
||||||
|
self.assertEqual(
|
||||||
|
["outer.created"],
|
||||||
|
[event.type for event in seen],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
|
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
|
||||||
bus = EventBus()
|
bus = EventBus()
|
||||||
seen: list[PlatformEvent] = []
|
seen: list[PlatformEvent] = []
|
||||||
@@ -267,6 +354,11 @@ class CoreEventTests(unittest.TestCase):
|
|||||||
object_id="user-2",
|
object_id="user-2",
|
||||||
details={"password": "secret", "field": "display_name"},
|
details={"password": "secret", "field": "display_name"},
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
||||||
|
|
||||||
|
SqlAuditOutbox().dispatch_pending(session)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
action_events = [event for event in seen if event.type == "user.updated"]
|
action_events = [event for event in seen if event.type == "user.updated"]
|
||||||
self.assertEqual(1, len(action_events))
|
self.assertEqual(1, len(action_events))
|
||||||
@@ -315,6 +407,11 @@ class CoreEventTests(unittest.TestCase):
|
|||||||
object_type="demo",
|
object_type="demo",
|
||||||
object_id=action,
|
object_id=action,
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
from govoplan_audit.backend.outbox import SqlAuditOutbox
|
||||||
|
|
||||||
|
SqlAuditOutbox().dispatch_pending(session)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
by_type = {event.type: event.module_id for event in seen if event.type in cases}
|
by_type = {event.type: event.module_id for event in seen if event.type in cases}
|
||||||
self.assertEqual(cases, by_type)
|
self.assertEqual(cases, by_type)
|
||||||
|
|||||||
245
tests/test_credential_envelopes.py
Normal file
245
tests/test_credential_envelopes.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.security.credential_envelopes import (
|
||||||
|
CredentialAccessContext,
|
||||||
|
CredentialEnvelope,
|
||||||
|
CredentialEnvelopeError,
|
||||||
|
create_credential_envelope,
|
||||||
|
credential_envelope_summary,
|
||||||
|
credential_visible_to_context,
|
||||||
|
list_managed_credential_envelopes,
|
||||||
|
resolve_credential_envelope,
|
||||||
|
update_credential_envelope,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.secrets import encrypt_secret
|
||||||
|
from govoplan_core.server.credentials import _target_scope
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEnvelopeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
ChangeSequenceEntry.__table__.create(self.engine)
|
||||||
|
CredentialEnvelope.__table__.create(self.engine)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_inherited_credential_is_filtered_by_module_and_server(self) -> None:
|
||||||
|
row = CredentialEnvelope(
|
||||||
|
id="credential-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Shared account",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={"username": "service@example.org"},
|
||||||
|
secret_data_encrypted=encrypt_secret(json.dumps({"password": "not-returned"})),
|
||||||
|
secret_keys=["password"],
|
||||||
|
allowed_modules=["mail"],
|
||||||
|
allowed_server_refs=["mail:server-1"],
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
is_active=True,
|
||||||
|
revision="revision-1",
|
||||||
|
)
|
||||||
|
allowed = CredentialAccessContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
target_scope_type="user",
|
||||||
|
target_scope_id="user-1",
|
||||||
|
module_id="mail",
|
||||||
|
server_ref="mail:server-1",
|
||||||
|
)
|
||||||
|
wrong_module = CredentialAccessContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
target_scope_type="user",
|
||||||
|
target_scope_id="user-1",
|
||||||
|
module_id="calendar",
|
||||||
|
server_ref="mail:server-1",
|
||||||
|
)
|
||||||
|
wrong_server = CredentialAccessContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
target_scope_type="user",
|
||||||
|
target_scope_id="user-1",
|
||||||
|
module_id="mail",
|
||||||
|
server_ref="mail:server-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(credential_visible_to_context(row, allowed))
|
||||||
|
self.assertFalse(credential_visible_to_context(row, wrong_module))
|
||||||
|
self.assertFalse(credential_visible_to_context(row, wrong_server))
|
||||||
|
self.assertNotIn("secret_data_encrypted", credential_envelope_summary(row))
|
||||||
|
|
||||||
|
def test_resolution_returns_secret_only_after_access_check(self) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add(
|
||||||
|
CredentialEnvelope(
|
||||||
|
id="credential-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Shared account",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={"username": "service@example.org"},
|
||||||
|
secret_data_encrypted=encrypt_secret(json.dumps({"password": "secret"})),
|
||||||
|
secret_keys=["password"],
|
||||||
|
allowed_modules=["mail"],
|
||||||
|
allowed_server_refs=[],
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
is_active=True,
|
||||||
|
revision="revision-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resolved = resolve_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id="credential-1",
|
||||||
|
context=CredentialAccessContext(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
target_scope_type="user",
|
||||||
|
target_scope_id="user-1",
|
||||||
|
module_id="mail",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(resolved.public_data["username"], "service@example.org")
|
||||||
|
self.assertEqual(resolved.secret_data["password"], "secret")
|
||||||
|
|
||||||
|
with self.assertRaises(CredentialEnvelopeError):
|
||||||
|
resolve_credential_envelope(
|
||||||
|
session,
|
||||||
|
credential_id="credential-1",
|
||||||
|
context=CredentialAccessContext(
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
user_id="user-2",
|
||||||
|
target_scope_type="user",
|
||||||
|
target_scope_id="user-2",
|
||||||
|
module_id="mail",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_management_listing_stays_in_tenant_but_ignores_use_site_limits(self) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
CredentialEnvelope(
|
||||||
|
id="tenant-credential",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Tenant restricted",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={},
|
||||||
|
secret_keys=[],
|
||||||
|
allowed_modules=["calendar"],
|
||||||
|
allowed_server_refs=["calendar:source-1"],
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
is_active=True,
|
||||||
|
revision="revision-1",
|
||||||
|
),
|
||||||
|
CredentialEnvelope(
|
||||||
|
id="other-credential",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-2",
|
||||||
|
name="Other tenant",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={},
|
||||||
|
secret_keys=[],
|
||||||
|
allowed_modules=[],
|
||||||
|
allowed_server_refs=[],
|
||||||
|
inherit_to_lower_scopes=False,
|
||||||
|
is_active=True,
|
||||||
|
revision="revision-2",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
rows = list_managed_credential_envelopes(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([row.id for row in rows], ["tenant-credential"])
|
||||||
|
|
||||||
|
def test_tenant_credential_permission_cannot_manage_system_scope(self) -> None:
|
||||||
|
tenant_principal = _principal({"access:credential:write"})
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
_target_scope(
|
||||||
|
tenant_principal,
|
||||||
|
scope_type="system",
|
||||||
|
scope_id=None,
|
||||||
|
write=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
|
||||||
|
system_principal = _principal({"access:system_credential:write"})
|
||||||
|
self.assertEqual(
|
||||||
|
_target_scope(
|
||||||
|
system_principal,
|
||||||
|
scope_type="system",
|
||||||
|
scope_id=None,
|
||||||
|
write=True,
|
||||||
|
),
|
||||||
|
(None, "system", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_data_rejects_secrets_and_kind_changes_require_secret_decision(self) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
with self.assertRaisesRegex(CredentialEnvelopeError, "public_data"):
|
||||||
|
create_credential_envelope(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Unsafe",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={"nested": [{"clientSecret": "not-public"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
row = create_credential_envelope(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id="tenant-1",
|
||||||
|
name="Safe",
|
||||||
|
credential_kind="username_password",
|
||||||
|
public_data={"username": "service@example.org"},
|
||||||
|
secret_data={"password": "secret"},
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CredentialEnvelopeError, "requires replacing"):
|
||||||
|
update_credential_envelope(
|
||||||
|
session,
|
||||||
|
row,
|
||||||
|
credential_kind="token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -131,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
|
||||||
database = Path(directory) / "core.db"
|
database = Path(directory) / "core.db"
|
||||||
url = f"sqlite:///{database}"
|
url = f"sqlite:///{database}"
|
||||||
|
enabled_modules = ("access",)
|
||||||
|
|
||||||
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads")
|
command.upgrade(
|
||||||
|
alembic_config(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=enabled_modules,
|
||||||
|
),
|
||||||
|
"heads",
|
||||||
|
)
|
||||||
|
|
||||||
engine = create_engine(url)
|
engine = create_engine(url)
|
||||||
try:
|
try:
|
||||||
@@ -157,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
).scalar_one()
|
).scalar_one()
|
||||||
current = database_migration_heads(connection)
|
current = database_migration_heads(connection)
|
||||||
|
|
||||||
self.assertEqual(current, configured_migration_heads(url, enabled_modules=()))
|
self.assertEqual(
|
||||||
|
current,
|
||||||
|
configured_migration_heads(
|
||||||
|
url,
|
||||||
|
enabled_modules=enabled_modules,
|
||||||
|
),
|
||||||
|
)
|
||||||
self.assertIn("core_scopes", tables)
|
self.assertIn("core_scopes", tables)
|
||||||
self.assertNotIn("tenancy_tenants", tables)
|
self.assertNotIn("tenancy_tenants", tables)
|
||||||
self.assertIn("access_accounts", tables)
|
self.assertIn("access_accounts", tables)
|
||||||
@@ -232,7 +245,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
|
self.assertEqual(current, configured_migration_heads(url, migration_track="dev"))
|
||||||
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
self.assertEqual(result.current_revision, ",".join(sorted(current)))
|
||||||
self.assertIn("0f1e2d3c4b5a", current)
|
self.assertIn("core_credential_envelopes", tables)
|
||||||
self.assertIn("audit_outbox_events", tables)
|
self.assertIn("audit_outbox_events", tables)
|
||||||
self.assertIn("calendar_outbox_operations", tables)
|
self.assertIn("calendar_outbox_operations", tables)
|
||||||
self.assertIn("file_connector_profiles", tables)
|
self.assertIn("file_connector_profiles", tables)
|
||||||
|
|||||||
99
tests/test_dataflow_contract.py
Normal file
99
tests/test_dataflow_contract.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.dataflows import (
|
||||||
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||||
|
CAPABILITY_DATAFLOW_RUN_WORKER,
|
||||||
|
DataflowRunDescriptor,
|
||||||
|
DataflowRunLifecycleProvider,
|
||||||
|
DataflowRunWorker,
|
||||||
|
dataflow_run_lifecycle,
|
||||||
|
dataflow_run_worker,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def start_run(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
return DataflowRunDescriptor(
|
||||||
|
ref="dataflow-run:1",
|
||||||
|
pipeline_ref=request.pipeline_ref,
|
||||||
|
revision=request.revision,
|
||||||
|
status="succeeded",
|
||||||
|
definition_hash="abc",
|
||||||
|
executor_version="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_run(self, session, principal, *, run_ref):
|
||||||
|
del session, principal
|
||||||
|
if run_ref != "dataflow-run:1":
|
||||||
|
return None
|
||||||
|
return DataflowRunDescriptor(
|
||||||
|
ref=run_ref,
|
||||||
|
pipeline_ref="pipeline:1",
|
||||||
|
revision=1,
|
||||||
|
status="succeeded",
|
||||||
|
definition_hash="abc",
|
||||||
|
executor_version="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel_run(self, session, principal, *, run_ref):
|
||||||
|
del session, principal
|
||||||
|
return DataflowRunDescriptor(
|
||||||
|
ref=run_ref,
|
||||||
|
pipeline_ref="pipeline:1",
|
||||||
|
revision=1,
|
||||||
|
status="cancelled",
|
||||||
|
definition_hash="abc",
|
||||||
|
executor_version="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Worker:
|
||||||
|
def dispatch_pending(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
now=None,
|
||||||
|
limit=10,
|
||||||
|
worker_id=None,
|
||||||
|
):
|
||||||
|
return {"claimed": 0}
|
||||||
|
|
||||||
|
def purge_expired(self, session, *, now=None, limit=500):
|
||||||
|
return {"purged": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowContractTests(unittest.TestCase):
|
||||||
|
def test_run_lifecycle_is_runtime_checkable_and_resolved(self) -> None:
|
||||||
|
provider = _Provider()
|
||||||
|
worker = _Worker()
|
||||||
|
self.assertIsInstance(provider, DataflowRunLifecycleProvider)
|
||||||
|
self.assertIsInstance(worker, DataflowRunWorker)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="dataflow_contract_test",
|
||||||
|
name="Dataflow contract test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_DATAFLOW_RUN_LIFECYCLE: lambda context: provider,
|
||||||
|
CAPABILITY_DATAFLOW_RUN_WORKER: lambda context: worker,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(provider, dataflow_run_lifecycle(registry))
|
||||||
|
self.assertIs(worker, dataflow_run_worker(registry))
|
||||||
|
self.assertIsNone(dataflow_run_lifecycle(PlatformRegistry()))
|
||||||
|
self.assertIsNone(dataflow_run_worker(PlatformRegistry()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
91
tests/test_dataflow_run_worker.py
Normal file
91
tests/test_dataflow_run_worker.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import (
|
||||||
|
celery,
|
||||||
|
dispatch_dataflow_runs,
|
||||||
|
purge_dataflow_runs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowRunWorkerTests(unittest.TestCase):
|
||||||
|
def test_dispatch_commits_worker_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.dispatch_pending.return_value = {
|
||||||
|
"claimed": 1,
|
||||||
|
"succeeded": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._dataflow_run_worker",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = dispatch_dataflow_runs.run(7)
|
||||||
|
|
||||||
|
provider.dispatch_pending.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
limit=7,
|
||||||
|
worker_id=ANY,
|
||||||
|
)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(1, result["succeeded"])
|
||||||
|
|
||||||
|
def test_retention_commits_worker_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.purge_expired.return_value = {"purged": 2}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._dataflow_run_worker",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = purge_dataflow_runs.run(25)
|
||||||
|
|
||||||
|
provider.purge_expired.assert_called_once_with(session, limit=25)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(2, result["purged"])
|
||||||
|
|
||||||
|
def test_routes_and_periodic_jobs_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
celery.conf.task_routes["govoplan.dataflow.dispatch_runs"],
|
||||||
|
{"queue": "dataflow"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
celery.conf.task_routes["govoplan.dataflow.purge_runs"],
|
||||||
|
{"queue": "dataflow"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"govoplan.dataflow.dispatch_runs",
|
||||||
|
celery.conf.beat_schedule[
|
||||||
|
"dataflow-runs-every-five-seconds"
|
||||||
|
]["task"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"govoplan.dataflow.purge_runs",
|
||||||
|
celery.conf.beat_schedule[
|
||||||
|
"dataflow-run-retention-daily"
|
||||||
|
]["task"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
56
tests/test_dataflow_trigger_worker.py
Normal file
56
tests/test_dataflow_trigger_worker.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import celery, dispatch_dataflow_triggers
|
||||||
|
|
||||||
|
|
||||||
|
class DataflowTriggerWorkerTests(unittest.TestCase):
|
||||||
|
def test_worker_commits_dispatch_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.dispatch_due.return_value = {
|
||||||
|
"queued": 1,
|
||||||
|
"processed": 1,
|
||||||
|
"succeeded": 1,
|
||||||
|
"failed": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = dispatch_dataflow_triggers.run(25)
|
||||||
|
|
||||||
|
provider.dispatch_due.assert_called_once_with(session, limit=25)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(result["succeeded"], 1)
|
||||||
|
|
||||||
|
def test_worker_route_and_periodic_dispatch_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
celery.conf.task_routes["govoplan.dataflow.dispatch_triggers"],
|
||||||
|
{"queue": "dataflow"},
|
||||||
|
)
|
||||||
|
schedule = celery.conf.beat_schedule[
|
||||||
|
"dataflow-triggers-every-minute"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
schedule["task"],
|
||||||
|
"govoplan.dataflow.dispatch_triggers",
|
||||||
|
)
|
||||||
|
self.assertEqual(schedule["schedule"], 60.0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
186
tests/test_datasource_contract.py
Normal file
186
tests/test_datasource_contract.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.datasources import (
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE,
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE,
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS,
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION,
|
||||||
|
DatasourceCatalogueProvider,
|
||||||
|
DatasourceDescriptor,
|
||||||
|
DatasourceField,
|
||||||
|
DatasourceLifecycleProvider,
|
||||||
|
DatasourceMaterialization,
|
||||||
|
DatasourceOrigin,
|
||||||
|
DatasourceOriginProvider,
|
||||||
|
DatasourceOriginReadResult,
|
||||||
|
DatasourcePublicationProvider,
|
||||||
|
DatasourcePublicationResult,
|
||||||
|
DatasourceReadResult,
|
||||||
|
DatasourceStage,
|
||||||
|
datasource_catalogue,
|
||||||
|
datasource_lifecycle,
|
||||||
|
datasource_origins,
|
||||||
|
datasource_publication,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
descriptor = DatasourceDescriptor(
|
||||||
|
ref="datasource:source-1",
|
||||||
|
source_name="monthly_cases",
|
||||||
|
name="Monthly cases",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
schema=(DatasourceField(name="case_id", data_type="string", nullable=False),),
|
||||||
|
fingerprint="abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_datasources(self, session, principal, *, query="", limit=100):
|
||||||
|
del session, principal, query
|
||||||
|
return (self.descriptor,)[:limit]
|
||||||
|
|
||||||
|
def get_datasource(self, session, principal, *, datasource_ref):
|
||||||
|
del session, principal
|
||||||
|
return self.descriptor if datasource_ref == self.descriptor.ref else None
|
||||||
|
|
||||||
|
def read_datasource(self, session, principal, *, request):
|
||||||
|
del session, principal, request
|
||||||
|
return DatasourceReadResult(
|
||||||
|
datasource=self.descriptor,
|
||||||
|
rows=({"case_id": "0012"},),
|
||||||
|
total_rows=1,
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_materializations(self, session, principal, *, datasource_ref):
|
||||||
|
del session, principal, datasource_ref
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def list_stages(self, session, principal, *, limit=100):
|
||||||
|
del session, principal, limit
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def create_stage(self, session, principal, *, stage):
|
||||||
|
del session, principal, stage
|
||||||
|
return DatasourceStage(
|
||||||
|
ref="stage:1",
|
||||||
|
name="Stage",
|
||||||
|
source_name="stage",
|
||||||
|
kind="upload",
|
||||||
|
mode="static",
|
||||||
|
shape="tabular",
|
||||||
|
state="ready",
|
||||||
|
)
|
||||||
|
|
||||||
|
def promote_stage(self, session, principal, *, stage_ref, freeze=False, frozen_label=None):
|
||||||
|
del session, principal, stage_ref, freeze, frozen_label
|
||||||
|
return self.descriptor, DatasourceMaterialization(
|
||||||
|
ref="materialization:1",
|
||||||
|
datasource_ref=self.descriptor.ref,
|
||||||
|
revision=1,
|
||||||
|
state="published",
|
||||||
|
fingerprint=self.descriptor.fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_origin(self, session, principal, **kwargs):
|
||||||
|
del session, principal, kwargs
|
||||||
|
return self.descriptor
|
||||||
|
|
||||||
|
def refresh_datasource(self, session, principal, *, datasource_ref):
|
||||||
|
del session, principal, datasource_ref
|
||||||
|
return self.promote_stage(object(), object(), stage_ref="stage:1")
|
||||||
|
|
||||||
|
def freeze_datasource(self, session, principal, *, datasource_ref, label=None):
|
||||||
|
del session, principal, datasource_ref, label
|
||||||
|
return self.promote_stage(object(), object(), stage_ref="stage:1")[1]
|
||||||
|
|
||||||
|
def retire_datasource(self, session, principal, *, datasource_ref):
|
||||||
|
del session, principal, datasource_ref
|
||||||
|
return self.descriptor
|
||||||
|
|
||||||
|
def publish_rows(self, session, principal, *, request):
|
||||||
|
del session, principal, request
|
||||||
|
return DatasourcePublicationResult(
|
||||||
|
ref="publication:1",
|
||||||
|
status="published",
|
||||||
|
datasource=self.descriptor,
|
||||||
|
materialization=DatasourceMaterialization(
|
||||||
|
ref="materialization:1",
|
||||||
|
datasource_ref=self.descriptor.ref,
|
||||||
|
revision=1,
|
||||||
|
state="published",
|
||||||
|
fingerprint=self.descriptor.fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_origins(self, session, principal, *, query="", limit=100):
|
||||||
|
del session, principal, query
|
||||||
|
return (
|
||||||
|
DatasourceOrigin(
|
||||||
|
ref="origin:1",
|
||||||
|
source_name="origin",
|
||||||
|
name="Origin",
|
||||||
|
kind="database",
|
||||||
|
shape="tabular",
|
||||||
|
supported_modes=("live", "cached"),
|
||||||
|
provider="test",
|
||||||
|
),
|
||||||
|
)[:limit]
|
||||||
|
|
||||||
|
def get_origin(self, session, principal, *, origin_ref):
|
||||||
|
return self.list_origins(session, principal)[0] if origin_ref == "origin:1" else None
|
||||||
|
|
||||||
|
def read_origin(self, session, principal, *, request):
|
||||||
|
origin = self.get_origin(session, principal, origin_ref=request.origin_ref)
|
||||||
|
return DatasourceOriginReadResult(
|
||||||
|
origin=origin,
|
||||||
|
rows=({"id": 1},),
|
||||||
|
total_rows=1,
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasourceContractTests(unittest.TestCase):
|
||||||
|
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
|
||||||
|
provider = _Provider()
|
||||||
|
self.assertIsInstance(provider, DatasourceCatalogueProvider)
|
||||||
|
self.assertIsInstance(provider, DatasourceLifecycleProvider)
|
||||||
|
self.assertIsInstance(provider, DatasourcePublicationProvider)
|
||||||
|
self.assertIsInstance(provider, DatasourceOriginProvider)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="datasource_contract_test",
|
||||||
|
name="Datasource contract test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_DATASOURCE_CATALOGUE: lambda context: provider,
|
||||||
|
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
|
||||||
|
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
|
||||||
|
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
self.assertIs(provider, datasource_catalogue(registry))
|
||||||
|
self.assertIs(provider, datasource_lifecycle(registry))
|
||||||
|
self.assertIs(provider, datasource_publication(registry))
|
||||||
|
self.assertIs(provider, datasource_origins(registry))
|
||||||
|
self.assertIsNone(datasource_catalogue(PlatformRegistry()))
|
||||||
|
|
||||||
|
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
|
||||||
|
descriptor = _Provider.descriptor
|
||||||
|
|
||||||
|
self.assertEqual("static", descriptor.mode)
|
||||||
|
self.assertEqual("upload", descriptor.kind)
|
||||||
|
self.assertEqual("tabular", descriptor.shape)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
79
tests/test_definition_governance_contract.py
Normal file
79
tests/test_definition_governance_contract.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||||
|
DefinitionGovernancePolicy,
|
||||||
|
DefinitionGovernanceRequest,
|
||||||
|
DefinitionScopeRef,
|
||||||
|
PolicyDecision,
|
||||||
|
definition_governance_policy,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _Policy:
|
||||||
|
def resolve_definition_action(self, *, request):
|
||||||
|
return PolicyDecision(
|
||||||
|
allowed=request.definition_kind != "template"
|
||||||
|
or request.action != "run",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefinitionGovernanceContractTests(unittest.TestCase):
|
||||||
|
def test_scope_paths_reuse_policy_provenance_format(self) -> None:
|
||||||
|
self.assertEqual("system", DefinitionScopeRef("system").path)
|
||||||
|
self.assertEqual(
|
||||||
|
"tenant:tenant-1",
|
||||||
|
DefinitionScopeRef("tenant", "tenant-1").path,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_policy_is_runtime_resolved(self) -> None:
|
||||||
|
provider = _Policy()
|
||||||
|
self.assertIsInstance(provider, DefinitionGovernancePolicy)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="definition_governance_test",
|
||||||
|
name="Definition governance test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_POLICY_DEFINITION_GOVERNANCE: (
|
||||||
|
lambda context: provider
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = definition_governance_policy(registry)
|
||||||
|
self.assertIs(provider, resolved)
|
||||||
|
decision = resolved.resolve_definition_action(
|
||||||
|
request=DefinitionGovernanceRequest(
|
||||||
|
module_id="dataflow",
|
||||||
|
definition_ref="pipeline:1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_scope=DefinitionScopeRef(
|
||||||
|
"tenant",
|
||||||
|
"tenant-1",
|
||||||
|
),
|
||||||
|
target_scope=DefinitionScopeRef("tenant", "tenant-1"),
|
||||||
|
definition_kind="template",
|
||||||
|
action="run",
|
||||||
|
actor=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertFalse(decision.allowed)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
145
tests/test_definition_graphs.py
Normal file
145
tests/test_definition_graphs.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.definition_graphs import (
|
||||||
|
DefinitionEdge,
|
||||||
|
DefinitionGraphConstraints,
|
||||||
|
DefinitionGraphLibrary,
|
||||||
|
DefinitionNode,
|
||||||
|
DefinitionNodeCountConstraint,
|
||||||
|
DefinitionNodeType,
|
||||||
|
DefinitionPort,
|
||||||
|
validate_definition_graph,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def library(*, allow_cycles: bool) -> DefinitionGraphLibrary:
|
||||||
|
return DefinitionGraphLibrary(
|
||||||
|
id="test",
|
||||||
|
version="0.1.0",
|
||||||
|
category_labels={"control": "Control"},
|
||||||
|
node_types=(
|
||||||
|
DefinitionNodeType(
|
||||||
|
type="start",
|
||||||
|
category="control",
|
||||||
|
label="Start",
|
||||||
|
description="Start",
|
||||||
|
icon="play",
|
||||||
|
),
|
||||||
|
DefinitionNodeType(
|
||||||
|
type="step",
|
||||||
|
category="control",
|
||||||
|
label="Step",
|
||||||
|
description="Step",
|
||||||
|
icon="square",
|
||||||
|
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||||
|
),
|
||||||
|
DefinitionNodeType(
|
||||||
|
type="end",
|
||||||
|
category="control",
|
||||||
|
label="End",
|
||||||
|
description="End",
|
||||||
|
icon="circle-stop",
|
||||||
|
input_ports=(DefinitionPort(id="input", label="Input"),),
|
||||||
|
output_ports=(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
constraints=DefinitionGraphConstraints(
|
||||||
|
allow_cycles=allow_cycles,
|
||||||
|
node_counts=(
|
||||||
|
DefinitionNodeCountConstraint(
|
||||||
|
code="graph.start_count",
|
||||||
|
label="start",
|
||||||
|
minimum=1,
|
||||||
|
maximum=1,
|
||||||
|
node_types=("start",),
|
||||||
|
),
|
||||||
|
DefinitionNodeCountConstraint(
|
||||||
|
code="graph.end_count",
|
||||||
|
label="end",
|
||||||
|
minimum=1,
|
||||||
|
node_types=("end",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefinitionGraphTests(unittest.TestCase):
|
||||||
|
def test_library_driven_ports_and_counts_validate_a_definition(self) -> None:
|
||||||
|
diagnostics = validate_definition_graph(
|
||||||
|
library(allow_cycles=False),
|
||||||
|
nodes=(
|
||||||
|
DefinitionNode(id="start", type="start"),
|
||||||
|
DefinitionNode(id="step", type="step"),
|
||||||
|
DefinitionNode(id="end", type="end"),
|
||||||
|
),
|
||||||
|
edges=(
|
||||||
|
DefinitionEdge(id="edge-1", source="start", target="step"),
|
||||||
|
DefinitionEdge(id="edge-2", source="step", target="end"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), diagnostics)
|
||||||
|
|
||||||
|
def test_constraints_change_cycle_semantics_without_changing_graph_shape(self) -> None:
|
||||||
|
nodes = (
|
||||||
|
DefinitionNode(id="start", type="start"),
|
||||||
|
DefinitionNode(id="step", type="step"),
|
||||||
|
DefinitionNode(id="retry", type="step"),
|
||||||
|
DefinitionNode(id="end", type="end"),
|
||||||
|
)
|
||||||
|
edges = (
|
||||||
|
DefinitionEdge(id="edge-1", source="start", target="step"),
|
||||||
|
DefinitionEdge(id="edge-2", source="step", target="retry"),
|
||||||
|
DefinitionEdge(id="edge-3", source="retry", target="step"),
|
||||||
|
DefinitionEdge(id="edge-4", source="retry", target="end"),
|
||||||
|
)
|
||||||
|
|
||||||
|
dag_codes = {
|
||||||
|
item.code
|
||||||
|
for item in validate_definition_graph(
|
||||||
|
library(allow_cycles=False),
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
workflow_codes = {
|
||||||
|
item.code
|
||||||
|
for item in validate_definition_graph(
|
||||||
|
library(allow_cycles=True),
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertIn("graph.cycle", dag_codes)
|
||||||
|
self.assertNotIn("graph.cycle", workflow_codes)
|
||||||
|
|
||||||
|
def test_unknown_ports_and_missing_required_inputs_are_explicit(self) -> None:
|
||||||
|
diagnostics = validate_definition_graph(
|
||||||
|
library(allow_cycles=False),
|
||||||
|
nodes=(
|
||||||
|
DefinitionNode(id="start", type="start"),
|
||||||
|
DefinitionNode(id="end", type="end"),
|
||||||
|
),
|
||||||
|
edges=(
|
||||||
|
DefinitionEdge(
|
||||||
|
id="edge",
|
||||||
|
source="start",
|
||||||
|
target="end",
|
||||||
|
target_port="missing",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
{"edge.unknown_target_port", "node.input_required"}.issubset(
|
||||||
|
{item.code for item in diagnostics}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,11 +6,92 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_core.devserver import redacted_database_url
|
from govoplan_core.devserver import build_reload_dirs, redacted_database_url
|
||||||
|
|
||||||
|
|
||||||
class DevserverSmokeTests(unittest.TestCase):
|
class DevserverSmokeTests(unittest.TestCase):
|
||||||
|
def test_reload_dirs_can_be_limited_to_selected_modules(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-reload-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
core_root = root / "core"
|
||||||
|
calendar_root = root / "calendar"
|
||||||
|
mail_root = root / "mail"
|
||||||
|
extra_root = root / "extra"
|
||||||
|
for path in (core_root, calendar_root, mail_root, extra_root):
|
||||||
|
path.mkdir()
|
||||||
|
|
||||||
|
manifests = (
|
||||||
|
SimpleNamespace(id="calendar"),
|
||||||
|
SimpleNamespace(id="mail"),
|
||||||
|
)
|
||||||
|
config = SimpleNamespace(enabled_modules=(), manifest_factories=())
|
||||||
|
registry = SimpleNamespace(manifests=lambda: manifests)
|
||||||
|
|
||||||
|
def manifest_roots(manifest: object) -> tuple[Path, ...]:
|
||||||
|
return (
|
||||||
|
calendar_root
|
||||||
|
if getattr(manifest, "id") == "calendar"
|
||||||
|
else mail_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.devserver._config_source_roots",
|
||||||
|
return_value=(core_root,),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.devserver._entry_point_source_roots",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.devserver._manifest_source_roots",
|
||||||
|
side_effect=manifest_roots,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
broad = build_reload_dirs(config, registry=registry)
|
||||||
|
focused = build_reload_dirs(
|
||||||
|
config,
|
||||||
|
registry=registry,
|
||||||
|
module_ids=("calendar",),
|
||||||
|
extra_dirs=(str(extra_root),),
|
||||||
|
)
|
||||||
|
core_only = build_reload_dirs(
|
||||||
|
config,
|
||||||
|
registry=registry,
|
||||||
|
module_ids=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{str(core_root), str(calendar_root), str(mail_root)},
|
||||||
|
set(broad),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{str(core_root), str(calendar_root), str(extra_root)},
|
||||||
|
set(focused),
|
||||||
|
)
|
||||||
|
self.assertEqual([str(core_root)], core_only)
|
||||||
|
|
||||||
|
def test_reload_dirs_reject_disabled_module_selector(self) -> None:
|
||||||
|
config = SimpleNamespace(enabled_modules=(), manifest_factories=())
|
||||||
|
registry = SimpleNamespace(
|
||||||
|
manifests=lambda: (SimpleNamespace(id="calendar"),)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.devserver._config_source_roots",
|
||||||
|
return_value=(),
|
||||||
|
),
|
||||||
|
self.assertRaisesRegex(SystemExit, "mail"),
|
||||||
|
):
|
||||||
|
build_reload_dirs(
|
||||||
|
config,
|
||||||
|
registry=registry,
|
||||||
|
module_ids=("mail",),
|
||||||
|
)
|
||||||
|
|
||||||
def test_database_url_redaction_hides_passwords(self) -> None:
|
def test_database_url_redaction_hides_passwords(self) -> None:
|
||||||
rendered = redacted_database_url(
|
rendered = redacted_database_url(
|
||||||
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
|
"postgresql+psycopg://govoplan:database-secret@db.example.test/govoplan"
|
||||||
|
|||||||
36
tests/test_external_reference_contract.py
Normal file
36
tests/test_external_reference_contract.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.external_references import (
|
||||||
|
ExternalObjectReference,
|
||||||
|
ExternalReferenceValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalReferenceContractTests(unittest.TestCase):
|
||||||
|
def test_reference_has_stable_identity_and_ordered_maturity(self) -> None:
|
||||||
|
reference = ExternalObjectReference(
|
||||||
|
system="openproject-main",
|
||||||
|
object_type="work_package",
|
||||||
|
object_id="42",
|
||||||
|
maturity="synchronize",
|
||||||
|
canonical_url="https://projects.example.test/work_packages/42",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("openproject-main:work_package:42", reference.identity_key)
|
||||||
|
self.assertTrue(reference.supports("read"))
|
||||||
|
self.assertFalse(reference.supports("replace"))
|
||||||
|
|
||||||
|
def test_reference_rejects_credentials_in_urls(self) -> None:
|
||||||
|
with self.assertRaises(ExternalReferenceValidationError):
|
||||||
|
ExternalObjectReference(
|
||||||
|
system="wiki",
|
||||||
|
object_type="page",
|
||||||
|
object_id="Main_Page",
|
||||||
|
canonical_url="https://user:secret@example.test/wiki/Main_Page",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -119,20 +119,66 @@ class _FakeIdmDirectory:
|
|||||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||||
return self.assignment if assignment_id == self.assignment.id else None
|
return self.assignment if assignment_id == self.assignment.id else None
|
||||||
|
|
||||||
def organization_function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None):
|
def organization_function_assignments_for_account(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
del effective_at
|
||||||
if account_id != self.assignment.account_id:
|
if account_id != self.assignment.account_id:
|
||||||
return ()
|
return ()
|
||||||
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
||||||
return ()
|
return ()
|
||||||
return (self.assignment,)
|
return (self.assignment,)
|
||||||
|
|
||||||
def organization_function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None):
|
def organization_function_assignments_for_identity(
|
||||||
|
self,
|
||||||
|
identity_id: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
del effective_at
|
||||||
if identity_id != self.assignment.identity_id:
|
if identity_id != self.assignment.identity_id:
|
||||||
return ()
|
return ()
|
||||||
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
if tenant_id is not None and tenant_id != self.assignment.tenant_id:
|
||||||
return ()
|
return ()
|
||||||
return (self.assignment,)
|
return (self.assignment,)
|
||||||
|
|
||||||
|
def organization_function_assignments_for_identities(
|
||||||
|
self,
|
||||||
|
identity_ids,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
identity_id: self.organization_function_assignments_for_identity(
|
||||||
|
identity_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
for identity_id in identity_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
def organization_function_assignments_for_accounts(
|
||||||
|
self,
|
||||||
|
account_ids,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
effective_at=None,
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
account_id: self.organization_function_assignments_for_account(
|
||||||
|
account_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
for account_id in account_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class IdentityOrganizationContractTests(unittest.TestCase):
|
class IdentityOrganizationContractTests(unittest.TestCase):
|
||||||
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
def test_protocol_shapes_match_reference_implementations(self) -> None:
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||||
|
|
||||||
|
def test_platform_event_outbox_limits_are_validated(self) -> None:
|
||||||
|
defaults = Settings()
|
||||||
|
self.assertEqual(defaults.platform_event_outbox_max_attempts, 8)
|
||||||
|
self.assertEqual(
|
||||||
|
defaults.platform_event_outbox_terminal_retention_days,
|
||||||
|
90,
|
||||||
|
)
|
||||||
|
configured = Settings(
|
||||||
|
PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS="12",
|
||||||
|
PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS="30",
|
||||||
|
)
|
||||||
|
self.assertEqual(configured.platform_event_outbox_max_attempts, 12)
|
||||||
|
self.assertEqual(
|
||||||
|
configured.platform_event_outbox_terminal_retention_days,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
for values in (
|
||||||
|
{"PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS": "0"},
|
||||||
|
{"PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS": "101"},
|
||||||
|
{"PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS": "-1"},
|
||||||
|
):
|
||||||
|
with self.subTest(values=values), self.assertRaises(ValidationError):
|
||||||
|
Settings(**values)
|
||||||
|
|
||||||
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
def test_scheduling_cancellation_notice_setting_is_bounded(self) -> None:
|
||||||
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
self.assertEqual(Settings().scheduling_cancellation_notice_days, 30)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -230,7 +230,10 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
|
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
|
||||||
self.assertEqual(manifests["campaigns"].dependencies, ())
|
self.assertEqual(manifests["campaigns"].dependencies, ())
|
||||||
self.assertTrue(manifests["campaigns"].required_capabilities)
|
self.assertTrue(manifests["campaigns"].required_capabilities)
|
||||||
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses"))
|
self.assertEqual(
|
||||||
|
manifests["campaigns"].optional_dependencies,
|
||||||
|
("files", "mail", "notifications", "addresses", "postbox"),
|
||||||
|
)
|
||||||
self.assertEqual(manifests["dashboard"].dependencies, ())
|
self.assertEqual(manifests["dashboard"].dependencies, ())
|
||||||
self.assertTrue(manifests["dashboard"].required_capabilities)
|
self.assertTrue(manifests["dashboard"].required_capabilities)
|
||||||
self.assertEqual(manifests["docs"].dependencies, ())
|
self.assertEqual(manifests["docs"].dependencies, ())
|
||||||
@@ -587,7 +590,7 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
def test_enabled_module_permutations_register_expected_routes(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
("core_only", (), {"access"}, set()),
|
("core_only", (), set(), set()),
|
||||||
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
|
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
|
||||||
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
|
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
|
||||||
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
|
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
|
||||||
@@ -602,8 +605,14 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
app, _settings_obj = self._app_for_modules(enabled_modules)
|
app, _settings_obj = self._app_for_modules(enabled_modules)
|
||||||
route_paths = _route_paths(app)
|
route_paths = _route_paths(app)
|
||||||
self.assertIn("/api/v1/platform/modules", route_paths)
|
self.assertIn("/api/v1/platform/modules", route_paths)
|
||||||
self.assertIn("/api/v1/auth/login", route_paths)
|
self.assertEqual(
|
||||||
self.assertIn("/api/v1/admin/users", route_paths)
|
"access" in expected_modules,
|
||||||
|
"/api/v1/auth/login" in route_paths,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"access" in expected_modules,
|
||||||
|
"/api/v1/admin/users" in route_paths,
|
||||||
|
)
|
||||||
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
|
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
|
||||||
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
|
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
|
||||||
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
|
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
|
||||||
@@ -613,12 +622,19 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertEqual(health.status_code, 200, health.text)
|
self.assertEqual(health.status_code, 200, health.text)
|
||||||
self.assertEqual({"status": "ok"}, health.json())
|
self.assertEqual({"status": "ok"}, health.json())
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
401 if "access" in expected_modules else 503,
|
||||||
|
response.status_code,
|
||||||
|
response.text,
|
||||||
|
)
|
||||||
|
payload_modules = {
|
||||||
|
item.id
|
||||||
|
for item in app.state.govoplan_registry.manifests()
|
||||||
|
}
|
||||||
self.assertEqual(expected_modules, payload_modules)
|
self.assertEqual(expected_modules, payload_modules)
|
||||||
|
|
||||||
def test_governance_template_routes_are_contributed_by_admin_module(self) -> None:
|
def test_governance_template_routes_are_contributed_by_admin_module(self) -> None:
|
||||||
access_only_app, _settings_obj = self._app_for_modules(())
|
access_only_app, _settings_obj = self._app_for_modules(("access",))
|
||||||
access_only_paths = _route_paths(access_only_app)
|
access_only_paths = _route_paths(access_only_app)
|
||||||
self.assertIn("/api/v1/admin/users", access_only_paths)
|
self.assertIn("/api/v1/admin/users", access_only_paths)
|
||||||
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
|
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
|
||||||
@@ -827,6 +843,37 @@ finally:
|
|||||||
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
|
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
|
||||||
registry.validate()
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_keeps_optional_auth_modules_access_free(self) -> None:
|
||||||
|
for modules in ((), ("poll",), ("evaluation",), ("scheduling",)):
|
||||||
|
with self.subTest(modules=modules):
|
||||||
|
registry = build_platform_registry(modules)
|
||||||
|
self.assertFalse(registry.has_module("access"))
|
||||||
|
|
||||||
|
def test_registry_prefers_selected_auth_provider_over_default(self) -> None:
|
||||||
|
provider = ModuleManifest(
|
||||||
|
id="alternative_auth",
|
||||||
|
name="Alternative auth",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
"auth.principalResolver": lambda _context: object(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
consumer = ModuleManifest(
|
||||||
|
id="consumer",
|
||||||
|
name="Consumer",
|
||||||
|
version="test",
|
||||||
|
required_capabilities=("auth.principalResolver",),
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = build_platform_registry(
|
||||||
|
("alternative_auth", "consumer"),
|
||||||
|
manifest_factories=(lambda: provider, lambda: consumer),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(registry.has_module("access"))
|
||||||
|
self.assertTrue(registry.has_module("alternative_auth"))
|
||||||
|
self.assertTrue(registry.has_module("consumer"))
|
||||||
|
|
||||||
def test_registry_resolves_required_interface_ranges(self) -> None:
|
def test_registry_resolves_required_interface_ranges(self) -> None:
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
registry.register(ModuleManifest(
|
registry.register(ModuleManifest(
|
||||||
@@ -903,7 +950,7 @@ finally:
|
|||||||
plan = plan_desired_enabled_modules(("campaigns",), manifests)
|
plan = plan_desired_enabled_modules(("campaigns",), manifests)
|
||||||
|
|
||||||
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
|
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
|
||||||
self.assertEqual((), plan.added_dependencies)
|
self.assertEqual(("access",), plan.added_dependencies)
|
||||||
|
|
||||||
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
|
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT))
|
||||||
@@ -917,9 +964,9 @@ finally:
|
|||||||
"module_id": "files",
|
"module_id": "files",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4",
|
"python_ref": "govoplan-files @ git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git@v0.1.4",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4",
|
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.4",
|
||||||
}])
|
}])
|
||||||
save_desired_enabled_modules(session, ("tenancy", "access", "files"))
|
save_desired_enabled_modules(session, ("tenancy", "access", "files"))
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -931,8 +978,8 @@ finally:
|
|||||||
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
|
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
|
||||||
self.assertEqual("manual", saved.items[0].source)
|
self.assertEqual("manual", saved.items[0].source)
|
||||||
self.assertEqual((
|
self.assertEqual((
|
||||||
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'",
|
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git@v0.1.4'",
|
||||||
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4' && npm install",
|
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.4' && npm install",
|
||||||
), module_install_plan_commands(saved))
|
), module_install_plan_commands(saved))
|
||||||
|
|
||||||
def test_module_install_plan_preserves_catalog_source(self) -> None:
|
def test_module_install_plan_preserves_catalog_source(self) -> None:
|
||||||
@@ -961,7 +1008,7 @@ finally:
|
|||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files==0.2.0",
|
"python_ref": "govoplan-files==0.2.0",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0",
|
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.2.0",
|
||||||
"data_safety_acknowledged": True,
|
"data_safety_acknowledged": True,
|
||||||
})
|
})
|
||||||
plan = ModuleInstallPlan(items=(item,))
|
plan = ModuleInstallPlan(items=(item,))
|
||||||
@@ -971,7 +1018,7 @@ finally:
|
|||||||
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
||||||
self.assertEqual((
|
self.assertEqual((
|
||||||
"python -m pip install govoplan-files==0.2.0",
|
"python -m pip install govoplan-files==0.2.0",
|
||||||
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0' && npm install",
|
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.2.0' && npm install",
|
||||||
), module_install_plan_commands(plan))
|
), module_install_plan_commands(plan))
|
||||||
|
|
||||||
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
||||||
@@ -996,7 +1043,7 @@ finally:
|
|||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files==0.1.6",
|
"python_ref": "govoplan-files==0.1.6",
|
||||||
"webui_package": "@govoplan/files-webui",
|
"webui_package": "@govoplan/files-webui",
|
||||||
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.6",
|
"webui_ref": "git+ssh://git.example.invalid/GovOPlaN/govoplan-files.git#v0.1.6",
|
||||||
}],
|
}],
|
||||||
}), encoding="utf-8")
|
}), encoding="utf-8")
|
||||||
|
|
||||||
@@ -3300,7 +3347,10 @@ finally:
|
|||||||
"version_min": "0.2.0",
|
"version_min": "0.2.0",
|
||||||
"version_max_exclusive": "0.3.0",
|
"version_max_exclusive": "0.3.0",
|
||||||
}, modules["campaigns"]["requires_interfaces"])
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"])
|
self.assertEqual(
|
||||||
|
["files", "mail", "notifications", "addresses", "postbox"],
|
||||||
|
modules["campaigns"]["optional_dependencies"],
|
||||||
|
)
|
||||||
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||||
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||||
self.assertEqual("0.1.9", modules["files"]["version"])
|
self.assertEqual("0.1.9", modules["files"]["version"])
|
||||||
@@ -3946,8 +3996,10 @@ finally:
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 401, response.text)
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
payload_modules = {
|
||||||
|
item.id for item in app.state.govoplan_registry.manifests()
|
||||||
|
}
|
||||||
self.assertEqual({"access", "files"}, payload_modules)
|
self.assertEqual({"access", "files"}, payload_modules)
|
||||||
|
|
||||||
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
|
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
|
||||||
@@ -3979,8 +4031,10 @@ finally:
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 401, response.text)
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
payload_modules = {
|
||||||
|
item.id for item in app.state.govoplan_registry.manifests()
|
||||||
|
}
|
||||||
self.assertEqual({"access", "files"}, payload_modules)
|
self.assertEqual({"access", "files"}, payload_modules)
|
||||||
|
|
||||||
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
|
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
|
||||||
@@ -4012,8 +4066,10 @@ finally:
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 401, response.text)
|
||||||
payload_modules = {item["id"] for item in response.json()["modules"]}
|
payload_modules = {
|
||||||
|
item.id for item in app.state.govoplan_registry.manifests()
|
||||||
|
}
|
||||||
self.assertEqual({"access", "admin", "files"}, payload_modules)
|
self.assertEqual({"access", "admin", "files"}, payload_modules)
|
||||||
|
|
||||||
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
|
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
|
||||||
@@ -4023,27 +4079,37 @@ finally:
|
|||||||
|
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 503, response.text)
|
||||||
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
|
self.assertEqual((), lifecycle.active_module_ids())
|
||||||
self.assertFalse("/api/v1/files" in _route_paths(app))
|
self.assertFalse("/api/v1/files" in _route_paths(app))
|
||||||
|
|
||||||
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
|
result = lifecycle.apply_enabled_modules(("files",), migrate=False)
|
||||||
self.assertEqual(("files",), result.activated_modules)
|
self.assertEqual(("access", "files"), result.activated_modules)
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 401, response.text)
|
||||||
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]})
|
self.assertEqual(("access", "files"), lifecycle.active_module_ids())
|
||||||
self.assertTrue("/api/v1/files" in _route_paths(app))
|
self.assertTrue("/api/v1/files" in _route_paths(app))
|
||||||
self.assertEqual(401, client.get("/api/v1/files").status_code)
|
self.assertEqual(401, client.get("/api/v1/files").status_code)
|
||||||
|
|
||||||
result = lifecycle.apply_enabled_modules((), migrate=False)
|
result = lifecycle.apply_enabled_modules((), migrate=False)
|
||||||
self.assertEqual(("files",), result.deactivated_modules)
|
self.assertEqual(("access", "files"), result.deactivated_modules)
|
||||||
response = client.get("/api/v1/platform/modules")
|
response = client.get("/api/v1/platform/modules")
|
||||||
self.assertEqual(response.status_code, 200, response.text)
|
self.assertEqual(response.status_code, 503, response.text)
|
||||||
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]})
|
self.assertEqual((), lifecycle.active_module_ids())
|
||||||
disabled_response = client.get("/api/v1/files")
|
disabled_response = client.get("/api/v1/files")
|
||||||
self.assertEqual(404, disabled_response.status_code)
|
self.assertEqual(404, disabled_response.status_code)
|
||||||
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
|
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
|
||||||
|
|
||||||
|
def test_module_lifecycle_live_apply_defaults_by_environment(self) -> None:
|
||||||
|
app, settings = self._app_for_modules(())
|
||||||
|
lifecycle = app.state.govoplan_lifecycle
|
||||||
|
|
||||||
|
self.assertTrue(lifecycle.live_apply_enabled())
|
||||||
|
settings.app_env = "production"
|
||||||
|
self.assertFalse(lifecycle.live_apply_enabled())
|
||||||
|
settings.module_live_apply_enabled = True
|
||||||
|
self.assertTrue(lifecycle.live_apply_enabled())
|
||||||
|
|
||||||
|
|
||||||
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
|
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
@@ -4073,10 +4139,9 @@ finally:
|
|||||||
|
|
||||||
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
|
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
|
||||||
core_plan = migration_metadata_plan(build_platform_registry(()))
|
core_plan = migration_metadata_plan(build_platform_registry(()))
|
||||||
self.assertEqual(1, len(core_plan.script_locations))
|
self.assertEqual((), core_plan.script_locations)
|
||||||
self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions"))
|
self.assertEqual((), core_plan.metadata)
|
||||||
self.assertEqual(1, len(core_plan.metadata))
|
self.assertEqual((), core_plan.modules)
|
||||||
self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules))
|
|
||||||
|
|
||||||
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
|
files_plan = migration_metadata_plan(build_platform_registry(("files",)))
|
||||||
self.assertEqual(2, len(files_plan.script_locations))
|
self.assertEqual(2, len(files_plan.script_locations))
|
||||||
|
|||||||
140
tests/test_organization_hierarchy_contract.py
Normal file
140
tests/test_organization_hierarchy_contract.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.organizations import (
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
|
||||||
|
OrganizationDirectory,
|
||||||
|
OrganizationFunctionTypeRef,
|
||||||
|
OrganizationHierarchyCatalogRef,
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
OrganizationUnitTypeRef,
|
||||||
|
organization_hierarchy_directory,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyOrganizationDirectory:
|
||||||
|
def get_organization_unit(self, _organization_unit_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def organization_units_for_tenant(self, _tenant_id):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_function(self, _function_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def functions_for_organization_unit(
|
||||||
|
self,
|
||||||
|
_organization_unit_id,
|
||||||
|
*,
|
||||||
|
include_subunits=False,
|
||||||
|
):
|
||||||
|
del include_subunits
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class HierarchyDirectory:
|
||||||
|
def hierarchy_catalog(self, tenant_id):
|
||||||
|
return OrganizationHierarchyCatalogRef(tenant_id=tenant_id)
|
||||||
|
|
||||||
|
def get_unit_type(self, _tenant_id, _unit_type_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_function_type(self, _tenant_id, _function_type_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_functions_by_type(
|
||||||
|
self,
|
||||||
|
_tenant_id,
|
||||||
|
_function_type_id,
|
||||||
|
*,
|
||||||
|
organization_unit_ids=(),
|
||||||
|
):
|
||||||
|
del organization_unit_ids
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_units_by_type(
|
||||||
|
self,
|
||||||
|
_tenant_id,
|
||||||
|
_unit_type_id,
|
||||||
|
**_options,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_hierarchy_relatives(
|
||||||
|
self,
|
||||||
|
_tenant_id,
|
||||||
|
_organization_unit_ids,
|
||||||
|
**_options,
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def resolve_hierarchy_paths(
|
||||||
|
self,
|
||||||
|
_tenant_id,
|
||||||
|
_unit_pairs,
|
||||||
|
**_options,
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationHierarchyContractTests(unittest.TestCase):
|
||||||
|
def test_legacy_directory_contract_is_not_broadened(self) -> None:
|
||||||
|
self.assertIsInstance(
|
||||||
|
LegacyOrganizationDirectory(),
|
||||||
|
OrganizationDirectory,
|
||||||
|
)
|
||||||
|
self.assertNotIsInstance(
|
||||||
|
LegacyOrganizationDirectory(),
|
||||||
|
OrganizationHierarchyDirectory,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_hierarchy_capability_resolves_independently(self) -> None:
|
||||||
|
provider = HierarchyDirectory()
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="organization_hierarchy_test",
|
||||||
|
name="Organization hierarchy test",
|
||||||
|
version="0.0.0",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY: (
|
||||||
|
lambda _context: provider
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(provider, OrganizationHierarchyDirectory)
|
||||||
|
self.assertIs(
|
||||||
|
provider,
|
||||||
|
organization_hierarchy_directory(registry),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_type_refs_preserve_tenant_and_status(self) -> None:
|
||||||
|
unit_type = OrganizationUnitTypeRef(
|
||||||
|
id="unit-type-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="department",
|
||||||
|
name="Department",
|
||||||
|
status="inactive",
|
||||||
|
)
|
||||||
|
function_type = OrganizationFunctionTypeRef(
|
||||||
|
id="function-type-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
slug="intake",
|
||||||
|
name="Intake",
|
||||||
|
organization_unit_type_id=unit_type.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("inactive", unit_type.status)
|
||||||
|
self.assertEqual(unit_type.id, function_type.organization_unit_type_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
131
tests/test_platform_event_worker.py
Normal file
131
tests/test_platform_event_worker.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import (
|
||||||
|
celery,
|
||||||
|
dispatch_platform_events,
|
||||||
|
purge_platform_events,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.events import PlatformEvent
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEventWorkerTests(unittest.TestCase):
|
||||||
|
def test_dispatch_uses_a_durable_dataflow_consumer_and_commits(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
outbox = MagicMock()
|
||||||
|
outbox.dispatch_pending.return_value = {
|
||||||
|
"selected": 1,
|
||||||
|
"delivered": 1,
|
||||||
|
"retrying": 0,
|
||||||
|
"quarantined": 0,
|
||||||
|
"dispatched": 1,
|
||||||
|
"observer_failed": 0,
|
||||||
|
}
|
||||||
|
dataflow = MagicMock()
|
||||||
|
registry = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._platform_registry",
|
||||||
|
return_value=registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._platform_event_outbox",
|
||||||
|
return_value=outbox,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._dataflow_trigger_dispatcher",
|
||||||
|
return_value=dataflow,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = dispatch_platform_events.run(25)
|
||||||
|
|
||||||
|
call = outbox.dispatch_pending.call_args
|
||||||
|
self.assertEqual(session, call.args[0])
|
||||||
|
self.assertEqual(25, call.kwargs["limit"])
|
||||||
|
consumer = call.kwargs["consumers"][0]
|
||||||
|
self.assertEqual(
|
||||||
|
"dataflow.event-triggers.v1",
|
||||||
|
consumer.consumer_id,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
frozenset({"public", "internal"}),
|
||||||
|
consumer.classifications,
|
||||||
|
)
|
||||||
|
event = PlatformEvent(
|
||||||
|
type="files.file.created",
|
||||||
|
module_id="files",
|
||||||
|
)
|
||||||
|
consumer.handler(event, consumer.delivery_key(event))
|
||||||
|
dataflow.ingest_event.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(1, result["delivered"])
|
||||||
|
|
||||||
|
def test_retention_task_uses_the_configured_terminal_window(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
outbox = MagicMock()
|
||||||
|
outbox.purge_terminal.return_value = {"deleted": 2}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._platform_event_outbox",
|
||||||
|
return_value=outbox,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app.settings."
|
||||||
|
"platform_event_outbox_terminal_retention_days",
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = purge_platform_events.run(75)
|
||||||
|
|
||||||
|
call = outbox.purge_terminal.call_args
|
||||||
|
self.assertEqual(session, call.args[0])
|
||||||
|
self.assertEqual(75, call.kwargs["limit"])
|
||||||
|
before = call.kwargs["before"]
|
||||||
|
self.assertIsInstance(before, datetime)
|
||||||
|
self.assertEqual(timezone.utc, before.tzinfo)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual({"deleted": 2}, result)
|
||||||
|
|
||||||
|
def test_worker_routes_and_periodic_tasks_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
{"queue": "events"},
|
||||||
|
celery.conf.task_routes[
|
||||||
|
"govoplan.events.dispatch_outbox"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{"queue": "events"},
|
||||||
|
celery.conf.task_routes[
|
||||||
|
"govoplan.events.purge_outbox"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"govoplan.events.purge_outbox",
|
||||||
|
celery.conf.beat_schedule[
|
||||||
|
"platform-event-retention-daily"
|
||||||
|
]["task"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
119
tests/test_postbox_contract.py
Normal file
119
tests/test_postbox_contract.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.postbox import (
|
||||||
|
CAPABILITY_POSTBOX_ACCESS,
|
||||||
|
CAPABILITY_POSTBOX_DELIVERY,
|
||||||
|
CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
|
CAPABILITY_POSTBOX_MESSAGES,
|
||||||
|
PostboxAccessProvider,
|
||||||
|
PostboxDeliveryProvider,
|
||||||
|
PostboxDirectoryProvider,
|
||||||
|
PostboxEvidenceProvider,
|
||||||
|
PostboxMessagesProvider,
|
||||||
|
postbox_access_provider,
|
||||||
|
postbox_delivery_provider,
|
||||||
|
postbox_directory_provider,
|
||||||
|
postbox_evidence_provider,
|
||||||
|
postbox_messages_provider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _PostboxProvider:
|
||||||
|
def list_visible_postboxes(self, session, *, tenant_id, actor):
|
||||||
|
del session, tenant_id, actor
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def resolve_postbox(self, session, *, tenant_id, target, materialize=False):
|
||||||
|
del session, tenant_id, target, materialize
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delivery_catalog(self, session, *, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
def explain_access(self, session, *, tenant_id, postbox_id, actor, action):
|
||||||
|
del session, tenant_id, postbox_id, actor, action
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_messages(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id,
|
||||||
|
postbox_ids,
|
||||||
|
actor,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
):
|
||||||
|
del session, tenant_id, postbox_ids, actor, limit, offset
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_message(self, session, *, tenant_id, message_id, actor):
|
||||||
|
del session, tenant_id, message_id, actor
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mark_message(self, session, *, tenant_id, message_id, actor, state):
|
||||||
|
del session, tenant_id, message_id, actor, state
|
||||||
|
return None
|
||||||
|
|
||||||
|
def deliver(self, session, request):
|
||||||
|
del session, request
|
||||||
|
return None
|
||||||
|
|
||||||
|
def link_evidence(self, session, *, tenant_id, message_id, attachment):
|
||||||
|
del session, tenant_id, message_id, attachment
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxContractTests(unittest.TestCase):
|
||||||
|
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
|
||||||
|
provider = _PostboxProvider()
|
||||||
|
self.assertIsInstance(provider, PostboxDirectoryProvider)
|
||||||
|
self.assertIsInstance(provider, PostboxAccessProvider)
|
||||||
|
self.assertIsInstance(provider, PostboxMessagesProvider)
|
||||||
|
self.assertIsInstance(provider, PostboxDeliveryProvider)
|
||||||
|
self.assertIsInstance(provider, PostboxEvidenceProvider)
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="postbox_contract_test",
|
||||||
|
name="Postbox contract test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
name: lambda context: provider
|
||||||
|
for name in (
|
||||||
|
CAPABILITY_POSTBOX_DIRECTORY,
|
||||||
|
CAPABILITY_POSTBOX_ACCESS,
|
||||||
|
CAPABILITY_POSTBOX_MESSAGES,
|
||||||
|
CAPABILITY_POSTBOX_DELIVERY,
|
||||||
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(provider, postbox_directory_provider(registry))
|
||||||
|
self.assertIs(provider, postbox_access_provider(registry))
|
||||||
|
self.assertIs(provider, postbox_messages_provider(registry))
|
||||||
|
self.assertIs(provider, postbox_delivery_provider(registry))
|
||||||
|
self.assertIs(provider, postbox_evidence_provider(registry))
|
||||||
|
|
||||||
|
empty = PlatformRegistry()
|
||||||
|
self.assertIsNone(postbox_directory_provider(empty))
|
||||||
|
self.assertIsNone(postbox_access_provider(empty))
|
||||||
|
self.assertIsNone(postbox_messages_provider(empty))
|
||||||
|
self.assertIsNone(postbox_delivery_provider(empty))
|
||||||
|
self.assertIsNone(postbox_evidence_provider(empty))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
93
tests/test_postbox_routing_worker.py
Normal file
93
tests/test_postbox_routing_worker.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import celery, dispatch_postbox_routes
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.postbox import (
|
||||||
|
CAPABILITY_POSTBOX_ROUTING,
|
||||||
|
PostboxRoutingProvider,
|
||||||
|
postbox_routing_provider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class _RoutingProvider:
|
||||||
|
def dispatch_due_routes(
|
||||||
|
self,
|
||||||
|
session,
|
||||||
|
*,
|
||||||
|
tenant_id=None,
|
||||||
|
limit=50,
|
||||||
|
):
|
||||||
|
del session, tenant_id, limit
|
||||||
|
return {"selected": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class PostboxRoutingWorkerTests(unittest.TestCase):
|
||||||
|
def test_contract_is_runtime_checkable_and_resolved(self) -> None:
|
||||||
|
provider = _RoutingProvider()
|
||||||
|
self.assertIsInstance(provider, PostboxRoutingProvider)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="postbox_routing_test",
|
||||||
|
name="Postbox routing test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_POSTBOX_ROUTING: (
|
||||||
|
lambda _context: provider
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(provider, postbox_routing_provider(registry))
|
||||||
|
self.assertIsNone(postbox_routing_provider(PlatformRegistry()))
|
||||||
|
|
||||||
|
def test_worker_commits_provider_outcome(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.dispatch_due_routes.return_value = {
|
||||||
|
"selected": 1,
|
||||||
|
"delivered": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._postbox_routing_provider",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = dispatch_postbox_routes.run("tenant-1", 25)
|
||||||
|
|
||||||
|
provider.dispatch_due_routes.assert_called_once_with(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=25,
|
||||||
|
)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(1, result["delivered"])
|
||||||
|
|
||||||
|
def test_route_and_periodic_recovery_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
{"queue": "postbox"},
|
||||||
|
celery.conf.task_routes["govoplan.postbox.dispatch_routes"],
|
||||||
|
)
|
||||||
|
schedule = celery.conf.beat_schedule["postbox-routes-every-minute"]
|
||||||
|
self.assertEqual("govoplan.postbox.dispatch_routes", schedule["task"])
|
||||||
|
self.assertEqual(60.0, schedule["schedule"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
435
tests/test_postgres_retirement_atomicity.py
Normal file
435
tests/test_postgres_retirement_atomicity.py
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event, inspect, text
|
||||||
|
from sqlalchemy.engine import Connection, Engine
|
||||||
|
from sqlalchemy.exc import DBAPIError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.schema import CreateSchema, DropSchema
|
||||||
|
|
||||||
|
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||||
|
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleContext
|
||||||
|
from govoplan_core.core.runtime import clear_runtime, configure_runtime, get_runtime_context
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
_CREDENTIAL_ID = "retirement-credential"
|
||||||
|
_TENANT_ID = "retirement-tenant"
|
||||||
|
_PRIVATE_FIXTURE_VALUES = (
|
||||||
|
"opaque-password-ciphertext",
|
||||||
|
"opaque-token-ciphertext",
|
||||||
|
"vault:test-only:credential",
|
||||||
|
"private-test-metadata",
|
||||||
|
)
|
||||||
|
_ROW_COUNT_QUERIES = {
|
||||||
|
"audit_log": text("SELECT count(*) FROM audit_log"),
|
||||||
|
"core_change_sequence": text("SELECT count(*) FROM core_change_sequence"),
|
||||||
|
"retirement_scrub_probe": text("SELECT count(*) FROM retirement_scrub_probe"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _postgres_database_url() -> str:
|
||||||
|
candidate = (
|
||||||
|
os.environ.get("GOVOPLAN_POSTGRES_DATABASE_URL")
|
||||||
|
or os.environ.get("DATABASE_URL")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
return candidate if candidate.startswith("postgresql") else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _proof_is_required() -> bool:
|
||||||
|
return os.environ.get("GOVOPLAN_REQUIRE_POSTGRES_RETIREMENT_PROOF", "").strip().casefold() in {
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _AuditCapabilityRegistry:
|
||||||
|
def __init__(self, recorder: object) -> None:
|
||||||
|
self._recorder = recorder
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == CAPABILITY_AUDIT_RECORDER
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise LookupError(name)
|
||||||
|
return self._recorder
|
||||||
|
|
||||||
|
|
||||||
|
class PostgreSQLRetirementAtomicityTests(unittest.TestCase):
|
||||||
|
"""Production-reference proof for destructive module retirement.
|
||||||
|
|
||||||
|
Every test owns a random PostgreSQL schema and removes it afterward. The
|
||||||
|
fixture stores only synthetic credential material. Assertions and audit
|
||||||
|
inspection expose booleans and allow-listed metadata, never stored values
|
||||||
|
or the database URL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
database_url = _postgres_database_url()
|
||||||
|
if not database_url:
|
||||||
|
if _proof_is_required():
|
||||||
|
self.fail("the required PostgreSQL retirement proof has no PostgreSQL database")
|
||||||
|
self.skipTest("a PostgreSQL integration database is not configured")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
||||||
|
from govoplan_audit.backend.db.models import AuditLog
|
||||||
|
from govoplan_audit.backend.recording import SqlAuditRecorder
|
||||||
|
from govoplan_files.backend.db.models import FileConnectorCredential, FileConnectorProfile
|
||||||
|
from govoplan_files.backend.manifest import manifest as files_manifest
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
if _proof_is_required():
|
||||||
|
self.fail(f"the required PostgreSQL retirement proof is missing package: {exc.name}")
|
||||||
|
self.skipTest(f"the full-stack integration packages are not installed: {exc.name}")
|
||||||
|
|
||||||
|
self.audit_log_model = AuditLog
|
||||||
|
self.credential_model = FileConnectorCredential
|
||||||
|
self.profile_model = FileConnectorProfile
|
||||||
|
self.files_manifest = files_manifest
|
||||||
|
self.previous_runtime = get_runtime_context()
|
||||||
|
self.admin_engine: Engine | None = None
|
||||||
|
self.engine: Engine | None = None
|
||||||
|
self.schema_name = f"govoplan_retirement_{uuid4().hex}"
|
||||||
|
self.capture_retirement_ddl = False
|
||||||
|
self.retirement_ddl_backend_pids: list[int] = []
|
||||||
|
self.ddl_listener_registered = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.admin_engine = create_engine(database_url, pool_pre_ping=True)
|
||||||
|
with self.admin_engine.begin() as connection:
|
||||||
|
if connection.dialect.name != "postgresql":
|
||||||
|
self.skipTest("the configured integration database is not PostgreSQL")
|
||||||
|
connection.execute(CreateSchema(self.schema_name))
|
||||||
|
|
||||||
|
self.engine = create_engine(
|
||||||
|
database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=0,
|
||||||
|
connect_args={
|
||||||
|
"options": (
|
||||||
|
f"-csearch_path={self.schema_name},pg_catalog "
|
||||||
|
"-clock_timeout=500ms -cstatement_timeout=10s "
|
||||||
|
"-capplication_name=govoplan-retirement-atomicity"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
event.listen(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
||||||
|
self.ddl_listener_registered = True
|
||||||
|
self._create_fixture_schema()
|
||||||
|
self._seed_credential()
|
||||||
|
|
||||||
|
recorder = SqlAuditRecorder()
|
||||||
|
configure_runtime(ModuleContext(registry=_AuditCapabilityRegistry(recorder), settings=object()))
|
||||||
|
except Exception:
|
||||||
|
self._cleanup()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
def test_scrub_audit_and_table_retirement_commit_together(self) -> None:
|
||||||
|
assert self.engine is not None
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
installer_backend_pid = self._session_backend_pid(session)
|
||||||
|
plan = self._retirement_plan(session)
|
||||||
|
self._execute_retirement(session, plan)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertTrue(self.retirement_ddl_backend_pids, "the retirement provider emitted no DROP TABLE statement")
|
||||||
|
self.assertEqual(
|
||||||
|
{installer_backend_pid},
|
||||||
|
set(self.retirement_ddl_backend_pids),
|
||||||
|
"destructive DDL did not use the installer Session connection",
|
||||||
|
)
|
||||||
|
with self.engine.connect() as connection:
|
||||||
|
self.assertFalse(inspect(connection).has_table(self.credential_model.__tablename__))
|
||||||
|
self.assertFalse(inspect(connection).has_table(self.profile_model.__tablename__))
|
||||||
|
|
||||||
|
probe = connection.execute(text(
|
||||||
|
"SELECT password_removed, token_removed, username_removed, "
|
||||||
|
"secret_reference_removed, metadata_removed "
|
||||||
|
"FROM retirement_scrub_probe"
|
||||||
|
)).mappings().one()
|
||||||
|
self.assertTrue(all(probe.values()), "the credential scrub probe did not observe every removal")
|
||||||
|
|
||||||
|
audit_rows = connection.execute(text(
|
||||||
|
"SELECT action, object_type, object_id, details "
|
||||||
|
"FROM audit_log ORDER BY created_at, id"
|
||||||
|
)).mappings().all()
|
||||||
|
self.assertEqual(1, len(audit_rows))
|
||||||
|
audit_row = audit_rows[0]
|
||||||
|
self.assertEqual("files.connector_credential_deleted", audit_row["action"])
|
||||||
|
self.assertEqual("file_connector_credential", audit_row["object_type"])
|
||||||
|
self.assertEqual(_CREDENTIAL_ID, audit_row["object_id"])
|
||||||
|
self._assert_non_secret_audit_details(dict(audit_row["details"] or {}))
|
||||||
|
self.assertEqual(1, self._row_count(connection, "core_change_sequence"))
|
||||||
|
|
||||||
|
def test_database_audit_failure_rolls_back_scrub_and_audit_state(self) -> None:
|
||||||
|
assert self.engine is not None
|
||||||
|
with self.engine.begin() as connection:
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION reject_retirement_audit() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.action = 'files.connector_credential_deleted' THEN
|
||||||
|
RAISE EXCEPTION 'injected retirement audit failure';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER reject_retirement_audit_insert
|
||||||
|
BEFORE INSERT ON audit_log
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION reject_retirement_audit()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
self._session_backend_pid(session)
|
||||||
|
plan = self._retirement_plan(session)
|
||||||
|
with self.assertRaises(DBAPIError) as caught:
|
||||||
|
self._execute_retirement(session, plan)
|
||||||
|
self.assertEqual("P0001", getattr(caught.exception.orig, "sqlstate", None))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
self.assertEqual([], self.retirement_ddl_backend_pids, "DDL ran after the injected audit failure")
|
||||||
|
self._assert_failed_retirement_rolled_back()
|
||||||
|
|
||||||
|
def test_database_ddl_failure_rolls_back_scrub_audit_and_prior_drop(self) -> None:
|
||||||
|
assert self.engine is not None
|
||||||
|
with self.engine.begin() as connection:
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE retirement_drop_guard (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
credential_id varchar(255) NOT NULL
|
||||||
|
REFERENCES file_connector_credentials(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("INSERT INTO retirement_drop_guard (id, credential_id) VALUES (1, :credential_id)"),
|
||||||
|
{"credential_id": _CREDENTIAL_ID},
|
||||||
|
)
|
||||||
|
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
installer_backend_pid = self._session_backend_pid(session)
|
||||||
|
plan = self._retirement_plan(session)
|
||||||
|
with self.assertRaises(DBAPIError) as caught:
|
||||||
|
self._execute_retirement(session, plan)
|
||||||
|
self.assertEqual("2BP01", getattr(caught.exception.orig, "sqlstate", None))
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
self.assertTrue(self.retirement_ddl_backend_pids, "the injected DDL failure was not reached")
|
||||||
|
self.assertEqual(
|
||||||
|
{installer_backend_pid},
|
||||||
|
set(self.retirement_ddl_backend_pids),
|
||||||
|
"destructive DDL did not use the installer Session connection",
|
||||||
|
)
|
||||||
|
self._assert_failed_retirement_rolled_back()
|
||||||
|
|
||||||
|
def _create_fixture_schema(self) -> None:
|
||||||
|
assert self.engine is not None
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
|
||||||
|
with self.engine.begin() as connection:
|
||||||
|
# Audit and Files reference Access actors. The proof does not need
|
||||||
|
# actor rows, so small FK targets keep its schema bounded.
|
||||||
|
connection.exec_driver_sql("CREATE TABLE access_users (id varchar(36) PRIMARY KEY)")
|
||||||
|
connection.exec_driver_sql("CREATE TABLE access_api_keys (id varchar(36) PRIMARY KEY)")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=connection,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
self.audit_log_model.__table__,
|
||||||
|
self.credential_model.__table__,
|
||||||
|
self.profile_model.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE TABLE retirement_scrub_probe (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
password_removed boolean NOT NULL,
|
||||||
|
token_removed boolean NOT NULL,
|
||||||
|
username_removed boolean NOT NULL,
|
||||||
|
secret_reference_removed boolean NOT NULL,
|
||||||
|
metadata_removed boolean NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE FUNCTION record_retirement_scrub() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO retirement_scrub_probe (
|
||||||
|
password_removed,
|
||||||
|
token_removed,
|
||||||
|
username_removed,
|
||||||
|
secret_reference_removed,
|
||||||
|
metadata_removed
|
||||||
|
) VALUES (
|
||||||
|
OLD.password_encrypted IS NOT NULL AND NEW.password_encrypted IS NULL,
|
||||||
|
OLD.token_encrypted IS NOT NULL AND NEW.token_encrypted IS NULL,
|
||||||
|
OLD.username IS NOT NULL AND NEW.username IS NULL,
|
||||||
|
OLD.secret_ref IS NOT NULL AND NEW.secret_ref IS NULL,
|
||||||
|
OLD.metadata IS NOT NULL AND NEW.metadata::jsonb = '{}'::jsonb
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.exec_driver_sql(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER record_retirement_credential_scrub
|
||||||
|
BEFORE UPDATE ON file_connector_credentials
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION record_retirement_scrub()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def _seed_credential(self) -> None:
|
||||||
|
assert self.engine is not None
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add(self.credential_model(
|
||||||
|
id=_CREDENTIAL_ID,
|
||||||
|
tenant_id=_TENANT_ID,
|
||||||
|
scope_type="tenant",
|
||||||
|
scope_id=_TENANT_ID,
|
||||||
|
label="Retirement integration credential",
|
||||||
|
provider="webdav",
|
||||||
|
enabled=True,
|
||||||
|
credential_mode="basic",
|
||||||
|
username="integration-user",
|
||||||
|
password_encrypted=_PRIVATE_FIXTURE_VALUES[0],
|
||||||
|
token_encrypted=_PRIVATE_FIXTURE_VALUES[1],
|
||||||
|
password_env="INTEGRATION_PASSWORD",
|
||||||
|
token_env="INTEGRATION_TOKEN",
|
||||||
|
secret_ref=_PRIVATE_FIXTURE_VALUES[2],
|
||||||
|
policy={},
|
||||||
|
metadata_={"private_hint": _PRIVATE_FIXTURE_VALUES[3]},
|
||||||
|
))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def _retirement_plan(self, session: Session) -> MigrationRetirementPlan:
|
||||||
|
migration = self.files_manifest.migration_spec
|
||||||
|
self.assertIsNotNone(migration)
|
||||||
|
assert migration is not None
|
||||||
|
self.assertIsNotNone(migration.retirement_provider)
|
||||||
|
assert migration.retirement_provider is not None
|
||||||
|
plan = migration.retirement_provider(session, "files")
|
||||||
|
self.assertTrue(plan.destroy_data_supported)
|
||||||
|
self.assertIsNotNone(plan.destroy_data_executor)
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def _execute_retirement(self, session: Session, plan: MigrationRetirementPlan) -> None:
|
||||||
|
executor = plan.destroy_data_executor
|
||||||
|
self.assertIsNotNone(executor)
|
||||||
|
self.capture_retirement_ddl = True
|
||||||
|
try:
|
||||||
|
executor(session, "files")
|
||||||
|
finally:
|
||||||
|
self.capture_retirement_ddl = False
|
||||||
|
|
||||||
|
def _capture_ddl_connection(
|
||||||
|
self,
|
||||||
|
connection: Connection,
|
||||||
|
_cursor: object,
|
||||||
|
statement: str,
|
||||||
|
_parameters: object,
|
||||||
|
_context: object,
|
||||||
|
_executemany: bool,
|
||||||
|
) -> None:
|
||||||
|
if not self.capture_retirement_ddl or not statement.lstrip().upper().startswith("DROP TABLE"):
|
||||||
|
return
|
||||||
|
self.retirement_ddl_backend_pids.append(self._connection_backend_pid(connection))
|
||||||
|
|
||||||
|
def _session_backend_pid(self, session: Session) -> int:
|
||||||
|
connection = session.connection()
|
||||||
|
sql_pid = int(connection.execute(text("SELECT pg_backend_pid()")).scalar_one())
|
||||||
|
driver_pid = self._connection_backend_pid(connection)
|
||||||
|
self.assertEqual(sql_pid, driver_pid)
|
||||||
|
return sql_pid
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _connection_backend_pid(connection: Connection) -> int:
|
||||||
|
driver_connection = connection.connection.driver_connection
|
||||||
|
return int(driver_connection.info.backend_pid)
|
||||||
|
|
||||||
|
def _assert_failed_retirement_rolled_back(self) -> None:
|
||||||
|
assert self.engine is not None
|
||||||
|
with self.engine.connect() as connection:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
self.assertTrue(inspector.has_table(self.credential_model.__tablename__))
|
||||||
|
self.assertTrue(inspector.has_table(self.profile_model.__tablename__))
|
||||||
|
state = connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT enabled, password_encrypted IS NOT NULL AS has_password, "
|
||||||
|
"token_encrypted IS NOT NULL AS has_token, username IS NOT NULL AS has_username, "
|
||||||
|
"secret_ref IS NOT NULL AS has_secret_reference, metadata::jsonb <> '{}'::jsonb AS has_metadata "
|
||||||
|
"FROM file_connector_credentials WHERE id = :credential_id"
|
||||||
|
),
|
||||||
|
{"credential_id": _CREDENTIAL_ID},
|
||||||
|
).mappings().one()
|
||||||
|
self.assertTrue(all(state.values()), "credential state was not fully restored")
|
||||||
|
self.assertEqual(0, self._row_count(connection, "retirement_scrub_probe"))
|
||||||
|
self.assertEqual(0, self._row_count(connection, "audit_log"))
|
||||||
|
self.assertEqual(0, self._row_count(connection, "core_change_sequence"))
|
||||||
|
|
||||||
|
def _assert_non_secret_audit_details(self, details: dict[str, object]) -> None:
|
||||||
|
self.assertEqual("module_data_retired", details.get("deletion_reason"))
|
||||||
|
self.assertEqual("unowned_external_reference_detached", details.get("storage_backend"))
|
||||||
|
self.assertEqual("<redacted>", details.get("deleted_secret_kinds"))
|
||||||
|
self.assertEqual(
|
||||||
|
["password_env", "token_env", "unowned_external_secret_ref"],
|
||||||
|
details.get("removed_reference_kinds"),
|
||||||
|
)
|
||||||
|
self.assertTrue(details.get("removed_metadata"))
|
||||||
|
serialized = json.dumps(details, sort_keys=True)
|
||||||
|
self.assertFalse(
|
||||||
|
any(private_value in serialized for private_value in _PRIVATE_FIXTURE_VALUES),
|
||||||
|
"audit diagnostics contain fixture-private material",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_count(connection: Connection, table_name: str) -> int:
|
||||||
|
query = _ROW_COUNT_QUERIES.get(table_name)
|
||||||
|
if query is None:
|
||||||
|
raise ValueError("unsupported retirement proof table")
|
||||||
|
return int(connection.execute(query).scalar_one())
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
if getattr(self, "previous_runtime", None) is None:
|
||||||
|
clear_runtime()
|
||||||
|
else:
|
||||||
|
configure_runtime(self.previous_runtime)
|
||||||
|
if self.engine is not None:
|
||||||
|
if self.ddl_listener_registered:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", self._capture_ddl_connection)
|
||||||
|
self.engine.dispose()
|
||||||
|
self.engine = None
|
||||||
|
if self.admin_engine is not None:
|
||||||
|
try:
|
||||||
|
with self.admin_engine.begin() as connection:
|
||||||
|
connection.execute(DropSchema(self.schema_name, cascade=True, if_exists=True))
|
||||||
|
finally:
|
||||||
|
self.admin_engine.dispose()
|
||||||
|
self.admin_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
95
tests/test_principal_cache.py
Normal file
95
tests/test_principal_cache.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.core.change_sequence import (
|
||||||
|
ChangeSequenceEntry,
|
||||||
|
ChangeSequenceRetentionFloor,
|
||||||
|
prune_sequence_entries,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.principal_cache import (
|
||||||
|
auth_principal_revision,
|
||||||
|
invalidate_auth_principals,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuthPrincipalRevisionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=self.engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.SessionLocal = sessionmaker(bind=self.engine)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_revisions_are_tenant_scoped_and_include_system_changes(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
initial = auth_principal_revision(session, tenant_id="tenant-1")
|
||||||
|
self.assertEqual(initial.system, 0)
|
||||||
|
self.assertEqual(initial.tenant, 0)
|
||||||
|
|
||||||
|
system_entry = invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=None,
|
||||||
|
source_module="access",
|
||||||
|
resource_type="system_role",
|
||||||
|
resource_id="role-1",
|
||||||
|
)
|
||||||
|
tenant_entry = invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_module="idm",
|
||||||
|
resource_type="function_assignment",
|
||||||
|
resource_id="assignment-1",
|
||||||
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
source_module="access",
|
||||||
|
resource_type="role",
|
||||||
|
resource_id="role-2",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
revision = auth_principal_revision(session, tenant_id="tenant-1")
|
||||||
|
self.assertEqual(revision.system, system_entry.id)
|
||||||
|
self.assertEqual(revision.tenant, tenant_entry.id)
|
||||||
|
|
||||||
|
def test_retention_floor_keeps_revision_monotonic_after_pruning(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
entry = invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_module="access",
|
||||||
|
resource_type="role",
|
||||||
|
resource_id="role-1",
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
expected_revision = entry.id
|
||||||
|
prune_sequence_entries(
|
||||||
|
session,
|
||||||
|
before_or_equal_id=expected_revision,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="core",
|
||||||
|
collections=("core.auth-principal-revisions",),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
auth_principal_revision(session, tenant_id="tenant-1").tenant,
|
||||||
|
expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,6 +15,55 @@ from govoplan_core.server.fastapi import create_govoplan_app
|
|||||||
|
|
||||||
|
|
||||||
class QueryMetricsTests(TestCase):
|
class QueryMetricsTests(TestCase):
|
||||||
|
def test_non_sqlite_engine_uses_bounded_pool_configuration(self) -> None:
|
||||||
|
configured = {
|
||||||
|
"GOVOPLAN_DB_POOL_SIZE": "8",
|
||||||
|
"GOVOPLAN_DB_MAX_OVERFLOW": "16",
|
||||||
|
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": "45",
|
||||||
|
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": "900",
|
||||||
|
}
|
||||||
|
engine = object()
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, configured, clear=False),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.create_engine",
|
||||||
|
return_value=engine,
|
||||||
|
) as create_engine,
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.instrument_engine",
|
||||||
|
side_effect=lambda value: value,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = create_database_engine(
|
||||||
|
"postgresql+psycopg://govoplan@example/govoplan",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(engine, result)
|
||||||
|
create_engine.assert_called_once_with(
|
||||||
|
"postgresql+psycopg://govoplan@example/govoplan",
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={},
|
||||||
|
pool_size=8,
|
||||||
|
max_overflow=16,
|
||||||
|
pool_timeout=45,
|
||||||
|
pool_recycle=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_non_sqlite_engine_rejects_pool_values_outside_safe_bounds(self) -> None:
|
||||||
|
invalid_values = (
|
||||||
|
("GOVOPLAN_DB_POOL_SIZE", "0"),
|
||||||
|
("GOVOPLAN_DB_MAX_OVERFLOW", "201"),
|
||||||
|
("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", "not-a-number"),
|
||||||
|
("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", "86401"),
|
||||||
|
)
|
||||||
|
for name, value in invalid_values:
|
||||||
|
with self.subTest(name=name, value=value):
|
||||||
|
with patch.dict(os.environ, {name: value}, clear=False):
|
||||||
|
with self.assertRaisesRegex(ValueError, name):
|
||||||
|
create_database_engine(
|
||||||
|
"postgresql+psycopg://govoplan@example/govoplan",
|
||||||
|
)
|
||||||
|
|
||||||
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
|
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
|
||||||
engine = create_database_engine("sqlite:///:memory:")
|
engine = create_database_engine("sqlite:///:memory:")
|
||||||
try:
|
try:
|
||||||
|
|||||||
237
tests/test_reference_options.py
Normal file
237
tests/test_reference_options.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_DIRECTORY,
|
||||||
|
AccountRef,
|
||||||
|
GroupRef,
|
||||||
|
UserRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.references import (
|
||||||
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
|
ReferenceOption,
|
||||||
|
ReferenceSearchPage,
|
||||||
|
access_scope_reference_page,
|
||||||
|
access_scope_reference_options,
|
||||||
|
validate_access_scope_reference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Directory:
|
||||||
|
users = (
|
||||||
|
UserRef(
|
||||||
|
id="membership-1",
|
||||||
|
account_id="account-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
email="ada@example.test",
|
||||||
|
display_name="Ada",
|
||||||
|
),
|
||||||
|
UserRef(
|
||||||
|
id="membership-2",
|
||||||
|
account_id="account-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
email="inactive@example.test",
|
||||||
|
display_name="Inactive",
|
||||||
|
status="inactive",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
groups = (
|
||||||
|
GroupRef(id="group-1", tenant_id="tenant-1", name="Finance"),
|
||||||
|
GroupRef(
|
||||||
|
id="group-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
name="Former team",
|
||||||
|
status="inactive",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_account(self, account_id):
|
||||||
|
if account_id not in {"account-1", "account-2"}:
|
||||||
|
return None
|
||||||
|
return AccountRef(id=account_id, email=f"{account_id}@example.test")
|
||||||
|
|
||||||
|
def get_user(self, user_id):
|
||||||
|
return next((item for item in self.users if item.id == user_id), None)
|
||||||
|
|
||||||
|
def get_users(self, user_ids):
|
||||||
|
return {item.id: item for item in self.users if item.id in set(user_ids)}
|
||||||
|
|
||||||
|
def users_for_tenant(self, tenant_id):
|
||||||
|
return tuple(item for item in self.users if item.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
def get_group(self, group_id):
|
||||||
|
return next((item for item in self.groups if item.id == group_id), None)
|
||||||
|
|
||||||
|
def get_groups(self, group_ids):
|
||||||
|
return {item.id: item for item in self.groups if item.id in set(group_ids)}
|
||||||
|
|
||||||
|
def groups_for_tenant(self, tenant_id):
|
||||||
|
return tuple(item for item in self.groups if item.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
def groups_for_user(self, user_id, *, tenant_id):
|
||||||
|
del user_id
|
||||||
|
return self.groups_for_tenant(tenant_id)
|
||||||
|
|
||||||
|
def display_label(self, subject):
|
||||||
|
return subject.label
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
directory = _Directory()
|
||||||
|
|
||||||
|
def has_capability(self, name):
|
||||||
|
return name == CAPABILITY_ACCESS_DIRECTORY
|
||||||
|
|
||||||
|
def capability(self, name):
|
||||||
|
return self.directory if self.has_capability(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
class _ReferenceProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def search_reference_options(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
self.requests.append(request)
|
||||||
|
return ReferenceSearchPage(
|
||||||
|
options=(
|
||||||
|
ReferenceOption(
|
||||||
|
value="account-1",
|
||||||
|
label="Ada",
|
||||||
|
kind="user",
|
||||||
|
source_module="access",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
next_cursor="offset:1",
|
||||||
|
has_more=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ProviderRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.provider = _ReferenceProvider()
|
||||||
|
|
||||||
|
def has_capability(self, name):
|
||||||
|
return name == CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||||
|
|
||||||
|
def capability(self, name):
|
||||||
|
return self.provider if self.has_capability(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceOptionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.principal = SimpleNamespace(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
display_name="Ada",
|
||||||
|
email="ada@example.test",
|
||||||
|
group_ids=frozenset({"group-1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_values_are_canonical_account_ids(self) -> None:
|
||||||
|
options = access_scope_reference_options(
|
||||||
|
_Registry(),
|
||||||
|
self.principal,
|
||||||
|
scope_type="user",
|
||||||
|
administrative=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["account-1", "account-2"], [item.value for item in options])
|
||||||
|
self.assertEqual("membership-1", options[0].provenance["membership_id"])
|
||||||
|
self.assertTrue(options[1].disabled)
|
||||||
|
self.assertEqual("inactive", options[1].availability)
|
||||||
|
|
||||||
|
def test_unavailable_selected_values_remain_readable(self) -> None:
|
||||||
|
options = access_scope_reference_options(
|
||||||
|
_Registry(),
|
||||||
|
self.principal,
|
||||||
|
scope_type="group",
|
||||||
|
query="missing",
|
||||||
|
selected_values=("removed-group",),
|
||||||
|
administrative=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(options))
|
||||||
|
self.assertEqual("removed-group", options[0].value)
|
||||||
|
self.assertEqual("unavailable", options[0].availability)
|
||||||
|
self.assertTrue(options[0].disabled)
|
||||||
|
|
||||||
|
def test_non_administrators_only_receive_permitted_scope_targets(self) -> None:
|
||||||
|
users = access_scope_reference_options(
|
||||||
|
_Registry(),
|
||||||
|
self.principal,
|
||||||
|
scope_type="user",
|
||||||
|
administrative=False,
|
||||||
|
)
|
||||||
|
groups = access_scope_reference_options(
|
||||||
|
_Registry(),
|
||||||
|
self.principal,
|
||||||
|
scope_type="group",
|
||||||
|
administrative=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["account-1"], [item.value for item in users])
|
||||||
|
self.assertEqual(["group-1"], [item.value for item in groups])
|
||||||
|
|
||||||
|
def test_inactive_targets_are_rejected_but_existing_values_survive(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "Inactive groups"):
|
||||||
|
validate_access_scope_reference(
|
||||||
|
_Registry(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="group",
|
||||||
|
scope_id="group-2",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"group-2",
|
||||||
|
validate_access_scope_reference(
|
||||||
|
_Registry(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scope_type="group",
|
||||||
|
scope_id="group-2",
|
||||||
|
preserve_existing="group-2",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_missing_optional_access_provider_keeps_reduced_mode(self) -> None:
|
||||||
|
options = access_scope_reference_options(
|
||||||
|
None,
|
||||||
|
self.principal,
|
||||||
|
scope_type="user",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("account-1", options[0].value)
|
||||||
|
self.assertEqual("core", options[0].source_module)
|
||||||
|
|
||||||
|
def test_bounded_provider_receives_normalized_search_and_retains_stale_selection(self) -> None:
|
||||||
|
registry = _ProviderRegistry()
|
||||||
|
|
||||||
|
page = access_scope_reference_page(
|
||||||
|
registry,
|
||||||
|
self.principal,
|
||||||
|
scope_type="user",
|
||||||
|
query=" Ada ",
|
||||||
|
selected_values=("removed-account",),
|
||||||
|
limit=500,
|
||||||
|
cursor="offset:20",
|
||||||
|
administrative=True,
|
||||||
|
session=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
request = registry.provider.requests[0]
|
||||||
|
self.assertEqual("ada", request.query)
|
||||||
|
self.assertEqual(200, request.limit)
|
||||||
|
self.assertEqual("offset:20", request.cursor)
|
||||||
|
self.assertTrue(request.context["administrative"])
|
||||||
|
self.assertEqual(
|
||||||
|
["account-1", "removed-account"],
|
||||||
|
[option.value for option in page.options],
|
||||||
|
)
|
||||||
|
self.assertEqual("unavailable", page.options[1].availability)
|
||||||
|
self.assertTrue(page.has_more)
|
||||||
|
self.assertEqual("offset:1", page.next_cursor)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
170
tests/test_sanctions_contract.py
Normal file
170
tests/test_sanctions_contract.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.sanctions import (
|
||||||
|
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS,
|
||||||
|
CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING,
|
||||||
|
SanctionsScreeningEvidence,
|
||||||
|
SanctionsScreeningFreshness,
|
||||||
|
SanctionsScreeningFreshnessRequest,
|
||||||
|
SanctionsScreeningPolicy,
|
||||||
|
SanctionsScreeningProvider,
|
||||||
|
SanctionsScreeningRequest,
|
||||||
|
SanctionsScreeningResult,
|
||||||
|
SanctionsScreeningSubject,
|
||||||
|
SanctionsSnapshotPayload,
|
||||||
|
SanctionsSnapshotProvider,
|
||||||
|
SanctionsSnapshotReference,
|
||||||
|
sanctions_screening_provider,
|
||||||
|
sanctions_snapshot_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def list_snapshots(self, session, principal, *, limit=100):
|
||||||
|
del session, principal, limit
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_snapshot(self, session, principal, *, snapshot_ref):
|
||||||
|
del session, principal, snapshot_ref
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read_snapshot(self, session, principal, *, snapshot_ref):
|
||||||
|
del session, principal, snapshot_ref
|
||||||
|
raise LookupError
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider):
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name):
|
||||||
|
return name == CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS
|
||||||
|
|
||||||
|
def capability(self, name):
|
||||||
|
return self.provider if self.has_capability(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
class _ScreeningProvider:
|
||||||
|
def request_screening(self, session, principal, request):
|
||||||
|
del session, principal, request
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def check_freshness(self, session, principal, request):
|
||||||
|
del session, principal, request
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class _ScreeningRegistry:
|
||||||
|
def __init__(self, provider):
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name):
|
||||||
|
return name == CAPABILITY_RISK_COMPLIANCE_SANCTIONS_SCREENING
|
||||||
|
|
||||||
|
def capability(self, name):
|
||||||
|
return self.provider if self.has_capability(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
class SanctionsContractTests(unittest.TestCase):
|
||||||
|
def test_snapshot_evidence_is_versioned_and_bounded(self) -> None:
|
||||||
|
content = b"<CONSOLIDATED_LIST/>"
|
||||||
|
snapshot = SanctionsSnapshotReference(
|
||||||
|
ref="sanctions-snapshot:snapshot-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id="un.security_council",
|
||||||
|
publisher="United Nations Security Council",
|
||||||
|
jurisdiction="UN",
|
||||||
|
list_type="consolidated",
|
||||||
|
source_id="unsc-consolidated",
|
||||||
|
source_version="2026-07-22",
|
||||||
|
publication_at=None,
|
||||||
|
effective_at=None,
|
||||||
|
acquired_at=datetime.now(timezone.utc),
|
||||||
|
content_type="application/xml",
|
||||||
|
byte_count=len(content),
|
||||||
|
sha256=hashlib.sha256(content).hexdigest(),
|
||||||
|
parser_version="unsc-xml-v1",
|
||||||
|
raw_evidence_ref="connector-evidence:snapshot-1",
|
||||||
|
connector_run_id="run-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = SanctionsSnapshotPayload(
|
||||||
|
snapshot=snapshot,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
provider = _Provider()
|
||||||
|
|
||||||
|
self.assertEqual(content, payload.content)
|
||||||
|
self.assertIsInstance(provider, SanctionsSnapshotProvider)
|
||||||
|
self.assertIs(
|
||||||
|
provider,
|
||||||
|
sanctions_snapshot_provider(_Registry(provider)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_screening_evidence_and_freshness_are_versioned(self) -> None:
|
||||||
|
subject = SanctionsScreeningSubject(
|
||||||
|
subject_type="person",
|
||||||
|
primary_name="Example Person",
|
||||||
|
)
|
||||||
|
policy = SanctionsScreeningPolicy(failure_policy="review")
|
||||||
|
request = SanctionsScreeningRequest(
|
||||||
|
list_snapshot_id="snapshot-1",
|
||||||
|
idempotency_key="request-1",
|
||||||
|
subject=subject,
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
evidence = SanctionsScreeningEvidence(
|
||||||
|
ref="risk-screening:run-1",
|
||||||
|
run_id="run-1",
|
||||||
|
outcome="clear",
|
||||||
|
candidate_count=0,
|
||||||
|
list_snapshot_id=request.list_snapshot_id,
|
||||||
|
source_version="2026-07-30",
|
||||||
|
subject_fingerprint="a" * 64,
|
||||||
|
matcher_version="matcher-v1",
|
||||||
|
normalization_version="normalization-v1",
|
||||||
|
policy_version="policy-v1",
|
||||||
|
completed_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
freshness_request = SanctionsScreeningFreshnessRequest(
|
||||||
|
evidence_ref=evidence.ref,
|
||||||
|
current_subject=subject,
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
freshness = SanctionsScreeningFreshness(
|
||||||
|
evidence=evidence,
|
||||||
|
fresh=True,
|
||||||
|
reasons=(),
|
||||||
|
checked_at=datetime.now(timezone.utc),
|
||||||
|
current_list_snapshot_id=request.list_snapshot_id,
|
||||||
|
gate_decision="allow",
|
||||||
|
gate_reasons=("screening_clear",),
|
||||||
|
)
|
||||||
|
result = SanctionsScreeningResult(
|
||||||
|
evidence=evidence,
|
||||||
|
freshness=freshness,
|
||||||
|
created=True,
|
||||||
|
)
|
||||||
|
provider = _ScreeningProvider()
|
||||||
|
|
||||||
|
self.assertEqual(evidence.ref, freshness_request.evidence_ref)
|
||||||
|
self.assertTrue(result.freshness.fresh)
|
||||||
|
self.assertIsInstance(provider, SanctionsScreeningProvider)
|
||||||
|
self.assertIs(
|
||||||
|
provider,
|
||||||
|
sanctions_screening_provider(
|
||||||
|
_ScreeningRegistry(provider)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_screening_contract_rejects_ambiguous_subjects(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
SanctionsScreeningSubject(subject_type="entity")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
163
tests/test_search_contract.py
Normal file
163
tests/test_search_contract.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.search import (
|
||||||
|
SearchAuthorizationRequest,
|
||||||
|
SearchBackfillPage,
|
||||||
|
SearchBackfillRequest,
|
||||||
|
SearchDocument,
|
||||||
|
SearchProvider,
|
||||||
|
SearchProviderRegistration,
|
||||||
|
SearchQuery,
|
||||||
|
SearchResourceReference,
|
||||||
|
SearchResourceType,
|
||||||
|
SearchResult,
|
||||||
|
SearchSourceProvider,
|
||||||
|
SearchSourceProviderRegistration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def search(self, session, principal, *, query):
|
||||||
|
del session, principal
|
||||||
|
return (
|
||||||
|
SearchResult(
|
||||||
|
provider_id="test",
|
||||||
|
module_id="test",
|
||||||
|
resource_type="record",
|
||||||
|
resource_id="1",
|
||||||
|
title=query.text,
|
||||||
|
url="/test/1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Source:
|
||||||
|
def resource_types(self):
|
||||||
|
return (
|
||||||
|
SearchResourceType(
|
||||||
|
provider_id="test.records",
|
||||||
|
module_id="test",
|
||||||
|
resource_type="record",
|
||||||
|
label="Records",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def backfill(self, session, *, request):
|
||||||
|
del session
|
||||||
|
return SearchBackfillPage(
|
||||||
|
documents=(),
|
||||||
|
next_cursor=None,
|
||||||
|
complete=True,
|
||||||
|
high_watermark=request.cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize(self, session, principal, *, requests):
|
||||||
|
del session, principal
|
||||||
|
return {
|
||||||
|
request.reference.key: True
|
||||||
|
for request in requests
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SearchContractTests(unittest.TestCase):
|
||||||
|
def test_query_and_restricted_document_are_bounded(self) -> None:
|
||||||
|
query = SearchQuery(text=" permit ", tenant_id="tenant-1", limit=20)
|
||||||
|
document = SearchDocument(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Permit",
|
||||||
|
url="/cases/case-1",
|
||||||
|
acl_tokens=("account:account-1",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("permit", query.text)
|
||||||
|
self.assertEqual("restricted", document.visibility)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
SearchDocument(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Permit",
|
||||||
|
url="/cases/case-1",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "body"):
|
||||||
|
SearchDocument(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
title="Permit",
|
||||||
|
url="/cases/case-1",
|
||||||
|
body="x" * 200_001,
|
||||||
|
acl_tokens=("account:account-1",),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_manifest_provider_registration_resolves_lazily(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="test",
|
||||||
|
name="Test",
|
||||||
|
version="1",
|
||||||
|
search_providers=(
|
||||||
|
SearchProviderRegistration(
|
||||||
|
id="test.records",
|
||||||
|
factory=lambda context: _Provider(),
|
||||||
|
resource_types=("record",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="test.records",
|
||||||
|
factory=lambda context: _Source(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
providers = registry.search_providers()
|
||||||
|
self.assertEqual(1, len(providers))
|
||||||
|
self.assertIsInstance(providers[0][1], SearchProvider)
|
||||||
|
sources = registry.search_sources()
|
||||||
|
self.assertEqual(1, len(sources))
|
||||||
|
self.assertIsInstance(sources[0][1], SearchSourceProvider)
|
||||||
|
request = SearchAuthorizationRequest(
|
||||||
|
reference=SearchResourceReference(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
module_id="test",
|
||||||
|
resource_type="record",
|
||||||
|
resource_id="1",
|
||||||
|
),
|
||||||
|
source_revision="1",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
sources[0][1].authorize(
|
||||||
|
object(),
|
||||||
|
object(),
|
||||||
|
requests=(request,),
|
||||||
|
)[request.reference.key]
|
||||||
|
)
|
||||||
|
page = sources[0][1].backfill(
|
||||||
|
object(),
|
||||||
|
request=SearchBackfillRequest(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
provider_id="test.records",
|
||||||
|
resource_type="record",
|
||||||
|
rebuild_id="rebuild-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertTrue(page.complete)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
117
tests/test_tabular_source_contract.py
Normal file
117
tests/test_tabular_source_contract.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.tabular_sources import (
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER,
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SOURCES,
|
||||||
|
TabularColumn,
|
||||||
|
TabularReadRequest,
|
||||||
|
TabularReadResult,
|
||||||
|
TabularSnapshotInput,
|
||||||
|
TabularSnapshotWriter,
|
||||||
|
TabularSource,
|
||||||
|
TabularSourceProvider,
|
||||||
|
TabularSourceValidationError,
|
||||||
|
parse_tabular_csv,
|
||||||
|
tabular_snapshot_writer,
|
||||||
|
tabular_source_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _TabularProvider:
|
||||||
|
source = TabularSource(
|
||||||
|
ref="snapshot:source-1",
|
||||||
|
provider="snapshot",
|
||||||
|
source_name="monthly_cases",
|
||||||
|
name="Monthly cases",
|
||||||
|
schema=(TabularColumn(name="case_id", data_type="string", nullable=False),),
|
||||||
|
fingerprint="abc123",
|
||||||
|
row_count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_sources(self, session, principal, *, query="", limit=100):
|
||||||
|
del session, principal
|
||||||
|
if query and query.casefold() not in self.source.name.casefold():
|
||||||
|
return ()
|
||||||
|
return (self.source,)[:limit]
|
||||||
|
|
||||||
|
def get_source(self, session, principal, *, source_ref):
|
||||||
|
del session, principal
|
||||||
|
return self.source if source_ref == self.source.ref else None
|
||||||
|
|
||||||
|
def read_source(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
rows = ({"case_id": "A-1"},)
|
||||||
|
selected = rows[request.offset : request.offset + request.limit]
|
||||||
|
return TabularReadResult(
|
||||||
|
source=self.source,
|
||||||
|
rows=selected,
|
||||||
|
total_rows=len(rows),
|
||||||
|
truncated=len(selected) < len(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_snapshot(self, session, principal, *, snapshot):
|
||||||
|
del session, principal, snapshot
|
||||||
|
return self.source
|
||||||
|
|
||||||
|
|
||||||
|
class TabularSourceContractTests(unittest.TestCase):
|
||||||
|
def test_provider_and_snapshot_writer_are_runtime_checkable(self) -> None:
|
||||||
|
provider = _TabularProvider()
|
||||||
|
|
||||||
|
self.assertIsInstance(provider, TabularSourceProvider)
|
||||||
|
self.assertIsInstance(provider, TabularSnapshotWriter)
|
||||||
|
|
||||||
|
def test_capabilities_resolve_without_importing_connectors(self) -> None:
|
||||||
|
provider = _TabularProvider()
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="tabular_contract_test",
|
||||||
|
name="Tabular contract test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SOURCES: lambda context: provider,
|
||||||
|
CAPABILITY_CONNECTORS_TABULAR_SNAPSHOT_WRITER: lambda context: provider,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||||
|
|
||||||
|
self.assertIs(provider, tabular_source_provider(registry))
|
||||||
|
self.assertIs(provider, tabular_snapshot_writer(registry))
|
||||||
|
self.assertIsNone(tabular_source_provider(PlatformRegistry()))
|
||||||
|
|
||||||
|
def test_read_and_snapshot_dtos_are_immutable_and_bounded_by_callers(self) -> None:
|
||||||
|
provider = _TabularProvider()
|
||||||
|
request = TabularReadRequest(source_ref=provider.source.ref, limit=1)
|
||||||
|
result = provider.read_source(object(), object(), request=request)
|
||||||
|
snapshot = TabularSnapshotInput(
|
||||||
|
name="Monthly cases",
|
||||||
|
source_name="monthly_cases",
|
||||||
|
rows=({"case_id": "A-1"},),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(({"case_id": "A-1"},), result.rows)
|
||||||
|
self.assertEqual(provider.source, provider.create_snapshot(object(), object(), snapshot=snapshot))
|
||||||
|
|
||||||
|
def test_shared_csv_parser_preserves_identifier_zeroes_and_rejects_extra_values(self) -> None:
|
||||||
|
rows = parse_tabular_csv(
|
||||||
|
"case_id;amount;active\n0012;7.5;true\n\n",
|
||||||
|
delimiter=";",
|
||||||
|
max_rows=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
({"case_id": "0012", "amount": 7.5, "active": True},),
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
with self.assertRaises(TabularSourceValidationError):
|
||||||
|
parse_tabular_csv("id,name\n1,Ada,extra\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
237
tests/test_tenant_summary_batching.py
Normal file
237
tests/test_tenant_summary_batching.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.tenancy.service import tenant_counts_many
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_EMPTY_COUNTS = {
|
||||||
|
"users": 0,
|
||||||
|
"active_users": 0,
|
||||||
|
"groups": 0,
|
||||||
|
"campaigns": 0,
|
||||||
|
"files": 0,
|
||||||
|
"api_keys": 0,
|
||||||
|
"active_api_keys": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _BatchAccessAdministration:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def tenant_counts_many(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
del session
|
||||||
|
ids = tuple(tenant_ids)
|
||||||
|
self.calls.append(ids)
|
||||||
|
return {
|
||||||
|
tenant_id: {
|
||||||
|
"users": index + 1,
|
||||||
|
"active_users": index + 1,
|
||||||
|
"groups": 1,
|
||||||
|
"api_keys": 0,
|
||||||
|
"active_api_keys": 0,
|
||||||
|
}
|
||||||
|
for index, tenant_id in enumerate(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TenantSummaryBatchingTests(unittest.TestCase):
|
||||||
|
def test_absent_providers_return_complete_zero_summaries(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.tenancy.service.get_registry", return_value=registry),
|
||||||
|
patch("govoplan_core.tenancy.service._access_administration", return_value=None),
|
||||||
|
):
|
||||||
|
result = tenant_counts_many(object(), ["tenant-1", "tenant-2"])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"tenant-1": EXPECTED_EMPTY_COUNTS,
|
||||||
|
"tenant-2": EXPECTED_EMPTY_COUNTS,
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_single_tenant_provider_remains_supported(self) -> None:
|
||||||
|
calls: list[str] = []
|
||||||
|
|
||||||
|
def single_provider(session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
|
del session
|
||||||
|
calls.append(tenant_id)
|
||||||
|
return {"campaigns": 1}
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="campaigns",
|
||||||
|
name="Campaigns",
|
||||||
|
version="test",
|
||||||
|
tenant_summary_providers=(single_provider,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.tenancy.service.get_registry", return_value=registry),
|
||||||
|
patch("govoplan_core.tenancy.service._access_administration", return_value=None),
|
||||||
|
):
|
||||||
|
result = tenant_counts_many(
|
||||||
|
object(),
|
||||||
|
["tenant-1", "tenant-2"],
|
||||||
|
module_ids=("campaigns",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["tenant-1", "tenant-2"], calls)
|
||||||
|
self.assertEqual(1, result["tenant-1"]["campaigns"])
|
||||||
|
self.assertEqual(1, result["tenant-2"]["campaigns"])
|
||||||
|
|
||||||
|
def test_batch_provider_handles_single_item_and_partial_results(self) -> None:
|
||||||
|
calls: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def single_provider(session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
|
raise AssertionError(f"single provider should not run for {tenant_id}")
|
||||||
|
|
||||||
|
def batch_provider(
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
del session
|
||||||
|
calls.append(tuple(tenant_ids))
|
||||||
|
return {tenant_ids[0]: {"files": 7}}
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="test",
|
||||||
|
tenant_summary_providers=(single_provider,),
|
||||||
|
tenant_summary_batch_providers=(batch_provider,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.tenancy.service.get_registry", return_value=registry),
|
||||||
|
patch("govoplan_core.tenancy.service._access_administration", return_value=None),
|
||||||
|
):
|
||||||
|
single = tenant_counts_many(object(), ["tenant-1"], module_ids=("files",))
|
||||||
|
partial = tenant_counts_many(
|
||||||
|
object(),
|
||||||
|
["tenant-1", "tenant-2"],
|
||||||
|
module_ids=("files",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([("tenant-1",), ("tenant-1", "tenant-2")], calls)
|
||||||
|
self.assertEqual(7, single["tenant-1"]["files"])
|
||||||
|
self.assertEqual(7, partial["tenant-1"]["files"])
|
||||||
|
self.assertEqual(0, partial["tenant-2"]["files"])
|
||||||
|
|
||||||
|
def test_batch_provider_failures_remain_visible(self) -> None:
|
||||||
|
def single_provider(session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def failing_provider(
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
raise RuntimeError("summary unavailable")
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="test",
|
||||||
|
tenant_summary_providers=(single_provider,),
|
||||||
|
tenant_summary_batch_providers=(failing_provider,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.tenancy.service.get_registry", return_value=registry),
|
||||||
|
patch("govoplan_core.tenancy.service._access_administration", return_value=None),
|
||||||
|
self.assertRaisesRegex(RuntimeError, "summary unavailable"),
|
||||||
|
):
|
||||||
|
tenant_counts_many(object(), ["tenant-1"], module_ids=("files",))
|
||||||
|
|
||||||
|
def test_provider_calls_are_bounded_for_large_pages(self) -> None:
|
||||||
|
provider_calls = {"campaigns": 0, "files": 0}
|
||||||
|
|
||||||
|
def single_provider(session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
|
raise AssertionError(f"single provider should not run for {tenant_id}")
|
||||||
|
|
||||||
|
def batch_provider(module_id: str):
|
||||||
|
def provide(
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
del session
|
||||||
|
provider_calls[module_id] += 1
|
||||||
|
return {tenant_id: {} for tenant_id in tenant_ids}
|
||||||
|
|
||||||
|
return provide
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
for module_id in ("campaigns", "files"):
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id=module_id,
|
||||||
|
name=module_id.title(),
|
||||||
|
version="test",
|
||||||
|
tenant_summary_providers=(single_provider,),
|
||||||
|
tenant_summary_batch_providers=(batch_provider(module_id),),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
access = _BatchAccessAdministration()
|
||||||
|
tenant_ids = [f"tenant-{index}" for index in range(100)]
|
||||||
|
with (
|
||||||
|
patch("govoplan_core.tenancy.service.get_registry", return_value=registry),
|
||||||
|
patch("govoplan_core.tenancy.service._access_administration", return_value=access),
|
||||||
|
):
|
||||||
|
result = tenant_counts_many(
|
||||||
|
object(),
|
||||||
|
tenant_ids,
|
||||||
|
module_ids=("campaigns", "files"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual({"campaigns": 1, "files": 1}, provider_calls)
|
||||||
|
self.assertEqual([tuple(tenant_ids)], access.calls)
|
||||||
|
self.assertEqual(100, len(result))
|
||||||
|
self.assertEqual(100, result["tenant-99"]["users"])
|
||||||
|
|
||||||
|
def test_registry_replacement_carries_batch_providers(self) -> None:
|
||||||
|
def single_provider(session: object, tenant_id: str) -> Mapping[str, int]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def batch_provider(
|
||||||
|
session: object,
|
||||||
|
tenant_ids: Sequence[str],
|
||||||
|
) -> Mapping[str, Mapping[str, int]]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.replace(
|
||||||
|
[
|
||||||
|
ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="test",
|
||||||
|
tenant_summary_providers=(single_provider,),
|
||||||
|
tenant_summary_batch_providers=(batch_provider,),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(
|
||||||
|
batch_provider,
|
||||||
|
registry.tenant_summary_batch_providers()["files"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
262
tests/test_view_surfaces.py
Normal file
262
tests/test_view_surfaces.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from govoplan_core.auth import get_api_principal
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import (
|
||||||
|
PlatformRegistry,
|
||||||
|
RegistryError,
|
||||||
|
manifest_view_surfaces,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
from govoplan_core.server.registry import available_module_manifests
|
||||||
|
|
||||||
|
|
||||||
|
def example_manifest(
|
||||||
|
*,
|
||||||
|
custom_surfaces: tuple[ViewSurface, ...] = (),
|
||||||
|
) -> ModuleManifest:
|
||||||
|
return ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/example",
|
||||||
|
label="Example",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/example",
|
||||||
|
component="ExamplePage",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=custom_surfaces,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewSurfaceContractTests(unittest.TestCase):
|
||||||
|
def test_manifest_derives_module_navigation_and_route_surfaces(self) -> None:
|
||||||
|
surfaces = manifest_view_surfaces(example_manifest())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"example.module",
|
||||||
|
"example.nav.example",
|
||||||
|
"example.route.example",
|
||||||
|
],
|
||||||
|
[surface.id for surface in surfaces],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"example.module",
|
||||||
|
next(
|
||||||
|
surface.parent_id
|
||||||
|
for surface in surfaces
|
||||||
|
if surface.id == "example.route.example"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_includes_valid_custom_surface(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.settings.connection",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Connection settings",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
custom_surface = next(
|
||||||
|
surface
|
||||||
|
for surface in registry.view_surfaces()
|
||||||
|
if surface.id == "example.settings.connection"
|
||||||
|
)
|
||||||
|
self.assertEqual("example.module", custom_surface.parent_id)
|
||||||
|
|
||||||
|
def test_registry_rejects_surface_owned_by_another_module(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="other.settings.connection",
|
||||||
|
module_id="other",
|
||||||
|
kind="section",
|
||||||
|
label="Connection settings",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "belongs to"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_rejects_unknown_surface_parent(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.settings.connection",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Connection settings",
|
||||||
|
parent_id="example.missing",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "unknown parent"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_rejects_cyclic_surface_hierarchy(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.section.one",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="One",
|
||||||
|
parent_id="example.section.two",
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="example.section.two",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Two",
|
||||||
|
parent_id="example.section.one",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "contains a cycle"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_required_surface_must_be_visible_by_default(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.recovery",
|
||||||
|
module_id="example",
|
||||||
|
kind="action",
|
||||||
|
label="Recovery",
|
||||||
|
required=True,
|
||||||
|
default_visible=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "visible by default"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_validates_automatically_derived_surface_ids(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path=f"/{'segment-' * 30}",
|
||||||
|
component="LongRoutePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "View surface id"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_platform_module_payload_exposes_normalized_surface_contract(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.section.details",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Details",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.validate()
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: object()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/platform/modules")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
frontend = response.json()["modules"][0]["frontend"]
|
||||||
|
self.assertEqual("1", frontend["view_surface_contract_version"])
|
||||||
|
self.assertEqual(
|
||||||
|
"example.route.example",
|
||||||
|
frontend["routes"][0]["surface_id"],
|
||||||
|
)
|
||||||
|
surfaces = {
|
||||||
|
surface["id"]: surface
|
||||||
|
for surface in frontend["view_surfaces"]
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
"example.module",
|
||||||
|
surfaces["example.section.details"]["parent_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_access_admin_route_accepts_view_manager_permissions(self) -> None:
|
||||||
|
access = available_module_manifests()["access"]
|
||||||
|
admin_route = next(
|
||||||
|
route
|
||||||
|
for route in access.frontend.routes
|
||||||
|
if route.path == "/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"views:definition:read",
|
||||||
|
"views:assignment:read",
|
||||||
|
"views:system_definition:read",
|
||||||
|
"views:system_assignment:read",
|
||||||
|
}.issubset(admin_route.required_any)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
127
tests/test_wheel_runtime.py
Normal file
127
tests/test_wheel_runtime.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import sysconfig
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class WheelRuntimeTests(unittest.TestCase):
|
||||||
|
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
|
||||||
|
repository_root = Path(__file__).resolve().parents[1]
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
|
||||||
|
temporary_root = Path(directory)
|
||||||
|
wheel_directory = temporary_root / "wheels"
|
||||||
|
wheel_directory.mkdir()
|
||||||
|
self._run(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"wheel",
|
||||||
|
"--no-deps",
|
||||||
|
"--wheel-dir",
|
||||||
|
str(wheel_directory),
|
||||||
|
str(repository_root),
|
||||||
|
cwd=temporary_root,
|
||||||
|
)
|
||||||
|
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
|
||||||
|
self.assertEqual(1, len(core_wheels))
|
||||||
|
|
||||||
|
virtual_environment = temporary_root / "venv"
|
||||||
|
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
|
||||||
|
venv_python = virtual_environment / "bin" / "python"
|
||||||
|
self._run(
|
||||||
|
str(venv_python),
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--no-deps",
|
||||||
|
str(core_wheels[0]),
|
||||||
|
cwd=temporary_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
database_path = temporary_root / "wheel-runtime.db"
|
||||||
|
probe = self._run(
|
||||||
|
str(venv_python),
|
||||||
|
"-c",
|
||||||
|
self._probe_script(),
|
||||||
|
str(database_path),
|
||||||
|
cwd=temporary_root,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
# Reuse only third-party test dependencies from the parent
|
||||||
|
# environment. Editable .pth files are not processed from
|
||||||
|
# PYTHONPATH, so Core itself can only come from the wheel.
|
||||||
|
"PYTHONPATH": sysconfig.get_path("purelib"),
|
||||||
|
"GOVOPLAN_CORE_SOURCE_ROOT": "",
|
||||||
|
"GOVOPLAN_ENABLED_MODULES": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result = json.loads(probe.stdout)
|
||||||
|
installed_package = Path(result["package_file"])
|
||||||
|
runtime_root = Path(result["runtime_root"])
|
||||||
|
self.assertTrue(installed_package.is_relative_to(virtual_environment))
|
||||||
|
self.assertTrue(runtime_root.is_relative_to(virtual_environment))
|
||||||
|
self.assertNotEqual(repository_root, runtime_root)
|
||||||
|
self.assertTrue((runtime_root / "alembic.ini").is_file())
|
||||||
|
self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
|
||||||
|
self.assertEqual(["c91f0a72be34"], result["heads"])
|
||||||
|
self.assertIn("core_scopes", result["tables"])
|
||||||
|
self.assertIn("core_system_settings", result["tables"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run(*command: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=cwd,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Command failed ({result.returncode}): {' '.join(command)}\n"
|
||||||
|
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _probe_script() -> str:
|
||||||
|
return """
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
import govoplan_core
|
||||||
|
from govoplan_core.db.migrations import _repo_root, alembic_config
|
||||||
|
|
||||||
|
database_url = f"sqlite:///{Path(sys.argv[1])}"
|
||||||
|
config = alembic_config(database_url=database_url, enabled_modules=())
|
||||||
|
command.upgrade(config, "heads")
|
||||||
|
script = ScriptDirectory.from_config(config)
|
||||||
|
engine = create_engine(database_url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
tables = sorted(inspect(connection).get_table_names())
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
print(json.dumps({
|
||||||
|
"package_file": govoplan_core.__file__,
|
||||||
|
"runtime_root": str(_repo_root()),
|
||||||
|
"heads": sorted(script.get_heads()),
|
||||||
|
"tables": tables,
|
||||||
|
}))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
87
tests/test_workflow_runtime_worker.py
Normal file
87
tests/test_workflow_runtime_worker.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from govoplan_core.celery_app import (
|
||||||
|
celery,
|
||||||
|
reconcile_workflow_instances,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
|
from govoplan_core.core.workflows import (
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||||
|
WorkflowRuntimeWorker,
|
||||||
|
workflow_runtime_worker,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Worker:
|
||||||
|
def reconcile_pending(self, session, *, now=None, limit=50):
|
||||||
|
return {"inspected": 0, "advanced": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRuntimeWorkerTests(unittest.TestCase):
|
||||||
|
def test_contract_is_runtime_checkable_and_resolved(self) -> None:
|
||||||
|
worker = _Worker()
|
||||||
|
self.assertIsInstance(worker, WorkflowRuntimeWorker)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="workflow_runtime_test",
|
||||||
|
name="Workflow runtime test",
|
||||||
|
version="test",
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_WORKFLOW_RUNTIME_WORKER: (
|
||||||
|
lambda _context: worker
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(worker, workflow_runtime_worker(registry))
|
||||||
|
self.assertIsNone(workflow_runtime_worker(PlatformRegistry()))
|
||||||
|
|
||||||
|
def test_celery_task_commits_reconciliation(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
database = MagicMock()
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
worker = MagicMock()
|
||||||
|
worker.reconcile_pending.return_value = {
|
||||||
|
"inspected": 2,
|
||||||
|
"advanced": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_core.celery_app._workflow_runtime_worker",
|
||||||
|
return_value=worker,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_core.db.session.get_database",
|
||||||
|
return_value=database,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = reconcile_workflow_instances.run(25)
|
||||||
|
|
||||||
|
worker.reconcile_pending.assert_called_once_with(session, limit=25)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
self.assertEqual(1, result["advanced"])
|
||||||
|
|
||||||
|
def test_route_and_periodic_job_are_registered(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
{"queue": "workflow"},
|
||||||
|
celery.conf.task_routes["govoplan.workflow.reconcile"],
|
||||||
|
)
|
||||||
|
schedule = celery.conf.beat_schedule[
|
||||||
|
"workflow-reconcile-every-five-seconds"
|
||||||
|
]
|
||||||
|
self.assertEqual("govoplan.workflow.reconcile", schedule["task"])
|
||||||
|
self.assertEqual(5.0, schedule["schedule"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
10
webui/bundle-budget.json
Normal file
10
webui/bundle-budget.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"initialJs": {
|
||||||
|
"rawBytes": 524288,
|
||||||
|
"gzipBytes": 163840
|
||||||
|
},
|
||||||
|
"asyncChunk": {
|
||||||
|
"rawBytes": 393216,
|
||||||
|
"gzipBytes": 112640
|
||||||
|
}
|
||||||
|
}
|
||||||
526
webui/package-lock.json
generated
526
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||||
|
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
||||||
|
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
@@ -23,16 +25,22 @@
|
|||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui"
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
"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",
|
||||||
"read-excel-file": "^9.2.0",
|
"read-excel-file": "^9.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^6.0.6"
|
||||||
@@ -41,21 +49,21 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../../govoplan-access/webui": {
|
"../../govoplan-access/webui": {
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -65,13 +73,13 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-addresses/webui": {
|
"../../govoplan-addresses/webui": {
|
||||||
"name": "@govoplan/addresses-webui",
|
"name": "@govoplan/addresses-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -86,11 +94,11 @@
|
|||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -102,11 +110,11 @@
|
|||||||
"name": "@govoplan/audit-webui",
|
"name": "@govoplan/audit-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -118,12 +126,12 @@
|
|||||||
"name": "@govoplan/calendar-webui",
|
"name": "@govoplan/calendar-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -135,7 +143,7 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-campaign/webui": {
|
"../../govoplan-campaign/webui": {
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
@@ -143,11 +151,11 @@
|
|||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.12",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -163,7 +171,42 @@
|
|||||||
"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"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../govoplan-dataflow/webui": {
|
||||||
|
"name": "@govoplan/dataflow-webui",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../govoplan-datasources/webui": {
|
||||||
|
"name": "@govoplan/datasources-webui",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -173,14 +216,14 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-docs/webui": {
|
"../../govoplan-docs/webui": {
|
||||||
"name": "@govoplan/docs-webui",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.10",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"@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"
|
||||||
},
|
},
|
||||||
@@ -192,14 +235,14 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-files/webui": {
|
"../../govoplan-files/webui": {
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -213,12 +256,12 @@
|
|||||||
"name": "@govoplan/idm-webui",
|
"name": "@govoplan/idm-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -230,16 +273,16 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-mail/webui": {
|
"../../govoplan-mail/webui": {
|
||||||
"name": "@govoplan/mail-webui",
|
"name": "@govoplan/mail-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.10",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -251,12 +294,12 @@
|
|||||||
"name": "@govoplan/notifications-webui",
|
"name": "@govoplan/notifications-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -275,7 +318,7 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -289,12 +332,12 @@
|
|||||||
"name": "@govoplan/organizations-webui",
|
"name": "@govoplan/organizations-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -306,13 +349,45 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-policy/webui": {
|
"../../govoplan-policy/webui": {
|
||||||
"name": "@govoplan/policy-webui",
|
"name": "@govoplan/policy-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.9",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.9",
|
||||||
"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"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../govoplan-postbox/webui": {
|
||||||
|
"name": "@govoplan/postbox-webui",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../govoplan-risk-compliance/webui": {
|
||||||
|
"name": "@govoplan/risk-compliance-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
@@ -322,14 +397,14 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-scheduling/webui": {
|
"../../govoplan-scheduling/webui": {
|
||||||
"name": "@govoplan/scheduling-webui",
|
"name": "@govoplan/scheduling-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"@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"
|
||||||
},
|
},
|
||||||
@@ -339,6 +414,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-search/webui": {
|
||||||
|
"name": "@govoplan/search-webui",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../govoplan-views/webui": {
|
||||||
|
"name": "@govoplan/views-webui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../../govoplan-workflow/webui": {
|
||||||
|
"name": "@govoplan/workflow-webui",
|
||||||
|
"version": "0.1.14",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": ">=7.18.2 <8",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1091,6 +1216,14 @@
|
|||||||
"resolved": "../../govoplan-dashboard/webui",
|
"resolved": "../../govoplan-dashboard/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/dataflow-webui": {
|
||||||
|
"resolved": "../../govoplan-dataflow/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@govoplan/datasources-webui": {
|
||||||
|
"resolved": "../../govoplan-datasources/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/docs-webui": {
|
"node_modules/@govoplan/docs-webui": {
|
||||||
"resolved": "../../govoplan-docs/webui",
|
"resolved": "../../govoplan-docs/webui",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1123,10 +1256,30 @@
|
|||||||
"resolved": "../../govoplan-policy/webui",
|
"resolved": "../../govoplan-policy/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/postbox-webui": {
|
||||||
|
"resolved": "../../govoplan-postbox/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@govoplan/risk-compliance-webui": {
|
||||||
|
"resolved": "../../govoplan-risk-compliance/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/scheduling-webui": {
|
"node_modules/@govoplan/scheduling-webui": {
|
||||||
"resolved": "../../govoplan-scheduling/webui",
|
"resolved": "../../govoplan-scheduling/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/search-webui": {
|
||||||
|
"resolved": "../../govoplan-search/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@govoplan/views-webui": {
|
||||||
|
"resolved": "../../govoplan-views/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@govoplan/workflow-webui": {
|
||||||
|
"resolved": "../../govoplan-workflow/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@@ -1618,6 +1771,61 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-drag": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-transition": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1676,6 +1884,50 @@
|
|||||||
"node": ">=14.6"
|
"node": ">=14.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
|
||||||
|
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.79",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=17",
|
||||||
|
"@types/react-dom": ">=17",
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.79",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
|
||||||
|
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.38",
|
"version": "2.10.38",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||||
@@ -1744,6 +1996,13 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/convert-source-map": {
|
"node_modules/convert-source-map": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||||
@@ -1772,6 +2031,120 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1967,9 +2340,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.16",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2023,9 +2396,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.23",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2043,7 +2416,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.16",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -2085,9 +2458,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==",
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2108,13 +2481,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.0",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==",
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.0"
|
"react-router": "7.18.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -2294,6 +2667,16 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
@@ -2375,6 +2758,35 @@
|
|||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#v0.1.8",
|
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.8",
|
||||||
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#v0.1.8",
|
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.8",
|
||||||
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#v0.1.8",
|
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.8",
|
||||||
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#v0.1.8",
|
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.8",
|
||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.8",
|
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
|
||||||
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#v0.1.8",
|
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.8",
|
||||||
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#v0.1.8",
|
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.8",
|
||||||
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.8",
|
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.8",
|
||||||
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#v0.1.8",
|
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.8",
|
||||||
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.8",
|
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.10",
|
||||||
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#v0.1.8",
|
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
|
||||||
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#v0.1.8",
|
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
|
||||||
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
@@ -721,7 +721,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/access-webui": {
|
"node_modules/@govoplan/access-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-access.git#f2afcaa09c8201b141b6593f8a1ba574354e4db8",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#f2afcaa09c8201b141b6593f8a1ba574354e4db8",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -737,7 +737,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/admin-webui": {
|
"node_modules/@govoplan/admin-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-admin.git#11ecf362a36d2244633194e55b0e9900669d72b7",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#11ecf362a36d2244633194e55b0e9900669d72b7",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -753,7 +753,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/audit-webui": {
|
"node_modules/@govoplan/audit-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-audit.git#d3d2c60d7dc180cbd402cfeae38fa4f47f96969a",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#d3d2c60d7dc180cbd402cfeae38fa4f47f96969a",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -769,7 +769,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/calendar-webui": {
|
"node_modules/@govoplan/calendar-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-calendar.git#9bcf41bb1fbb3d6dfc81adcd97d781ab411b695d",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#9bcf41bb1fbb3d6dfc81adcd97d781ab411b695d",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -787,13 +787,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/campaign-webui": {
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#4774a8025cea3fd0962a23741e6d0241d4062663",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.12",
|
||||||
"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",
|
||||||
@@ -807,7 +807,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/dashboard-webui": {
|
"node_modules/@govoplan/dashboard-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-dashboard.git#4b960ad37f0de0ef5224a0db22e95f84f0c9b3ca",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#4b960ad37f0de0ef5224a0db22e95f84f0c9b3ca",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
@@ -823,7 +823,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/docs-webui": {
|
"node_modules/@govoplan/docs-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-docs.git#ec748e3fcfb96f52a99c004ec218058db73d01f6",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#ec748e3fcfb96f52a99c004ec218058db73d01f6",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -842,7 +842,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/files-webui": {
|
"node_modules/@govoplan/files-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#8bf7296bf5b251c8309a5f0d023ff6428b31a353",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#8bf7296bf5b251c8309a5f0d023ff6428b31a353",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -861,7 +861,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/idm-webui": {
|
"node_modules/@govoplan/idm-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-idm.git#b47f71916da8438f5f748d170b45f7b97fa3bfb3",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#b47f71916da8438f5f748d170b45f7b97fa3bfb3",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -879,10 +879,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@govoplan/mail-webui": {
|
"node_modules/@govoplan/mail-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.10",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#5371cb3ad4b3882a3ffc377643183f66ab318619",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#3e23029090224fd01741242008e4991e0e548de1",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"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",
|
||||||
@@ -896,7 +896,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/ops-webui": {
|
"node_modules/@govoplan/ops-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-ops.git#341773a4ff8a76914a631e716e6f3fff2c0dc5ec",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#341773a4ff8a76914a631e716e6f3fff2c0dc5ec",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -915,7 +915,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/organizations-webui": {
|
"node_modules/@govoplan/organizations-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-organizations.git#39c081c4fb8f37547c5f20b6a4fcad003c164b26",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#39c081c4fb8f37547c5f20b6a4fcad003c164b26",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -934,7 +934,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@govoplan/policy-webui": {
|
"node_modules/@govoplan/policy-webui": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#664eb38ab92c8ca2e755668eb125938c70ab27fb",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#664eb38ab92c8ca2e755668eb125938c70ab27fb",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user