Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e11ea81008 | |||
| bc8afeb139 | |||
| f876345656 | |||
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 |
@@ -5,7 +5,11 @@ 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.core.migrations import migration_metadata_plan
|
from govoplan_core.core.migrations import migration_metadata_plan
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -788,7 +788,18 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -462,11 +463,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:
|
||||||
|
|||||||
@@ -4211,18 +4211,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 +4285,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 +4352,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)
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
143
tests/test_wheel_runtime.py
Normal file
143
tests/test_wheel_runtime.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
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]
|
||||||
|
access_repository = repository_root.parent / "govoplan-access"
|
||||||
|
self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
self._run(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pip",
|
||||||
|
"wheel",
|
||||||
|
"--no-deps",
|
||||||
|
"--wheel-dir",
|
||||||
|
str(wheel_directory),
|
||||||
|
str(access_repository),
|
||||||
|
cwd=temporary_root,
|
||||||
|
)
|
||||||
|
core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl"))
|
||||||
|
access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl"))
|
||||||
|
self.assertEqual(1, len(core_wheels))
|
||||||
|
self.assertEqual(1, len(access_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]),
|
||||||
|
str(access_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.assertIn("4f2a9c8e7b6d", 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()
|
||||||
48
webui/package-lock.json
generated
48
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",
|
||||||
@@ -46,12 +46,12 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
@@ -65,9 +65,9 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"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",
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
"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",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"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",
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
},
|
},
|
||||||
"../../govoplan-campaign/webui": {
|
"../../govoplan-campaign/webui": {
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"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",
|
||||||
@@ -173,9 +173,9 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
@@ -192,9 +192,9 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
"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",
|
||||||
@@ -230,12 +230,12 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
"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",
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
"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",
|
||||||
@@ -306,9 +306,9 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
@@ -322,9 +322,9 @@
|
|||||||
},
|
},
|
||||||
"../../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",
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"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/add-ideas/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/add-ideas/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/add-ideas/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/add-ideas/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/add-ideas/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/add-ideas/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/add-ideas/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/add-ideas/govoplan-files.git#v0.1.8",
|
||||||
@@ -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/add-ideas/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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js",
|
||||||
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
"test:module-permutations": "node scripts/test-module-permutations.mjs",
|
||||||
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
|
||||||
|
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
|
||||||
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
|
||||||
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
|
||||||
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.11",
|
"version": "0.1.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
"@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/add-ideas/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/add-ideas/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/add-ideas/govoplan-mail.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/add-ideas/govoplan-campaign.git#v0.1.11",
|
||||||
"@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/add-ideas/govoplan-organizations.git#v0.1.8",
|
||||||
"@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/add-ideas/govoplan-ops.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/add-ideas/govoplan-policy.git#v0.1.8"
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
export default function MetricCard({ label, value, tone = "neutral", detail }: { label: string; value: string | number; tone?: "neutral" | "good" | "warning" | "danger" | "info"; detail?: string }) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const renderedValue = typeof value === "string" ? translateText(value) : value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`metric-card metric-${tone}`}>
|
<div className={`metric-card metric-${tone}`}>
|
||||||
<div className="metric-label">{label}</div>
|
<div className="metric-label">{translateText(label)}</div>
|
||||||
<div className="metric-value">{value}</div>
|
<div className="metric-value">{renderedValue}</div>
|
||||||
{detail && <div className="metric-detail">{detail}</div>}
|
{detail && <div className="metric-detail">{translateText(detail)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
|
|||||||
filters: Record<string, string>;
|
filters: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DataGridPagination = {
|
type DataGridPaginationBase = {
|
||||||
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
|
|
||||||
mode?: "client" | "server";
|
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
totalRows?: number;
|
|
||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
onPageSizeChange?: (pageSize: number) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataGridClientPagination = DataGridPaginationBase & {
|
||||||
|
/**
|
||||||
|
* Client mode filters and sorts before slicing. `rows` must therefore contain
|
||||||
|
* the complete logical result set, never an already paginated backend page.
|
||||||
|
*/
|
||||||
|
mode?: "client";
|
||||||
|
totalRows?: never;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridServerPagination = DataGridPaginationBase & {
|
||||||
|
/**
|
||||||
|
* Server mode treats `rows` as the current backend page. The query callback
|
||||||
|
* must apply every emitted sort/filter to the backend before pagination.
|
||||||
|
*/
|
||||||
|
mode: "server";
|
||||||
|
/** Total rows after the backend has applied the current filters. */
|
||||||
|
totalRows: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataGridPagination = DataGridClientPagination | DataGridServerPagination;
|
||||||
|
|
||||||
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "before" | "after";
|
||||||
|
|
||||||
export type DataGridColumn<T> = {
|
export type DataGridColumn<T> = {
|
||||||
@@ -78,7 +96,7 @@ type DataGridState = {
|
|||||||
bufferWidth?: number;
|
bufferWidth?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DataGridProps<T> = {
|
type DataGridBaseProps<T> = {
|
||||||
id: string;
|
id: string;
|
||||||
rows: T[];
|
rows: T[];
|
||||||
columns: DataGridColumn<T>[];
|
columns: DataGridColumn<T>[];
|
||||||
@@ -98,11 +116,24 @@ type DataGridProps<T> = {
|
|||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
initialFilters?: Record<string, string | string[]>;
|
initialFilters?: Record<string, string | string[]>;
|
||||||
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
||||||
pagination?: DataGridPagination;
|
/**
|
||||||
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
|
* Synchronize a query selected outside the grid, for example by a summary
|
||||||
onQueryChange?: (query: DataGridQueryState) => void;
|
* count shortcut. Server grids should feed `onQueryChange` back into this
|
||||||
|
* value so the header controls and backend query remain in agreement.
|
||||||
|
*/
|
||||||
|
query?: DataGridQueryState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query ownership is deliberately explicit: client grids receive all rows and
|
||||||
|
* DataGrid queries them locally; server grids receive one page and must handle
|
||||||
|
* the complete query through `onQueryChange`.
|
||||||
|
*/
|
||||||
|
export type DataGridProps<T> = DataGridBaseProps<T> & (
|
||||||
|
| {pagination?: DataGridClientPagination;onQueryChange?: never;}
|
||||||
|
| {pagination: DataGridServerPagination;onQueryChange: (query: DataGridQueryState) => void;}
|
||||||
|
);
|
||||||
|
|
||||||
export type DataGridRowActionsProps = {
|
export type DataGridRowActionsProps = {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
removeDisabled?: boolean;
|
removeDisabled?: boolean;
|
||||||
@@ -179,6 +210,7 @@ export default function DataGrid<T>({
|
|||||||
storageKey,
|
storageKey,
|
||||||
initialFilters = {},
|
initialFilters = {},
|
||||||
initialSort,
|
initialSort,
|
||||||
|
query,
|
||||||
pagination,
|
pagination,
|
||||||
onQueryChange
|
onQueryChange
|
||||||
}: DataGridProps<T>) {
|
}: DataGridProps<T>) {
|
||||||
@@ -205,7 +237,15 @@ export default function DataGrid<T>({
|
|||||||
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
||||||
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
||||||
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
const normalizedInitialSort = useMemo(() => initialSort ? { ...initialSort } : undefined, [initialSortKey]);
|
||||||
const [state, setState] = useState<DataGridState>(() => mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort));
|
const externalQueryKey = query ? JSON.stringify(query) : "";
|
||||||
|
const normalizedExternalQuery = useMemo(
|
||||||
|
() => query ? { sort: query.sort ? { ...query.sort } : null, filters: { ...query.filters } } : undefined,
|
||||||
|
[externalQueryKey]
|
||||||
|
);
|
||||||
|
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
|
||||||
|
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
|
||||||
|
normalizedExternalQuery
|
||||||
|
));
|
||||||
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
||||||
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
const [openFilterColumnId, setOpenFilterColumnId] = useState<string | null>(null);
|
||||||
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
const [filterPosition, setFilterPosition] = useState<FilterPosition | null>(null);
|
||||||
@@ -213,14 +253,21 @@ export default function DataGrid<T>({
|
|||||||
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const headerCellRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const serverQueryMode = pagination?.mode === "server";
|
||||||
const onQueryChangeRef = useRef(onQueryChange);
|
const onQueryChangeRef = useRef(onQueryChange);
|
||||||
|
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
||||||
|
const lastServerQueryRef = useRef<DataGridQueryState | null>(null);
|
||||||
const lastLayoutSignatureRef = useRef<string | null>(null);
|
const lastLayoutSignatureRef = useRef<string | null>(null);
|
||||||
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
const lastContainerWidthRef = useRef<number | undefined>(undefined);
|
||||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||||
const serverQueryMode = pagination?.mode === "server";
|
|
||||||
|
|
||||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
|
||||||
|
if (!serverQueryMode) lastServerQueryRef.current = null;
|
||||||
|
}, [pagination, serverQueryMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
||||||
}, [columns, effectiveResizeBehavior]);
|
}, [columns, effectiveResizeBehavior]);
|
||||||
@@ -233,6 +280,10 @@ export default function DataGrid<T>({
|
|||||||
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
||||||
}, [normalizedInitialSort]);
|
}, [normalizedInitialSort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
|
||||||
|
}, [normalizedExternalQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
||||||
@@ -460,10 +511,17 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverQueryMode) return;
|
if (!serverQueryMode) return;
|
||||||
onQueryChangeRef.current?.({
|
const query = {
|
||||||
sort: state.sort ?? null,
|
sort: state.sort ?? null,
|
||||||
filters: { ...(state.filters ?? {}) }
|
filters: { ...(state.filters ?? {}) }
|
||||||
});
|
};
|
||||||
|
const previousQuery = lastServerQueryRef.current;
|
||||||
|
lastServerQueryRef.current = query;
|
||||||
|
const serverPagination = serverPaginationRef.current;
|
||||||
|
if (previousQuery && !dataGridQueriesEqual(previousQuery, query) && serverPagination && serverPagination.page !== 1) {
|
||||||
|
serverPagination.onPageChange(1);
|
||||||
|
}
|
||||||
|
onQueryChangeRef.current?.(query);
|
||||||
}, [serverQueryMode, state.sort, state.filters]);
|
}, [serverQueryMode, state.sort, state.filters]);
|
||||||
|
|
||||||
const filterTypes = useMemo(() => {
|
const filterTypes = useMemo(() => {
|
||||||
@@ -1202,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
|
|||||||
return { ...state, sort: initialSort };
|
return { ...state, sort: initialSort };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeExternalQuery(state: DataGridState, query?: DataGridQueryState): DataGridState {
|
||||||
|
if (!query) return state;
|
||||||
|
const current = {
|
||||||
|
sort: state.sort ?? null,
|
||||||
|
filters: state.filters ?? {}
|
||||||
|
};
|
||||||
|
if (dataGridQueriesEqual(current, query)) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sort: query.sort ? { ...query.sort } : null,
|
||||||
|
filters: { ...query.filters }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatListFilter(values: string[]): string {
|
function formatListFilter(values: string[]): string {
|
||||||
return `list:${JSON.stringify([...new Set(values)])}`;
|
return `list:${JSON.stringify([...new Set(values)])}`;
|
||||||
}
|
}
|
||||||
@@ -1668,6 +1740,16 @@ function compareValues(a: unknown, b: unknown): number {
|
|||||||
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
return stringifyCell(a).localeCompare(stringifyCell(b), undefined, { numeric: true, sensitivity: "base" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||||
|
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||||
|
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||||
|
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||||
|
for (const key of keys) {
|
||||||
|
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function stringifyCell(value: unknown): string {
|
function stringifyCell(value: unknown): string {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
|
|||||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||||
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
export { default as DataGrid, DataGridEmptyAction, DataGridPaginationBar, DataGridRowActions } from "./components/table/DataGrid";
|
||||||
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridQueryState, DataGridSortDirection } from "./components/table/DataGrid";
|
export type { DataGridClientPagination, DataGridColumn, DataGridListOption, DataGridPagination, DataGridPaginationBarProps, DataGridProps, DataGridQueryState, DataGridServerPagination, DataGridSortDirection } from "./components/table/DataGrid";
|
||||||
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
export { default as TableActionGroup } from "./components/table/TableActionGroup";
|
||||||
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
export type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
|
|||||||
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import Button from "../src/components/Button";
|
import Button from "../src/components/Button";
|
||||||
import { DataGridEmptyAction, DataGridRowActions } from "../src/components/table/DataGrid";
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../src/components/table/DataGrid";
|
||||||
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
import TableActionGroup, { runTableAction } from "../src/components/table/TableActionGroup";
|
||||||
|
|
||||||
function noop() {}
|
function noop() {}
|
||||||
@@ -77,3 +77,55 @@ const reasonedButton = renderToStaticMarkup(<Button disabledReason="Permission d
|
|||||||
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
assertEqual(reasonedButton.includes("disabled=\"\""), true, "a disabled reason disables the central button");
|
||||||
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
assertEqual(reasonedButton.includes("disabled-action-tooltip"), true, "a disabled reason remains keyboard discoverable");
|
||||||
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
assertEqual(reasonedButton.includes("disabledReason"), false, "the central-only disabled reason is not passed to the DOM");
|
||||||
|
|
||||||
|
type FilterRow = { id: string; name: string };
|
||||||
|
const filterRows: FilterRow[] = [
|
||||||
|
{ id: "first-non-match", name: "Ordinary row" },
|
||||||
|
{ id: "second-non-match", name: "Another row" },
|
||||||
|
{ id: "first-match", name: "Target alpha" },
|
||||||
|
{ id: "third-non-match", name: "Unrelated row" },
|
||||||
|
{ id: "second-match", name: "Target beta" },
|
||||||
|
{ id: "third-match", name: "Target gamma" }
|
||||||
|
];
|
||||||
|
const filterColumns: DataGridColumn<FilterRow>[] = [
|
||||||
|
{ id: "name", header: "Name", filterable: true, value: (row) => row.name }
|
||||||
|
];
|
||||||
|
const fullResultFilterMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="full-result-filter-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
initialFilters={{ name: "target" }}
|
||||||
|
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target alpha"), true, "client filtering finds matches beyond the unfiltered first page");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target beta"), true, "client filtering happens before the first page is sliced");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("Target gamma"), false, "client pagination still limits the filtered page size");
|
||||||
|
assertEqual(fullResultFilterMarkup.includes("1\u20132"), true, "pagination counts describe the filtered result set");
|
||||||
|
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?3/.test(fullResultFilterMarkup), true, "the filtered total includes matches from every source row");
|
||||||
|
|
||||||
|
const clearedFilterMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="cleared-full-result-filter-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
pagination={{ page: 1, pageSize: 2, onPageChange: noop }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(clearedFilterMarkup.includes("Ordinary row"), true, "clearing filters restores the first unfiltered row");
|
||||||
|
assertEqual(/of(?:<!-- -->)?\s*(?:<!-- -->)?6/.test(clearedFilterMarkup), true, "clearing filters restores the full pagination total");
|
||||||
|
|
||||||
|
const externalShortcutMarkup = renderToStaticMarkup(
|
||||||
|
<DataGrid
|
||||||
|
id="external-query-shortcut-regression"
|
||||||
|
rows={filterRows}
|
||||||
|
columns={filterColumns}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
query={{ sort: null, filters: { name: "target" } }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
assertEqual(externalShortcutMarkup.includes("Target alpha"), true, "an external count shortcut synchronizes the grid query");
|
||||||
|
assertEqual(externalShortcutMarkup.includes("Ordinary row"), false, "the synchronized query does not disagree with visible rows");
|
||||||
|
|||||||
26
webui/tests/metric-card.test.tsx
Normal file
26
webui/tests/metric-card.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import MetricCard from "../src/components/MetricCard";
|
||||||
|
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||||
|
|
||||||
|
const translatedMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<MetricCard
|
||||||
|
label="i18n:govoplan-core.installed_modules.32b3e799"
|
||||||
|
value="i18n:govoplan-core.core_only.d46ce7d9"
|
||||||
|
detail="i18n:govoplan-core.install_the_campaign_module_to_show_campaign_act.3085f23e"
|
||||||
|
tone="info"
|
||||||
|
/>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved");
|
||||||
|
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
|
||||||
|
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
|
||||||
|
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
|
||||||
|
|
||||||
|
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
|
||||||
|
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"tests/explorer-tree.test.tsx",
|
"tests/explorer-tree.test.tsx",
|
||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
"tests/mail-components.test.tsx",
|
"tests/mail-components.test.tsx",
|
||||||
|
"tests/metric-card.test.tsx",
|
||||||
"tests/people-picker.test.tsx",
|
"tests/people-picker.test.tsx",
|
||||||
"tests/resource-access-explanation.test.tsx",
|
"tests/resource-access-explanation.test.tsx",
|
||||||
"tests/selection-list.test.tsx",
|
"tests/selection-list.test.tsx",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
"src/components/PasswordField.tsx",
|
"src/components/PasswordField.tsx",
|
||||||
"src/components/MessageDisplayPanel.tsx",
|
"src/components/MessageDisplayPanel.tsx",
|
||||||
|
"src/components/MetricCard.tsx",
|
||||||
"src/components/people/PeoplePicker.tsx",
|
"src/components/people/PeoplePicker.tsx",
|
||||||
"src/components/people/peoplePickerTypes.ts",
|
"src/components/people/peoplePickerTypes.ts",
|
||||||
"src/components/ResourceAccessExplanation.tsx",
|
"src/components/ResourceAccessExplanation.tsx",
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"src/components/IconButton.tsx",
|
"src/components/IconButton.tsx",
|
||||||
"src/components/admin/AdminIconButton.tsx",
|
"src/components/admin/AdminIconButton.tsx",
|
||||||
"src/components/ToggleSwitch.tsx",
|
"src/components/ToggleSwitch.tsx",
|
||||||
|
"src/components/table/DataGrid.tsx",
|
||||||
"src/components/table/TableActionGroup.tsx"
|
"src/components/table/TableActionGroup.tsx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user