Compare commits
18 Commits
b89a2d15f1
...
v0.1.13
| Author | SHA1 | Date | |
|---|---|---|---|
| d487726f4d | |||
| e6fc07da37 | |||
| e6d589eb07 | |||
| 59610e21d2 | |||
| cece71d945 | |||
| 22e8183846 | |||
| aa111a5fe1 | |||
| e6062fe9e4 | |||
| 987ca894ed | |||
| 4caa326878 | |||
| 8c4c4456c6 | |||
| 6abe292ac8 | |||
| fea2807754 | |||
| 22646c614c | |||
| 17376332a2 | |||
| 0946bc84a9 | |||
| a18499cbb5 | |||
| 36d7b73bb5 |
@@ -5,7 +5,11 @@ from logging.config import fileConfig
|
||||
from alembic import context
|
||||
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.core import change_sequence as core_change_sequence_models # noqa: F401 - populate core metadata
|
||||
from govoplan_core.core.migrations import migration_metadata_plan
|
||||
|
||||
@@ -233,6 +233,15 @@ instead of reproducing their behavior.
|
||||
`disabledReason` for row state; omit an action only when that action does not
|
||||
belong to the table. `minimumSlots` reserves trailing positions for an empty
|
||||
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
|
||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
||||
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
],
|
||||
"recorded_at": "2026-07-11T01:39:45Z",
|
||||
"release": "0.1.7",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
@@ -165,8 +165,380 @@
|
||||
],
|
||||
"recorded_at": "2026-07-20T02:45:41Z",
|
||||
"release": "0.1.8",
|
||||
"track": "release",
|
||||
"squash_policy": "reviewed-manual"
|
||||
"squash_policy": "reviewed-manual",
|
||||
"track": "release"
|
||||
},
|
||||
{
|
||||
"heads": [
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revision": "4d5e6f7a9203"
|
||||
},
|
||||
{
|
||||
"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-addresses",
|
||||
"revision": "e1f2a4b5c6d"
|
||||
}
|
||||
],
|
||||
"owner_heads": [
|
||||
{
|
||||
"owner": "govoplan-access",
|
||||
"revisions": [
|
||||
"4a5b6c7d8e9f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-addresses",
|
||||
"revisions": [
|
||||
"e1f2a4b5c6d"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-calendar",
|
||||
"revisions": [
|
||||
"af1b2c3d4e5f"
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": "govoplan-campaign",
|
||||
"revisions": [
|
||||
"4d5e6f7a9203"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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-22T02:42:27Z",
|
||||
"release": "0.1.11",
|
||||
"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-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"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-core"
|
||||
version = "0.1.10"
|
||||
version = "0.1.13"
|
||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -27,6 +27,12 @@ where = ["src"]
|
||||
[tool.setuptools.package-data]
|
||||
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]
|
||||
govoplan-config = "govoplan_core.commands.config:main"
|
||||
govoplan-devserver = "govoplan_core.devserver:main"
|
||||
|
||||
@@ -340,6 +340,7 @@ CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
# Deployment-wide connector egress policy. Enable private networks only when
|
||||
# this installation intentionally integrates with internal services.
|
||||
@@ -405,6 +406,7 @@ REDIS_URL=redis://127.0.0.1:56379/0
|
||||
CELERY_ENABLED=true
|
||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,default
|
||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||
SCHEDULING_CANCELLATION_NOTICE_DAYS=30
|
||||
|
||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true
|
||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||
|
||||
@@ -70,6 +70,15 @@ class FrontendRoute:
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicFrontendRoute:
|
||||
"""Explicitly allowlisted route that can render without authentication."""
|
||||
|
||||
path: str
|
||||
component: str
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendModule:
|
||||
module_id: str
|
||||
@@ -80,6 +89,7 @@ class FrontendModule:
|
||||
asset_manifest_integrity: str | None = None
|
||||
asset_manifest_contract_version: str = "1"
|
||||
routes: tuple[FrontendRoute, ...] = ()
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
|
||||
@@ -237,6 +247,35 @@ class DocumentationTopic:
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
|
||||
"""Return fail-closed authoring issues for a user-facing workflow topic.
|
||||
|
||||
Topic conditions are alternatives. Every alternative therefore needs an
|
||||
explicit scope constraint; otherwise one unscoped alternative would make
|
||||
the workflow visible regardless of the actor's task authority.
|
||||
"""
|
||||
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||
return ()
|
||||
if not topic.conditions:
|
||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||
|
||||
unscoped_alternatives = tuple(
|
||||
index
|
||||
for index, condition in enumerate(topic.conditions, start=1)
|
||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||
)
|
||||
if not unscoped_alternatives:
|
||||
return ()
|
||||
alternatives = ", ".join(str(index) for index in unscoped_alternatives)
|
||||
return (
|
||||
"every user workflow condition alternative must declare required_scopes or any_scopes; "
|
||||
f"unscoped alternative(s): {alternatives}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DocumentationContext:
|
||||
registry: object
|
||||
|
||||
169
src/govoplan_core/core/people.py
Normal file
169
src/govoplan_core/core/people.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH = "access.people_search"
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH = "addresses.people_search"
|
||||
|
||||
# Identity search is deliberately absent here. ``identity.search`` is an
|
||||
# instance-wide canonical-identity directory and has no tenant/principal
|
||||
# visibility contract. Ordinary task pickers must use principal-aware search
|
||||
# providers instead.
|
||||
DEFAULT_PEOPLE_SEARCH_CAPABILITIES = (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
)
|
||||
|
||||
|
||||
class PeopleSearchError(ValueError):
|
||||
"""Stable, non-diagnostic error raised by people-search providers."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonSearchCandidate:
|
||||
"""A policy-filtered selection target returned by a directory provider."""
|
||||
|
||||
selection_key: str
|
||||
kind: str
|
||||
reference_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
source_module: str | None = None
|
||||
source_label: str | None = None
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
description: 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 PeopleSearchGroup:
|
||||
key: str
|
||||
label: str
|
||||
candidates: tuple[PersonSearchCandidate, ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PeopleSearchProvider(Protocol):
|
||||
"""Server-side, principal-aware people/delivery-target search.
|
||||
|
||||
Implementations must derive visibility from ``principal`` and may only
|
||||
return records the principal can discover in its active tenant and policy
|
||||
context. Callers remain responsible for authorizing the task for which the
|
||||
picker is used (for example, creating a scheduling request).
|
||||
"""
|
||||
|
||||
def search_people(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
) -> Sequence[PeopleSearchGroup]:
|
||||
...
|
||||
|
||||
|
||||
def people_search_providers(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchProvider, ...]:
|
||||
if registry is None or not hasattr(registry, "has_capability") or not hasattr(registry, "capability"):
|
||||
return ()
|
||||
|
||||
providers: list[PeopleSearchProvider] = []
|
||||
for capability_name in capability_names:
|
||||
if not registry.has_capability(capability_name):
|
||||
continue
|
||||
capability = registry.capability(capability_name)
|
||||
if isinstance(capability, PeopleSearchProvider):
|
||||
providers.append(capability)
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def search_visible_people(
|
||||
registry: object | None,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str,
|
||||
limit: int = 25,
|
||||
capability_names: Sequence[str] = DEFAULT_PEOPLE_SEARCH_CAPABILITIES,
|
||||
) -> tuple[PeopleSearchGroup, ...]:
|
||||
"""Collect grouped results without importing optional feature modules.
|
||||
|
||||
``limit`` is enforced per provider so one directory cannot starve another
|
||||
result group. Providers are expected to return at most that many candidates
|
||||
in total. Duplicate group keys are merged and candidate selection keys are
|
||||
de-duplicated while preserving provider order.
|
||||
"""
|
||||
|
||||
normalized_query = str(query or "").strip()
|
||||
normalized_limit = max(1, min(int(limit), 100))
|
||||
ordered_group_keys: list[str] = []
|
||||
labels: dict[str, str] = {}
|
||||
candidates_by_group: dict[str, list[PersonSearchCandidate]] = {}
|
||||
candidate_keys_by_group: dict[str, set[str]] = {}
|
||||
|
||||
for provider in people_search_providers(registry, capability_names=capability_names):
|
||||
groups = provider.search_people(
|
||||
session,
|
||||
principal,
|
||||
query=normalized_query,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
provider_candidate_count = 0
|
||||
for group in groups:
|
||||
if not group.key:
|
||||
continue
|
||||
if group.key not in candidates_by_group:
|
||||
ordered_group_keys.append(group.key)
|
||||
labels[group.key] = group.label
|
||||
candidates_by_group[group.key] = []
|
||||
candidate_keys_by_group[group.key] = set()
|
||||
for candidate in group.candidates:
|
||||
if provider_candidate_count >= normalized_limit:
|
||||
break
|
||||
if not candidate.selection_key or candidate.selection_key in candidate_keys_by_group[group.key]:
|
||||
continue
|
||||
candidate_keys_by_group[group.key].add(candidate.selection_key)
|
||||
candidates_by_group[group.key].append(candidate)
|
||||
provider_candidate_count += 1
|
||||
|
||||
return tuple(
|
||||
PeopleSearchGroup(
|
||||
key=group_key,
|
||||
label=labels[group_key],
|
||||
candidates=tuple(candidates_by_group[group_key]),
|
||||
)
|
||||
for group_key in ordered_group_keys
|
||||
if candidates_by_group[group_key]
|
||||
)
|
||||
|
||||
|
||||
def person_selection_key(kind: str, reference_id: str, *, email: str | None = None) -> str:
|
||||
normalized_kind = str(kind).strip().casefold()
|
||||
normalized_reference = str(reference_id).strip()
|
||||
normalized_email = str(email or "").strip().casefold()
|
||||
if not normalized_kind or not normalized_reference:
|
||||
raise ValueError("Person selection keys require a kind and reference id.")
|
||||
return ":".join(part for part in (normalized_kind, normalized_reference, normalized_email) if part)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_ACCESS_PEOPLE_SEARCH",
|
||||
"CAPABILITY_ADDRESSES_PEOPLE_SEARCH",
|
||||
"DEFAULT_PEOPLE_SEARCH_CAPABILITIES",
|
||||
"PeopleSearchError",
|
||||
"PeopleSearchGroup",
|
||||
"PeopleSearchProvider",
|
||||
"PersonSearchCandidate",
|
||||
"people_search_providers",
|
||||
"person_selection_key",
|
||||
"search_visible_people",
|
||||
]
|
||||
@@ -73,6 +73,18 @@ class PollOptionUpdateCommand:
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionOrderCommand:
|
||||
"""Complete active option order supplied by an owning module.
|
||||
|
||||
Providers must reject partial, duplicate, or unknown option collections.
|
||||
An exact repeat is an idempotent no-op. Reordering changes positions only;
|
||||
option identities and their existing answers remain valid.
|
||||
"""
|
||||
|
||||
option_ids: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationCommand:
|
||||
respondent_id: str | None = None
|
||||
@@ -98,6 +110,22 @@ class PollSubmitResponseCommand:
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementCommand:
|
||||
"""Retire live responses while preserving their auditable history.
|
||||
|
||||
Owning modules provide every server-trusted identity that can refer to the
|
||||
participant. Providers must soft-delete matching live responses, retain
|
||||
their answers, and treat an exact ``idempotency_key`` replay as a no-op.
|
||||
"""
|
||||
|
||||
respondent_ids: tuple[str, ...] = ()
|
||||
invitation_id: str | None = None
|
||||
reason: str = "participant_removed"
|
||||
idempotency_key: str = ""
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionRef:
|
||||
id: str
|
||||
@@ -138,6 +166,16 @@ class PollResponseRef:
|
||||
answers: tuple[PollAnswerRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseRetirementRef:
|
||||
"""Outcome of an auditable response-retirement request."""
|
||||
|
||||
response_ids: tuple[str, ...] = ()
|
||||
retired_at: datetime | None = None
|
||||
newly_retired_count: int = 0
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollSchedulingProvider(Protocol):
|
||||
def create_poll(
|
||||
@@ -183,6 +221,18 @@ class PollSchedulingProvider(Protocol):
|
||||
|
||||
...
|
||||
|
||||
def reorder_options(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionOrderCommand,
|
||||
) -> PollRef:
|
||||
"""Atomically replace the complete active option order."""
|
||||
|
||||
...
|
||||
|
||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||
...
|
||||
|
||||
@@ -251,6 +301,21 @@ class PollResponseSubmissionProvider(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollResponseRetirementProvider(Protocol):
|
||||
"""Optional extension for owning modules that remove participants."""
|
||||
|
||||
def retire_responses(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollResponseRetirementCommand,
|
||||
) -> PollResponseRetirementRef:
|
||||
...
|
||||
|
||||
|
||||
def poll_scheduling_provider(registry: object | None) -> PollSchedulingProvider | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
@@ -265,3 +330,20 @@ def poll_response_submission_provider(
|
||||
) -> PollResponseSubmissionProvider | None:
|
||||
provider = poll_scheduling_provider(registry)
|
||||
return provider if isinstance(provider, PollResponseSubmissionProvider) else None
|
||||
|
||||
|
||||
def poll_response_retirement_provider(
|
||||
registry: object | None,
|
||||
) -> PollResponseRetirementProvider | None:
|
||||
"""Resolve response retirement without making it mandatory for Poll v1 providers."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_SCHEDULING):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_SCHEDULING)
|
||||
return (
|
||||
capability
|
||||
if isinstance(capability, PollResponseRetirementProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
272
src/govoplan_core/core/poll_participation.py
Normal file
272
src/govoplan_core/core/poll_participation.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.poll import (
|
||||
PollAnswerRequest,
|
||||
PollInvitationRef,
|
||||
PollOptionRequest,
|
||||
PollResponseRef,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway"
|
||||
# Policy-attestation identifier, not a credential or credential default.
|
||||
ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105
|
||||
PARTICIPATION_POLICY_VERSION = 1
|
||||
|
||||
|
||||
def participation_token_fingerprint(token: str) -> str:
|
||||
"""Return a non-reversible identifier suitable for audit/throttle keys."""
|
||||
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollResponseGatewayRef:
|
||||
"""Stable identity of the module resource governing a participation link."""
|
||||
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationPolicy:
|
||||
"""Generic response rules snapshotted onto one signed invitation.
|
||||
|
||||
``single_choice`` treats every non-``unavailable`` availability answer as
|
||||
a selection. Capacity is reserved only by ``available`` answers (and by
|
||||
selected answers for non-availability polls), so ``maybe`` never consumes
|
||||
a place.
|
||||
"""
|
||||
|
||||
version: int = PARTICIPATION_POLICY_VERSION
|
||||
single_choice: bool = False
|
||||
allow_maybe: bool = True
|
||||
max_participants_per_option: int | None = None
|
||||
allow_comments: bool = False
|
||||
participant_email_required: bool = False
|
||||
anonymous_password_required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedInvitationCommand:
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseCommand:
|
||||
"""Submission already authorized by the named in-process gateway.
|
||||
|
||||
Passwords never cross this boundary. A gateway that owns a password
|
||||
verifier reports the completed check through ``verified_requirements``.
|
||||
Poll independently re-enforces the remaining snapshotted rules while
|
||||
holding its Poll-row lock.
|
||||
"""
|
||||
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
participant_email: str | None = None
|
||||
participant_is_authenticated: bool = False
|
||||
answers: tuple[PollAnswerRequest, ...] = ()
|
||||
comment: str | None = None
|
||||
verified_requirements: frozenset[str] = frozenset()
|
||||
idempotency_key: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollGovernedResponseRef:
|
||||
response: PollResponseRef
|
||||
participant_email: str | None = None
|
||||
comment: str | None = None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollOptionMutationRef:
|
||||
id: str
|
||||
position: int
|
||||
replayed: bool = False
|
||||
invalidated_response_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationRevocationRef:
|
||||
id: str
|
||||
revoked_at: datetime
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollInvitationExpiryRef:
|
||||
id: str
|
||||
expires_at: datetime | None
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PollParticipationContextRef:
|
||||
invitation_id: str
|
||||
tenant_id: str
|
||||
poll_id: str
|
||||
gateway: PollResponseGatewayRef
|
||||
policy: PollParticipationPolicy
|
||||
respondent_id: str | None = None
|
||||
respondent_label: str | None = None
|
||||
email: str | None = None
|
||||
response: PollGovernedResponseRef | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PollParticipationGatewayProvider(Protocol):
|
||||
def create_governed_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollGovernedInvitationCommand,
|
||||
) -> PollInvitationRef:
|
||||
...
|
||||
|
||||
def resolve_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str | None = None,
|
||||
participant_email: str | None = None,
|
||||
participant_is_authenticated: bool = False,
|
||||
verified_requirements: frozenset[str] = frozenset(),
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve and prefill one valid invitation for the exact gateway."""
|
||||
|
||||
...
|
||||
|
||||
def submit_governed_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
token: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
...
|
||||
|
||||
def resolve_authenticated_participation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
) -> PollParticipationContextRef:
|
||||
"""Resolve one governed invitation without retaining its public token."""
|
||||
|
||||
...
|
||||
|
||||
def submit_authenticated_response(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
respondent_id: str,
|
||||
command: PollGovernedResponseCommand,
|
||||
) -> PollGovernedResponseRef:
|
||||
"""Submit atomically for the exact authenticated invitation identity."""
|
||||
|
||||
...
|
||||
|
||||
def add_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
command: PollOptionRequest,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def remove_option(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
option_id: str,
|
||||
) -> PollOptionMutationRef:
|
||||
...
|
||||
|
||||
def revoke_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
) -> PollInvitationRevocationRef:
|
||||
...
|
||||
|
||||
def update_invitation_expiry(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
poll_id: str,
|
||||
invitation_id: str,
|
||||
gateway: PollResponseGatewayRef,
|
||||
expires_at: datetime | None,
|
||||
) -> PollInvitationExpiryRef:
|
||||
"""Expire or extend a non-revoked governed invitation in place."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
def poll_participation_gateway_provider(
|
||||
registry: object | None,
|
||||
) -> PollParticipationGatewayProvider | None:
|
||||
"""Resolve the governed Poll gateway without importing its implementation."""
|
||||
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
return capability if isinstance(capability, PollParticipationGatewayProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||
"PARTICIPATION_POLICY_VERSION",
|
||||
"PollGovernedInvitationCommand",
|
||||
"PollGovernedResponseCommand",
|
||||
"PollGovernedResponseRef",
|
||||
"PollInvitationExpiryRef",
|
||||
"PollInvitationRevocationRef",
|
||||
"PollOptionMutationRef",
|
||||
"PollParticipationContextRef",
|
||||
"PollParticipationGatewayProvider",
|
||||
"PollParticipationPolicy",
|
||||
"PollResponseGatewayRef",
|
||||
"participation_token_fingerprint",
|
||||
"poll_participation_gateway_provider",
|
||||
]
|
||||
@@ -16,11 +16,13 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
PublicFrontendRoute,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
TenantSummaryProvider,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
|
||||
@@ -194,6 +196,7 @@ class PlatformRegistry:
|
||||
available_capabilities=available_capabilities,
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_public_frontend_route_uniqueness(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
@@ -369,6 +372,11 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_manifest_frontend(manifest)
|
||||
for item in manifest.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
for topic in manifest.documentation:
|
||||
for issue in user_workflow_scope_condition_issues(topic):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||
@@ -428,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
@@ -441,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||
|
||||
|
||||
def _validate_public_frontend_route_uniqueness(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> None:
|
||||
owners: dict[str, tuple[str, PublicFrontendRoute]] = {}
|
||||
for manifest in manifests:
|
||||
if manifest.frontend is None:
|
||||
continue
|
||||
for route in manifest.frontend.public_routes:
|
||||
previous = owners.get(route.path)
|
||||
if previous is not None:
|
||||
previous_module, _previous_route = previous
|
||||
raise RegistryError(
|
||||
f"Duplicate public frontend route {route.path!r} in modules "
|
||||
f"{previous_module!r} and {manifest.id!r}"
|
||||
)
|
||||
owners[route.path] = (manifest.id, route)
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sysconfig
|
||||
from typing import Any
|
||||
|
||||
from alembic import command
|
||||
@@ -462,11 +463,13 @@ def _jsonable_migration_task_details(value: Mapping[str, Any]) -> Mapping[str, A
|
||||
|
||||
def _repo_root() -> Path:
|
||||
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")
|
||||
candidates = [
|
||||
Path(configured).expanduser() if configured else None,
|
||||
Path.cwd(),
|
||||
packaged_root,
|
||||
installed_runtime_root,
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate is None:
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
@@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"order": route.order,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
if frontend is None:
|
||||
return None
|
||||
@@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
}
|
||||
|
||||
|
||||
def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]:
|
||||
"""Return only assets and routes explicitly approved for signed-out use."""
|
||||
|
||||
return {
|
||||
"module_id": frontend.module_id,
|
||||
"package_name": frontend.package_name,
|
||||
"asset_manifest": frontend.asset_manifest,
|
||||
"asset_manifest_signature": frontend.asset_manifest_signature,
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||
capabilities: list[str] = []
|
||||
if manifest_id == "mail":
|
||||
@@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/public-modules")
|
||||
def public_modules(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"frontend": _public_frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
if manifest.frontend is not None
|
||||
and manifest.frontend.public_routes
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/permissions")
|
||||
def permissions(request: Request):
|
||||
registry = _registry(request)
|
||||
|
||||
@@ -76,6 +76,12 @@ class Settings(BaseSettings):
|
||||
ge=0,
|
||||
alias="CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS",
|
||||
)
|
||||
scheduling_cancellation_notice_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=90,
|
||||
alias="SCHEDULING_CANCELLATION_NOTICE_DAYS",
|
||||
)
|
||||
mock_mailbox_dir: str = Field(default="runtime/mock-mailbox", alias="MOCK_MAILBOX_DIR")
|
||||
|
||||
# Development bootstrap only. Do not use this in production.
|
||||
|
||||
@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
for path in ("/api/v1/platform/status", "/api/v1/platform/modules"):
|
||||
for path in (
|
||||
"/api/v1/platform/status",
|
||||
"/api/v1/platform/modules",
|
||||
"/api/v1/platform/public-modules",
|
||||
):
|
||||
first = client.get(path)
|
||||
self.assertEqual(200, first.status_code, first.text)
|
||||
etag = first.headers.get("etag")
|
||||
|
||||
85
tests/test_documentation_topic_contract.py
Normal file
85
tests/test_documentation_topic_contract.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
|
||||
def workflow_topic(
|
||||
*,
|
||||
conditions: tuple[DocumentationCondition, ...],
|
||||
documentation_types: tuple[str, ...] = ("user",),
|
||||
kind: str = "workflow",
|
||||
) -> DocumentationTopic:
|
||||
return DocumentationTopic(
|
||||
id="example.workflow.task",
|
||||
title="Complete a task",
|
||||
summary="Complete the example task.",
|
||||
documentation_types=documentation_types, # type: ignore[arg-type]
|
||||
conditions=conditions,
|
||||
metadata={"kind": kind},
|
||||
)
|
||||
|
||||
|
||||
class DocumentationTopicContractTests(unittest.TestCase):
|
||||
def test_user_workflow_requires_scope_conditions(self) -> None:
|
||||
topic = workflow_topic(conditions=())
|
||||
|
||||
self.assertEqual(
|
||||
user_workflow_scope_condition_issues(topic),
|
||||
("user workflow topics must declare at least one scope-conditioned alternative",),
|
||||
)
|
||||
with self.assertRaisesRegex(RegistryError, "scope-conditioned alternative"):
|
||||
registry_for(topic).validate()
|
||||
|
||||
def test_every_condition_alternative_must_be_scope_conditioned(self) -> None:
|
||||
topic = workflow_topic(
|
||||
conditions=(
|
||||
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||
DocumentationCondition(required_modules=("example",)),
|
||||
)
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, r"unscoped alternative\(s\): 2"):
|
||||
registry_for(topic).validate()
|
||||
|
||||
def test_scoped_user_workflow_and_non_user_topics_are_accepted(self) -> None:
|
||||
scoped = workflow_topic(
|
||||
conditions=(
|
||||
DocumentationCondition(required_scopes=("example:item:read",)),
|
||||
DocumentationCondition(any_scopes=("example:item:write", "example:item:admin")),
|
||||
)
|
||||
)
|
||||
admin_workflow = workflow_topic(
|
||||
conditions=(),
|
||||
documentation_types=("admin",),
|
||||
)
|
||||
user_reference = workflow_topic(conditions=(), kind="reference")
|
||||
|
||||
self.assertEqual(user_workflow_scope_condition_issues(scoped), ())
|
||||
self.assertEqual(user_workflow_scope_condition_issues(admin_workflow), ())
|
||||
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
|
||||
registry_for(scoped, admin_workflow, user_reference).validate()
|
||||
|
||||
|
||||
def registry_for(*topics: DocumentationTopic) -> PlatformRegistry:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=topics,
|
||||
)
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -21,6 +21,18 @@ class InstallConfigTests(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
Settings(CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS="-1")
|
||||
|
||||
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="14"
|
||||
).scheduling_cancellation_notice_days,
|
||||
14,
|
||||
)
|
||||
for invalid in ("0", "91"):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
Settings(SCHEDULING_CANCELLATION_NOTICE_DAYS=invalid)
|
||||
|
||||
def test_self_hosted_validation_reports_actionable_missing_settings(self) -> None:
|
||||
result = validate_runtime_configuration({}, profile="self-hosted")
|
||||
|
||||
@@ -113,6 +125,7 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=self-hosted", self_hosted)
|
||||
self.assertIn("MASTER_KEY_B64=<generate", self_hosted)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", self_hosted)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", self_hosted)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", self_hosted)
|
||||
@@ -122,6 +135,7 @@ class InstallConfigTests(unittest.TestCase):
|
||||
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
|
||||
self.assertIn("SCHEDULING_CANCELLATION_NOTICE_DAYS=30", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=true", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_SECRET_ENV_ALLOWLIST=", production_like)
|
||||
self.assertIn("GOVOPLAN_CONNECTOR_CA_BUNDLE_ALLOWLIST=", production_like)
|
||||
|
||||
@@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db'
|
||||
os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false")
|
||||
os.environ.setdefault("CELERY_ENABLED", "false")
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
@@ -99,7 +99,7 @@ from govoplan_core.core.module_package_catalog import (
|
||||
sign_module_package_catalog,
|
||||
validate_module_package_catalog,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider
|
||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
@@ -112,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import scope_grants
|
||||
from govoplan_core.server.app import create_app
|
||||
from govoplan_core.server.config import GovoplanServerConfig
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
|
||||
from govoplan_core.server.route_validation import RouteCollisionError
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
@@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
||||
if manifest.frontend.package_name is not None:
|
||||
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes):
|
||||
for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes):
|
||||
self.assertTrue(route.path.startswith("/"))
|
||||
self.assertTrue(route.component.strip())
|
||||
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
||||
@@ -457,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
||||
registry.validate()
|
||||
|
||||
def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
package_name="@govoplan/example-webui",
|
||||
routes=(FrontendRoute(path="/example", component="ExamplePage"),),
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/example/public/:token",
|
||||
component="ExamplePublicPage",
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="private",
|
||||
name="Private",
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id="private",
|
||||
package_name="@govoplan/private-webui",
|
||||
routes=(FrontendRoute(path="/private", component="PrivatePage"),),
|
||||
),
|
||||
))
|
||||
registry.validate()
|
||||
|
||||
app = FastAPI()
|
||||
app.state.govoplan_registry = registry
|
||||
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/api/v1/platform/public-modules")
|
||||
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]])
|
||||
public_module = response.json()["modules"][0]
|
||||
self.assertNotIn("dependencies", public_module)
|
||||
self.assertNotIn("nav", public_module["frontend"])
|
||||
self.assertNotIn("routes", public_module["frontend"])
|
||||
self.assertEqual(
|
||||
["/example/public/:token"],
|
||||
[item["path"] for item in public_module["frontend"]["public_routes"]],
|
||||
)
|
||||
|
||||
def test_registry_rejects_duplicate_public_frontend_routes(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
for module_id in ("first", "second"):
|
||||
registry.register(ModuleManifest(
|
||||
id=module_id,
|
||||
name=module_id.title(),
|
||||
version="test",
|
||||
frontend=FrontendModule(
|
||||
module_id=module_id,
|
||||
public_routes=(
|
||||
PublicFrontendRoute(
|
||||
path="/shared/public/:token",
|
||||
component=f"{module_id.title()}PublicPage",
|
||||
),
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"):
|
||||
registry.validate()
|
||||
|
||||
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
||||
first = APIRouter()
|
||||
second = APIRouter()
|
||||
|
||||
99
tests/test_people_search_contract.py
Normal file
99
tests/test_people_search_contract.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.people import (
|
||||
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
|
||||
PeopleSearchGroup,
|
||||
PersonSearchCandidate,
|
||||
people_search_providers,
|
||||
person_selection_key,
|
||||
search_visible_people,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class FakePeopleSearchProvider:
|
||||
def __init__(self, *groups: PeopleSearchGroup) -> None:
|
||||
self.groups = groups
|
||||
self.calls: list[tuple[object, object, str, int]] = []
|
||||
|
||||
def search_people(self, session: object, principal: object, *, query: str, limit: int = 25):
|
||||
self.calls.append((session, principal, query, limit))
|
||||
return self.groups
|
||||
|
||||
|
||||
class PeopleSearchContractTests(unittest.TestCase):
|
||||
def test_collects_optional_providers_in_stable_group_order(self) -> None:
|
||||
duplicate = PersonSearchCandidate(
|
||||
selection_key="account:1",
|
||||
kind="account",
|
||||
reference_id="1",
|
||||
display_name="Ada Lovelace",
|
||||
)
|
||||
access = FakePeopleSearchProvider(PeopleSearchGroup(key="accounts", label="Accounts", candidates=(duplicate, duplicate)))
|
||||
addresses = FakePeopleSearchProvider(
|
||||
PeopleSearchGroup(
|
||||
key="contacts",
|
||||
label="Contacts",
|
||||
candidates=(
|
||||
PersonSearchCandidate(
|
||||
selection_key="contact:2:ada@example.test",
|
||||
kind="contact",
|
||||
reference_id="2",
|
||||
display_name="Ada Lovelace",
|
||||
email="ada@example.test",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: access},
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="addresses",
|
||||
name="Addresses",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ADDRESSES_PEOPLE_SEARCH: lambda _context: addresses},
|
||||
))
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
session = object()
|
||||
principal = object()
|
||||
groups = search_visible_people(registry, session, principal, query=" ada ", limit=200)
|
||||
|
||||
self.assertEqual([group.key for group in groups], ["accounts", "contacts"])
|
||||
self.assertEqual([len(group.candidates) for group in groups], [1, 1])
|
||||
self.assertEqual(access.calls, [(session, principal, "ada", 100)])
|
||||
self.assertEqual(addresses.calls, [(session, principal, "ada", 100)])
|
||||
self.assertEqual(people_search_providers(None), ())
|
||||
|
||||
def test_omits_empty_groups_and_ignores_unrelated_capabilities(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="1.0.0",
|
||||
capability_factories={CAPABILITY_ACCESS_PEOPLE_SEARCH: lambda _context: object()},
|
||||
))
|
||||
registry.configure_capability_context(ModuleContext(registry=registry, settings=object()))
|
||||
|
||||
self.assertEqual(search_visible_people(registry, object(), object(), query="ada"), ())
|
||||
|
||||
def test_selection_key_is_stable_and_validated(self) -> None:
|
||||
self.assertEqual(
|
||||
person_selection_key("Account", "account-1", email=" Ada@Example.Test "),
|
||||
"account:account-1:ada@example.test",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
person_selection_key("", "account-1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -3,7 +3,36 @@ from __future__ import annotations
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from govoplan_core.core.poll import PollAnswerRef, PollOptionUpdateCommand, PollResponseRef
|
||||
from govoplan_core.core.poll import (
|
||||
CAPABILITY_POLL_SCHEDULING,
|
||||
PollAnswerRef,
|
||||
PollOptionUpdateCommand,
|
||||
PollResponseRef,
|
||||
PollResponseRetirementCommand,
|
||||
PollResponseRetirementProvider,
|
||||
poll_response_retirement_provider,
|
||||
)
|
||||
|
||||
|
||||
class _RetirementProvider:
|
||||
def retire_responses(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None) -> None:
|
||||
self.capability_value = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLL_SCHEDULING
|
||||
and self.capability_value is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.capability_value
|
||||
|
||||
|
||||
class PollContractTests(unittest.TestCase):
|
||||
@@ -31,6 +60,25 @@ class PollContractTests(unittest.TestCase):
|
||||
self.assertEqual(command.label, "Tuesday 10:00")
|
||||
self.assertEqual(command.value, {"slot_id": "slot-1"})
|
||||
|
||||
def test_response_retirement_is_an_optional_idempotent_extension(self) -> None:
|
||||
command = PollResponseRetirementCommand(
|
||||
respondent_ids=("account-1", "alice@example.test"),
|
||||
invitation_id="invitation-1",
|
||||
reason="scheduling_participant_removed",
|
||||
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||
metadata={"source_module": "scheduling"},
|
||||
)
|
||||
provider = _RetirementProvider()
|
||||
|
||||
self.assertEqual(command.respondent_ids[0], "account-1")
|
||||
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||
self.assertIs(
|
||||
poll_response_retirement_provider(_Registry(provider)),
|
||||
provider,
|
||||
)
|
||||
self.assertIsNone(poll_response_retirement_provider(_Registry(object())))
|
||||
self.assertIsNone(poll_response_retirement_provider(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
84
tests/test_poll_participation_contract.py
Normal file
84
tests/test_poll_participation_contract.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.poll_participation import (
|
||||
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||
PollParticipationGatewayProvider,
|
||||
PollParticipationPolicy,
|
||||
participation_token_fingerprint,
|
||||
poll_participation_gateway_provider,
|
||||
)
|
||||
|
||||
|
||||
class _CompleteGateway:
|
||||
def create_governed_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_governed_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def resolve_authenticated_participation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def submit_authenticated_response(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def add_option(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def remove_option(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def revoke_invitation(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_invitation_expiry(self, *args, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, capability: object | None) -> None:
|
||||
self.capability_value = capability
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
name == CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||
and self.capability_value is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str) -> object:
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return self.capability_value
|
||||
|
||||
|
||||
class PollParticipationContractTests(unittest.TestCase):
|
||||
def test_capability_name_and_policy_defaults_remain_stable(self) -> None:
|
||||
policy = PollParticipationPolicy()
|
||||
|
||||
self.assertEqual("poll.participation_gateway", CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||
self.assertEqual(1, policy.version)
|
||||
self.assertTrue(policy.allow_maybe)
|
||||
|
||||
def test_token_fingerprint_is_deterministic_and_non_reversible(self) -> None:
|
||||
fingerprint = participation_token_fingerprint("secret-token")
|
||||
|
||||
self.assertEqual(fingerprint, participation_token_fingerprint("secret-token"))
|
||||
self.assertEqual(64, len(fingerprint))
|
||||
self.assertNotIn("secret-token", fingerprint)
|
||||
|
||||
def test_registry_lookup_accepts_only_the_complete_structural_contract(self) -> None:
|
||||
provider = _CompleteGateway()
|
||||
|
||||
self.assertIsInstance(provider, PollParticipationGatewayProvider)
|
||||
self.assertIs(provider, poll_participation_gateway_provider(_Registry(provider)))
|
||||
self.assertIsNone(poll_participation_gateway_provider(_Registry(object())))
|
||||
self.assertIsNone(poll_participation_gateway_provider(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()
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"dependencies": {
|
||||
"@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/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/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.10",
|
||||
"@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/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": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#30b43913e84b709124512262cc7a5b5fabbf317a",
|
||||
"version": "0.1.10",
|
||||
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#735e874bd03c55c626347f5356301fe221145b98",
|
||||
"dependencies": {
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -29,6 +29,8 @@
|
||||
"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: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: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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/core-webui",
|
||||
"version": "0.1.10",
|
||||
"version": "0.1.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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/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/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.10",
|
||||
"@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/policy-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-policy.git#v0.1.8"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||
import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
@@ -30,7 +30,9 @@ export default function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [checkingSession, setCheckingSession] = useState(true);
|
||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
@@ -38,9 +40,13 @@ export default function App() {
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||
const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]);
|
||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||
|
||||
function updateSettings(next: ApiSettings) {
|
||||
@@ -119,6 +125,14 @@ export default function App() {
|
||||
};
|
||||
}, [settings.apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchPlatformPublicModules(settings).
|
||||
then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}).
|
||||
catch(() => {if (!cancelled) setPlatformPublicModules(null);});
|
||||
return () => {cancelled = true;};
|
||||
}, [settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCheckingSession(true);
|
||||
@@ -233,6 +247,18 @@ export default function App() {
|
||||
return () => {cancelled = true;};
|
||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!platformPublicModules?.length) {
|
||||
setRemotePublicWebModules([]);
|
||||
return () => {cancelled = true;};
|
||||
}
|
||||
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
||||
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
||||
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
||||
return () => {cancelled = true;};
|
||||
}, [platformPublicModules, localPublicWebModules]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth) return;
|
||||
|
||||
@@ -284,7 +310,7 @@ export default function App() {
|
||||
if (checkingSession) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<div className="public-landing">
|
||||
@@ -304,10 +330,21 @@ export default function App() {
|
||||
if (!auth) {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||
<Routes>
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformModulesProvider>
|
||||
@@ -342,6 +379,13 @@ export default function App() {
|
||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||
{publicRoutes.map((route) =>
|
||||
<Route
|
||||
key={`public:${route.path}`}
|
||||
path={route.path}
|
||||
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||
/>
|
||||
)}
|
||||
{moduleRoutes.map((route) =>
|
||||
<Route
|
||||
key={route.path}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { PlatformModuleInfo } from "../types";
|
||||
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||
|
||||
export type PlatformStatusResponse = {
|
||||
maintenance_mode: {
|
||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||
}
|
||||
|
||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const renderedValue = typeof value === "string" ? translateText(value) : value;
|
||||
|
||||
return (
|
||||
<div className={`metric-card metric-${tone}`}>
|
||||
<div className="metric-label">{label}</div>
|
||||
<div className="metric-value">{value}</div>
|
||||
{detail && <div className="metric-detail">{detail}</div>}
|
||||
<div className="metric-label">{translateText(label)}</div>
|
||||
<div className="metric-value">{renderedValue}</div>
|
||||
{detail && <div className="metric-detail">{translateText(detail)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
517
webui/src/components/people/PeoplePicker.tsx
Normal file
517
webui/src/components/people/PeoplePicker.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { Plus, Search, Trash2, UserPlus } from "lucide-react";
|
||||
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import { isValidEmailAddress } from "../../utils/emailAddresses";
|
||||
import Button from "../Button";
|
||||
import FieldLabel from "../help/FieldLabel";
|
||||
import DataGrid, { DataGridEmptyAction, type DataGridColumn } from "../table/DataGrid";
|
||||
import TableActionGroup from "../table/TableActionGroup";
|
||||
import {
|
||||
dedupePeoplePickerItems,
|
||||
externalPeoplePickerItem,
|
||||
peoplePickerDedupeKey,
|
||||
selectionFromPeoplePickerCandidate,
|
||||
type PeoplePickerItem,
|
||||
type PeoplePickerSearch,
|
||||
type PeoplePickerSearchCandidate,
|
||||
type PeoplePickerSearchGroup
|
||||
} from "./peoplePickerTypes";
|
||||
|
||||
export type PeoplePickerProps = {
|
||||
id?: string;
|
||||
label?: ReactNode;
|
||||
help?: ReactNode;
|
||||
selectedLabel?: ReactNode;
|
||||
value: readonly PeoplePickerItem[];
|
||||
onChange: (value: PeoplePickerItem[]) => void;
|
||||
search: PeoplePickerSearch;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
allowManualExternal?: boolean;
|
||||
manualEmailRequired?: boolean;
|
||||
minQueryLength?: number;
|
||||
searchLimit?: number;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type IndexedCandidate = {
|
||||
candidate: PeoplePickerSearchCandidate;
|
||||
group: PeoplePickerSearchGroup;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const I18N = {
|
||||
people: "i18n:govoplan-core.people.b37554f6",
|
||||
searchPeople: "i18n:govoplan-core.search_people_and_contacts.8e028a78",
|
||||
searchPlaceholder: "i18n:govoplan-core.search_by_name_or_email_address.6283d84d",
|
||||
searching: "i18n:govoplan-core.searching.1a6a5ba8",
|
||||
noMatches: "i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688",
|
||||
addExternal: "i18n:govoplan-core.add_external_person.b19c7abc",
|
||||
externalPerson: "i18n:govoplan-core.external_person.35115d8e",
|
||||
selectedPeople: "i18n:govoplan-core.selected_people.49a953a1",
|
||||
noSelection: "i18n:govoplan-core.no_people_selected_yet.9383cc74",
|
||||
source: "i18n:govoplan-core.source.6da13add",
|
||||
addPerson: "i18n:govoplan-core.add_person.3b10561b",
|
||||
removePerson: "i18n:govoplan-core.remove_person.2ea46758",
|
||||
alreadySelected: "i18n:govoplan-core.this_person_is_already_selected.d337154c",
|
||||
enterName: "i18n:govoplan-core.enter_a_name.86c810d9",
|
||||
searchFailed: "i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7",
|
||||
addSomeone: "i18n:govoplan-core.add_someone_to_the_selection.c617dd69"
|
||||
} as const;
|
||||
|
||||
function translatedNode(node: ReactNode, translateText: (value: string) => string): ReactNode {
|
||||
return typeof node === "string" ? translateText(node) : node;
|
||||
}
|
||||
|
||||
function flattenGroups(groups: readonly PeoplePickerSearchGroup[]): IndexedCandidate[] {
|
||||
const flattened: IndexedCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const group of groups) {
|
||||
for (const candidate of group.candidates) {
|
||||
if (!candidate.selection_key || seen.has(candidate.selection_key)) continue;
|
||||
seen.add(candidate.selection_key);
|
||||
flattened.push({ candidate, group, index: flattened.length });
|
||||
}
|
||||
}
|
||||
return flattened;
|
||||
}
|
||||
|
||||
export default function PeoplePicker({
|
||||
id,
|
||||
label = I18N.people,
|
||||
help,
|
||||
selectedLabel = I18N.selectedPeople,
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
disabled = false,
|
||||
required = false,
|
||||
allowManualExternal = true,
|
||||
manualEmailRequired = true,
|
||||
minQueryLength = 2,
|
||||
searchLimit = 25,
|
||||
debounceMs = 250,
|
||||
className = ""
|
||||
}: PeoplePickerProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const pickerId = id || `people-picker-${generatedId}`;
|
||||
const labelId = `${pickerId}-label`;
|
||||
const inputId = `${pickerId}-search`;
|
||||
const listboxId = `${pickerId}-results`;
|
||||
const statusId = `${pickerId}-status`;
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const manualNameRef = useRef<HTMLInputElement | null>(null);
|
||||
const manualEmailRef = useRef<HTMLInputElement | null>(null);
|
||||
const selectedItems = useMemo(() => dedupePeoplePickerItems(value), [value]);
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(selectedItems.map(peoplePickerDedupeKey)),
|
||||
[selectedItems]
|
||||
);
|
||||
const [query, setQuery] = useState("");
|
||||
const [groups, setGroups] = useState<readonly PeoplePickerSearchGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [searchError, setSearchError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualName, setManualName] = useState("");
|
||||
const [manualEmail, setManualEmail] = useState("");
|
||||
const [manualError, setManualError] = useState("");
|
||||
const normalizedMinQueryLength = Math.max(0, Math.floor(minQueryLength));
|
||||
const normalizedSearchLimit = Math.max(1, Math.min(Math.floor(searchLimit), 100));
|
||||
const flattened = useMemo(() => flattenGroups(groups), [groups]);
|
||||
|
||||
function candidateUnavailable(candidate: PeoplePickerSearchCandidate): boolean {
|
||||
return Boolean(candidate.disabled || selectedKeys.has(peoplePickerDedupeKey(candidate)));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = query.trim();
|
||||
if (disabled || normalizedQuery.length < normalizedMinQueryLength) {
|
||||
setGroups([]);
|
||||
setLoading(false);
|
||||
setHasSearched(false);
|
||||
setSearchError("");
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setSearchError("");
|
||||
void search(normalizedQuery, { limit: normalizedSearchLimit, signal: controller.signal })
|
||||
.then((nextGroups) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setGroups(nextGroups);
|
||||
setHasSearched(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
setGroups([]);
|
||||
setHasSearched(true);
|
||||
setSearchError(I18N.searchFailed);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, Math.max(0, Math.floor(debounceMs)));
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [debounceMs, disabled, normalizedMinQueryLength, normalizedSearchLimit, query, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const firstAvailable = flattened.find((item) => !candidateUnavailable(item.candidate));
|
||||
setActiveIndex(firstAvailable?.index ?? -1);
|
||||
}, [flattened, selectedKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualOpen) manualNameRef.current?.focus();
|
||||
}, [manualOpen]);
|
||||
|
||||
function focusSearch() {
|
||||
if (disabled) return;
|
||||
setOpen(true);
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function addCandidate(candidate: PeoplePickerSearchCandidate) {
|
||||
if (disabled || candidateUnavailable(candidate)) return;
|
||||
onChange(dedupePeoplePickerItems([...selectedItems, selectionFromPeoplePickerCandidate(candidate)]));
|
||||
setQuery("");
|
||||
setGroups([]);
|
||||
setHasSearched(false);
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
|
||||
function removeItem(item: PeoplePickerItem) {
|
||||
if (disabled) return;
|
||||
const removedKey = peoplePickerDedupeKey(item);
|
||||
onChange(selectedItems.filter((candidate) => peoplePickerDedupeKey(candidate) !== removedKey));
|
||||
}
|
||||
|
||||
function moveActive(delta: 1 | -1) {
|
||||
const available = flattened.filter((item) => !candidateUnavailable(item.candidate));
|
||||
if (available.length === 0) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
const currentPosition = available.findIndex((item) => item.index === activeIndex);
|
||||
const nextPosition = currentPosition < 0
|
||||
? (delta > 0 ? 0 : available.length - 1)
|
||||
: (currentPosition + delta + available.length) % available.length;
|
||||
setActiveIndex(available[nextPosition].index);
|
||||
}
|
||||
|
||||
function onSearchKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && activeIndex >= 0 && open) {
|
||||
event.preventDefault();
|
||||
const active = flattened.find((item) => item.index === activeIndex);
|
||||
if (active) addCandidate(active.candidate);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function addManualPerson(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const name = manualName.trim();
|
||||
const email = manualEmail.trim().toLowerCase();
|
||||
if (!name) {
|
||||
setManualError(I18N.enterName);
|
||||
manualNameRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if ((manualEmailRequired && !email) || (email && !isValidEmailAddress(email))) {
|
||||
setManualError("i18n:govoplan-core.enter_a_valid_email_address.2e09edea");
|
||||
manualEmailRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const item = externalPeoplePickerItem(name, email || null);
|
||||
if (selectedKeys.has(peoplePickerDedupeKey(item))) {
|
||||
setManualError(I18N.alreadySelected);
|
||||
return;
|
||||
}
|
||||
onChange(dedupePeoplePickerItems([...selectedItems, item]));
|
||||
setManualName("");
|
||||
setManualEmail("");
|
||||
setManualError("");
|
||||
setManualOpen(false);
|
||||
}
|
||||
|
||||
function closeResultsOnFocusLeave(event: FocusEvent<HTMLElement>) {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return;
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const statusMessage = loading
|
||||
? I18N.searching
|
||||
: searchError
|
||||
? searchError
|
||||
: query.trim() && query.trim().length < normalizedMinQueryLength
|
||||
? i18nMessage("i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad", {
|
||||
value0: normalizedMinQueryLength
|
||||
})
|
||||
: hasSearched && flattened.length === 0
|
||||
? I18N.noMatches
|
||||
: "";
|
||||
const resultsVisible = open && !disabled && query.trim().length >= normalizedMinQueryLength;
|
||||
const columns: DataGridColumn<PeoplePickerItem>[] = [
|
||||
{
|
||||
id: "name",
|
||||
header: "i18n:govoplan-core.name.709a2322",
|
||||
minWidth: 180,
|
||||
resizable: true,
|
||||
render: (item) => (
|
||||
<div className="people-picker-selected-person">
|
||||
<strong>{item.display_name}</strong>
|
||||
{item.description && <small>{item.description}</small>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
header: "i18n:govoplan-core.email_address.c94d3175",
|
||||
minWidth: 220,
|
||||
resizable: true,
|
||||
value: (item) => item.email || "—"
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: I18N.source,
|
||||
minWidth: 130,
|
||||
resizable: true,
|
||||
value: (item) => translateText(item.source_label || kindLabel(item.kind))
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-core.actions.c3cd636a",
|
||||
width: 104,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (item) => (
|
||||
<TableActionGroup
|
||||
minimumSlots={2}
|
||||
actions={[
|
||||
{
|
||||
id: "add",
|
||||
label: I18N.addPerson,
|
||||
icon: <Plus size={16} aria-hidden="true" />,
|
||||
disabled,
|
||||
onClick: focusSearch
|
||||
},
|
||||
{
|
||||
id: "remove",
|
||||
label: I18N.removePerson,
|
||||
icon: <Trash2 size={16} aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled,
|
||||
onClick: () => removeItem(item)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={`people-picker ${className}`.trim()} aria-labelledby={labelId} onBlur={closeResultsOnFocusLeave}>
|
||||
<div className="people-picker-search-field">
|
||||
<label htmlFor={inputId} id={labelId}>
|
||||
<FieldLabel help={help}>{translatedNode(label, translateText)}</FieldLabel>
|
||||
</label>
|
||||
<div className="people-picker-search-control">
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
id={inputId}
|
||||
type="search"
|
||||
role="combobox"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
required={required && selectedItems.length === 0}
|
||||
placeholder={translateText(I18N.searchPlaceholder)}
|
||||
aria-label={translateText(I18N.searchPeople)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={resultsVisible}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `${pickerId}-option-${activeIndex}` : undefined}
|
||||
aria-describedby={statusId}
|
||||
aria-busy={loading}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
setSearchError("");
|
||||
}}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resultsVisible && (
|
||||
<div id={listboxId} className="people-picker-results" role="listbox" aria-label={translateText(I18N.searchPeople)}>
|
||||
{groups.map((group) => {
|
||||
const candidates = flattened.filter((item) => item.group.key === group.key);
|
||||
if (candidates.length === 0) return null;
|
||||
const groupLabelId = `${pickerId}-group-${group.key.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
||||
return (
|
||||
<div key={group.key} className="people-picker-result-group" role="group" aria-labelledby={groupLabelId}>
|
||||
<div id={groupLabelId} className="people-picker-result-group-label">{translateText(group.label)}</div>
|
||||
{candidates.map(({ candidate, index }) => {
|
||||
const selected = selectedKeys.has(peoplePickerDedupeKey(candidate));
|
||||
const unavailable = candidateUnavailable(candidate);
|
||||
const disabledReason = selected ? I18N.alreadySelected : candidate.disabled_reason;
|
||||
return (
|
||||
<button
|
||||
id={`${pickerId}-option-${index}`}
|
||||
key={candidate.selection_key}
|
||||
type="button"
|
||||
role="option"
|
||||
className={`people-picker-result ${activeIndex === index ? "is-active" : ""}`.trim()}
|
||||
aria-selected={selected}
|
||||
aria-disabled={unavailable}
|
||||
disabled={unavailable}
|
||||
title={disabledReason ? translateText(disabledReason) : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => !unavailable && setActiveIndex(index)}
|
||||
onFocus={() => !unavailable && setActiveIndex(index)}
|
||||
onClick={() => addCandidate(candidate)}
|
||||
>
|
||||
<span className="people-picker-result-main">
|
||||
<strong>{candidate.display_name}</strong>
|
||||
{candidate.email && <small>{candidate.email}</small>}
|
||||
</span>
|
||||
<span className="people-picker-result-context">
|
||||
{candidate.description && <small>{candidate.description}</small>}
|
||||
<small>{translateText(candidate.source_label || group.label)}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div id={statusId} className={`people-picker-status ${searchError ? "danger-text" : ""}`.trim()} role={searchError ? "alert" : "status"} aria-live="polite">
|
||||
{statusMessage && translateText(statusMessage)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allowManualExternal && (
|
||||
<div className="people-picker-manual">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={disabled}
|
||||
aria-expanded={manualOpen}
|
||||
onClick={() => {
|
||||
setManualOpen((current) => !current);
|
||||
setManualError("");
|
||||
}}
|
||||
>
|
||||
<UserPlus size={16} aria-hidden="true" />
|
||||
{translateText(I18N.addExternal)}
|
||||
</Button>
|
||||
{manualOpen && (
|
||||
<form className="people-picker-manual-form" onSubmit={addManualPerson}>
|
||||
<label>
|
||||
<FieldLabel>{translateText("i18n:govoplan-core.name.709a2322")}</FieldLabel>
|
||||
<input
|
||||
ref={manualNameRef}
|
||||
value={manualName}
|
||||
disabled={disabled}
|
||||
required
|
||||
autoComplete="name"
|
||||
onChange={(event) => {
|
||||
setManualName(event.target.value);
|
||||
setManualError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<FieldLabel>{translateText("i18n:govoplan-core.email_address.c94d3175")}</FieldLabel>
|
||||
<input
|
||||
ref={manualEmailRef}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
value={manualEmail}
|
||||
disabled={disabled}
|
||||
required={manualEmailRequired}
|
||||
autoComplete="email"
|
||||
onChange={(event) => {
|
||||
setManualEmail(event.target.value);
|
||||
setManualError("");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="people-picker-manual-actions">
|
||||
<Button type="button" disabled={disabled} onClick={() => setManualOpen(false)}>
|
||||
{translateText("i18n:govoplan-core.cancel.77dfd213")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={disabled}>
|
||||
{translateText(I18N.addPerson)}
|
||||
</Button>
|
||||
</div>
|
||||
{manualError && <p className="form-help danger-text" role="alert">{translateText(manualError)}</p>}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="people-picker-selection" aria-labelledby={`${pickerId}-selection-label`}>
|
||||
<span id={`${pickerId}-selection-label`}><FieldLabel>{translatedNode(selectedLabel, translateText)}</FieldLabel></span>
|
||||
<DataGrid
|
||||
id={`${pickerId}-selection`}
|
||||
rows={selectedItems}
|
||||
columns={columns}
|
||||
getRowKey={peoplePickerDedupeKey}
|
||||
emptyText={I18N.noSelection}
|
||||
emptyActionColumnId="actions"
|
||||
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={focusSearch} label={I18N.addSomeone} />}
|
||||
initialFit="container"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
if (kind === "account") return "i18n:govoplan-core.accounts.36bae316";
|
||||
if (kind === "contact") return "i18n:govoplan-core.contacts.b0dd615c";
|
||||
if (kind === "external") return I18N.externalPerson;
|
||||
return kind;
|
||||
}
|
||||
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
78
webui/src/components/people/peoplePickerTypes.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type PeoplePickerItemKind = "account" | "contact" | "external" | "function" | (string & {});
|
||||
|
||||
export type PeoplePickerItem = {
|
||||
selection_key: string;
|
||||
kind: PeoplePickerItemKind;
|
||||
reference_id?: string | null;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
source_module?: string | null;
|
||||
source_label?: string | null;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
description?: string | null;
|
||||
provenance?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PeoplePickerSearchCandidate = PeoplePickerItem & {
|
||||
disabled?: boolean;
|
||||
disabled_reason?: string | null;
|
||||
};
|
||||
|
||||
export type PeoplePickerSearchGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
candidates: readonly PeoplePickerSearchCandidate[];
|
||||
};
|
||||
|
||||
export type PeoplePickerSearchOptions = {
|
||||
limit: number;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type PeoplePickerSearch = (
|
||||
query: string,
|
||||
options: PeoplePickerSearchOptions
|
||||
) => Promise<readonly PeoplePickerSearchGroup[]>;
|
||||
|
||||
export function peoplePickerDedupeKey(item: Pick<PeoplePickerItem, "selection_key" | "email">): string {
|
||||
const email = item.email?.trim().toLowerCase();
|
||||
return email ? `email:${email}` : `selection:${item.selection_key.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function dedupePeoplePickerItems(items: readonly PeoplePickerItem[]): PeoplePickerItem[] {
|
||||
const seen = new Set<string>();
|
||||
const result: PeoplePickerItem[] = [];
|
||||
for (const item of items) {
|
||||
const key = peoplePickerDedupeKey(item);
|
||||
if (!item.selection_key.trim() || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function externalPeoplePickerItem(displayName: string, email?: string | null): PeoplePickerItem {
|
||||
const normalizedName = displayName.trim();
|
||||
const normalizedEmail = email?.trim().toLowerCase() || null;
|
||||
const identityPart = normalizedEmail || normalizedName.toLowerCase();
|
||||
return {
|
||||
selection_key: `external:${identityPart}`,
|
||||
kind: "external",
|
||||
reference_id: null,
|
||||
display_name: normalizedName,
|
||||
email: normalizedEmail,
|
||||
source_module: null,
|
||||
source_label: "i18n:govoplan-core.external_person.35115d8e",
|
||||
source_ref: null,
|
||||
source_revision: null,
|
||||
provenance: {},
|
||||
metadata: { manual: true }
|
||||
};
|
||||
}
|
||||
|
||||
export function selectionFromPeoplePickerCandidate(candidate: PeoplePickerSearchCandidate): PeoplePickerItem {
|
||||
const { disabled: _disabled, disabled_reason: _disabledReason, ...selection } = candidate;
|
||||
return selection;
|
||||
}
|
||||
@@ -30,18 +30,36 @@ export type DataGridQueryState = {
|
||||
filters: Record<string, string>;
|
||||
};
|
||||
|
||||
export type DataGridPagination = {
|
||||
/** Client mode slices the filtered rows; server mode treats rows as the loaded page. */
|
||||
mode?: "client" | "server";
|
||||
type DataGridPaginationBase = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRows?: number;
|
||||
pageSizeOptions?: number[];
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
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";
|
||||
|
||||
export type DataGridColumn<T> = {
|
||||
@@ -78,7 +96,7 @@ type DataGridState = {
|
||||
bufferWidth?: number;
|
||||
};
|
||||
|
||||
type DataGridProps<T> = {
|
||||
type DataGridBaseProps<T> = {
|
||||
id: string;
|
||||
rows: T[];
|
||||
columns: DataGridColumn<T>[];
|
||||
@@ -98,11 +116,24 @@ type DataGridProps<T> = {
|
||||
storageKey?: string;
|
||||
initialFilters?: Record<string, string | string[]>;
|
||||
initialSort?: {columnId: string;direction: DataGridSortDirection;};
|
||||
pagination?: DataGridPagination;
|
||||
/** In server pagination mode, sorting/filtering is emitted instead of applied locally. */
|
||||
onQueryChange?: (query: DataGridQueryState) => void;
|
||||
/**
|
||||
* Synchronize a query selected outside the grid, for example by a summary
|
||||
* 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 = {
|
||||
disabled?: boolean;
|
||||
removeDisabled?: boolean;
|
||||
@@ -179,6 +210,7 @@ export default function DataGrid<T>({
|
||||
storageKey,
|
||||
initialFilters = {},
|
||||
initialSort,
|
||||
query,
|
||||
pagination,
|
||||
onQueryChange
|
||||
}: DataGridProps<T>) {
|
||||
@@ -205,7 +237,15 @@ export default function DataGrid<T>({
|
||||
const normalizedInitialFilters = useMemo(() => normalizeInitialFilters(initialFilters), [initialFiltersKey]);
|
||||
const initialSortKey = initialSort ? `${initialSort.columnId}:${initialSort.direction}` : "";
|
||||
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 [openFilterColumnId, setOpenFilterColumnId] = useState<string | 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 filterButtonRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const filterPopoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const serverQueryMode = pagination?.mode === "server";
|
||||
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 lastContainerWidthRef = useRef<number | undefined>(undefined);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||
const serverQueryMode = pagination?.mode === "server";
|
||||
|
||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||
|
||||
useEffect(() => {
|
||||
serverPaginationRef.current = serverQueryMode ? pagination as DataGridServerPagination : null;
|
||||
if (!serverQueryMode) lastServerQueryRef.current = null;
|
||||
}, [pagination, serverQueryMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
||||
}, [columns, effectiveResizeBehavior]);
|
||||
@@ -233,6 +280,10 @@ export default function DataGrid<T>({
|
||||
setState((current) => mergeInitialSort(current, normalizedInitialSort));
|
||||
}, [normalizedInitialSort]);
|
||||
|
||||
useEffect(() => {
|
||||
setState((current) => mergeExternalQuery(current, normalizedExternalQuery));
|
||||
}, [normalizedExternalQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
||||
@@ -460,10 +511,17 @@ export default function DataGrid<T>({
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverQueryMode) return;
|
||||
onQueryChangeRef.current?.({
|
||||
const query = {
|
||||
sort: state.sort ?? null,
|
||||
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]);
|
||||
|
||||
const filterTypes = useMemo(() => {
|
||||
@@ -1202,6 +1260,20 @@ initialSort?: {columnId: string;direction: DataGridSortDirection;})
|
||||
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 {
|
||||
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" });
|
||||
}
|
||||
|
||||
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 {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
|
||||
@@ -563,6 +563,25 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||
"i18n:govoplan-core.workspace.4ca0a75c": "Workspace",
|
||||
"i18n:govoplan-core.accounts.36bae316": "Accounts",
|
||||
"i18n:govoplan-core.contacts.b0dd615c": "Contacts",
|
||||
"i18n:govoplan-core.people.b37554f6": "People",
|
||||
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Search people and contacts",
|
||||
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Search by name or email address",
|
||||
"i18n:govoplan-core.searching.1a6a5ba8": "Searching…",
|
||||
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "No matching people or contacts found.",
|
||||
"i18n:govoplan-core.external_person.35115d8e": "External person",
|
||||
"i18n:govoplan-core.add_external_person.b19c7abc": "Add external person",
|
||||
"i18n:govoplan-core.selected_people.49a953a1": "Selected people",
|
||||
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "No people selected yet.",
|
||||
"i18n:govoplan-core.source.6da13add": "Source",
|
||||
"i18n:govoplan-core.add_person.3b10561b": "Add person",
|
||||
"i18n:govoplan-core.remove_person.2ea46758": "Remove person",
|
||||
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "This person is already selected.",
|
||||
"i18n:govoplan-core.enter_a_name.86c810d9": "Enter a name.",
|
||||
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Unable to search people right now.",
|
||||
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Type at least {value0} characters to search.",
|
||||
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Add someone to the selection",
|
||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||
@@ -1132,6 +1151,25 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-core.word_document.f593629d": "Word document",
|
||||
"i18n:govoplan-core.working.13b7bfca": "Working…",
|
||||
"i18n:govoplan-core.workspace.4ca0a75c": "Arbeitsbereich",
|
||||
"i18n:govoplan-core.accounts.36bae316": "Benutzerkonten",
|
||||
"i18n:govoplan-core.contacts.b0dd615c": "Kontakte",
|
||||
"i18n:govoplan-core.people.b37554f6": "Personen",
|
||||
"i18n:govoplan-core.search_people_and_contacts.8e028a78": "Personen und Kontakte suchen",
|
||||
"i18n:govoplan-core.search_by_name_or_email_address.6283d84d": "Nach Name oder E-Mail-Adresse suchen",
|
||||
"i18n:govoplan-core.searching.1a6a5ba8": "Suche…",
|
||||
"i18n:govoplan-core.no_matching_people_or_contacts_found.4c134688": "Keine passenden Personen oder Kontakte gefunden.",
|
||||
"i18n:govoplan-core.external_person.35115d8e": "Externe Person",
|
||||
"i18n:govoplan-core.add_external_person.b19c7abc": "Externe Person hinzufügen",
|
||||
"i18n:govoplan-core.selected_people.49a953a1": "Ausgewählte Personen",
|
||||
"i18n:govoplan-core.no_people_selected_yet.9383cc74": "Noch keine Personen ausgewählt.",
|
||||
"i18n:govoplan-core.source.6da13add": "Quelle",
|
||||
"i18n:govoplan-core.add_person.3b10561b": "Person hinzufügen",
|
||||
"i18n:govoplan-core.remove_person.2ea46758": "Person entfernen",
|
||||
"i18n:govoplan-core.this_person_is_already_selected.d337154c": "Diese Person ist bereits ausgewählt.",
|
||||
"i18n:govoplan-core.enter_a_name.86c810d9": "Geben Sie einen Namen ein.",
|
||||
"i18n:govoplan-core.unable_to_search_people_right_now.fcf680f7": "Die Personensuche ist derzeit nicht möglich.",
|
||||
"i18n:govoplan-core.type_at_least__value0__characters_to_search.d7d04bad": "Geben Sie mindestens {value0} Zeichen für die Suche ein.",
|
||||
"i18n:govoplan-core.add_someone_to_the_selection.c617dd69": "Person zur Auswahl hinzufügen",
|
||||
"i18n:govoplan-core.xlsx_spreadsheet.db8c2fc9": "XLSX spreadsheet",
|
||||
"i18n:govoplan-core.yes_active.b1ef7d60": "Yes / active",
|
||||
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
|
||||
|
||||
@@ -84,6 +84,10 @@ export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel
|
||||
export type { MessageDisplayAttachment, MessageDisplayField } from "./components/MessageDisplayPanel";
|
||||
export { default as PageTitle } from "./components/PageTitle";
|
||||
export { default as PasswordField } from "./components/PasswordField";
|
||||
export { default as PeoplePicker } from "./components/people/PeoplePicker";
|
||||
export type { PeoplePickerProps } from "./components/people/PeoplePicker";
|
||||
export { dedupePeoplePickerItems, externalPeoplePickerItem, peoplePickerDedupeKey, selectionFromPeoplePickerCandidate } from "./components/people/peoplePickerTypes";
|
||||
export type { PeoplePickerItem, PeoplePickerItemKind, PeoplePickerSearch, PeoplePickerSearchCandidate, PeoplePickerSearchGroup, PeoplePickerSearchOptions } from "./components/people/peoplePickerTypes";
|
||||
export { default as ResourceAccessExplanation, formatResourceAccessProvenanceDetails, resourceAccessProvenanceKindLabel } from "./components/ResourceAccessExplanation";
|
||||
export type { ResourceAccessExplanationProps } from "./components/ResourceAccessExplanation";
|
||||
export type { PasswordFieldProps } from "./components/PasswordField";
|
||||
@@ -110,7 +114,7 @@ export type { MailServerConnectionTestResult, MailServerCredentialSettings, Mail
|
||||
export { default as FieldLabel } from "./components/help/FieldLabel";
|
||||
export { default as InlineHelp } from "./components/help/InlineHelp";
|
||||
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 type { TableActionDefinition, TableActionGroupProps } from "./components/table/TableActionGroup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||
return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
||||
import {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapabilities as uiCapabilitiesForModules,
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
};
|
||||
}
|
||||
|
||||
function filterPublicRoutes(
|
||||
module: PlatformWebModule,
|
||||
routes: NonNullable<PlatformModuleInfo["frontend"]>["public_routes"] | undefined
|
||||
) {
|
||||
if (!routes?.length) return [];
|
||||
const allowedPaths = new Set(routes.map((route) => route.path));
|
||||
return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path));
|
||||
}
|
||||
|
||||
function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
||||
return {
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
version: info.version,
|
||||
dependencies: [],
|
||||
optional_dependencies: [],
|
||||
enabled: true,
|
||||
runtime_ui_capabilities: [],
|
||||
nav: [],
|
||||
frontend: {
|
||||
...info.frontend,
|
||||
routes: [],
|
||||
nav: [],
|
||||
settings_routes: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveInstalledPublicWebModules(
|
||||
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
||||
): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return [];
|
||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
||||
return platformModules.flatMap((info) => {
|
||||
const local = localById.get(info.id);
|
||||
if (!local) return [];
|
||||
const resolved = applyServerMetadata(local, publicModuleInfo(info));
|
||||
return resolved.publicRoutes?.length ? [resolved] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadRemotePublicWebModules(
|
||||
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
|
||||
): Promise<PlatformWebModule[]> {
|
||||
if (!platformModules?.length) return [];
|
||||
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
||||
return loaded.filter((module) => module.publicRoutes?.length);
|
||||
}
|
||||
|
||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||
if (!platformModules?.length) return localModules;
|
||||
|
||||
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return routeContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
||||
return publicRouteContributionsForModuleList(modules);
|
||||
}
|
||||
|
||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||
flatMap((capability) => capability.widgets ?? []).
|
||||
|
||||
@@ -1516,6 +1516,166 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Principal-aware account/contact picker with structured external entry. */
|
||||
.people-picker {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-search-field,
|
||||
.people-picker-selection,
|
||||
.people-picker-manual {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-search-control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.people-picker-search-control > svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
z-index: 1;
|
||||
color: var(--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.people-picker-search-control input {
|
||||
padding-left: 38px;
|
||||
}
|
||||
.people-picker-search-control:focus-within > svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
.people-picker-results {
|
||||
display: grid;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border: var(--border-line-dark);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-popover);
|
||||
padding: 5px;
|
||||
}
|
||||
.people-picker-result-group {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.people-picker-result-group + .people-picker-result-group {
|
||||
margin-top: 5px;
|
||||
border-top: var(--border-line);
|
||||
padding-top: 5px;
|
||||
}
|
||||
.people-picker-result-group-label {
|
||||
color: var(--text-label);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .04em;
|
||||
padding: 5px 9px 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.people-picker-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(120px, auto);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
}
|
||||
.people-picker-result:hover:not(:disabled),
|
||||
.people-picker-result.is-active:not(:disabled),
|
||||
.people-picker-result:focus-visible:not(:disabled) {
|
||||
background: var(--primary-soft);
|
||||
box-shadow: var(--primary-inset-ring);
|
||||
outline: none;
|
||||
}
|
||||
.people-picker-result:disabled {
|
||||
background: var(--control-disabled-bg);
|
||||
color: var(--control-disabled-text);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.people-picker-result-main,
|
||||
.people-picker-result-context,
|
||||
.people-picker-selected-person {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-result-main strong,
|
||||
.people-picker-result-main small,
|
||||
.people-picker-result-context small,
|
||||
.people-picker-selected-person strong,
|
||||
.people-picker-selected-person small {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.people-picker-result-main small,
|
||||
.people-picker-result-context,
|
||||
.people-picker-selected-person small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.people-picker-result-context {
|
||||
justify-items: end;
|
||||
text-align: right;
|
||||
}
|
||||
.people-picker-status {
|
||||
min-height: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.people-picker-manual {
|
||||
justify-items: start;
|
||||
}
|
||||
.people-picker-manual-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(220px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
border: var(--border-line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
.people-picker-manual-form > label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.people-picker-manual-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.people-picker-manual-form > .form-help {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
}
|
||||
.people-picker-selection > .field-label,
|
||||
.people-picker-selection > span > .field-label {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.people-picker-result {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
.people-picker-result-context {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
.people-picker-manual-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bootstrap-like switch controls without importing Bootstrap. */
|
||||
.toggle-switch-row {
|
||||
display: flex;
|
||||
|
||||
@@ -94,7 +94,8 @@
|
||||
padding: 18px 34px 16px;
|
||||
}
|
||||
.workspace-heading .mono-small { margin-top: 8px; }
|
||||
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.panel, .card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||||
.panel { overflow: hidden; }
|
||||
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
|
||||
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
|
||||
|
||||
@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContext = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo | null;
|
||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||
};
|
||||
|
||||
export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
||||
|
||||
export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformPublicRouteContribution = {
|
||||
path: string;
|
||||
order?: number;
|
||||
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||
|
||||
export type PlatformTranslationDictionary = Record<string, string>;
|
||||
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
|
||||
remoteAssetContractVersion?: string | null;
|
||||
navItems?: PlatformNavItem[];
|
||||
routes?: PlatformRouteContribution[];
|
||||
publicRoutes?: PlatformPublicRouteContribution[];
|
||||
translations?: PlatformTranslations;
|
||||
uiCapabilities?: PlatformUiCapabilities;
|
||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
|
||||
asset_manifest_integrity?: string | null;
|
||||
asset_manifest_contract_version?: string | null;
|
||||
routes: PlatformFrontendRouteInfo[];
|
||||
public_routes: Array<{
|
||||
path: string;
|
||||
component: string;
|
||||
order: number;
|
||||
}>;
|
||||
nav: Array<{
|
||||
path: string;
|
||||
label: string;
|
||||
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
|
||||
settings_routes: PlatformFrontendRouteInfo[];
|
||||
};
|
||||
|
||||
export type PlatformPublicModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
frontend: Pick<
|
||||
PlatformFrontendModuleInfo,
|
||||
| "module_id"
|
||||
| "package_name"
|
||||
| "asset_manifest"
|
||||
| "asset_manifest_signature"
|
||||
| "asset_manifest_public_key_id"
|
||||
| "asset_manifest_integrity"
|
||||
| "asset_manifest_contract_version"
|
||||
| "public_routes"
|
||||
>;
|
||||
};
|
||||
|
||||
export type PlatformModuleInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ function assertEqual<T>(actual: T, expected: T, message: string): void {
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
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";
|
||||
|
||||
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-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");
|
||||
|
||||
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");
|
||||
68
webui/tests/people-picker.test.tsx
Normal file
68
webui/tests/people-picker.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
function assert(condition: unknown, message = "assertion failed"): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PeoplePicker from "../src/components/people/PeoplePicker";
|
||||
import {
|
||||
dedupePeoplePickerItems,
|
||||
externalPeoplePickerItem,
|
||||
selectionFromPeoplePickerCandidate,
|
||||
type PeoplePickerSearch
|
||||
} from "../src/components/people/peoplePickerTypes";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
|
||||
const account = {
|
||||
selection_key: "account:account-1",
|
||||
kind: "account" as const,
|
||||
reference_id: "account-1",
|
||||
display_name: "Ada Lovelace",
|
||||
email: "Ada@Example.Test",
|
||||
source_module: "access",
|
||||
source_label: "Accounts"
|
||||
};
|
||||
const contact = {
|
||||
selection_key: "contact:contact-1:ada@example.test",
|
||||
kind: "contact" as const,
|
||||
reference_id: "contact-1",
|
||||
display_name: "Ada Contact",
|
||||
email: "ada@example.test",
|
||||
source_module: "addresses",
|
||||
source_label: "Contacts"
|
||||
};
|
||||
|
||||
const deduped = dedupePeoplePickerItems([account, contact]);
|
||||
assert(deduped.length === 1, "the same email is selected only once across directory sources");
|
||||
assert(deduped[0].reference_id === "account-1", "selection order decides which discoverable reference is retained");
|
||||
|
||||
const external = externalPeoplePickerItem(" Grace Hopper ", " GRACE@EXAMPLE.TEST ");
|
||||
assert(external.display_name === "Grace Hopper", "manual names are normalized");
|
||||
assert(external.email === "grace@example.test", "manual email addresses are normalized");
|
||||
assert(external.selection_key === "external:grace@example.test", "manual entries receive stable opaque selection keys");
|
||||
|
||||
const selected = selectionFromPeoplePickerCandidate({ ...contact, disabled: true, disabled_reason: "Policy" });
|
||||
assert(!("disabled" in selected), "result-only disabled state is not persisted in selections");
|
||||
assert(!("disabled_reason" in selected), "result-only policy wording is not persisted in selections");
|
||||
|
||||
let searchCalled = false;
|
||||
const search: PeoplePickerSearch = async () => {
|
||||
searchCalled = true;
|
||||
return [];
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<PeoplePicker id="reviewers" value={[account]} onChange={() => undefined} search={search} help="Choose a reviewer." />
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
|
||||
assert(!searchCalled, "directory search is server-driven after user input, not during static rendering");
|
||||
assert(markup.includes('role="combobox"'), "search exposes combobox semantics");
|
||||
assert(markup.includes('aria-autocomplete="list"'), "search declares list autocomplete behavior");
|
||||
assert(markup.includes('aria-controls="reviewers-results"'), "search and grouped result list are associated");
|
||||
assert(markup.includes("inline-help"), "non-obvious picker fields use central inline help");
|
||||
assert(markup.includes('role="table"'), "selected people are rendered through the central DataGrid");
|
||||
assert(markup.includes("Ada Lovelace"), "selected structured rows retain the display name");
|
||||
assert(markup.includes("Ada@Example.Test"), "selected structured rows retain the email address");
|
||||
assert(markup.includes('aria-label="Add person"'), "row add uses an accessible central table action");
|
||||
assert(markup.includes('aria-label="Remove person"'), "row removal uses an accessible central table action");
|
||||
assert(markup.includes("Add external person"), "manual external entry remains a separate structured action");
|
||||
@@ -23,12 +23,17 @@
|
||||
"tests/explorer-tree.test.tsx",
|
||||
"tests/icon-button.test.tsx",
|
||||
"tests/mail-components.test.tsx",
|
||||
"tests/metric-card.test.tsx",
|
||||
"tests/people-picker.test.tsx",
|
||||
"tests/resource-access-explanation.test.tsx",
|
||||
"tests/selection-list.test.tsx",
|
||||
"src/components/CredentialPanel.tsx",
|
||||
"src/components/email/EmailAddressInput.tsx",
|
||||
"src/components/PasswordField.tsx",
|
||||
"src/components/MessageDisplayPanel.tsx",
|
||||
"src/components/MetricCard.tsx",
|
||||
"src/components/people/PeoplePicker.tsx",
|
||||
"src/components/people/peoplePickerTypes.ts",
|
||||
"src/components/ResourceAccessExplanation.tsx",
|
||||
"src/components/SelectionList.tsx",
|
||||
"src/components/mail/MailServerSettingsPanel.tsx",
|
||||
@@ -42,6 +47,7 @@
|
||||
"src/components/IconButton.tsx",
|
||||
"src/components/admin/AdminIconButton.tsx",
|
||||
"src/components/ToggleSwitch.tsx",
|
||||
"src/components/table/DataGrid.tsx",
|
||||
"src/components/table/TableActionGroup.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user