feat: strengthen module contracts and shared WebUI runtime

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent 53e947935a
commit 68328f3d8e
57 changed files with 4358 additions and 371 deletions

View File

@@ -42,8 +42,18 @@ inheritance, reuse, derivation, and automation are unavailable.
## Delivery Durability ## Delivery Durability
Domain trigger implementations persist idempotent deliveries before running. Domain trigger implementations persist idempotent deliveries before running.
The existing `PlatformEvent` bus is process-local and is not a durable `emit_platform_event` binds event delivery to the producer's SQLAlchemy
automation source. A transactional Core event/outbox bridge is still required transaction. When an enabled module provides `platform.eventOutbox`, the event
to guarantee capture across commits, restarts, and multiple workers. Until is stored in that transaction and a dispatcher may retry it across restarts and
that bridge exists, direct event ingestion must be authenticated, tenant workers. The Audit module provides the current SQL outbox implementation; the
scoped, and limited to public or internal event envelopes. Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow
event-ingestion capability and the local event bus.
The outbox capability remains optional so reduced module combinations can
start. Without it, Core queues events on the SQLAlchemy transaction and
publishes them to the process-local bus only after the outer commit. A rollback,
including a nested savepoint rollback, discards the corresponding events. This
fallback is suitable for local or non-critical reactions, but it is not a
durable multi-worker automation source. Deployments that rely on event-triggered
work must enable the outbox provider and run the `events` worker queue and
periodic dispatcher.

View File

@@ -209,9 +209,13 @@ prefer `FILE_STORAGE_*`.
Interactive password login is enabled with fixed-window limits of 10 failures Interactive password login is enabled with fixed-window limits of 10 failures
per normalized identity and 100 failures per direct client over 900 seconds. per normalized identity and 100 failures per direct client over 900 seconds.
`AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL` `AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL`
when Redis is reachable so replicas share state; a bounded process-local when Redis is reachable so replicas share state. Production-like startup fails
fallback keeps development and Redis outages functional, with per-process when throttling is enabled without `REDIS_URL`. Set
enforcement until Redis recovers. `GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` only as an explicit
single-process risk acceptance. A bounded process-local fallback keeps
development and temporary Redis outages functional, with per-process
enforcement until Redis recovers; monitor Redis because protection is weaker
during that fallback.
### Outbound Connector Egress ### Outbound Connector Egress

View File

@@ -8,25 +8,32 @@ module reactions, and operator diagnostics.
## Production Transport Decision ## Production Transport Decision
The first production target is a **database outbox plus in-process immediate The production transport is a **transactional database outbox plus retrying
dispatch**: dispatcher**:
- Use `govoplan_core.core.events.PlatformEvent` for domain and platform events. - Use `govoplan_core.core.events.PlatformEvent` for domain and platform events.
- Use `EventBus` as the in-process dispatch contract for same-process module - Call `emit_platform_event(session, event)` to bind event delivery to the
reactions that are safe to run inline. domain transaction.
- The optional `platform.eventOutbox` capability persists events atomically.
The Audit module provides the current SQL implementation.
- Without an outbox provider, Core publishes to `EventBus` only after the outer
transaction commits. This preserves reduced installations but is not durable
across process failure or multiple workers.
- Use `EventBus` as the in-process dispatch contract for module reactions
invoked by the outbox dispatcher or for non-critical fallback reactions.
- Use the shared `audit_event` / `audit_from_principal` helper for audited - Use the shared `audit_event` / `audit_from_principal` helper for audited
module actions. The helper persists the audit row and immediately publishes a module actions. The helper persists the audit row and transactionally emits a
governed `PlatformEvent` whose `type` is the audit action. governed `PlatformEvent` whose `type` is the audit action.
- Use `record_change` for module delta feeds. It persists the change-sequence - Use `record_change` for module delta feeds. It persists the change-sequence
row and immediately publishes a generic module change event such as row and transactionally emits a generic module change event such as
`mail.profile.updated`. `mail.profile.updated`.
- Persist durable integration/workflow events through a database outbox before - Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the
acknowledging the state change that produced them. `events` queue. The periodic schedule also retries pending rows.
- Drain the outbox through a small dispatcher process. The dispatcher may call - The dispatcher invokes Dataflow event ingestion when that capability is
in-process handlers in the same deployment first, but its storage contract is active, then publishes to the process-local bus.
database-backed.
- Treat Redis/Celery as worker/job infrastructure, not as the authoritative - Treat Redis/Celery as worker/job infrastructure, not as the authoritative
first event transport. A Celery dispatcher can consume the outbox later. first event transport. PostgreSQL remains authoritative until dispatch is
recorded.
- Keep the dispatch implementation pluggable behind the `PlatformEvent` - Keep the dispatch implementation pluggable behind the `PlatformEvent`
envelope so a future message broker can be added without changing event envelope so a future message broker can be added without changing event
producers. producers.
@@ -44,17 +51,18 @@ Event producers should write their domain state and outbox event in the same
database transaction wherever possible. Handlers must be idempotent because the database transaction wherever possible. Handlers must be idempotent because the
outbox dispatcher can retry after a crash or timeout. outbox dispatcher can retry after a crash or timeout.
Recommended first outbox columns: The current outbox stores:
- `event_id`, `event_type`, `module_id` - `event_id`, `event_type`, `module_id`
- `correlation_id`, `causation_id` - `correlation_id`, `causation_id`
- `payload`, `occurred_at` - `classification`, serialized event `payload`
- `available_at`, `attempt_count`, `claimed_at`, `claim_token` - `status`, `attempts`, `next_attempt_at`
- `processed_at`, `last_error` - `dispatched_at`, `last_error`, timestamps
Inline `EventBus` handlers are allowed only for non-critical local reactions. Handlers must be idempotent: a worker may complete an external effect and fail
Anything that must survive process failure, restart, package update, or worker before marking its outbox row dispatched. Anything that must survive process
redeployment belongs in the outbox. failure, restart, package update, or worker redeployment requires the outbox
provider and dispatcher.
## Trace IDs ## Trace IDs

View File

@@ -3,10 +3,13 @@
Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints
that need bounded fixed-window counters. Subjects are SHA-256 hashed before they that need bounded fixed-window counters. Subjects are SHA-256 hashed before they
become store keys. A configured Redis instance provides atomic counters shared become store keys. A configured Redis instance provides atomic counters shared
across API workers; development, a missing Redis configuration, and temporary across API workers. Development and temporary Redis outages use a bounded
Redis outages use a bounded process-local fallback. When Redis fails, local process-local fallback. Production-like startup rejects an enabled login
attempts are still mirrored so losing the distributed store does not reset the throttle without `REDIS_URL` unless
active worker's protection window. `GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true` explicitly acknowledges the
single-process limitation. When Redis fails at runtime, local attempts are still
mirrored so losing the distributed store does not reset the active worker's
protection window.
Callers define one or more `ThrottleDimension` values with a controlled Callers define one or more `ThrottleDimension` values with a controlled
namespace, a subject and a positive limit. They must call `check` before an namespace, a subject and a positive limit. They must call `check` before an

View File

@@ -39,6 +39,22 @@ class DeltaCollectionResponse(BaseModel):
full: bool = False full: bool = False
class ReferenceOptionResponse(BaseModel):
value: str
label: str
description: str | None = None
kind: str | None = None
availability: Literal["available", "inactive", "unavailable"] = "available"
disabled: bool = False
source_module: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class ReferenceOptionListResponse(BaseModel):
options: list[ReferenceOptionResponse] = Field(default_factory=list)
provider_available: bool = True
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")

View File

@@ -16,7 +16,7 @@ from govoplan_core.core.events import (
PlatformEvent, PlatformEvent,
current_event_trace, current_event_trace,
normalize_trace_id, normalize_trace_id,
publish_platform_event, emit_platform_event,
) )
from govoplan_core.core.runtime import get_registry from govoplan_core.core.runtime import get_registry
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
@@ -171,9 +171,13 @@ class _NullAuditRecorder:
) )
def _publish_audit_platform_event(item: AuditRecordRef) -> None: def _publish_audit_platform_event(
session: Session,
item: AuditRecordRef,
) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None) trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event( emit_platform_event(
session,
PlatformEvent( PlatformEvent(
type=item.action, type=item.action,
module_id=_module_id_for_audit_action(item.action), module_id=_module_id_for_audit_action(item.action),
@@ -252,7 +256,7 @@ def audit_event(
actor_id=user_id or api_key_id, actor_id=user_id or api_key_id,
payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id}, payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id},
) )
_publish_audit_platform_event(item) _publish_audit_platform_event(session, item)
if commit: if commit:
session.commit() session.commit()
return item return item

View File

@@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None:
def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider: def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider:
registry = _registry_from_request(request) registry = _registry_from_request(request)
if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER): if registry is None or not registry.has_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Auth provider is not available") raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Auth provider is not available")
capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER) capability = registry.require_capability(CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER)
if not isinstance(capability, ApiPrincipalProvider): if not isinstance(capability, ApiPrincipalProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability")

View File

@@ -8,6 +8,12 @@ from govoplan_core.core.dataflows import (
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER,
DataflowTriggerDispatcher, DataflowTriggerDispatcher,
) )
from govoplan_core.core.events import (
CAPABILITY_PLATFORM_EVENT_OUTBOX,
PlatformEvent,
PlatformEventOutbox,
publish_platform_event,
)
from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider
@@ -34,6 +40,7 @@ celery.conf.update(
"govoplan.notifications.deliver_pending": {"queue": "notifications"}, "govoplan.notifications.deliver_pending": {"queue": "notifications"},
"govoplan.calendar.dispatch_outbox": {"queue": "calendar"}, "govoplan.calendar.dispatch_outbox": {"queue": "calendar"},
"govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"}, "govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"},
"govoplan.events.dispatch_outbox": {"queue": "events"},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
task_acks_late=True, task_acks_late=True,
@@ -49,6 +56,11 @@ celery.conf.update(
"schedule": 60.0, "schedule": 60.0,
"args": (100,), "args": (100,),
}, },
"platform-events-every-ten-seconds": {
"task": "govoplan.events.dispatch_outbox",
"schedule": 10.0,
"args": (100,),
},
}, },
) )
@@ -96,8 +108,10 @@ def _calendar_outbox() -> CalendarOutboxProvider | None:
return capability return capability
def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None: def _dataflow_trigger_dispatcher(
registry = _platform_registry() registry: PlatformRegistry | None = None,
) -> DataflowTriggerDispatcher | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER): if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER):
return None return None
capability = registry.require_capability( capability = registry.require_capability(
@@ -108,6 +122,18 @@ def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None:
return capability return capability
def _platform_event_outbox(
registry: PlatformRegistry | None = None,
) -> PlatformEventOutbox | None:
registry = registry or _platform_registry()
if not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX):
return None
capability = registry.require_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
if not isinstance(capability, PlatformEventOutbox):
raise RuntimeError("Platform event outbox capability is invalid")
return capability
@celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0) @celery.task(name="govoplan.campaigns.send_email", bind=True, max_retries=0)
def send_email(self, job_id: str): def send_email(self, job_id: str):
"""Send one explicitly queued campaign job. """Send one explicitly queued campaign job.
@@ -197,3 +223,39 @@ def dispatch_dataflow_triggers(self, limit: int = 100):
result = dict(provider.dispatch_due(session, limit=limit)) result = dict(provider.dispatch_due(session, limit=limit))
session.commit() session.commit()
return result return result
@celery.task(
name="govoplan.events.dispatch_outbox",
bind=True,
max_retries=0,
)
def dispatch_platform_events(self, limit: int = 100):
"""Deliver committed platform events once across all worker processes."""
from govoplan_core.db.session import get_database
with get_database().SessionLocal() as session:
registry = _platform_registry()
outbox = _platform_event_outbox(registry)
if outbox is None:
return {"selected": 0, "dispatched": 0, "failed": 0}
dataflow_dispatcher = _dataflow_trigger_dispatcher(registry)
def dispatch(event: PlatformEvent) -> None:
if (
dataflow_dispatcher is not None
and event.classification in {"public", "internal"}
):
dataflow_dispatcher.ingest_event(session, event=event)
publish_platform_event(event)
result = dict(
outbox.dispatch_pending(
session,
dispatcher=dispatch,
limit=limit,
)
)
session.commit()
return result

View File

@@ -68,6 +68,12 @@ AUTH_CAPABILITY_NAMES = frozenset(
} }
) )
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
}
AuthMethod = Literal["session", "api_key", "service_account"] AuthMethod = Literal["session", "api_key", "service_account"]
AccessSubjectKind = Literal[ AccessSubjectKind = Literal[
"identity", "identity",

View File

@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, Session, mapped_column from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
from govoplan_core.db.base import Base, utcnow from govoplan_core.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:" WATERMARK_PREFIX = "seq:"
@@ -96,13 +96,14 @@ def record_change(
payload=payload or {}, payload=payload or {},
) )
session.add(entry) session.add(entry)
_publish_change_event(entry) _publish_change_event(session, entry)
return entry return entry
def _publish_change_event(entry: ChangeSequenceEntry) -> None: def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation) event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
publish_platform_event( emit_platform_event(
session,
PlatformEvent( PlatformEvent(
type=event_type, type=event_type,
module_id=entry.module_id, module_id=entry.module_id,

View File

@@ -111,96 +111,189 @@ def validate_definition_graph(
nodes: Sequence[DefinitionNode], nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge], edges: Sequence[DefinitionEdge],
) -> tuple[DefinitionDiagnostic, ...]: ) -> tuple[DefinitionDiagnostic, ...]:
diagnostics: list[DefinitionDiagnostic] = []
constraints = library.constraints
if not nodes: if not nodes:
return (_error("graph.empty", "Add nodes before saving the definition."),) return (_error("graph.empty", "Add nodes before saving the definition."),)
if len(nodes) > constraints.max_nodes: constraints = library.constraints
diagnostics = _graph_size_diagnostics(
node_count=len(nodes),
edge_count=len(edges),
constraints=constraints,
)
node_by_id = {node.id: node for node in nodes}
definitions: dict[str, DefinitionNodeType] = {}
for item in library.node_types:
definitions.setdefault(item.type, item)
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
incoming, undirected, topology_edges, edge_diagnostics = (
_validate_definition_edges(
edges=edges,
node_by_id=node_by_id,
definitions=definitions,
)
)
diagnostics.extend(edge_diagnostics)
diagnostics.extend(
_node_port_diagnostics(
nodes=nodes,
definitions=definitions,
incoming=incoming,
library_id=library.id,
)
)
diagnostics.extend(
_node_count_diagnostics(
nodes=nodes,
definitions=definitions,
constraints=constraints,
)
)
diagnostics.extend(
_topology_diagnostics(
nodes=nodes,
node_by_id=node_by_id,
topology_edges=topology_edges,
undirected=undirected,
constraints=constraints,
)
)
return tuple(diagnostics)
def _graph_size_diagnostics(
*,
node_count: int,
edge_count: int,
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if node_count > constraints.max_nodes:
diagnostics.append( diagnostics.append(
_error( _error(
"graph.node_limit", "graph.node_limit",
f"Definitions are limited to {constraints.max_nodes:,} nodes.", f"Definitions are limited to {constraints.max_nodes:,} nodes.",
) )
) )
if len(edges) > constraints.max_edges: if edge_count > constraints.max_edges:
diagnostics.append( diagnostics.append(
_error( _error(
"graph.edge_limit", "graph.edge_limit",
f"Definitions are limited to {constraints.max_edges:,} edges.", f"Definitions are limited to {constraints.max_edges:,} edges.",
) )
) )
return diagnostics
node_by_id = {node.id: node for node in nodes}
if len(node_by_id) != len(nodes): def _identifier_diagnostics(
*,
nodes: Sequence[DefinitionNode],
edges: Sequence[DefinitionEdge],
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
if len({node.id for node in nodes}) != len(nodes):
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique.")) diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
if len({edge.id for edge in edges}) != len(edges): if len({edge.id for edge in edges}) != len(edges):
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique.")) diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
return diagnostics
def _validate_definition_edges(
*,
edges: Sequence[DefinitionEdge],
node_by_id: Mapping[str, DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
) -> tuple[
dict[str, list[DefinitionEdge]],
dict[str, set[str]],
list[DefinitionEdge],
list[DefinitionDiagnostic],
]:
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id} incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id} undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
topology_edges: list[DefinitionEdge] = [] topology_edges: list[DefinitionEdge] = []
diagnostics: list[DefinitionDiagnostic] = []
for edge in edges: for edge in edges:
if edge.source not in node_by_id: structural_error = _edge_structure_diagnostic(edge, node_by_id)
diagnostics.append( if structural_error is not None:
_error( diagnostics.append(structural_error)
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
)
continue
if edge.target not in node_by_id:
diagnostics.append(
_error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
)
continue
if edge.source == edge.target:
diagnostics.append(
_error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
)
continue continue
topology_edges.append(edge) topology_edges.append(edge)
source_definition = library.node_type(node_by_id[edge.source].type) port_error = _edge_port_diagnostic(
target_definition = library.node_type(node_by_id[edge.target].type) edge,
if source_definition is not None and edge.source_port not in { source_definition=definitions.get(node_by_id[edge.source].type),
port.id for port in source_definition.output_ports target_definition=definitions.get(node_by_id[edge.target].type),
}: )
diagnostics.append( if port_error is not None:
_error( diagnostics.append(port_error)
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
)
continue
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
diagnostics.append(
_error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
)
continue continue
incoming[edge.target].append(edge) incoming[edge.target].append(edge)
undirected[edge.source].add(edge.target) undirected[edge.source].add(edge.target)
undirected[edge.target].add(edge.source) undirected[edge.target].add(edge.source)
return incoming, undirected, topology_edges, diagnostics
def _edge_structure_diagnostic(
edge: DefinitionEdge,
node_by_id: Mapping[str, DefinitionNode],
) -> DefinitionDiagnostic | None:
if edge.source not in node_by_id:
return _error(
"edge.unknown_source",
f"Edge {edge.id!r} references an unknown source node.",
)
if edge.target not in node_by_id:
return _error(
"edge.unknown_target",
f"Edge {edge.id!r} references an unknown target node.",
)
if edge.source == edge.target:
return _error(
"edge.self_reference",
"A node cannot connect to itself.",
node_id=edge.source,
)
return None
def _edge_port_diagnostic(
edge: DefinitionEdge,
*,
source_definition: DefinitionNodeType | None,
target_definition: DefinitionNodeType | None,
) -> DefinitionDiagnostic | None:
if source_definition is not None and edge.source_port not in {
port.id for port in source_definition.output_ports
}:
return _error(
"edge.unknown_source_port",
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
node_id=edge.source,
)
if target_definition is not None and edge.target_port not in {
port.id for port in target_definition.input_ports
}:
return _error(
"edge.unknown_target_port",
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
node_id=edge.target,
)
return None
def _node_port_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
incoming: Mapping[str, Sequence[DefinitionEdge]],
library_id: str,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for node in nodes: for node in nodes:
definition = library.node_type(node.type) definition = definitions.get(node.type)
if definition is None: if definition is None:
diagnostics.append( diagnostics.append(
_error( _error(
"node.unsupported_type", "node.unsupported_type",
f"Node type {node.type!r} is not in the {library.id!r} library.", f"Node type {node.type!r} is not in the {library_id!r} library.",
node_id=node.id, node_id=node.id,
field="type", field="type",
) )
@@ -231,10 +324,19 @@ def validate_definition_graph(
node_id=node.id, node_id=node.id,
) )
) )
return diagnostics
def _node_count_diagnostics(
*,
nodes: Sequence[DefinitionNode],
definitions: Mapping[str, DefinitionNodeType],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
for count_constraint in constraints.node_counts: for count_constraint in constraints.node_counts:
count = sum( count = sum(
count_constraint.matches(node, library.node_type(node.type)) count_constraint.matches(node, definitions.get(node.type))
for node in nodes for node in nodes
) )
if count < count_constraint.minimum: if count < count_constraint.minimum:
@@ -259,7 +361,18 @@ def validate_definition_graph(
), ),
) )
) )
return diagnostics
def _topology_diagnostics(
*,
nodes: Sequence[DefinitionNode],
node_by_id: Mapping[str, DefinitionNode],
topology_edges: Sequence[DefinitionEdge],
undirected: Mapping[str, set[str]],
constraints: DefinitionGraphConstraints,
) -> list[DefinitionDiagnostic]:
diagnostics: list[DefinitionDiagnostic] = []
_, cyclic = definition_topological_order(nodes, topology_edges) _, cyclic = definition_topological_order(nodes, topology_edges)
if cyclic and not constraints.allow_cycles: if cyclic and not constraints.allow_cycles:
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph.")) diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
@@ -269,7 +382,7 @@ def validate_definition_graph(
diagnostics.append( diagnostics.append(
_error("graph.disconnected", "Every node must belong to one connected definition.") _error("graph.disconnected", "Every node must belong to one connected definition.")
) )
return tuple(diagnostics) return diagnostics
def definition_topological_order( def definition_topological_order(

View File

@@ -7,11 +7,16 @@ from contextvars import ContextVar
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
import re import re
from typing import Any, Literal from typing import Any, Literal, Protocol, runtime_checkable
import uuid import uuid
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.orm import Session
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$") _TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
def new_event_id() -> str: def new_event_id() -> str:
@@ -102,6 +107,21 @@ class PlatformEvent:
EventHandler = Callable[[PlatformEvent], None] EventHandler = Callable[[PlatformEvent], None]
@runtime_checkable
class PlatformEventOutbox(Protocol):
def enqueue(self, session: object, event: PlatformEvent) -> object:
...
def dispatch_pending(
self,
session: object,
*,
dispatcher: EventHandler,
limit: int = 100,
) -> Mapping[str, int]:
...
def current_event_trace() -> EventTrace | None: def current_event_trace() -> EventTrace | None:
return _current_trace.get() return _current_trace.get()
@@ -189,5 +209,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
platform_event_bus().publish(event) platform_event_bus().publish(event)
def platform_event_outbox(
registry: object | None = None,
) -> PlatformEventOutbox | None:
if registry is None:
from govoplan_core.core.runtime import get_registry
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
):
return None
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
return capability if isinstance(capability, PlatformEventOutbox) else None
def emit_platform_event(
session: Session,
event: PlatformEvent,
*,
registry: object | None = None,
) -> None:
"""Persist an event with its transaction or publish it after commit.
The durable outbox is optional so reduced module combinations remain
usable. Without it, the event is kept on the SQLAlchemy session and only
delivered to the process-local bus after the outer transaction commits.
"""
traced = ensure_event_trace(event)
outbox = platform_event_outbox(registry)
if outbox is not None:
outbox.enqueue(session, traced)
return
transaction = (
session.get_nested_transaction()
or session.get_transaction()
or session.begin()
)
pending_by_transaction = session.info.setdefault(
_PENDING_EVENTS_KEY,
{},
)
pending_by_transaction.setdefault(transaction, []).append(
(platform_event_bus(), traced)
)
@sqlalchemy_event.listens_for(Session, "after_commit")
def _publish_committed_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
if transaction is None:
return
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if not isinstance(pending_by_transaction, dict):
return
pending = pending_by_transaction.pop(transaction, ())
parent = transaction.parent
if parent is not None:
pending_by_transaction.setdefault(parent, []).extend(pending)
else:
for bus, event in pending:
bus.publish(event)
if not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
@sqlalchemy_event.listens_for(Session, "after_rollback")
def _discard_rolled_back_events(session: Session) -> None:
transaction = session.get_nested_transaction() or session.get_transaction()
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
if transaction is None or not isinstance(pending_by_transaction, dict):
return
pending_by_transaction.pop(transaction, None)
if transaction.parent is None or not pending_by_transaction:
session.info.pop(_PENDING_EVENTS_KEY, None)
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]: def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
return {key: item for key, item in value.items() if item is not None} return {key: item for key, item in value.items() if item is not None}

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Literal, Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
IDM_MODULE_ID = "idm" IDM_MODULE_ID = "idm"
CAPABILITY_IDM_DIRECTORY = "idm.directory" CAPABILITY_IDM_DIRECTORY = "idm.directory"
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
IdmStatus = Literal["active", "inactive", "suspended"] IdmStatus = Literal["active", "inactive", "suspended"]
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"] OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
@@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef:
status: IdmStatus = "active" status: IdmStatus = "active"
@dataclass(frozen=True, slots=True)
class OrganizationFunctionIncumbencyRef:
tenant_id: str
function_id: str
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
function_active: bool = True
@property
def vacant(self) -> bool:
return not self.assignments
@runtime_checkable @runtime_checkable
class IdmDirectory(Protocol): class IdmDirectory(Protocol):
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
@@ -40,6 +53,7 @@ class IdmDirectory(Protocol):
identity_id: str, identity_id: str,
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]: ) -> Sequence[OrganizationFunctionAssignmentRef]:
... ...
@@ -48,5 +62,47 @@ class IdmDirectory(Protocol):
account_id: str, account_id: str,
*, *,
tenant_id: str | None = None, tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]: ) -> Sequence[OrganizationFunctionAssignmentRef]:
... ...
def organization_function_assignments_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
def organization_function_assignments_for_accounts(
self,
account_ids: Sequence[str],
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
...
@runtime_checkable
class IdmFunctionAssignmentDirectory(Protocol):
"""Reverse lookup for effective incumbency and vacancy decisions."""
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
effective_at: datetime | None = None,
) -> Sequence[OrganizationFunctionAssignmentRef]:
...
def organization_function_incumbencies(
self,
function_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
...

View File

@@ -67,6 +67,17 @@ class ModuleLifecycleManager:
def mounted_module_ids(self) -> tuple[str, ...]: def mounted_module_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._mounted_modules)) return tuple(sorted(self._mounted_modules))
def live_apply_enabled(self) -> bool:
configured = getattr(
self.settings,
"module_live_apply_enabled",
None,
)
if configured is not None:
return bool(configured)
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
return app_env in {"dev", "development", "local", "test", "testing"}
def apply_enabled_modules( def apply_enabled_modules(
self, self,
requested_enabled: Sequence[str], requested_enabled: Sequence[str],

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
from govoplan_core.core.discovery import iter_module_entry_points from govoplan_core.core.discovery import iter_module_entry_points
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
MODULE_SETTINGS_KEY = "module_management" MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan" INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",) REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin") PROTECTED_MODULES = ("admin",)
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall") INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked") INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog") INSTALL_PLAN_SOURCES = ("manual", "catalog")
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
if desired is not None: if desired is not None:
candidates.extend(str(item).strip() for item in desired if str(item).strip()) candidates.extend(str(item).strip() for item in desired if str(item).strip())
candidates.extend(REQUIRED_PLATFORM_MODULES) candidates.extend(REQUIRED_PLATFORM_MODULES)
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
if "admin" in fallback: if "admin" in fallback:
candidates.append("admin") candidates.append("admin")
return tuple(dict.fromkeys(candidates)) return tuple(dict.fromkeys(candidates))
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
*, *,
protected_modules: Iterable[str] = PROTECTED_MODULES, protected_modules: Iterable[str] = PROTECTED_MODULES,
) -> ModuleStatePlan: ) -> ModuleStatePlan:
requested = {str(item).strip() for item in requested_enabled if str(item).strip()} requested = _normalized_module_ids(requested_enabled)
protected = {str(item).strip() for item in protected_modules if str(item).strip()} requested.update(_normalized_module_ids(protected_modules))
requested.update(protected)
missing = sorted(module_id for module_id in requested if module_id not in available) missing = sorted(module_id for module_id in requested if module_id not in available)
if missing: if missing:
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing)) raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
added_dependencies: set[str] = set() added_dependencies = _expand_enabled_module_closure(requested, available)
visiting: set[str] = set() ordered = _order_enabled_modules(requested, available, added_dependencies)
visited: set[str] = set()
ordered: list[str] = []
def visit(module_id: str) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
visit(dependency_id)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
for module_id in sorted(requested):
visit(module_id)
return ModuleStatePlan( return ModuleStatePlan(
enabled_modules=tuple(dict.fromkeys(ordered)), enabled_modules=tuple(dict.fromkeys(ordered)),
added_dependencies=tuple(sorted(added_dependencies)), added_dependencies=tuple(sorted(added_dependencies)),
) )
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
return {str(item).strip() for item in values if str(item).strip()}
def _expand_enabled_module_closure(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
added: set[str] = set()
while True:
dependencies = {
dependency
for module_id in requested
for dependency in available[module_id].dependencies
}
_require_installed_modules(
dependencies,
available,
message="Required modules are not installed: ",
)
dependency_additions = dependencies - requested
added.update(dependency_additions)
requested.update(dependency_additions)
providers = _missing_capability_providers(requested, available)
_require_installed_modules(
providers,
available,
message="Required capability providers are not installed: ",
)
additions = providers - requested
if not additions:
return added
added.update(additions)
requested.update(additions)
def _missing_capability_providers(
requested: set[str],
available: Mapping[str, ModuleManifest],
) -> set[str]:
provided = {
capability
for module_id in requested
for capability in available[module_id].capability_factories
}
return {
provider_id
for module_id in requested
for capability in available[module_id].required_capabilities
if capability not in provided
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
if provider_id is not None
}
def _require_installed_modules(
module_ids: set[str],
available: Mapping[str, ModuleManifest],
*,
message: str,
) -> None:
missing = sorted(module_id for module_id in module_ids if module_id not in available)
if missing:
raise ModuleManagementError(message + ", ".join(missing))
def _order_enabled_modules(
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
) -> list[str]:
visiting: set[str] = set()
visited: set[str] = set()
ordered: list[str] = []
for module_id in sorted(requested):
_visit_enabled_module(
module_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
return ordered
def _visit_enabled_module(
module_id: str,
*,
requested: set[str],
available: Mapping[str, ModuleManifest],
added_dependencies: set[str],
visiting: set[str],
visited: set[str],
ordered: list[str],
) -> None:
if module_id in visited:
return
if module_id in visiting:
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
manifest = available.get(module_id)
if manifest is None:
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
visiting.add(module_id)
for dependency_id in manifest.dependencies:
if dependency_id not in available:
raise ModuleManagementError(
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
)
if dependency_id not in requested:
added_dependencies.add(dependency_id)
requested.add(dependency_id)
_visit_enabled_module(
dependency_id,
requested=requested,
available=available,
added_dependencies=added_dependencies,
visiting=visiting,
visited=visited,
ordered=ordered,
)
visiting.remove(module_id)
visited.add(module_id)
ordered.append(module_id)
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]: def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
dependents: dict[str, list[str]] = {module_id: [] for module_id in available} dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
for manifest in available.values(): for manifest in available.values():

View File

@@ -0,0 +1,386 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
POSTBOX_MODULE_ID = "postbox"
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
@dataclass(frozen=True, slots=True)
class PostboxActorRef:
account_id: str
identity_id: str | None = None
selected_assignment_id: str | None = None
acting_for_account_id: str | None = None
authorized_actions: frozenset[PostboxAction] = frozenset()
@dataclass(frozen=True, slots=True)
class PostboxTargetRef:
postbox_id: str | None = None
address_key: str | None = None
template_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
context_key: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxDeliveryTemplateRef:
id: str
slug: str
name: str
description: str | None
published_revision_id: str
function_type_id: str | None
scope_kind: str
scope_id: str | None
classification: str
allow_vacant_delivery: bool
@dataclass(frozen=True, slots=True)
class PostboxOrganizationFunctionTargetRef:
id: str
slug: str
name: str
function_type_id: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxOrganizationUnitTargetRef:
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxDeliveryCatalogRef:
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
@dataclass(frozen=True, slots=True)
class PostboxAccessDecisionRef:
allowed: bool
action: PostboxAction
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: tuple[str, ...] = ()
assignment_sources: tuple[str, ...] = ()
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
@dataclass(frozen=True, slots=True)
class PostboxDirectoryEntryRef:
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
access: PostboxAccessDecisionRef | None = None
@dataclass(frozen=True, slots=True)
class PostboxParticipantRef:
kind: str
reference_type: str
reference_id: str | None = None
label: str | None = None
address: str | None = None
@dataclass(frozen=True, slots=True)
class PostboxAttachmentRef:
reference_type: str
reference_id: str
name: str | None = None
media_type: str | None = None
size_bytes: int | None = None
digest: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxMessageRef:
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None
status: str
classification: str
sender_label: str | None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
encryption_profile: str = "plaintext_v1"
key_epoch: int = 1
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryRequest:
tenant_id: str
target: PostboxTargetRef
producer_module: str
producer_resource_type: str
producer_resource_id: str | None
idempotency_key: str
subject: str
body_text: str | None = None
sender_label: str | None = None
classification: str = "internal"
participants: tuple[PostboxParticipantRef, ...] = ()
attachments: tuple[PostboxAttachmentRef, ...] = ()
expires_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class PostboxDeliveryResult:
delivery_id: str
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: Mapping[str, object] = field(default_factory=dict)
class PostboxDeliveryRejected(RuntimeError):
"""A delivery was rejected before the provider accepted any effect."""
def __init__(
self,
code: str,
message: str,
*,
temporary: bool = False,
) -> None:
super().__init__(message)
self.code = code
self.temporary = temporary
class PostboxDeliveryOutcomeUnknown(RuntimeError):
"""The provider may have accepted an effect and must not be bypassed."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@runtime_checkable
class PostboxDirectoryProvider(Protocol):
def list_visible_postboxes(
self,
session: object,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> Sequence[PostboxDirectoryEntryRef]:
...
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
...
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
...
@runtime_checkable
class PostboxAccessProvider(Protocol):
def explain_access(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
...
@runtime_checkable
class PostboxMessagesProvider(Protocol):
def list_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
offset: int = 0,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> Sequence[PostboxMessageRef]:
...
def get_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> PostboxMessageRef | None:
...
def mark_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
state: Literal["read", "acknowledged"],
) -> PostboxMessageRef:
...
@runtime_checkable
class PostboxDeliveryProvider(Protocol):
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
...
@runtime_checkable
class PostboxEvidenceProvider(Protocol):
def link_evidence(
self,
session: object,
*,
tenant_id: str,
message_id: str,
attachment: PostboxAttachmentRef,
) -> PostboxMessageRef:
...
def _postbox_provider(
registry: object | None,
*,
capability_name: str,
provider_type: type,
) -> object | None:
if registry is None or not hasattr(registry, "has_capability"):
return None
if not registry.has_capability(capability_name):
return None
capability = registry.capability(capability_name)
return capability if isinstance(capability, provider_type) else None
def postbox_directory_provider(
registry: object | None,
) -> PostboxDirectoryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
provider_type=PostboxDirectoryProvider,
)
return provider if isinstance(provider, PostboxDirectoryProvider) else None
def postbox_access_provider(
registry: object | None,
) -> PostboxAccessProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_ACCESS,
provider_type=PostboxAccessProvider,
)
return provider if isinstance(provider, PostboxAccessProvider) else None
def postbox_messages_provider(
registry: object | None,
) -> PostboxMessagesProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_MESSAGES,
provider_type=PostboxMessagesProvider,
)
return provider if isinstance(provider, PostboxMessagesProvider) else None
def postbox_delivery_provider(
registry: object | None,
) -> PostboxDeliveryProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_DELIVERY,
provider_type=PostboxDeliveryProvider,
)
return provider if isinstance(provider, PostboxDeliveryProvider) else None
def postbox_evidence_provider(
registry: object | None,
) -> PostboxEvidenceProvider | None:
provider = _postbox_provider(
registry,
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
provider_type=PostboxEvidenceProvider,
)
return provider if isinstance(provider, PostboxEvidenceProvider) else None

View File

@@ -0,0 +1,369 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
UserRef,
)
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
@dataclass(frozen=True, slots=True)
class ReferenceOption:
"""A stable identifier paired with presentation-only directory metadata."""
value: str
label: str
description: str | None = None
kind: str | None = None
availability: ReferenceAvailability = "available"
disabled: bool = False
source_module: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
def to_dict(self) -> dict[str, object]:
return {
"value": self.value,
"label": self.label,
"description": self.description,
"kind": self.kind,
"availability": self.availability,
"disabled": self.disabled,
"source_module": self.source_module,
"provenance": dict(self.provenance),
}
@dataclass(frozen=True, slots=True)
class ReferenceSearchRequest:
kind: str
tenant_id: str | None
query: str = ""
selected_values: tuple[str, ...] = ()
limit: int = 50
context: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class ReferenceOptionProvider(Protocol):
"""Optional module-neutral provider for typed reference selectors."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> Sequence[ReferenceOption]:
...
def reference_option_provider(
registry: object | None,
capability_name: str,
) -> ReferenceOptionProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(capability_name)
):
return None
provider = registry.capability(capability_name)
return provider if isinstance(provider, ReferenceOptionProvider) else None
def access_scope_reference_options(
registry: object | None,
principal: object,
*,
scope_type: str,
query: str = "",
selected_values: Sequence[str] = (),
limit: int = 50,
administrative: bool = False,
) -> tuple[ReferenceOption, ...]:
"""Return canonical user-account or group references for definition scopes."""
clean_type = str(scope_type or "").strip().casefold()
if clean_type not in {"user", "group"}:
raise ValueError("Scope reference type must be user or group.")
normalized_limit = max(1, min(int(limit), 200))
selected = tuple(
dict.fromkeys(
str(value).strip() for value in selected_values if str(value).strip()
)
)
directory = _access_directory(registry)
if directory is None:
return _fallback_scope_options(
principal,
scope_type=clean_type,
query=query,
selected_values=selected,
limit=normalized_limit,
)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
if not tenant_id:
return tuple(
_unavailable_option(value, kind=clean_type) for value in selected
)
if clean_type == "user":
candidates = _user_options(
directory.users_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
else:
candidates = _group_options(
directory.groups_for_tenant(tenant_id),
principal=principal,
administrative=administrative,
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected,
limit=normalized_limit,
kind=clean_type,
)
def access_scope_reference_provider_available(registry: object | None) -> bool:
return _access_directory(registry) is not None
def validate_access_scope_reference(
registry: object | None,
*,
tenant_id: str | None,
scope_type: str,
scope_id: str | None,
preserve_existing: str | None = None,
) -> str | None:
"""Validate new directory-backed scope references while preserving history."""
clean_type = str(scope_type or "").strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type not in {"user", "group"}:
return clean_id
if not clean_id:
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
if clean_id == preserve_existing:
return clean_id
directory = _access_directory(registry)
if directory is None:
return clean_id
if not tenant_id:
raise ValueError("Directory-backed scopes require an active tenant.")
if clean_type == "group":
group = directory.get_group(clean_id)
if group is None or group.tenant_id != tenant_id:
raise ValueError("The selected group is not available in the active tenant.")
if group.status != "active":
raise ValueError("Inactive groups cannot be selected for new definitions.")
return group.id
matching_user = next(
(
user
for user in directory.users_for_tenant(tenant_id)
if user.account_id == clean_id
),
None,
)
if matching_user is None:
raise ValueError("The selected user account is not available in the active tenant.")
if matching_user.status != "active":
raise ValueError("Inactive users cannot be selected for new definitions.")
account = directory.get_account(matching_user.account_id)
if account is not None and account.status != "active":
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
return matching_user.account_id
def _access_directory(registry: object | None) -> AccessDirectory | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
):
return None
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
return provider if isinstance(provider, AccessDirectory) else None
def _user_options(
users: Sequence[UserRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
principal_account_id = str(getattr(principal, "account_id", "") or "")
options: dict[str, ReferenceOption] = {}
for user in users:
if not administrative and user.account_id != principal_account_id:
continue
inactive = user.status != "active"
label = user.display_name or user.email or user.account_id
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
options[user.account_id] = ReferenceOption(
value=user.account_id,
label=label,
description=" · ".join(detail_parts) or None,
kind="user",
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"membership_id": user.id,
"tenant_id": user.tenant_id,
"account_id": user.account_id,
},
)
return tuple(options.values())
def _group_options(
groups: Sequence[GroupRef],
*,
principal: object,
administrative: bool,
) -> tuple[ReferenceOption, ...]:
permitted = {
str(value)
for value in getattr(principal, "group_ids", ())
if str(value)
}
return tuple(
ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if group.status != "active" else None,
kind="group",
availability=(
"inactive" if group.status != "active" else "available"
),
disabled=group.status != "active",
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
for group in groups
if administrative or group.id in permitted
)
def _filter_and_retain_options(
options: Sequence[ReferenceOption],
*,
query: str,
selected_values: Sequence[str],
limit: int,
kind: str,
) -> tuple[ReferenceOption, ...]:
by_value = {option.value: option for option in options}
needle = str(query or "").strip().casefold()
matches = [
option
for option in options
if not needle
or needle
in " ".join(
(
option.value,
option.label,
option.description or "",
)
).casefold()
][:limit]
returned = {option.value for option in matches}
for value in selected_values:
if value in returned:
continue
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
returned.add(value)
return tuple(matches)
def _fallback_scope_options(
principal: object,
*,
scope_type: str,
query: str,
selected_values: Sequence[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
candidates: list[ReferenceOption] = []
if scope_type == "user":
account_id = str(getattr(principal, "account_id", "") or "")
if account_id:
candidates.append(
ReferenceOption(
value=account_id,
label=(
str(getattr(principal, "display_name", "") or "")
or str(getattr(principal, "email", "") or "")
or account_id
),
description="Current user · Access directory unavailable",
kind="user",
source_module="core",
provenance={"fallback": True},
)
)
else:
candidates.extend(
ReferenceOption(
value=str(group_id),
label=str(group_id),
description="Permitted group · Access directory unavailable",
kind="group",
source_module="core",
provenance={"fallback": True},
)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
return _filter_and_retain_options(
candidates,
query=query,
selected_values=selected_values,
limit=limit,
kind=scope_type,
)
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
return ReferenceOption(
value=value,
label=f"Unavailable {kind}",
description=value,
kind=kind,
availability="unavailable",
disabled=True,
source_module=None,
provenance={"retained_reference": True},
)
__all__ = [
"ReferenceAvailability",
"ReferenceOption",
"ReferenceOptionProvider",
"ReferenceSearchRequest",
"access_scope_reference_options",
"access_scope_reference_provider_available",
"reference_option_provider",
"validate_access_scope_reference",
]

View File

@@ -514,59 +514,86 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
if frontend is None: if frontend is None:
return return
root_id = module_view_surface_id(manifest.id) root_id = module_view_surface_id(manifest.id)
_validate_view_surface_id(manifest.id, root_id)
known_ids = {root_id} known_ids = {root_id}
_validate_view_surface_id(manifest.id, root_id)
for item in frontend.nav_items: for item in frontend.nav_items:
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path) surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
_validate_view_surface_id(manifest.id, surface_id) _register_view_surface_id(manifest.id, surface_id, known_ids)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
)
known_ids.add(surface_id)
for route in (*frontend.routes, *frontend.settings_routes): for route in (*frontend.routes, *frontend.settings_routes):
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path) surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
_validate_view_surface_id(manifest.id, surface_id) _register_view_surface_id(manifest.id, surface_id, known_ids)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
)
known_ids.add(surface_id)
for surface in frontend.view_surfaces: for surface in frontend.view_surfaces:
if surface.module_id != manifest.id: _validate_declared_view_surface(manifest.id, surface)
raise RegistryError( _register_view_surface_id(manifest.id, surface.id, known_ids)
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {manifest.id!r}"
)
_validate_view_surface_id(manifest.id, surface.id)
if surface.id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface.id!r} in module {manifest.id!r}"
)
known_ids.add(surface.id)
for surface in frontend.view_surfaces: for surface in frontend.view_surfaces:
parent_id = surface.parent_id or root_id _validate_view_surface_definition(
if parent_id not in known_ids: manifest.id,
raise RegistryError( surface,
f"View surface {surface.id!r} in module {manifest.id!r} " root_id=root_id,
f"references unknown parent {parent_id!r}" known_ids=known_ids,
) )
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {manifest.id!r} "
"must be visible by default"
)
parent_by_id = { parent_by_id = {
surface.id: surface.parent_id or root_id surface.id: surface.parent_id or root_id
for surface in frontend.view_surfaces for surface in frontend.view_surfaces
} }
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
def _register_view_surface_id(
module_id: str,
surface_id: str,
known_ids: set[str],
) -> None:
_validate_view_surface_id(module_id, surface_id)
if surface_id in known_ids:
raise RegistryError(
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
)
known_ids.add(surface_id)
def _validate_declared_view_surface(
module_id: str,
surface: ViewSurface,
) -> None:
if surface.module_id != module_id:
raise RegistryError(
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
def _validate_view_surface_definition(
module_id: str,
surface: ViewSurface,
*,
root_id: str,
known_ids: set[str],
) -> None:
parent_id = surface.parent_id or root_id
if parent_id not in known_ids:
raise RegistryError(
f"View surface {surface.id!r} in module {module_id!r} "
f"references unknown parent {parent_id!r}"
)
if surface.required and not surface.default_visible:
raise RegistryError(
f"Required view surface {surface.id!r} in module {module_id!r} "
"must be visible by default"
)
def _validate_view_surface_hierarchy(
module_id: str,
parent_by_id: Mapping[str, str],
) -> None:
for surface_id in parent_by_id: for surface_id in parent_by_id:
path: set[str] = set() path: set[str] = set()
current_id: str | None = surface_id current_id: str | None = surface_id
while current_id in parent_by_id: while current_id in parent_by_id:
if current_id in path: if current_id in path:
raise RegistryError( raise RegistryError(
f"View surface hierarchy in module {manifest.id!r} " f"View surface hierarchy in module {module_id!r} "
f"contains a cycle at {current_id!r}" f"contains a cycle at {current_id!r}"
) )
path.add(current_id) path.add(current_id)

View File

@@ -41,39 +41,73 @@ def parse_tabular_csv(
raise TabularSourceValidationError("CSV delimiter must be one character.") raise TabularSourceValidationError("CSV delimiter must be one character.")
try: try:
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter) reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
if not reader.fieldnames or any(not str(name or "").strip() for name in reader.fieldnames): original_headers, normalized_headers = _csv_headers(reader.fieldnames)
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
normalized_headers = [str(name).strip() for name in reader.fieldnames]
normalized_headers[0] = normalized_headers[0].lstrip("\ufeff")
if any(not name for name in normalized_headers):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized_headers)) != len(normalized_headers):
raise TabularSourceValidationError("CSV column names must be unique.")
rows: list[dict[str, object]] = [] rows: list[dict[str, object]] = []
for row in reader: for row in reader:
extras = row.get(None) _validate_csv_row_shape(row)
if extras and any(str(value or "").strip() for value in extras): if _csv_row_is_empty(row, original_headers):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
values = [row.get(original) for original in reader.fieldnames]
if all(value is None or not value.strip() for value in values):
continue continue
if len(rows) >= max_rows: if len(rows) >= max_rows:
raise TabularSourceValidationError( raise TabularSourceValidationError(
f"CSV snapshots are limited to {max_rows:,} rows." f"CSV snapshots are limited to {max_rows:,} rows."
) )
rows.append( rows.append(_csv_row(row, original_headers, normalized_headers))
{
normalized_headers[position]: _csv_scalar(row.get(original))
for position, original in enumerate(reader.fieldnames)
}
)
return tuple(rows) return tuple(rows)
except csv.Error as exc: except csv.Error as exc:
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
def _csv_headers(
fieldnames: Sequence[str | None] | None,
) -> tuple[tuple[str, ...], tuple[str, ...]]:
if not fieldnames:
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
original = tuple(str(name or "") for name in fieldnames)
normalized = tuple(
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
for position, name in enumerate(original)
)
if any(not name for name in normalized):
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
if len(set(normalized)) != len(normalized):
raise TabularSourceValidationError("CSV column names must be unique.")
return original, normalized
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
extras = row.get(None)
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
raise TabularSourceValidationError(
"A CSV row contains more values than the header defines."
)
def _csv_row_is_empty(
row: Mapping[str | None, str | list[str] | None],
headers: Sequence[str],
) -> bool:
return all(
value is None or isinstance(value, list) or not value.strip()
for value in (row.get(header) for header in headers)
)
def _csv_row(
row: Mapping[str | None, str | list[str] | None],
original_headers: Sequence[str],
normalized_headers: Sequence[str],
) -> dict[str, object]:
return {
normalized: _csv_scalar(value if isinstance(value, str) else None)
for original, normalized in zip(
original_headers,
normalized_headers,
strict=True,
)
for value in (row.get(original),)
}
def _csv_scalar(value: str | None) -> object: def _csv_scalar(value: str | None) -> object:
if value is None: if value is None:
return None return None

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator, Mapping from collections.abc import Generator, Mapping
import os
from typing import Any from typing import Any
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.query_metrics import instrument_engine from govoplan_core.db.query_metrics import instrument_engine
_POOL_SETTING_BOUNDS: dict[str, tuple[int, int]] = {
"GOVOPLAN_DB_POOL_SIZE": (1, 100),
"GOVOPLAN_DB_MAX_OVERFLOW": (0, 200),
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": (1, 300),
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": (0, 86_400),
}
def default_connect_args(database_url: str) -> dict[str, Any]: def default_connect_args(database_url: str) -> dict[str, Any]:
return {"check_same_thread": False} if database_url.startswith("sqlite") else {} return {"check_same_thread": False} if database_url.startswith("sqlite") else {}
@@ -24,9 +33,42 @@ def create_database_engine(
merged_connect_args = dict(default_connect_args(database_url)) merged_connect_args = dict(default_connect_args(database_url))
if connect_args: if connect_args:
merged_connect_args.update(connect_args) merged_connect_args.update(connect_args)
if not database_url.startswith("sqlite"):
kwargs.setdefault(
"pool_size",
_pool_setting("GOVOPLAN_DB_POOL_SIZE", 5),
)
kwargs.setdefault(
"max_overflow",
_pool_setting("GOVOPLAN_DB_MAX_OVERFLOW", 10),
)
kwargs.setdefault(
"pool_timeout",
_pool_setting("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", 30),
)
kwargs.setdefault(
"pool_recycle",
_pool_setting("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", 1800),
)
return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs)) return instrument_engine(create_engine(database_url, pool_pre_ping=pool_pre_ping, connect_args=merged_connect_args, **kwargs))
def _pool_setting(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
minimum, maximum = _POOL_SETTING_BOUNDS[name]
if not minimum <= value <= maximum:
raise ValueError(
f"{name} must be between {minimum} and {maximum}",
)
return value
class DatabaseHandle: class DatabaseHandle:
def __init__(self, database_url: str, *, engine: Engine | None = None) -> None: def __init__(self, database_url: str, *, engine: Engine | None = None) -> None:
self.database_url = database_url self.database_url = database_url

View File

@@ -72,8 +72,19 @@ def _validate_production_startup() -> None:
"off", "off",
} }
if throttle_enabled and not os.getenv("REDIS_URL", "").strip(): if throttle_enabled and not os.getenv("REDIS_URL", "").strip():
allow_process_local = os.getenv(
"GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE",
"false",
).strip().lower() in {"1", "true", "yes", "on"}
if not allow_process_local:
raise RuntimeError(
"Production login throttling requires REDIS_URL. "
"Set GOVOPLAN_ALLOW_PROCESS_LOCAL_LOGIN_THROTTLE=true only "
"for an explicitly single-process deployment."
)
logger.warning( logger.warning(
"Redis is not configured; login throttling is process-local and will not coordinate horizontally" "Redis is not configured; login throttling is explicitly limited "
"to this process"
) )

View File

@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.models import SystemSettings
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.maintenance import saved_maintenance_mode from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
@@ -153,7 +154,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
} }
@router.get("/modules") @router.get("/modules")
def modules(request: Request): def modules(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request) registry = _registry(request)
return { return {
"modules": [ "modules": [
@@ -190,7 +194,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
} }
@router.get("/permissions") @router.get("/permissions")
def permissions(request: Request): def permissions(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request) registry = _registry(request)
return { return {
"permissions": [ "permissions": [
@@ -210,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
} }
@router.get("/navigation") @router.get("/navigation")
def navigation(request: Request): def navigation(
request: Request,
_principal: ApiPrincipal = Depends(get_api_principal),
):
registry = _registry(request) registry = _registry(request)
return { return {
"items": [_nav_item_payload(item) for item in registry.nav_items()] "items": [_nav_item_payload(item) for item in registry.nav_items()]

View File

@@ -3,6 +3,9 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence from collections.abc import Callable, Iterable, Sequence
import importlib import importlib
from govoplan_core.core.access import (
DEFAULT_CAPABILITY_PROVIDERS,
)
from govoplan_core.core.discovery import discover_module_manifests from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = {
"tenancy": "govoplan_tenancy.backend.manifest:get_manifest", "tenancy": "govoplan_tenancy.backend.manifest:get_manifest",
} }
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]: def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str): if isinstance(value, str):
items = [item.strip() for item in value.split(",")] items = [item.strip() for item in value.split(",")]
@@ -57,22 +59,68 @@ def available_module_manifests(
return manifests return manifests
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry: def build_platform_registry(
enabled_modules: str | Iterable[str],
*,
manifest_factories: Sequence[ManifestFactory] = (),
) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules) requested = parse_enabled_modules(enabled_modules)
if "access" not in requested: available = _resolve_required_manifests(
requested.insert(0, "access") requested,
manifest_factories=manifest_factories,
available = available_module_manifests(manifest_factories, enabled_modules=requested) )
registry = PlatformRegistry() registry = PlatformRegistry()
for module_id in requested: for module_id, manifest in available.items():
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
registry.register(manifest) registry.register(manifest)
registry.validate() registry.validate()
return registry return registry
def _resolve_required_manifests(
requested: Sequence[str],
*,
manifest_factories: Sequence[ManifestFactory],
) -> dict[str, ModuleManifest]:
manifests: dict[str, ModuleManifest] = {}
pending = list(dict.fromkeys(requested))
while pending:
module_ids = [module_id for module_id in pending if module_id not in manifests]
pending = []
if not module_ids:
break
available = available_module_manifests(
manifest_factories,
enabled_modules=module_ids,
)
for module_id in module_ids:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
manifests[module_id] = manifest
provided_capabilities = {
capability
for manifest in manifests.values()
for capability in manifest.capability_factories
}
for manifest in tuple(manifests.values()):
pending.extend(
dependency_id
for dependency_id in manifest.dependencies
if dependency_id not in manifests
)
for capability in manifest.required_capabilities:
if capability in provided_capabilities:
continue
provider_id = DEFAULT_CAPABILITY_PROVIDERS.get(capability)
if provider_id is not None and provider_id not in manifests:
pending.append(provider_id)
return manifests
def _load_builtin_manifest(module_id: str) -> ModuleManifest: def _load_builtin_manifest(module_id: str) -> ModuleManifest:
target = _BUILTIN_MANIFESTS[module_id] target = _BUILTIN_MANIFESTS[module_id]
module_name, function_name = validate_object_path(target) module_name, function_name = validate_object_path(target)

View File

@@ -6,18 +6,46 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=None, extra="ignore") model_config = SettingsConfigDict(env_file=None, extra="ignore")
app_env: str = Field(default="dev", alias="APP_ENV") app_env: str = Field(default="dev", alias="APP_ENV")
module_live_apply_enabled: bool | None = Field(
default=None,
alias="GOVOPLAN_MODULE_LIVE_APPLY_ENABLED",
)
database_url: str = Field( database_url: str = Field(
default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev", default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev",
alias="DATABASE_URL", alias="DATABASE_URL",
) )
database_pool_size: int = Field(
default=5,
alias="GOVOPLAN_DB_POOL_SIZE",
ge=1,
le=100,
)
database_max_overflow: int = Field(
default=10,
alias="GOVOPLAN_DB_MAX_OVERFLOW",
ge=0,
le=200,
)
database_pool_timeout_seconds: int = Field(
default=30,
alias="GOVOPLAN_DB_POOL_TIMEOUT_SECONDS",
ge=1,
le=300,
)
database_pool_recycle_seconds: int = Field(
default=1800,
alias="GOVOPLAN_DB_POOL_RECYCLE_SECONDS",
ge=0,
le=86_400,
)
access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL") access_database_url: str | None = Field(default=None, alias="ACCESS_DATABASE_URL")
access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA") access_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA")
access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX") access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX")
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE") tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE") tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE") tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,notifications,docs,ops", alias="ENABLED_MODULES") enabled_modules: str = Field(default="tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,postbox,notifications,docs,ops", alias="ENABLED_MODULES")
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK") migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED") celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
@@ -47,6 +75,11 @@ class Settings(BaseSettings):
auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE") auth_cookie_samesite: str = Field(default="lax", alias="AUTH_COOKIE_SAMESITE")
auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") auth_cookie_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN")
auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS") auth_session_hours: int = Field(default=12, alias="AUTH_SESSION_HOURS")
auth_activity_touch_interval_seconds: int = Field(
default=5 * 60,
ge=0,
alias="AUTH_ACTIVITY_TOUCH_INTERVAL_SECONDS",
)
auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED") auth_login_throttle_enabled: bool = Field(default=True, alias="AUTH_LOGIN_THROTTLE_ENABLED")
auth_login_throttle_identity_limit: int = Field( auth_login_throttle_identity_limit: int = Field(
default=10, default=10,

View File

@@ -46,12 +46,24 @@ def assert_tenant_governance_override_allowed(session: Session, *, field: str, v
raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.") raise AdminValidationError("Tenant governance cannot explicitly allow a capability denied by system settings.")
def _tenant_module_counts(session: Session, tenant_id: str) -> dict[str, int]: def _tenant_module_counts(
session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
registry = get_registry() registry = get_registry()
if registry is None or not hasattr(registry, "tenant_summary_providers"): if registry is None or not hasattr(registry, "tenant_summary_providers"):
return {} return {}
counts: dict[str, int] = {} counts: dict[str, int] = {}
for provider in registry.tenant_summary_providers().values(): providers = registry.tenant_summary_providers()
if module_ids is not None:
providers = {
module_id: provider
for module_id, provider in providers.items()
if module_id in module_ids
}
for provider in providers.values():
provided = provider(session, tenant_id) provided = provider(session, tenant_id)
counts.update({str(key): int(value) for key, value in provided.items()}) counts.update({str(key): int(value) for key, value in provided.items()})
return counts return counts
@@ -67,17 +79,27 @@ def _access_administration() -> AccessAdministration | None:
return capability return capability
def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]: def tenant_counts(
module_counts = _tenant_module_counts(session, tenant_id) session: Session,
tenant_id: str,
*,
module_ids: tuple[str, ...] | None = None,
) -> dict[str, int]:
module_counts = _tenant_module_counts(
session,
tenant_id,
module_ids=module_ids,
)
access_administration = _access_administration() access_administration = _access_administration()
access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {} access_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {}
return { return {
**module_counts,
"users": int(access_counts.get("users", 0)), "users": int(access_counts.get("users", 0)),
"active_users": int(access_counts.get("active_users", 0)), "active_users": int(access_counts.get("active_users", 0)),
"groups": int(access_counts.get("groups", 0)), "groups": int(access_counts.get("groups", 0)),
"campaigns": module_counts.get("campaigns", 0), "campaigns": int(module_counts.get("campaigns", 0)),
"files": module_counts.get("files", 0), "files": int(module_counts.get("files", 0)),
"api_keys": int(access_counts.get("api_keys", 0)), "api_keys": int(access_counts.get("api_keys", 0)),
"active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))), "active_api_keys": int(access_counts.get("active_api_keys", access_counts.get("api_keys", 0))),
} }

View File

@@ -25,6 +25,7 @@ from govoplan_core.core.access import (
CAPABILITY_AUDIT_RECORDER, CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION, CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUDIT_SINK, CAPABILITY_AUDIT_SINK,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_TENANCY_TENANT_RESOLVER, CAPABILITY_TENANCY_TENANT_RESOLVER,
AccessDecision, AccessDecision,
AccessAdministration, AccessAdministration,
@@ -1082,16 +1083,62 @@ class AccessContractTests(unittest.TestCase):
def get_organization_function_assignment(self, requested_assignment_id: str): def get_organization_function_assignment(self, requested_assignment_id: str):
return assignment if requested_assignment_id == assignment_id else None return assignment if requested_assignment_id == assignment_id else None
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None): def organization_function_assignments_for_identity(
self,
requested_identity_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id): if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
return (assignment,) return (assignment,)
return () return ()
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None): def organization_function_assignments_for_account(
self,
requested_account_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id): if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,) return (assignment,)
return () return ()
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_identity(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
item_id: self.organization_function_assignments_for_account(
item_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for item_id in account_ids
}
class FakeOrganizationDirectory(CoreOrganizationDirectory): class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str): def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id: if requested_organization_unit_id != organization_unit_id:
@@ -1317,7 +1364,7 @@ class AccessContractTests(unittest.TestCase):
clear_runtime() clear_runtime()
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None: def test_api_principal_dependency_uses_auth_provider_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-")) root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try: try:
account_id = "account-capability" account_id = "account-capability"
@@ -1346,6 +1393,31 @@ class AccessContractTests(unittest.TestCase):
def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision: def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision:
return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,)) return AccessDecision(allowed=self.has_scope(principal, required_scope), requirements=(required_scope,))
class CapabilityApiPrincipalProvider:
def resolve_api_principal(
self,
request,
session,
*,
authorization=None,
x_api_key=None,
):
del authorization, x_api_key
principal_ref = CapabilityPrincipalResolver().resolve_request(
request,
session=session,
)
account = session.get(Account, principal_ref.account_id)
user = session.get(User, principal_ref.membership_id)
assert account is not None
assert user is not None
return ApiPrincipal(
principal=principal_ref,
account=account,
user=user,
permission_evaluator=CapabilityPermissionEvaluator(),
)
router = APIRouter() router = APIRouter()
@router.get("/capability-principal") @router.get("/capability-principal")
@@ -1365,6 +1437,7 @@ class AccessContractTests(unittest.TestCase):
capability_factories={ capability_factories={
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(), CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(),
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(), CAPABILITY_ACCESS_PERMISSION_EVALUATOR: lambda context: CapabilityPermissionEvaluator(),
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: lambda context: CapabilityApiPrincipalProvider(),
}, },
) )
@@ -1372,7 +1445,7 @@ class AccessContractTests(unittest.TestCase):
title="access contract test", title="access contract test",
version="test", version="test",
settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"), settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"),
enabled_modules=(), enabled_modules=(ACCESS_MODULE_ID,),
manifest_factories=(access_manifest,), manifest_factories=(access_manifest,),
base_routers=(router,), base_routers=(router,),
post_module_routers=(), post_module_routers=(),

View File

@@ -52,6 +52,7 @@ engine = _database.engine
SessionLocal = _database.SessionLocal SessionLocal = _database.SessionLocal
from govoplan_core.server.app import app from govoplan_core.server.app import app
from govoplan_campaign.backend.sending.execution import SNAPSHOT_VERSION
class ApiSmokeTests(unittest.TestCase): class ApiSmokeTests(unittest.TestCase):
@@ -764,7 +765,7 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None assert version is not None
self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id}) self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id})
snapshot = version.execution_snapshot or {} snapshot = version.execution_snapshot or {}
self.assertEqual(snapshot["snapshot_version"], "5") self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["mail_profile_id"], profile_id) self.assertEqual(snapshot["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot) self.assertNotIn("smtp", snapshot)
self.assertNotIn("imap", snapshot) self.assertNotIn("imap", snapshot)
@@ -774,8 +775,10 @@ class ApiSmokeTests(unittest.TestCase):
deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers) deleted = self.client.delete(f"/api/v1/mail/profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 200, deleted.text) self.assertEqual(deleted.status_code, 200, deleted.text)
self.assertFalse(deleted.json()["is_active"]) self.assertFalse(deleted.json()["is_active"])
self.assertFalse(deleted.json()["smtp_password_configured"]) # Reusable hierarchy credentials outlive a deactivated profile. The
self.assertFalse(deleted.json()["imap_password_configured"]) # profile's retired legacy secret columns are still scrubbed below.
self.assertTrue(deleted.json()["smtp_password_configured"])
self.assertTrue(deleted.json()["imap_password_configured"])
from govoplan_audit.backend.db.models import AuditLog from govoplan_audit.backend.db.models import AuditLog
@@ -864,7 +867,10 @@ class ApiSmokeTests(unittest.TestCase):
self.assertIsNotNone(version) self.assertIsNotNone(version)
assert version is not None assert version is not None
snapshot_payload = version.execution_snapshot or {} snapshot_payload = version.execution_snapshot or {}
self.assertEqual(snapshot_payload["snapshot_version"], "5") self.assertEqual(
snapshot_payload["snapshot_version"],
SNAPSHOT_VERSION,
)
self.assertEqual(snapshot_payload["mail_profile_id"], profile_id) self.assertEqual(snapshot_payload["mail_profile_id"], profile_id)
self.assertNotIn("smtp", snapshot_payload) self.assertNotIn("smtp", snapshot_payload)
self.assertNotIn("imap", snapshot_payload) self.assertNotIn("imap", snapshot_payload)
@@ -2945,7 +2951,7 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown") self.assertEqual(delta_payload["jobs"][0]["send_status"], "outcome_unknown")
self.assertEqual( self.assertEqual(
delta_payload["jobs"][0]["last_error"], delta_payload["jobs"][0]["last_error"],
"SMTP delivery outcome requires operator reconciliation.", "Delivery outcome requires operator reconciliation.",
) )
self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1) self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1)
self.assertTrue(str(delta_payload["watermark"]).startswith("seq:")) self.assertTrue(str(delta_payload["watermark"]).startswith("seq:"))
@@ -4172,7 +4178,7 @@ class ApiSmokeTests(unittest.TestCase):
) )
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, SendAttempt
from govoplan_mail.backend.db.models import MailServerProfile from govoplan_mail.backend.db.models import MailServerEndpoint, MailServerProfile
with SessionLocal() as session: with SessionLocal() as session:
version = session.get(CampaignVersion, version_id) version = session.get(CampaignVersion, version_id)
@@ -4180,7 +4186,7 @@ class ApiSmokeTests(unittest.TestCase):
assert version is not None assert version is not None
snapshot = version.execution_snapshot snapshot = version.execution_snapshot
self.assertIsInstance(snapshot, dict) self.assertIsInstance(snapshot, dict)
self.assertEqual(snapshot["snapshot_version"], "5") self.assertEqual(snapshot["snapshot_version"], SNAPSHOT_VERSION)
self.assertEqual(snapshot["job_count"], 1) self.assertEqual(snapshot["job_count"], 1)
self.assertEqual(snapshot["queueable_job_count"], 1) self.assertEqual(snapshot["queueable_job_count"], 1)
self.assertTrue(snapshot["job_manifest_sha256"]) self.assertTrue(snapshot["job_manifest_sha256"])
@@ -4195,9 +4201,17 @@ class ApiSmokeTests(unittest.TestCase):
profile = session.get(MailServerProfile, snapshot["mail_profile_id"]) profile = session.get(MailServerProfile, snapshot["mail_profile_id"])
self.assertIsNotNone(profile) self.assertIsNotNone(profile)
assert profile is not None assert profile is not None
profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"} smtp_server_id = snapshot.get("smtp_server_id")
profile.smtp_transport_revision = "manually-rotated-transport-revision" self.assertTrue(smtp_server_id)
session.add(profile) smtp_server = session.get(MailServerEndpoint, smtp_server_id)
self.assertIsNotNone(smtp_server)
assert smtp_server is not None
smtp_server.config = {
**smtp_server.config,
"host": "smtp.example.invalid",
}
smtp_server.transport_revision = "manually-rotated-transport-revision"
session.add(smtp_server)
session.commit() session.commit()
sent = self.client.post( sent = self.client.post(

View File

@@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import (
sequence_watermark_is_expired, sequence_watermark_is_expired,
) )
from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context from govoplan_core.core.events import EventActorRef, EventBus, EventObjectRef, EventTenantRef, PlatformEvent, event_bus_context
from govoplan_core.core.runtime import (
clear_runtime,
configure_runtime,
get_runtime_context,
)
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
class ChangeSequenceTests(unittest.TestCase): class ChangeSequenceTests(unittest.TestCase):
def setUp(self) -> None:
self._runtime_context = get_runtime_context()
clear_runtime()
def tearDown(self) -> None:
if self._runtime_context is not None:
configure_runtime(self._runtime_context)
else:
clear_runtime()
def test_records_changes_and_returns_entries_after_watermark(self) -> None: def test_records_changes_and_returns_entries_after_watermark(self) -> None:
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine) SessionLocal = sessionmaker(bind=engine)
@@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase):
actor_id="user-1", actor_id="user-1",
payload={"scope_type": "tenant"}, payload={"scope_type": "tenant"},
) )
self.assertEqual([], seen)
session.commit()
self.assertEqual([case[-1] for case in cases], [event.type for event in seen]) self.assertEqual([case[-1] for case in cases], [event.type for event in seen])
event = seen[1] event = seen[1]
@@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase):
finally: finally:
engine.dispose() engine.dispose()
def test_record_change_discards_event_when_transaction_rolls_back(self) -> None:
engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine)
try:
Base.metadata.create_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
with SessionLocal() as session, event_bus_context(bus):
record_change(
session,
tenant_id="tenant-1",
module_id="files",
collection="files.assets",
resource_type="file",
resource_id="file-1",
operation="created",
)
session.rollback()
self.assertEqual([], seen)
finally:
engine.dispose()
def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None: def test_pruning_records_retention_floor_for_stale_watermarks(self) -> None:
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
SessionLocal = sessionmaker(bind=engine) SessionLocal = sessionmaker(bind=engine)

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Response
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.auth import get_api_principal
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.fastapi import create_govoplan_app from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router from govoplan_core.server.platform import create_platform_router
@@ -85,6 +86,7 @@ class ConditionalRequestTests(unittest.TestCase):
registry=PlatformRegistry(), registry=PlatformRegistry(),
api_router=api_router, api_router=api_router,
) )
app.dependency_overrides[get_api_principal] = lambda: object()
with TestClient(app) as client: with TestClient(app) as client:
for path in ( for path in (

View File

@@ -8,6 +8,8 @@ from unittest.mock import patch
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event, audit_operation_context from govoplan_core.audit.logging import audit_event, audit_operation_context
from govoplan_core.core.events import ( from govoplan_core.core.events import (
@@ -17,6 +19,7 @@ from govoplan_core.core.events import (
EventTenantRef, EventTenantRef,
PlatformEvent, PlatformEvent,
current_event_trace, current_event_trace,
emit_platform_event,
event_bus_context, event_bus_context,
event_context, event_context,
normalize_trace_id, normalize_trace_id,
@@ -31,13 +34,60 @@ from tests.db_isolation import temporary_database
def _configure_audit_runtime() -> None: def _configure_audit_runtime() -> None:
registry = build_platform_registry(("audit",)) registry = build_platform_registry(("access", "audit"))
context = ModuleContext(registry=registry, settings=object()) context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context) registry.configure_capability_context(context)
configure_runtime(context) configure_runtime(context)
class CoreEventTests(unittest.TestCase): class CoreEventTests(unittest.TestCase):
def test_fallback_event_waits_for_outer_commit_after_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
with session.begin_nested():
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
self.assertEqual([], seen)
self.assertEqual(
["nested.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_fallback_event_discards_only_rolled_back_savepoint(self) -> None:
engine = create_engine("sqlite:///:memory:")
seen: list[PlatformEvent] = []
bus = EventBus()
bus.subscribe("*", seen.append)
try:
with Session(engine) as session, event_bus_context(bus):
with session.begin():
emit_platform_event(
session,
PlatformEvent(type="outer.created", module_id="core"),
)
savepoint = session.begin_nested()
emit_platform_event(
session,
PlatformEvent(type="nested.created", module_id="core"),
)
savepoint.rollback()
self.assertEqual([], seen)
self.assertEqual(
["outer.created"],
[event.type for event in seen],
)
finally:
engine.dispose()
def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None: def test_event_bus_adds_trace_ids_and_propagates_causation_to_nested_events(self) -> None:
bus = EventBus() bus = EventBus()
seen: list[PlatformEvent] = [] seen: list[PlatformEvent] = []
@@ -267,6 +317,11 @@ class CoreEventTests(unittest.TestCase):
object_id="user-2", object_id="user-2",
details={"password": "secret", "field": "display_name"}, details={"password": "secret", "field": "display_name"},
) )
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
action_events = [event for event in seen if event.type == "user.updated"] action_events = [event for event in seen if event.type == "user.updated"]
self.assertEqual(1, len(action_events)) self.assertEqual(1, len(action_events))
@@ -315,6 +370,11 @@ class CoreEventTests(unittest.TestCase):
object_type="demo", object_type="demo",
object_id=action, object_id=action,
) )
session.commit()
from govoplan_audit.backend.outbox import SqlAuditOutbox
SqlAuditOutbox().dispatch_pending(session)
session.commit()
by_type = {event.type: event.module_id for event in seen if event.type in cases} by_type = {event.type: event.module_id for event in seen if event.type in cases}
self.assertEqual(cases, by_type) self.assertEqual(cases, by_type)

View File

@@ -131,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory: with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory:
database = Path(directory) / "core.db" database = Path(directory) / "core.db"
url = f"sqlite:///{database}" url = f"sqlite:///{database}"
enabled_modules = ("access",)
command.upgrade(alembic_config(database_url=url, enabled_modules=()), "heads") command.upgrade(
alembic_config(
database_url=url,
enabled_modules=enabled_modules,
),
"heads",
)
engine = create_engine(url) engine = create_engine(url)
try: try:
@@ -157,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase):
).scalar_one() ).scalar_one()
current = database_migration_heads(connection) current = database_migration_heads(connection)
self.assertEqual(current, configured_migration_heads(url, enabled_modules=())) self.assertEqual(
current,
configured_migration_heads(
url,
enabled_modules=enabled_modules,
),
)
self.assertIn("core_scopes", tables) self.assertIn("core_scopes", tables)
self.assertNotIn("tenancy_tenants", tables) self.assertNotIn("tenancy_tenants", tables)
self.assertIn("access_accounts", tables) self.assertIn("access_accounts", tables)

View File

@@ -119,20 +119,66 @@ class _FakeIdmDirectory:
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
return self.assignment if assignment_id == self.assignment.id else None return self.assignment if assignment_id == self.assignment.id else None
def organization_function_assignments_for_account(self, account_id: str, *, tenant_id: str | None = None): def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if account_id != self.assignment.account_id: if account_id != self.assignment.account_id:
return () return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id: if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return () return ()
return (self.assignment,) return (self.assignment,)
def organization_function_assignments_for_identity(self, identity_id: str, *, tenant_id: str | None = None): def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
effective_at=None,
):
del effective_at
if identity_id != self.assignment.identity_id: if identity_id != self.assignment.identity_id:
return () return ()
if tenant_id is not None and tenant_id != self.assignment.tenant_id: if tenant_id is not None and tenant_id != self.assignment.tenant_id:
return () return ()
return (self.assignment,) return (self.assignment,)
def organization_function_assignments_for_identities(
self,
identity_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
identity_id: self.organization_function_assignments_for_identity(
identity_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for identity_id in identity_ids
}
def organization_function_assignments_for_accounts(
self,
account_ids,
*,
tenant_id: str | None = None,
effective_at=None,
):
return {
account_id: self.organization_function_assignments_for_account(
account_id,
tenant_id=tenant_id,
effective_at=effective_at,
)
for account_id in account_ids
}
class IdentityOrganizationContractTests(unittest.TestCase): class IdentityOrganizationContractTests(unittest.TestCase):
def test_protocol_shapes_match_reference_implementations(self) -> None: def test_protocol_shapes_match_reference_implementations(self) -> None:

View File

@@ -230,7 +230,10 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests)) self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests))
self.assertEqual(manifests["campaigns"].dependencies, ()) self.assertEqual(manifests["campaigns"].dependencies, ())
self.assertTrue(manifests["campaigns"].required_capabilities) self.assertTrue(manifests["campaigns"].required_capabilities)
self.assertEqual(manifests["campaigns"].optional_dependencies, ("files", "mail", "notifications", "addresses")) self.assertEqual(
manifests["campaigns"].optional_dependencies,
("files", "mail", "notifications", "addresses", "postbox"),
)
self.assertEqual(manifests["dashboard"].dependencies, ()) self.assertEqual(manifests["dashboard"].dependencies, ())
self.assertTrue(manifests["dashboard"].required_capabilities) self.assertTrue(manifests["dashboard"].required_capabilities)
self.assertEqual(manifests["docs"].dependencies, ()) self.assertEqual(manifests["docs"].dependencies, ())
@@ -587,7 +590,7 @@ class ModuleSystemTests(unittest.TestCase):
def test_enabled_module_permutations_register_expected_routes(self) -> None: def test_enabled_module_permutations_register_expected_routes(self) -> None:
cases = ( cases = (
("core_only", (), {"access"}, set()), ("core_only", (), set(), set()),
("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}), ("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}),
("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}), ("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}),
("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}), ("campaign_without_files_or_mail", ("campaigns",), {"access", "campaigns"}, {"/api/v1/campaigns"}),
@@ -602,8 +605,14 @@ class ModuleSystemTests(unittest.TestCase):
app, _settings_obj = self._app_for_modules(enabled_modules) app, _settings_obj = self._app_for_modules(enabled_modules)
route_paths = _route_paths(app) route_paths = _route_paths(app)
self.assertIn("/api/v1/platform/modules", route_paths) self.assertIn("/api/v1/platform/modules", route_paths)
self.assertIn("/api/v1/auth/login", route_paths) self.assertEqual(
self.assertIn("/api/v1/admin/users", route_paths) "access" in expected_modules,
"/api/v1/auth/login" in route_paths,
)
self.assertEqual(
"access" in expected_modules,
"/api/v1/admin/users" in route_paths,
)
for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"): for prefix in ("/api/v1/campaigns", "/api/v1/files", "/api/v1/mail", "/api/v1/docs", "/api/v1/ops"):
has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths) has_prefix = any(path == prefix or path.startswith(f"{prefix}/") for path in route_paths)
self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}") self.assertEqual(prefix in expected_prefixes, has_prefix, f"{name}: {prefix}")
@@ -613,12 +622,19 @@ class ModuleSystemTests(unittest.TestCase):
self.assertEqual(health.status_code, 200, health.text) self.assertEqual(health.status_code, 200, health.text)
self.assertEqual({"status": "ok"}, health.json()) self.assertEqual({"status": "ok"}, health.json())
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(
payload_modules = {item["id"] for item in response.json()["modules"]} 401 if "access" in expected_modules else 503,
response.status_code,
response.text,
)
payload_modules = {
item.id
for item in app.state.govoplan_registry.manifests()
}
self.assertEqual(expected_modules, payload_modules) self.assertEqual(expected_modules, payload_modules)
def test_governance_template_routes_are_contributed_by_admin_module(self) -> None: def test_governance_template_routes_are_contributed_by_admin_module(self) -> None:
access_only_app, _settings_obj = self._app_for_modules(()) access_only_app, _settings_obj = self._app_for_modules(("access",))
access_only_paths = _route_paths(access_only_app) access_only_paths = _route_paths(access_only_app)
self.assertIn("/api/v1/admin/users", access_only_paths) self.assertIn("/api/v1/admin/users", access_only_paths)
self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths) self.assertNotIn("/api/v1/admin/system/governance-templates", access_only_paths)
@@ -827,6 +843,37 @@ finally:
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"): with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate() registry.validate()
def test_registry_keeps_optional_auth_modules_access_free(self) -> None:
for modules in ((), ("poll",), ("evaluation",), ("scheduling",)):
with self.subTest(modules=modules):
registry = build_platform_registry(modules)
self.assertFalse(registry.has_module("access"))
def test_registry_prefers_selected_auth_provider_over_default(self) -> None:
provider = ModuleManifest(
id="alternative_auth",
name="Alternative auth",
version="test",
capability_factories={
"auth.principalResolver": lambda _context: object(),
},
)
consumer = ModuleManifest(
id="consumer",
name="Consumer",
version="test",
required_capabilities=("auth.principalResolver",),
)
registry = build_platform_registry(
("alternative_auth", "consumer"),
manifest_factories=(lambda: provider, lambda: consumer),
)
self.assertFalse(registry.has_module("access"))
self.assertTrue(registry.has_module("alternative_auth"))
self.assertTrue(registry.has_module("consumer"))
def test_registry_resolves_required_interface_ranges(self) -> None: def test_registry_resolves_required_interface_ranges(self) -> None:
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(ModuleManifest( registry.register(ModuleManifest(
@@ -903,7 +950,7 @@ finally:
plan = plan_desired_enabled_modules(("campaigns",), manifests) plan = plan_desired_enabled_modules(("campaigns",), manifests)
self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules) self.assertEqual(("access", "admin", "campaigns"), plan.enabled_modules)
self.assertEqual((), plan.added_dependencies) self.assertEqual(("access",), plan.added_dependencies)
def test_module_install_plan_is_saved_alongside_desired_state(self) -> None: def test_module_install_plan_is_saved_alongside_desired_state(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT))
@@ -3300,7 +3347,10 @@ finally:
"version_min": "0.2.0", "version_min": "0.2.0",
"version_max_exclusive": "0.3.0", "version_max_exclusive": "0.3.0",
}, modules["campaigns"]["requires_interfaces"]) }, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail", "notifications", "addresses"], modules["campaigns"]["optional_dependencies"]) self.assertEqual(
["files", "mail", "notifications", "addresses", "postbox"],
modules["campaigns"]["optional_dependencies"],
)
self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower()) self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.9", modules["files"]["version"]) self.assertEqual("0.1.9", modules["files"]["version"])
@@ -3946,8 +3996,10 @@ finally:
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 401, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]} payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules) self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None: def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None:
@@ -3979,8 +4031,10 @@ finally:
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 401, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]} payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "files"}, payload_modules) self.assertEqual({"access", "files"}, payload_modules)
def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None: def test_create_app_preserves_admin_when_configured_and_saved_state_is_older(self) -> None:
@@ -4012,8 +4066,10 @@ finally:
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 401, response.text)
payload_modules = {item["id"] for item in response.json()["modules"]} payload_modules = {
item.id for item in app.state.govoplan_registry.manifests()
}
self.assertEqual({"access", "admin", "files"}, payload_modules) self.assertEqual({"access", "admin", "files"}, payload_modules)
def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None: def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None:
@@ -4023,27 +4079,37 @@ finally:
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 503, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]}) self.assertEqual((), lifecycle.active_module_ids())
self.assertFalse("/api/v1/files" in _route_paths(app)) self.assertFalse("/api/v1/files" in _route_paths(app))
result = lifecycle.apply_enabled_modules(("files",), migrate=False) result = lifecycle.apply_enabled_modules(("files",), migrate=False)
self.assertEqual(("files",), result.activated_modules) self.assertEqual(("access", "files"), result.activated_modules)
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 401, response.text)
self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]}) self.assertEqual(("access", "files"), lifecycle.active_module_ids())
self.assertTrue("/api/v1/files" in _route_paths(app)) self.assertTrue("/api/v1/files" in _route_paths(app))
self.assertEqual(401, client.get("/api/v1/files").status_code) self.assertEqual(401, client.get("/api/v1/files").status_code)
result = lifecycle.apply_enabled_modules((), migrate=False) result = lifecycle.apply_enabled_modules((), migrate=False)
self.assertEqual(("files",), result.deactivated_modules) self.assertEqual(("access", "files"), result.deactivated_modules)
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 503, response.text)
self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]}) self.assertEqual((), lifecycle.active_module_ids())
disabled_response = client.get("/api/v1/files") disabled_response = client.get("/api/v1/files")
self.assertEqual(404, disabled_response.status_code) self.assertEqual(404, disabled_response.status_code)
self.assertEqual("Module is disabled: files", disabled_response.json()["detail"]) self.assertEqual("Module is disabled: files", disabled_response.json()["detail"])
def test_module_lifecycle_live_apply_defaults_by_environment(self) -> None:
app, settings = self._app_for_modules(())
lifecycle = app.state.govoplan_lifecycle
self.assertTrue(lifecycle.live_apply_enabled())
settings.app_env = "production"
self.assertFalse(lifecycle.live_apply_enabled())
settings.module_live_apply_enabled = True
self.assertTrue(lifecycle.live_apply_enabled())
def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None: def test_module_permutations_start_when_absent_modules_are_physically_unavailable(self) -> None:
cases = ( cases = (
@@ -4073,10 +4139,9 @@ finally:
def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None: def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None:
core_plan = migration_metadata_plan(build_platform_registry(())) core_plan = migration_metadata_plan(build_platform_registry(()))
self.assertEqual(1, len(core_plan.script_locations)) self.assertEqual((), core_plan.script_locations)
self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions")) self.assertEqual((), core_plan.metadata)
self.assertEqual(1, len(core_plan.metadata)) self.assertEqual((), core_plan.modules)
self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules))
files_plan = migration_metadata_plan(build_platform_registry(("files",))) files_plan = migration_metadata_plan(build_platform_registry(("files",)))
self.assertEqual(2, len(files_plan.script_locations)) self.assertEqual(2, len(files_plan.script_locations))

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import unittest
from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
PostboxAccessProvider,
PostboxDeliveryProvider,
PostboxDirectoryProvider,
PostboxEvidenceProvider,
PostboxMessagesProvider,
postbox_access_provider,
postbox_delivery_provider,
postbox_directory_provider,
postbox_evidence_provider,
postbox_messages_provider,
)
from govoplan_core.core.registry import PlatformRegistry
class _PostboxProvider:
def list_visible_postboxes(self, session, *, tenant_id, actor):
del session, tenant_id, actor
return ()
def resolve_postbox(self, session, *, tenant_id, target, materialize=False):
del session, tenant_id, target, materialize
return None
def delivery_catalog(self, session, *, tenant_id):
del session, tenant_id
return None
def explain_access(self, session, *, tenant_id, postbox_id, actor, action):
del session, tenant_id, postbox_id, actor, action
return None
def list_messages(
self,
session,
*,
tenant_id,
postbox_ids,
actor,
limit=100,
offset=0,
):
del session, tenant_id, postbox_ids, actor, limit, offset
return ()
def get_message(self, session, *, tenant_id, message_id, actor):
del session, tenant_id, message_id, actor
return None
def mark_message(self, session, *, tenant_id, message_id, actor, state):
del session, tenant_id, message_id, actor, state
return None
def deliver(self, session, request):
del session, request
return None
def link_evidence(self, session, *, tenant_id, message_id, attachment):
del session, tenant_id, message_id, attachment
return None
class PostboxContractTests(unittest.TestCase):
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
provider = _PostboxProvider()
self.assertIsInstance(provider, PostboxDirectoryProvider)
self.assertIsInstance(provider, PostboxAccessProvider)
self.assertIsInstance(provider, PostboxMessagesProvider)
self.assertIsInstance(provider, PostboxDeliveryProvider)
self.assertIsInstance(provider, PostboxEvidenceProvider)
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="postbox_contract_test",
name="Postbox contract test",
version="test",
capability_factories={
name: lambda context: provider
for name in (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
)
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(provider, postbox_directory_provider(registry))
self.assertIs(provider, postbox_access_provider(registry))
self.assertIs(provider, postbox_messages_provider(registry))
self.assertIs(provider, postbox_delivery_provider(registry))
self.assertIs(provider, postbox_evidence_provider(registry))
empty = PlatformRegistry()
self.assertIsNone(postbox_directory_provider(empty))
self.assertIsNone(postbox_access_provider(empty))
self.assertIsNone(postbox_messages_provider(empty))
self.assertIsNone(postbox_delivery_provider(empty))
self.assertIsNone(postbox_evidence_provider(empty))
if __name__ == "__main__":
unittest.main()

View File

@@ -15,6 +15,55 @@ from govoplan_core.server.fastapi import create_govoplan_app
class QueryMetricsTests(TestCase): class QueryMetricsTests(TestCase):
def test_non_sqlite_engine_uses_bounded_pool_configuration(self) -> None:
configured = {
"GOVOPLAN_DB_POOL_SIZE": "8",
"GOVOPLAN_DB_MAX_OVERFLOW": "16",
"GOVOPLAN_DB_POOL_TIMEOUT_SECONDS": "45",
"GOVOPLAN_DB_POOL_RECYCLE_SECONDS": "900",
}
engine = object()
with (
patch.dict(os.environ, configured, clear=False),
patch(
"govoplan_core.db.session.create_engine",
return_value=engine,
) as create_engine,
patch(
"govoplan_core.db.session.instrument_engine",
side_effect=lambda value: value,
),
):
result = create_database_engine(
"postgresql+psycopg://govoplan@example/govoplan",
)
self.assertIs(engine, result)
create_engine.assert_called_once_with(
"postgresql+psycopg://govoplan@example/govoplan",
pool_pre_ping=True,
connect_args={},
pool_size=8,
max_overflow=16,
pool_timeout=45,
pool_recycle=900,
)
def test_non_sqlite_engine_rejects_pool_values_outside_safe_bounds(self) -> None:
invalid_values = (
("GOVOPLAN_DB_POOL_SIZE", "0"),
("GOVOPLAN_DB_MAX_OVERFLOW", "201"),
("GOVOPLAN_DB_POOL_TIMEOUT_SECONDS", "not-a-number"),
("GOVOPLAN_DB_POOL_RECYCLE_SECONDS", "86401"),
)
for name, value in invalid_values:
with self.subTest(name=name, value=value):
with patch.dict(os.environ, {name: value}, clear=False):
with self.assertRaisesRegex(ValueError, name):
create_database_engine(
"postgresql+psycopg://govoplan@example/govoplan",
)
def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None: def test_collect_query_metrics_counts_instrumented_engine_queries(self) -> None:
engine = create_database_engine("sqlite:///:memory:") engine = create_database_engine("sqlite:///:memory:")
try: try:

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccountRef,
GroupRef,
UserRef,
)
from govoplan_core.core.references import (
access_scope_reference_options,
validate_access_scope_reference,
)
class _Directory:
users = (
UserRef(
id="membership-1",
account_id="account-1",
tenant_id="tenant-1",
email="ada@example.test",
display_name="Ada",
),
UserRef(
id="membership-2",
account_id="account-2",
tenant_id="tenant-1",
email="inactive@example.test",
display_name="Inactive",
status="inactive",
),
)
groups = (
GroupRef(id="group-1", tenant_id="tenant-1", name="Finance"),
GroupRef(
id="group-2",
tenant_id="tenant-1",
name="Former team",
status="inactive",
),
)
def get_account(self, account_id):
if account_id not in {"account-1", "account-2"}:
return None
return AccountRef(id=account_id, email=f"{account_id}@example.test")
def get_user(self, user_id):
return next((item for item in self.users if item.id == user_id), None)
def get_users(self, user_ids):
return {item.id: item for item in self.users if item.id in set(user_ids)}
def users_for_tenant(self, tenant_id):
return tuple(item for item in self.users if item.tenant_id == tenant_id)
def get_group(self, group_id):
return next((item for item in self.groups if item.id == group_id), None)
def get_groups(self, group_ids):
return {item.id: item for item in self.groups if item.id in set(group_ids)}
def groups_for_tenant(self, tenant_id):
return tuple(item for item in self.groups if item.tenant_id == tenant_id)
def groups_for_user(self, user_id, *, tenant_id):
del user_id
return self.groups_for_tenant(tenant_id)
def display_label(self, subject):
return subject.label
class _Registry:
directory = _Directory()
def has_capability(self, name):
return name == CAPABILITY_ACCESS_DIRECTORY
def capability(self, name):
return self.directory if self.has_capability(name) else None
class ReferenceOptionTests(unittest.TestCase):
def setUp(self) -> None:
self.principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-1",
display_name="Ada",
email="ada@example.test",
group_ids=frozenset({"group-1"}),
)
def test_user_values_are_canonical_account_ids(self) -> None:
options = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="user",
administrative=True,
)
self.assertEqual(["account-1", "account-2"], [item.value for item in options])
self.assertEqual("membership-1", options[0].provenance["membership_id"])
self.assertTrue(options[1].disabled)
self.assertEqual("inactive", options[1].availability)
def test_unavailable_selected_values_remain_readable(self) -> None:
options = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="group",
query="missing",
selected_values=("removed-group",),
administrative=True,
)
self.assertEqual(1, len(options))
self.assertEqual("removed-group", options[0].value)
self.assertEqual("unavailable", options[0].availability)
self.assertTrue(options[0].disabled)
def test_non_administrators_only_receive_permitted_scope_targets(self) -> None:
users = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="user",
administrative=False,
)
groups = access_scope_reference_options(
_Registry(),
self.principal,
scope_type="group",
administrative=False,
)
self.assertEqual(["account-1"], [item.value for item in users])
self.assertEqual(["group-1"], [item.value for item in groups])
def test_inactive_targets_are_rejected_but_existing_values_survive(self) -> None:
with self.assertRaisesRegex(ValueError, "Inactive groups"):
validate_access_scope_reference(
_Registry(),
tenant_id="tenant-1",
scope_type="group",
scope_id="group-2",
)
self.assertEqual(
"group-2",
validate_access_scope_reference(
_Registry(),
tenant_id="tenant-1",
scope_type="group",
scope_id="group-2",
preserve_existing="group-2",
),
)
def test_missing_optional_access_provider_keeps_reduced_mode(self) -> None:
options = access_scope_reference_options(
None,
self.principal,
scope_type="user",
)
self.assertEqual("account-1", options[0].value)
self.assertEqual("core", options[0].source_module)
if __name__ == "__main__":
unittest.main()

View File

@@ -5,6 +5,7 @@ import unittest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.auth import get_api_principal
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
@@ -218,6 +219,7 @@ class ViewSurfaceContractTests(unittest.TestCase):
app = FastAPI() app = FastAPI()
app.state.govoplan_registry = registry app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1") app.include_router(create_platform_router(), prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: object()
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/v1/platform/modules") response = client.get("/api/v1/platform/modules")

View File

@@ -13,8 +13,6 @@ import unittest
class WheelRuntimeTests(unittest.TestCase): class WheelRuntimeTests(unittest.TestCase):
def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None: def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None:
repository_root = Path(__file__).resolve().parents[1] 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: with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory:
temporary_root = Path(directory) temporary_root = Path(directory)
wheel_directory = temporary_root / "wheels" wheel_directory = temporary_root / "wheels"
@@ -30,21 +28,8 @@ class WheelRuntimeTests(unittest.TestCase):
str(repository_root), str(repository_root),
cwd=temporary_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")) 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(core_wheels))
self.assertEqual(1, len(access_wheels))
virtual_environment = temporary_root / "venv" virtual_environment = temporary_root / "venv"
self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root) self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root)
@@ -56,7 +41,6 @@ class WheelRuntimeTests(unittest.TestCase):
"install", "install",
"--no-deps", "--no-deps",
str(core_wheels[0]), str(core_wheels[0]),
str(access_wheels[0]),
cwd=temporary_root, cwd=temporary_root,
) )
@@ -85,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase):
self.assertNotEqual(repository_root, runtime_root) self.assertNotEqual(repository_root, runtime_root)
self.assertTrue((runtime_root / "alembic.ini").is_file()) self.assertTrue((runtime_root / "alembic.ini").is_file())
self.assertTrue((runtime_root / "alembic" / "env.py").is_file()) self.assertTrue((runtime_root / "alembic" / "env.py").is_file())
self.assertIn("4f2a9c8e7b6d", result["heads"]) self.assertEqual(["c91f0a72be34"], result["heads"])
self.assertIn("core_scopes", result["tables"]) self.assertIn("core_scopes", result["tables"])
self.assertIn("core_system_settings", result["tables"]) self.assertIn("core_system_settings", result["tables"])

View File

@@ -25,6 +25,7 @@
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui" "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
@@ -37,7 +38,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "7.18.2",
"read-excel-file": "^9.2.0", "read-excel-file": "^9.2.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
@@ -46,7 +47,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
} }
}, },
"../../govoplan-access/webui": { "../../govoplan-access/webui": {
@@ -60,7 +61,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -76,7 +77,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -95,7 +96,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -111,7 +112,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -128,7 +129,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -140,7 +141,7 @@
}, },
"../../govoplan-campaign/webui": { "../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.11", "version": "0.1.12",
"dependencies": { "dependencies": {
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
@@ -152,7 +153,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -168,7 +169,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -185,7 +186,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -202,7 +203,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -220,7 +221,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -239,7 +240,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -258,7 +259,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -279,7 +280,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -296,7 +297,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -315,7 +316,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -334,7 +335,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -352,7 +353,23 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-postbox/webui": {
"name": "@govoplan/postbox-webui",
"version": "0.1.1",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -369,7 +386,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
}, },
@@ -387,7 +404,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -404,7 +421,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
@@ -1205,6 +1222,10 @@
"resolved": "../../govoplan-policy/webui", "resolved": "../../govoplan-policy/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/postbox-webui": {
"resolved": "../../govoplan-postbox/webui",
"link": true
},
"node_modules/@govoplan/scheduling-webui": { "node_modules/@govoplan/scheduling-webui": {
"resolved": "../../govoplan-scheduling/webui", "resolved": "../../govoplan-scheduling/webui",
"link": true "link": true
@@ -2395,9 +2416,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2418,13 +2439,13 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.0", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.0" "react-router": "7.18.2"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

View File

@@ -56,6 +56,7 @@
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui" "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
@@ -68,7 +69,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": "7.18.2",
"read-excel-file": "^9.2.0", "read-excel-file": "^9.2.0",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"
@@ -77,7 +78,7 @@
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.25.12": true "esbuild@0.25.12": true

View File

@@ -18,6 +18,7 @@ const packageByModule = {
organizations: "@govoplan/organizations-webui", organizations: "@govoplan/organizations-webui",
ops: "@govoplan/ops-webui", ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui", policy: "@govoplan/policy-webui",
postbox: "@govoplan/postbox-webui",
scheduling: "@govoplan/scheduling-webui", scheduling: "@govoplan/scheduling-webui",
views: "@govoplan/views-webui", views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui" workflow: "@govoplan/workflow-webui"
@@ -44,13 +45,15 @@ const cases = [
{ name: "notifications-only", modules: ["notifications"] }, { name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] }, { name: "organizations-only", modules: ["organizations"] },
{ name: "idm-with-organizations", modules: ["organizations", "idm"] }, { name: "idm-with-organizations", modules: ["organizations", "idm"] },
{ name: "postbox-only", modules: ["postbox"] },
{ name: "campaign-only", modules: ["campaigns"] }, { name: "campaign-only", modules: ["campaigns"] },
{ name: "campaign-with-postbox", modules: ["campaigns", "postbox"] },
{ name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] },
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
{ name: "scheduling-only", modules: ["scheduling"] }, { name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] }, { name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] } { name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] }
]; ];
const npmExec = process.env.npm_execpath; const npmExec = process.env.npm_execpath;

View File

@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type SharedNotificationSummary = {
total: number;
unread: number;
pending: number;
failed: number;
show_unread_badge?: boolean;
};
export type SharedNotificationSummarySnapshot = {
summary: SharedNotificationSummary | null;
loading: boolean;
error: unknown;
};
type Listener = (snapshot: SharedNotificationSummarySnapshot) => void;
type SummaryStore = {
settings: ApiSettings;
listeners: Map<Listener, number>;
snapshot: SharedNotificationSummarySnapshot;
inFlight: Promise<void> | null;
lastFetchedAt: number;
timerId: number | null;
focusListener: () => void;
changeListener: () => void;
};
const stores = new Map<string, SummaryStore>();
const EMPTY_SNAPSHOT: SharedNotificationSummarySnapshot = {
summary: null,
loading: false,
error: null
};
function storeKey(settings: ApiSettings, scopeKey: string): string {
return [
settings.apiBaseUrl,
settings.apiKey,
settings.accessToken,
scopeKey
].join("\u0000");
}
function notify(store: SummaryStore): void {
for (const listener of store.listeners.keys()) {
listener(store.snapshot);
}
}
function minimumInterval(store: SummaryStore): number {
return Math.max(
5_000,
Math.min(...Array.from(store.listeners.values()))
);
}
function refresh(store: SummaryStore, force = false): Promise<void> {
if (store.inFlight) return store.inFlight;
if (
!force &&
store.lastFetchedAt > 0 &&
Date.now() - store.lastFetchedAt < Math.min(minimumInterval(store), 5_000)
) {
return Promise.resolve();
}
if (store.snapshot.summary === null) {
store.snapshot = { ...store.snapshot, loading: true, error: null };
notify(store);
}
const request = apiFetch<SharedNotificationSummary>(
store.settings,
"/api/v1/notifications/summary"
)
.then((summary) => {
store.lastFetchedAt = Date.now();
store.snapshot = { summary, loading: false, error: null };
notify(store);
})
.catch((error) => {
store.snapshot = {
...store.snapshot,
loading: false,
error
};
notify(store);
})
.finally(() => {
if (store.inFlight === request) store.inFlight = null;
});
store.inFlight = request;
return request;
}
function schedule(store: SummaryStore): void {
if (store.timerId !== null) window.clearInterval(store.timerId);
if (store.listeners.size === 0) {
store.timerId = null;
return;
}
store.timerId = window.setInterval(
() => void refresh(store, true),
minimumInterval(store)
);
}
function createStore(settings: ApiSettings): SummaryStore {
const store = {} as SummaryStore;
store.settings = settings;
store.listeners = new Map();
store.snapshot = { ...EMPTY_SNAPSHOT };
store.inFlight = null;
store.lastFetchedAt = 0;
store.timerId = null;
store.focusListener = () => void refresh(store);
store.changeListener = () => void refresh(store, true);
return store;
}
function subscribe(
settings: ApiSettings,
scopeKey: string,
intervalMs: number,
listener: Listener
): { store: SummaryStore; unsubscribe: () => void } {
const key = storeKey(settings, scopeKey);
let store = stores.get(key);
if (!store) {
store = createStore(settings);
stores.set(key, store);
}
const firstListener = store.listeners.size === 0;
store.listeners.set(listener, Math.max(5_000, intervalMs));
if (firstListener) {
window.addEventListener("focus", store.focusListener);
window.addEventListener(
"govoplan:notifications-changed",
store.changeListener
);
}
listener(store.snapshot);
schedule(store);
void refresh(store);
return {
store,
unsubscribe: () => {
store?.listeners.delete(listener);
if (store?.listeners.size === 0) {
schedule(store);
window.removeEventListener("focus", store.focusListener);
window.removeEventListener(
"govoplan:notifications-changed",
store.changeListener
);
stores.delete(key);
} else if (store) {
schedule(store);
}
}
};
}
export function useSharedNotificationSummary(
settings: ApiSettings,
options: {
enabled: boolean;
scopeKey: string;
intervalMs?: number;
refreshKey?: string | number;
}
): SharedNotificationSummarySnapshot {
const [snapshot, setSnapshot] =
useState<SharedNotificationSummarySnapshot>(EMPTY_SNAPSHOT);
const activeStore = useRef<SummaryStore | null>(null);
const previousRefreshKey = useRef(options.refreshKey);
useEffect(() => {
if (!options.enabled) {
activeStore.current = null;
setSnapshot(EMPTY_SNAPSHOT);
return;
}
const subscription = subscribe(
settings,
options.scopeKey,
options.intervalMs ?? 60_000,
setSnapshot
);
activeStore.current = subscription.store;
return () => {
activeStore.current = null;
subscription.unsubscribe();
};
}, [
options.enabled,
options.intervalMs,
options.scopeKey,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
useEffect(() => {
if (previousRefreshKey.current === options.refreshKey) return;
previousRefreshKey.current = options.refreshKey;
if (activeStore.current) void refresh(activeStore.current, true);
}, [options.refreshKey]);
return snapshot;
}

View File

@@ -20,10 +20,26 @@ import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField"; import FormField from "./FormField";
import LoadingFrame from "./LoadingFrame"; import LoadingFrame from "./LoadingFrame";
import PasswordField from "./PasswordField"; import PasswordField from "./PasswordField";
import {
ReferenceMultiSelect,
combineReferenceOptionProviders,
customReferenceOption,
staticReferenceOptionProvider,
type ReferenceOption
} from "./ReferenceSelect";
import { platformModuleReferenceProvider } from "../platform/referenceProviders";
import StatusBadge from "./StatusBadge"; import StatusBadge from "./StatusBadge";
import TableActionGroup from "./table/TableActionGroup"; import TableActionGroup from "./table/TableActionGroup";
import ToggleSwitch from "./ToggleSwitch"; import ToggleSwitch from "./ToggleSwitch";
import { useUnsavedDraftGuard } from "./UnsavedChangesGuard"; import { useUnsavedDraftGuard } from "./UnsavedChangesGuard";
import {
usePlatformModules,
usePlatformUiCapabilities
} from "../platform/ModuleContext";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type {
CredentialReferenceSelectorsUiCapability
} from "./ReferenceSelect";
export type CredentialEnvelopeTargetOption = { export type CredentialEnvelopeTargetOption = {
id: string; id: string;
@@ -31,11 +47,19 @@ export type CredentialEnvelopeTargetOption = {
secondary?: string | null; secondary?: string | null;
}; };
export type CredentialEnvelopeServerOption = {
id: string;
label: string;
secondary?: string | null;
disabled?: boolean;
};
export type CredentialEnvelopeManagerProps = { export type CredentialEnvelopeManagerProps = {
settings: ApiSettings; settings: ApiSettings;
scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">; scopeType: Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
scopeId?: string | null; scopeId?: string | null;
targetOptions?: CredentialEnvelopeTargetOption[]; targetOptions?: CredentialEnvelopeTargetOption[];
serverOptions?: CredentialEnvelopeServerOption[];
targetLabel?: string; targetLabel?: string;
title?: string; title?: string;
canWrite: boolean; canWrite: boolean;
@@ -76,10 +100,13 @@ export default function CredentialEnvelopeManager({
scopeType, scopeType,
scopeId, scopeId,
targetOptions = [], targetOptions = [],
serverOptions = [],
targetLabel = "Target", targetLabel = "Target",
title = "Credential envelopes", title = "Credential envelopes",
canWrite canWrite
}: CredentialEnvelopeManagerProps) { }: CredentialEnvelopeManagerProps) {
const modules = usePlatformModules();
const { translateText } = usePlatformLanguage();
const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? ""); const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? "");
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]); const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -95,6 +122,56 @@ export default function CredentialEnvelopeManager({
: selectedTargetId || null; : selectedTargetId || null;
const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId); const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId);
const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey; const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey;
const referenceCapabilities =
usePlatformUiCapabilities<CredentialReferenceSelectorsUiCapability>(
"core.credentialReferenceSelectors"
);
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
() => modules.map((module) => ({
value: module.id,
label: translateText(module.label),
description: `${module.id} · version ${module.version}`,
kind: "module",
sourceModule: "core",
provenance: { version: module.version }
})),
[modules, translateText]
);
const serverReferenceOptions = useMemo<ReferenceOption[]>(
() => serverOptions.map((server) => ({
value: server.id,
label: server.label,
description: server.secondary ?? server.id,
kind: "server",
disabled: server.disabled,
availability: server.disabled ? "inactive" : "available",
provenance: { announcedByConsumer: true }
})),
[serverOptions]
);
const moduleReferenceProvider = useMemo(
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
[moduleReferenceOptions, settings]
);
const serverReferenceProvider = useMemo(
() =>
combineReferenceOptionProviders([
staticReferenceOptionProvider(serverReferenceOptions),
...referenceCapabilities.map((capability) =>
capability.serverProvider(settings, {
scopeType,
scopeId: activeScopeId
})
)
]),
[
activeScopeId,
referenceCapabilities,
scopeType,
serverReferenceOptions,
settings
]
);
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: draftDirty, dirty: draftDirty,
@@ -436,11 +513,26 @@ export default function CredentialEnvelopeManager({
<p>Empty module or server lists mean every module or server allowed by scope.</p> <p>Empty module or server lists mean every module or server allowed by scope.</p>
</header> </header>
<div className="form-grid two"> <div className="form-grid two">
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses."> <FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} /> <ReferenceMultiSelect
values={splitValues(draft.allowedModules)}
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
provider={moduleReferenceProvider}
aria-label="Credential modules"
placeholder="Add a module"
disabled={saving}
/>
</FormField> </FormField>
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id."> <FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} /> <ReferenceMultiSelect
values={splitValues(draft.allowedServerRefs)}
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
provider={serverReferenceProvider}
createCustomOption={(value) => customReferenceOption(value, "server")}
aria-label="Credential servers"
placeholder="Add a server reference"
disabled={saving}
/>
</FormField> </FormField>
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" /> <ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" /> <ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />

View File

@@ -0,0 +1,119 @@
import {
useEffect,
useState,
type ReactNode
} from "react";
import { Link } from "react-router-dom";
import { adminErrorMessage } from "./admin/adminUtils";
export type DashboardWidgetDataState<T> = {
data: T | null;
loading: boolean;
error: string;
};
export function useDashboardWidgetData<T>(
load: () => Promise<T>,
refreshKey: number
): DashboardWidgetDataState<T> {
const [state, setState] = useState<DashboardWidgetDataState<T>>({
data: null,
loading: true,
error: ""
});
useEffect(() => {
let active = true;
setState((current) => ({
...current,
loading: true,
error: ""
}));
void load()
.then((data) => {
if (active) setState({ data, loading: false, error: "" });
})
.catch((error: unknown) => {
if (!active) return;
setState((current) => ({
...current,
loading: false,
error: adminErrorMessage(error)
}));
});
return () => {
active = false;
};
}, [load, refreshKey]);
return state;
}
export type DashboardWidgetListItem = {
id: string;
title: ReactNode;
detail?: ReactNode;
meta?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
to?: string;
};
export function DashboardWidgetList({
items,
emptyText
}: {
items: DashboardWidgetListItem[];
emptyText: ReactNode;
}) {
if (!items.length) {
return <p className="muted dashboard-contribution-empty">{emptyText}</p>;
}
return (
<ul className="dashboard-contribution-list">
{items.map((item) => {
const content = (
<>
{item.leading && (
<span className="dashboard-contribution-leading">
{item.leading}
</span>
)}
<span className="dashboard-contribution-copy">
<span className="dashboard-contribution-title">
{item.title}
</span>
{item.detail && (
<span className="dashboard-contribution-detail">
{item.detail}
</span>
)}
</span>
{item.meta && (
<span className="dashboard-contribution-meta">
{item.meta}
</span>
)}
{item.trailing && (
<span className="dashboard-contribution-trailing">
{item.trailing}
</span>
)}
</>
);
return (
<li key={item.id}>
{item.to ? (
<Link className="dashboard-contribution-row" to={item.to}>
{content}
</Link>
) : (
<div className="dashboard-contribution-row">{content}</div>
)}
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,472 @@
import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react";
import SearchableSelect, {
filterSearchableSelectOptions,
type SearchableSelectCreateCustomOption,
type SearchableSelectOption,
type SearchableSelectProps
} from "./SearchableSelect";
import IconButton from "./IconButton";
import type { ApiSettings } from "../types";
export type ReferenceAvailability =
| "available"
| "inactive"
| "unavailable";
export type ReferenceOption = SearchableSelectOption & {
kind?: string | null;
availability?: ReferenceAvailability;
sourceModule?: string | null;
provenance?: Record<string, unknown>;
custom?: boolean;
};
export type ReferenceProviderContext = {
limit: number;
selectedValues: readonly string[];
signal: AbortSignal;
};
export type ReferenceOptionProvider = {
search: (
query: string,
context: ReferenceProviderContext
) => Promise<readonly ReferenceOption[]>;
resolve?: (
values: readonly string[],
context: Omit<ReferenceProviderContext, "limit"> & { limit?: number }
) => Promise<readonly ReferenceOption[]>;
};
export type ConfigurationReferenceSelectorsUiCapability = {
tenantProvider: (settings: ApiSettings) => ReferenceOptionProvider;
changeRequestProvider: (
settings: ApiSettings,
options: {
purpose: string;
tenantId?: string | null;
}
) => ReferenceOptionProvider;
};
export type CredentialReferenceSelectorContext = {
scopeType: "system" | "tenant" | "user" | "group";
scopeId?: string | null;
};
export type CredentialReferenceSelectorsUiCapability = {
serverProvider: (
settings: ApiSettings,
context: CredentialReferenceSelectorContext
) => ReferenceOptionProvider;
};
export type ReferenceSelectProps = Omit<
SearchableSelectProps,
"options" | "loadOptions" | "selectedOption"
> & {
provider: ReferenceOptionProvider;
selectedOption?: ReferenceOption | null;
};
export type ReferenceMultiSelectProps = {
id?: string;
values: readonly string[];
onChange: (values: string[]) => void;
provider: ReferenceOptionProvider;
selectedOptions?: readonly ReferenceOption[];
createCustomOption?: SearchableSelectCreateCustomOption;
"aria-label"?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
minQueryLength?: number;
searchLimit?: number;
className?: string;
};
const EMPTY_REFERENCE_OPTIONS: readonly ReferenceOption[] = [];
export default function ReferenceSelect({
provider,
value,
selectedOption,
searchLimit = 50,
...props
}: ReferenceSelectProps) {
const [resolved, setResolved] = useState<ReferenceOption | null>(
selectedOption?.value === value ? selectedOption : null
);
useEffect(() => {
if (!value) {
setResolved(null);
return;
}
setResolved(
selectedOption?.value === value
? selectedOption
: unavailableReferenceOption(value)
);
}, [selectedOption, value]);
useEffect(() => {
if (!value || selectedOption?.value === value || !provider.resolve) return;
const controller = new AbortController();
void provider
.resolve([value], {
selectedValues: [value],
signal: controller.signal,
limit: 1
})
.then((options) => {
if (!controller.signal.aborted) {
setResolved(
options.find((option) => option.value === value)
?? unavailableReferenceOption(value)
);
}
})
.catch((error: unknown) => {
if (
!controller.signal.aborted
&& !(error instanceof DOMException && error.name === "AbortError")
) {
setResolved(unavailableReferenceOption(value));
}
});
return () => controller.abort();
}, [provider, selectedOption?.value, value]);
return (
<SearchableSelect
{...props}
value={value}
searchLimit={searchLimit}
selectedOption={resolved}
loadOptions={(query, context) =>
provider.search(query, {
...context,
selectedValues: value ? [value] : []
})
}
/>
);
}
export function ReferenceMultiSelect({
id,
values,
onChange,
provider,
selectedOptions = EMPTY_REFERENCE_OPTIONS,
createCustomOption,
"aria-label": ariaLabel = "Select references",
placeholder = "Add a reference",
searchPlaceholder,
emptyText,
disabled = false,
minQueryLength,
searchLimit = 50,
className = ""
}: ReferenceMultiSelectProps) {
const normalizedValues = useMemo(
() => [...new Set(values.map((value) => value.trim()).filter(Boolean))],
[values]
);
const normalizedValuesKey = normalizedValues.join("\u0000");
const [resolvedOptions, setResolvedOptions] = useState<
readonly ReferenceOption[]
>(selectedOptions);
useEffect(() => {
setResolvedOptions(selectedOptions);
}, [selectedOptions]);
useEffect(() => {
if (!normalizedValues.length || !provider.resolve) return;
const controller = new AbortController();
void provider
.resolve(normalizedValues, {
selectedValues: normalizedValues,
signal: controller.signal,
limit: normalizedValues.length
})
.then((options) => {
if (!controller.signal.aborted) setResolvedOptions(options);
})
.catch((error: unknown) => {
if (
!controller.signal.aborted
&& !(error instanceof DOMException && error.name === "AbortError")
) {
setResolvedOptions(EMPTY_REFERENCE_OPTIONS);
}
});
return () => controller.abort();
}, [normalizedValuesKey, provider]);
const optionsByValue = useMemo(
() =>
new Map(
[...selectedOptions, ...resolvedOptions].map((option) => [
option.value,
option
])
),
[resolvedOptions, selectedOptions]
);
const selected = normalizedValues.map(
(value) =>
optionsByValue.get(value)
?? (createCustomOption?.(value) as ReferenceOption | null | undefined)
?? unavailableReferenceOption(value)
);
const rootClassName = ["reference-multi-select", className]
.filter(Boolean)
.join(" ");
function add(value: string, option: SearchableSelectOption | null) {
if (!value || normalizedValues.includes(value)) return;
if (option) {
setResolvedOptions((current) => [
...current.filter((item) => item.value !== value),
option as ReferenceOption
]);
}
onChange([...normalizedValues, value]);
}
function remove(value: string) {
onChange(normalizedValues.filter((item) => item !== value));
}
return (
<div className={rootClassName}>
{selected.length ? (
<div
className="reference-multi-select-values"
role="list"
aria-label={`${ariaLabel} selected`}
>
{selected.map((option) => (
<div
key={option.value}
className={[
"reference-multi-select-value",
option.availability
? `is-${option.availability}`
: "",
option.custom ? "is-custom" : ""
]
.filter(Boolean)
.join(" ")}
role="listitem"
>
<span className="reference-multi-select-value-copy">
<strong>{option.label}</strong>
<small>{referenceOptionDetail(option)}</small>
</span>
<IconButton
label={`Remove ${option.label}`}
icon={<X size={15} aria-hidden="true" />}
onClick={() => remove(option.value)}
disabled={disabled}
/>
</div>
))}
</div>
) : null}
<SearchableSelect
id={id}
aria-label={ariaLabel}
value=""
onChange={add}
loadOptions={async (query, context) => {
const options = await provider.search(query, {
...context,
selectedValues: normalizedValues
});
return options.filter(
(option) => !normalizedValues.includes(option.value)
);
}}
createCustomOption={(value) => {
if (normalizedValues.includes(value)) return null;
return createCustomOption?.(value) ?? null;
}}
placeholder={placeholder}
searchPlaceholder={searchPlaceholder}
emptyText={emptyText}
disabled={disabled}
minQueryLength={minQueryLength}
searchLimit={searchLimit}
/>
</div>
);
}
export function staticReferenceOptionProvider(
options: readonly ReferenceOption[]
): ReferenceOptionProvider {
const byValue = new Map(options.map((option) => [option.value, option]));
return {
async search(query, context) {
if (context.signal.aborted) throw abortError();
return filterSearchableSelectOptions(
options,
query,
context.limit
) as ReferenceOption[];
},
async resolve(values, context) {
if (context.signal.aborted) throw abortError();
return values.map(
(value) => byValue.get(value) ?? unavailableReferenceOption(value)
);
}
};
}
export function combineReferenceOptionProviders(
providers: readonly ReferenceOptionProvider[]
): ReferenceOptionProvider {
if (providers.length === 1) return providers[0];
async function collect(
operation: (
provider: ReferenceOptionProvider
) => Promise<readonly ReferenceOption[]>
): Promise<ReferenceOption[]> {
const results = await Promise.allSettled(providers.map(operation));
const successful = results.filter(
(
result
): result is PromiseFulfilledResult<readonly ReferenceOption[]> =>
result.status === "fulfilled"
);
if (!successful.length) {
const failure = results.find(
(result): result is PromiseRejectedResult =>
result.status === "rejected"
);
throw failure?.reason ?? new Error("Reference options are unavailable.");
}
return mergeReferenceOptions(
successful.flatMap((result) => [...result.value])
);
}
return {
async search(query, context) {
const options = await collect((provider) =>
provider.search(query, context)
);
return options.slice(0, context.limit);
},
async resolve(values, context) {
const options = await collect((provider) =>
provider.resolve
? provider.resolve(values, context)
: provider.search("", {
...context,
limit: context.limit ?? Math.max(values.length, 50)
})
);
const byValue = new Map(options.map((option) => [option.value, option]));
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value)
);
}
};
}
export function wildcardReferenceOption(
value: string,
kind = "pattern"
): ReferenceOption | null {
const clean = value.trim();
if (!clean || !/[*?[\]]/.test(clean)) return null;
return {
value: clean,
label: clean,
description: "Custom wildcard pattern",
kind,
availability: "available",
custom: true,
provenance: { input: "custom_pattern" }
};
}
export function customReferenceOption(
value: string,
kind = "custom"
): ReferenceOption | null {
const clean = value.trim();
if (!clean) return null;
return {
value: clean,
label: clean,
description: "Custom reference",
kind,
availability: "available",
custom: true,
provenance: { input: "custom_reference" }
};
}
export function unavailableReferenceOption(
value: string,
label = "Unavailable reference"
): ReferenceOption {
return {
value,
label,
description: value,
availability: "unavailable",
disabled: true,
provenance: { retainedReference: true }
};
}
function mergeReferenceOptions(
options: readonly ReferenceOption[]
): ReferenceOption[] {
const merged = new Map<string, ReferenceOption>();
for (const option of options) {
const current = merged.get(option.value);
if (!current || referenceOptionPriority(option) > referenceOptionPriority(current)) {
merged.set(option.value, option);
}
}
return [...merged.values()];
}
function referenceOptionPriority(option: ReferenceOption): number {
if (option.availability === "unavailable") return 0;
if (option.availability === "inactive" || option.disabled) return 1;
return 2;
}
function referenceOptionDetail(option: ReferenceOption): string {
const state = option.custom
? option.kind === "pattern"
? "Pattern"
: "Custom"
: option.availability === "inactive"
? "Inactive"
: option.availability === "unavailable"
? "Unavailable"
: "";
return [state, option.description, option.value !== option.label ? option.value : ""]
.filter(Boolean)
.join(" · ");
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}

View File

@@ -0,0 +1,379 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FocusEvent,
type KeyboardEvent
} from "react";
import { ChevronDown, Search } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type SearchableSelectOption = {
value: string;
label: string;
description?: string | null;
searchText?: string | null;
disabled?: boolean;
};
export type SearchableSelectLoadOptions = (
query: string,
options: { limit: number; signal: AbortSignal }
) => Promise<readonly SearchableSelectOption[]>;
export type SearchableSelectCreateCustomOption = (
value: string
) => SearchableSelectOption | null;
export type SearchableSelectProps = {
id?: string;
value: string;
onChange: (
value: string,
option: SearchableSelectOption | null
) => void;
options?: readonly SearchableSelectOption[];
loadOptions?: SearchableSelectLoadOptions;
createCustomOption?: SearchableSelectCreateCustomOption;
selectedOption?: SearchableSelectOption | null;
"aria-label"?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
loadingText?: string;
errorText?: string;
disabled?: boolean;
required?: boolean;
minQueryLength?: number;
searchLimit?: number;
debounceMs?: number;
className?: string;
};
const EMPTY_OPTIONS: readonly SearchableSelectOption[] = [];
export function filterSearchableSelectOptions(
options: readonly SearchableSelectOption[],
query: string,
limit = 50
): SearchableSelectOption[] {
const needle = query.trim().toLocaleLowerCase();
const filtered = needle
? options.filter((option) =>
[
option.label,
option.description ?? "",
option.searchText ?? "",
option.value
]
.join(" ")
.toLocaleLowerCase()
.includes(needle)
)
: [...options];
return filtered.slice(0, Math.max(1, Math.floor(limit)));
}
export default function SearchableSelect({
id,
value,
onChange,
options = EMPTY_OPTIONS,
loadOptions,
createCustomOption,
selectedOption,
"aria-label": ariaLabel = "Select an option",
placeholder = "Select an option",
searchPlaceholder = "Search...",
emptyText = "No matching options.",
loadingText = "Loading...",
errorText = "Options could not be loaded.",
disabled = false,
required = false,
minQueryLength = 0,
searchLimit = 50,
debounceMs = 200,
className = ""
}: SearchableSelectProps) {
const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
const pickerId = id || `searchable-select-${generatedId}`;
const listboxId = `${pickerId}-results`;
const statusId = `${pickerId}-status`;
const rootRef = useRef<HTMLDivElement | null>(null);
const resolvedSelected = useMemo(
() =>
selectedOption ??
options.find((option) => option.value === value) ??
null,
[options, selectedOption, value]
);
const selectedLabel = resolvedSelected?.label ?? value;
const [inputValue, setInputValue] = useState(selectedLabel);
const [results, setResults] =
useState<readonly SearchableSelectOption[]>(EMPTY_OPTIONS);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const normalizedMinLength = Math.max(0, Math.floor(minQueryLength));
const normalizedLimit = Math.max(1, Math.min(200, Math.floor(searchLimit)));
const visibleResults = useMemo(() => {
const customValue = inputValue.trim();
const customOption = customValue ? createCustomOption?.(customValue) : null;
if (
!customOption
|| results.some((option) => option.value === customOption.value)
) {
return results;
}
return [...results, customOption];
}, [createCustomOption, inputValue, results]);
useEffect(() => {
if (!open) setInputValue(selectedLabel);
}, [open, selectedLabel]);
useEffect(() => {
if (!open || disabled) {
setLoading(false);
setFailed(false);
setResults(EMPTY_OPTIONS);
return;
}
const query = inputValue.trim();
if (query.length < normalizedMinLength) {
setLoading(false);
setFailed(false);
setResults(EMPTY_OPTIONS);
return;
}
if (!loadOptions) {
setResults(filterSearchableSelectOptions(options, query, normalizedLimit));
setLoading(false);
setFailed(false);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(() => {
setLoading(true);
setFailed(false);
void loadOptions(query, {
limit: normalizedLimit,
signal: controller.signal
})
.then((nextOptions) => {
if (!controller.signal.aborted) setResults(nextOptions);
})
.catch((error: unknown) => {
if (
controller.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return;
}
setResults(EMPTY_OPTIONS);
setFailed(true);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
}, Math.max(0, Math.floor(debounceMs)));
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [
debounceMs,
disabled,
inputValue,
loadOptions,
normalizedLimit,
normalizedMinLength,
open,
options
]);
useEffect(() => {
const selectedIndex = visibleResults.findIndex(
(option) => option.value === value && !option.disabled
);
const firstEnabledIndex = visibleResults.findIndex(
(option) => !option.disabled
);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex);
}, [value, visibleResults]);
function choose(option: SearchableSelectOption) {
if (disabled || option.disabled) return;
onChange(option.value, option);
setInputValue(option.label);
setOpen(false);
setActiveIndex(-1);
}
function moveActive(delta: 1 | -1) {
const enabledIndexes = visibleResults
.map((option, index) => ({ option, index }))
.filter(({ option }) => !option.disabled)
.map(({ index }) => index);
if (!enabledIndexes.length) {
setActiveIndex(-1);
return;
}
const currentPosition = enabledIndexes.indexOf(activeIndex);
const nextPosition =
currentPosition < 0
? delta > 0
? 0
: enabledIndexes.length - 1
: (currentPosition + delta + enabledIndexes.length) %
enabledIndexes.length;
setActiveIndex(enabledIndexes[nextPosition]);
}
function handleKeyDown(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" && open && activeIndex >= 0) {
const option = visibleResults[activeIndex];
if (option && !option.disabled) {
event.preventDefault();
choose(option);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpen(false);
setInputValue(selectedLabel);
setActiveIndex(-1);
}
}
function closeOnFocusLeave(event: FocusEvent<HTMLDivElement>) {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) {
return;
}
setOpen(false);
setInputValue(selectedLabel);
setActiveIndex(-1);
}
const minimumLengthMissing =
inputValue.trim().length < normalizedMinLength;
const visibleStatus = loading
? loadingText
: failed
? errorText
: minimumLengthMissing
? `Type at least ${normalizedMinLength} characters.`
: !visibleResults.length
? emptyText
: "";
const rootClassName = ["searchable-select", className]
.filter(Boolean)
.join(" ");
return (
<div
ref={rootRef}
className={rootClassName}
onBlur={closeOnFocusLeave}
>
<div className="searchable-select-control">
<Search size={17} aria-hidden="true" />
<input
id={pickerId}
type="search"
role="combobox"
value={inputValue}
disabled={disabled}
required={required && !value}
placeholder={translateText(
open ? searchPlaceholder : placeholder
)}
aria-label={translateText(ariaLabel)}
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={
open && activeIndex >= 0
? `${pickerId}-option-${activeIndex}`
: undefined
}
aria-describedby={statusId}
aria-busy={loading}
onFocus={() => {
if (!open) setInputValue("");
setOpen(true);
}}
onChange={(event) => {
setInputValue(event.target.value);
setOpen(true);
setFailed(false);
if (value) onChange("", null);
}}
onKeyDown={handleKeyDown}
/>
<ChevronDown size={17} aria-hidden="true" />
</div>
{open && !disabled && (
<div
id={listboxId}
className="searchable-select-results"
role="listbox"
aria-label={translateText(ariaLabel)}
>
{visibleResults.map((option, index) => (
<button
key={option.value}
id={`${pickerId}-option-${index}`}
type="button"
role="option"
aria-selected={option.value === value}
className={[
"searchable-select-option",
option.value === value ? "is-selected" : "",
index === activeIndex ? "is-active" : ""
]
.filter(Boolean)
.join(" ")}
disabled={option.disabled}
onMouseEnter={() => {
if (!option.disabled) setActiveIndex(index);
}}
onClick={() => choose(option)}
>
<strong>{option.label}</strong>
{option.description && <small>{option.description}</small>}
</button>
))}
{visibleStatus && (
<p className="searchable-select-message">
{translateText(visibleStatus)}
</p>
)}
</div>
)}
<span id={statusId} className="sr-only" aria-live="polite">
{translateText(visibleStatus)}
</span>
</div>
);
}

View File

@@ -3,6 +3,7 @@ export * from "./types";
export * from "./api/client"; export * from "./api/client";
export * from "./api/auth"; export * from "./api/auth";
export * from "./api/platform"; export * from "./api/platform";
export * from "./api/notificationSummary";
export * from "./api/adminCommon"; export * from "./api/adminCommon";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts"; export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts";
export type { export type {
@@ -60,7 +61,7 @@ export { default as ColorPickerField } from "./components/ColorPickerField";
export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel"; export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel";
export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel"; export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel";
export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager"; export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager";
export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager"; export type { CredentialEnvelopeManagerProps, CredentialEnvelopeServerOption, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager";
export { export {
createCredentialEnvelope, createCredentialEnvelope,
deleteCredentialEnvelope, deleteCredentialEnvelope,
@@ -112,6 +113,34 @@ export { default as PolicySourcePath } from "./components/PolicySourcePath";
export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath"; export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath";
export { default as PolicyLockedHint } from "./components/PolicyLockedHint"; export { default as PolicyLockedHint } from "./components/PolicyLockedHint";
export type { PolicyLockedHintProps } from "./components/PolicyLockedHint"; export type { PolicyLockedHintProps } from "./components/PolicyLockedHint";
export { default as SearchableSelect, filterSearchableSelectOptions } from "./components/SearchableSelect";
export type { SearchableSelectCreateCustomOption, SearchableSelectLoadOptions, SearchableSelectOption, SearchableSelectProps } from "./components/SearchableSelect";
export {
default as ReferenceSelect,
ReferenceMultiSelect,
combineReferenceOptionProviders,
customReferenceOption,
staticReferenceOptionProvider,
unavailableReferenceOption,
wildcardReferenceOption
} from "./components/ReferenceSelect";
export type {
ReferenceAvailability,
ConfigurationReferenceSelectorsUiCapability,
CredentialReferenceSelectorContext,
CredentialReferenceSelectorsUiCapability,
ReferenceMultiSelectProps,
ReferenceOption,
ReferenceOptionProvider,
ReferenceProviderContext,
ReferenceSelectProps
} from "./components/ReferenceSelect";
export {
apiReferenceOptionProvider,
platformModuleReferenceProvider
} from "./platform/referenceProviders";
export { DashboardWidgetList, useDashboardWidgetData } from "./components/DashboardWidgetContent";
export type { DashboardWidgetDataState, DashboardWidgetListItem } from "./components/DashboardWidgetContent";
export { default as SegmentedControl } from "./components/SegmentedControl"; export { default as SegmentedControl } from "./components/SegmentedControl";
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl"; export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
export { default as SelectionList, SelectionListItem } from "./components/SelectionList"; export { default as SelectionList, SelectionListItem } from "./components/SelectionList";

View File

@@ -6,18 +6,13 @@ import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal"; import LoginModal from "../features/auth/LoginModal";
import DismissibleAlert from "../components/DismissibleAlert"; import DismissibleAlert from "../components/DismissibleAlert";
import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard";
import { apiFetch, isApiError } from "../api/client"; import { useSharedNotificationSummary } from "../api/notificationSummary";
import { logout, switchTenant } from "../api/auth"; import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions"; import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext"; import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
import { useEffectiveView } from "../platform/ViewContext"; import { useEffectiveView } from "../platform/ViewContext";
type NotificationSummary = {
unread: number;
show_unread_badge?: boolean;
};
type Props = { type Props = {
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo | null; auth: AuthInfo | null;
@@ -35,7 +30,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null); const [switchingTenantId, setSwitchingTenantId] = useState<string | null>(null);
const [tenantError, setTenantError] = useState(""); const [tenantError, setTenantError] = useState("");
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const accountRef = useRef<HTMLDivElement>(null); const accountRef = useRef<HTMLDivElement>(null);
const tenantRef = useRef<HTMLDivElement>(null); const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
@@ -55,7 +49,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
"system:tenants:suspend"] "system:tenants:suspend"]
); );
const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants)); const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants));
const showContextSelectors = Boolean(
auth && ((activeTenant && showTenantControl) || ViewSelector)
);
const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications")); const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications"));
const notificationSummary = useSharedNotificationSummary(settings, {
enabled: Boolean(auth && notificationsAvailable),
scopeKey: "current-session",
intervalMs: 60_000,
refreshKey: `${activeTenant?.id ?? ""}:${auth?.user?.id ?? ""}`
}).summary;
const unreadNotificationCount = notificationSummary?.show_unread_badge === false ?
0 :
Math.max(0, Number(notificationSummary?.unread) || 0);
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount); const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
useEffect(() => { useEffect(() => {
@@ -72,41 +78,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
return () => window.removeEventListener("mousedown", onPointerDown); return () => window.removeEventListener("mousedown", onPointerDown);
}, []); }, []);
useEffect(() => {
if (!auth || !notificationsAvailable) {
setUnreadNotificationCount(0);
return;
}
let cancelled = false;
async function refreshNotificationSummary() {
try {
const summary = await apiFetch<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
if (!cancelled) {
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
}
} catch (error) {
if (!cancelled) {
setUnreadNotificationCount(0);
}
if (!isApiError(error, 401, 403, 404)) {
console.error("Failed to load notification summary", error);
}
}
}
void refreshNotificationSummary();
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
window.addEventListener("focus", refreshNotificationSummary);
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", refreshNotificationSummary);
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
};
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function handleLogin(response: LoginResponse) { function handleLogin(response: LoginResponse) {
onAuthChange(response, ""); onAuthChange(response, "");
} }
@@ -185,8 +156,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
</button> </button>
} }
{auth && activeTenant && showTenantControl && {auth && showContextSelectors &&
<div className="tenant-selector" ref={tenantRef}> <div className="titlebar-context-selectors">
{activeTenant && showTenantControl &&
<div className="tenant-selector" ref={tenantRef}>
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span> <span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
{canSwitchTenant ? {canSwitchTenant ?
<> <>
@@ -218,13 +191,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<strong>{activeTenant.name}</strong> <strong>{activeTenant.name}</strong>
} }
</div> </div>
}
{ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
</div>
} }
<div className="titlebar-spacer" /> <div className="titlebar-spacer" />
{auth && ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
<LanguageMenu /> <LanguageMenu />
<HelpMenu auth={auth} /> <HelpMenu auth={auth} />

View File

@@ -1,4 +1,4 @@
import { createContext, type ReactNode, useContext } from "react"; import { createContext, type ReactNode, useContext, useMemo } from "react";
import type { PlatformWebModule } from "../types"; import type { PlatformWebModule } from "../types";
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules"; import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
import { useEffectiveView, useViewSurfaces } from "./ViewContext"; import { useEffectiveView, useViewSurfaces } from "./ViewContext";
@@ -19,22 +19,34 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
} }
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null { export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
return uiCapability<T>(capabilityName, useViewVisibleModules()); const modules = useViewVisibleModules();
return useMemo(
() => uiCapability<T>(capabilityName, modules),
[capabilityName, modules]
);
} }
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] { export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
return uiCapabilities<T>(capabilityName, useViewVisibleModules()); const modules = useViewVisibleModules();
return useMemo(
() => uiCapabilities<T>(capabilityName, modules),
[capabilityName, modules]
);
} }
function useViewVisibleModules(): PlatformWebModule[] { function useViewVisibleModules(): PlatformWebModule[] {
const modules = usePlatformModules(); const modules = usePlatformModules();
const projection = useEffectiveView(); const projection = useEffectiveView();
const surfaces = useViewSurfaces(); const surfaces = useViewSurfaces();
return modules.filter((module) => return useMemo(
isViewSurfaceVisible( () =>
projection, modules.filter((module) =>
moduleViewSurfaceId(module.id), isViewSurfaceVisible(
surfaces projection,
) moduleViewSurfaceId(module.id),
surfaces
)
),
[modules, projection, surfaces]
); );
} }

View File

@@ -0,0 +1,163 @@
import {
apiFetch,
apiPath,
type ApiQueryParams
} from "../api/client";
import { fetchPlatformModules } from "../api/platform";
import { filterSearchableSelectOptions } from "../components/SearchableSelect";
import {
unavailableReferenceOption,
type ReferenceOption,
type ReferenceOptionProvider
} from "../components/ReferenceSelect";
import type { ApiSettings } from "../types";
type ApiReferenceOptionPayload = {
value: string;
label: string;
description?: string | null;
kind?: string | null;
availability?: ReferenceOption["availability"];
disabled?: boolean;
source_module?: string | null;
provenance?: Record<string, unknown>;
};
export function apiReferenceOptionProvider(
settings: ApiSettings,
path: string,
params: ApiQueryParams = {}
): ReferenceOptionProvider {
async function load(
query: string,
selectedValues: readonly string[],
limit: number,
signal: AbortSignal
): Promise<ReferenceOption[]> {
const response = await apiFetch<{
options: ApiReferenceOptionPayload[];
provider_available: boolean;
}>(
settings,
apiPath(path, {
...params,
q: query,
selected: selectedValues,
limit
}),
{ signal }
);
return response.options.map(referenceOptionFromPayload);
}
return {
search: (query, context) =>
load(query, context.selectedValues, context.limit, context.signal),
resolve: (values, context) =>
load("", values, context.limit ?? values.length, context.signal)
};
}
export function platformModuleReferenceProvider(
settings: ApiSettings,
presentedOptions: readonly ReferenceOption[] = []
): ReferenceOptionProvider {
const presentedByValue = new Map(
presentedOptions.map((option) => [option.value, option])
);
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
cataloguePromise ??= loadPlatformModuleOptions(
settings,
presentedOptions,
presentedByValue
).catch((error: unknown) => {
cataloguePromise = null;
throw error;
});
const options = await cataloguePromise;
if (signal.aborted) throw abortError();
return options;
}
return {
async search(query, context) {
const options = await catalogue(context.signal);
return filterSearchableSelectOptions(
options,
query,
context.limit
) as ReferenceOption[];
},
async resolve(values, context) {
const options = await catalogue(context.signal);
const byValue = new Map(
[...presentedOptions, ...options].map((option) => [
option.value,
option
])
);
return values.map(
(value) =>
byValue.get(value)
?? unavailableReferenceOption(value, "Unavailable module")
);
}
};
}
async function loadPlatformModuleOptions(
settings: ApiSettings,
presentedOptions: readonly ReferenceOption[],
presentedByValue: ReadonlyMap<string, ReferenceOption>
): Promise<ReferenceOption[]> {
let modules;
try {
modules = (await fetchPlatformModules(settings)).modules;
} catch (error) {
if (presentedOptions.length) return [...presentedOptions];
throw error;
}
return modules
.filter((module) => module.enabled)
.map((module) => {
const presented = presentedByValue.get(module.id);
return {
value: module.id,
label: presented?.label ?? module.name ?? module.id,
description: presented?.description
?? `${module.id} · version ${module.version}`,
searchText: presented?.searchText
?? `${module.id} ${module.name} ${module.version}`,
kind: "module",
availability: presented?.availability ?? "available",
disabled: presented?.disabled ?? false,
sourceModule: "core",
provenance: {
version: module.version,
activeRegistry: true,
...(presented?.provenance ?? {})
}
} satisfies ReferenceOption;
});
}
function referenceOptionFromPayload(
option: ApiReferenceOptionPayload
): ReferenceOption {
return {
value: option.value,
label: option.label,
description: option.description,
kind: option.kind,
availability: option.availability,
disabled: option.disabled,
sourceModule: option.source_module,
provenance: option.provenance
};
}
function abortError(): DOMException {
return new DOMException("The operation was aborted.", "AbortError");
}

View File

@@ -1547,6 +1547,150 @@
.people-picker-search-control:focus-within > svg { .people-picker-search-control:focus-within > svg {
color: var(--primary); color: var(--primary);
} }
.searchable-select {
position: relative;
min-width: 0;
}
.searchable-select-control {
position: relative;
display: flex;
align-items: center;
}
.searchable-select-control > svg {
position: absolute;
z-index: 1;
color: var(--muted);
pointer-events: none;
}
.searchable-select-control > svg:first-child {
left: 12px;
}
.searchable-select-control > svg:last-child {
right: 12px;
}
.searchable-select-control input {
width: 100%;
padding-right: 38px;
padding-left: 38px;
}
.searchable-select-control:focus-within > svg:first-child {
color: var(--primary);
}
.searchable-select-results {
position: absolute;
top: calc(100% + 5px);
right: 0;
left: 0;
z-index: var(--z-popover, 50);
display: grid;
max-height: 320px;
overflow: auto;
padding: 5px;
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--surface);
box-shadow: var(--shadow-popover);
}
.searchable-select-option {
appearance: none;
display: grid;
gap: 2px;
width: 100%;
min-width: 0;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
}
.searchable-select-option:hover:not(:disabled),
.searchable-select-option.is-active:not(:disabled) {
background: var(--hover-tint-soft);
}
.searchable-select-option.is-selected {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel));
}
.searchable-select-option:focus-visible {
outline: var(--focus-outline);
outline-offset: -2px;
}
.searchable-select-option:disabled {
cursor: not-allowed;
opacity: .55;
}
.searchable-select-option strong,
.searchable-select-option small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.searchable-select-option small,
.searchable-select-message {
color: var(--muted);
font-size: 12px;
}
.searchable-select-message {
margin: 0;
padding: 10px;
}
.reference-multi-select {
display: grid;
min-width: 0;
gap: 7px;
}
.reference-multi-select-values {
display: grid;
min-width: 0;
max-height: 190px;
gap: 4px;
overflow: auto;
}
.reference-multi-select-value {
display: flex;
min-width: 0;
min-height: 38px;
align-items: center;
justify-content: space-between;
gap: 8px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
padding: 5px 6px 5px 9px;
}
.reference-multi-select-value.is-inactive,
.reference-multi-select-value.is-unavailable {
border-color: color-mix(in srgb, var(--warning) 45%, transparent);
background: var(--warning-soft);
}
.reference-multi-select-value.is-custom {
border-style: dashed;
}
.reference-multi-select-value-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.reference-multi-select-value-copy strong,
.reference-multi-select-value-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reference-multi-select-value-copy strong {
color: var(--text-strong);
font-size: 13px;
}
.reference-multi-select-value-copy small {
color: var(--muted);
font-size: 11px;
}
.people-picker-results { .people-picker-results {
display: grid; display: grid;
max-height: 360px; max-height: 360px;
@@ -3440,6 +3584,63 @@
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
} }
.dashboard-contribution-list {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.dashboard-contribution-list > li + li {
border-top: var(--border-line);
}
.dashboard-contribution-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 8px 2px;
color: inherit;
text-decoration: none;
}
a.dashboard-contribution-row:hover {
background: var(--hover-tint-soft);
}
.dashboard-contribution-leading,
.dashboard-contribution-trailing {
display: inline-flex;
align-items: center;
color: var(--muted);
}
.dashboard-contribution-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.dashboard-contribution-title,
.dashboard-contribution-detail,
.dashboard-contribution-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-contribution-title {
font-weight: 700;
}
.dashboard-contribution-detail,
.dashboard-contribution-meta {
color: var(--muted);
font-size: 12px;
}
.dashboard-contribution-empty {
margin: 0;
padding: 18px 2px;
}
.dashboard-contribution-footer {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-details-grid, .admin-details-grid,
.admin-tenant-assignment-row { .admin-tenant-assignment-row {

View File

@@ -27,6 +27,7 @@
.icon-rail.compact { width: 58px; } .icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: var(--shadow-chrome); } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: flex; align-items: center; padding: 0 18px; gap: 36px; z-index: 100; box-shadow: var(--shadow-chrome); }
.titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; }
.tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); } .tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); }
.tenant-label, .tenant-caret, .muted { color: var(--muted); } .tenant-label, .tenant-caret, .muted { color: var(--muted); }
.block { display: block; } .block { display: block; }

View File

@@ -410,11 +410,43 @@ export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info"; export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
export type DashboardWidgetConfigurationValue =
| string
| number
| boolean
| null;
export type DashboardWidgetConfiguration = Record<
string,
DashboardWidgetConfigurationValue
>;
export type DashboardWidgetConfigurationOption = {
value: string;
label: string;
};
export type DashboardWidgetConfigurationField = {
id: string;
label: string;
description?: string;
kind: "text" | "number" | "boolean" | "select";
required?: boolean;
placeholder?: string;
min?: number;
max?: number;
step?: number;
options?: DashboardWidgetConfigurationOption[];
};
export type DashboardWidgetRenderContext = PlatformRouteContext & { export type DashboardWidgetRenderContext = PlatformRouteContext & {
modules: PlatformWebModule[]; modules: PlatformWebModule[];
effectiveView: EffectiveViewProjection | null;
instanceId: string;
widgetId: string; widgetId: string;
refreshKey: number; refreshKey: number;
size: DashboardWidgetSize; size: DashboardWidgetSize;
configuration: DashboardWidgetConfiguration;
}; };
export type DashboardWidgetContribution = { export type DashboardWidgetContribution = {
@@ -428,6 +460,9 @@ export type DashboardWidgetContribution = {
defaultVisible?: boolean; defaultVisible?: boolean;
defaultSize?: DashboardWidgetSize; defaultSize?: DashboardWidgetSize;
supportedSizes?: DashboardWidgetSize[]; supportedSizes?: DashboardWidgetSize[];
allowMultiple?: boolean;
defaultConfiguration?: DashboardWidgetConfiguration;
configurationFields?: DashboardWidgetConfigurationField[];
refreshIntervalMs?: number; refreshIntervalMs?: number;
anyOf?: string[]; anyOf?: string[];
allOf?: string[]; allOf?: string[];

View File

@@ -1,4 +1,5 @@
import type { import type {
DashboardWidgetsUiCapability,
OrganizationFunctionActionContext, OrganizationFunctionActionContext,
OrganizationFunctionActionContribution, OrganizationFunctionActionContribution,
PlatformWebModule PlatformWebModule
@@ -71,6 +72,51 @@ for (const testCase of cases) {
assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object"); assert(uiCapability("files.fileExplorer", [access, files]) === filesCapability, "files capability should return the module-provided object");
assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object"); assert(uiCapability("mail.profiles", [access, mail]) === mailCapability, "mail capability should return the module-provided object");
const configurableDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "files.recent",
surfaceId: "files.widget.recent",
title: "Recent files",
moduleId: "files",
supportedSizes: ["medium", "wide"],
defaultConfiguration: { limit: 10, includeFolders: false },
configurationFields: [
{
id: "limit",
label: "Items",
kind: "number",
min: 1,
max: 50
},
{
id: "includeFolders",
label: "Include folders",
kind: "boolean"
}
],
render: ({ configuration, effectiveView, instanceId }) =>
`${instanceId}:${effectiveView?.activeViewId ?? "full"}:${String(configuration.limit)}`
}
]
};
const dashboardProvider: PlatformWebModule = {
id: "dashboard-provider",
label: "Dashboard provider",
version: "test",
uiCapabilities: {
"dashboard.widgets": configurableDashboardWidgets
}
};
const resolvedDashboardWidgets = uiCapability<DashboardWidgetsUiCapability>(
"dashboard.widgets",
[dashboardProvider]
);
assert(
resolvedDashboardWidgets?.widgets[0].configurationFields?.length === 2,
"dashboard widget capabilities should retain provider-defined configuration"
);
const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path); const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path);
assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order"); assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order");

View File

@@ -3,6 +3,14 @@ function assert(condition: unknown, message = "assertion failed"): void {
} }
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import SearchableSelect, {
filterSearchableSelectOptions
} from "../src/components/SearchableSelect";
import {
ReferenceMultiSelect,
staticReferenceOptionProvider,
wildcardReferenceOption
} from "../src/components/ReferenceSelect";
import SelectionList, { SelectionListItem } from "../src/components/SelectionList"; import SelectionList, { SelectionListItem } from "../src/components/SelectionList";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
@@ -50,3 +58,87 @@ const nativeLabelMarkup = renderToStaticMarkup(
</SelectionList> </SelectionList>
); );
assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged"); assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged");
const directoryOptions = [
{
value: "account-1",
label: "Ada Lovelace",
description: "ada@example.test"
},
{
value: "group-1",
label: "Finance",
searchText: "cost centre"
}
];
assert(
filterSearchableSelectOptions(directoryOptions, "example").length === 1,
"searchable selects match option descriptions"
);
assert(
filterSearchableSelectOptions(directoryOptions, "cost centre")[0]?.value ===
"group-1",
"searchable selects include explicit search aliases"
);
const searchableMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<SearchableSelect
id="assignment-target"
aria-label="Assignment target"
value="account-1"
options={directoryOptions}
onChange={() => undefined}
/>
</PlatformLanguageProvider>
);
assert(
searchableMarkup.includes('role="combobox"'),
"searchable selects expose combobox semantics"
);
assert(
searchableMarkup.includes('aria-controls="assignment-target-results"'),
"searchable selects associate their input and result list"
);
assert(
searchableMarkup.includes('value="Ada Lovelace"'),
"searchable selects render labels instead of technical ids"
);
const referenceMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<ReferenceMultiSelect
id="policy-references"
aria-label="Policy references"
values={["group-1", "legacy-*", "removed"]}
onChange={() => undefined}
selectedOptions={[
{
value: "group-1",
label: "Finance",
description: "Access group"
}
]}
provider={staticReferenceOptionProvider([
{
value: "group-1",
label: "Finance",
description: "Access group"
}
])}
createCustomOption={(value) => wildcardReferenceOption(value)}
/>
</PlatformLanguageProvider>
);
assert(
referenceMarkup.includes("Finance"),
"reference multi-selects render known labels"
);
assert(
referenceMarkup.includes("Pattern"),
"reference multi-selects distinguish wildcard patterns"
);
assert(
referenceMarkup.includes("Unavailable reference"),
"reference multi-selects retain unavailable values"
);

View File

@@ -35,6 +35,8 @@
"src/components/people/PeoplePicker.tsx", "src/components/people/PeoplePicker.tsx",
"src/components/people/peoplePickerTypes.ts", "src/components/people/peoplePickerTypes.ts",
"src/components/ResourceAccessExplanation.tsx", "src/components/ResourceAccessExplanation.tsx",
"src/components/ReferenceSelect.tsx",
"src/components/SearchableSelect.tsx",
"src/components/SelectionList.tsx", "src/components/SelectionList.tsx",
"src/components/mail/MailServerSettingsPanel.tsx", "src/components/mail/MailServerSettingsPanel.tsx",
"src/components/Button.tsx", "src/components/Button.tsx",

View File

@@ -28,6 +28,7 @@ const defaultWebModulePackages = [
"@govoplan/organizations-webui", "@govoplan/organizations-webui",
"@govoplan/ops-webui", "@govoplan/ops-webui",
"@govoplan/policy-webui", "@govoplan/policy-webui",
"@govoplan/postbox-webui",
"@govoplan/scheduling-webui", "@govoplan/scheduling-webui",
"@govoplan/views-webui", "@govoplan/views-webui",
"@govoplan/workflow-webui" "@govoplan/workflow-webui"
@@ -91,12 +92,14 @@ export default defineConfig({
chunkSizeWarningLimit: 1200 chunkSizeWarningLimit: 1200
}, },
resolve: { resolve: {
preserveSymlinks: true, preserveSymlinks: false,
dedupe: [ dedupe: [
"@xyflow/react", "@xyflow/react",
"lucide-react",
"react", "react",
"react-dom", "react-dom",
"react-router-dom" "react-router-dom",
"read-excel-file"
], ],
alias: [ alias: [
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) }, { find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
@@ -125,6 +128,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-campaign/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url)) fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))