diff --git a/docs/AUTOMATION_CONTRACTS.md b/docs/AUTOMATION_CONTRACTS.md index 3b77300..48ae151 100644 --- a/docs/AUTOMATION_CONTRACTS.md +++ b/docs/AUTOMATION_CONTRACTS.md @@ -42,8 +42,18 @@ inheritance, reuse, derivation, and automation are unavailable. ## Delivery Durability Domain trigger implementations persist idempotent deliveries before running. -The existing `PlatformEvent` bus is process-local and is not a durable -automation source. A transactional Core event/outbox bridge is still required -to guarantee capture across commits, restarts, and multiple workers. Until -that bridge exists, direct event ingestion must be authenticated, tenant -scoped, and limited to public or internal event envelopes. +`emit_platform_event` binds event delivery to the producer's SQLAlchemy +transaction. When an enabled module provides `platform.eventOutbox`, the event +is stored in that transaction and a dispatcher may retry it across restarts and +workers. The Audit module provides the current SQL outbox implementation; the +Celery `govoplan.events.dispatch_outbox` task drains it through the Dataflow +event-ingestion capability and the local event bus. + +The outbox capability remains optional so reduced module combinations can +start. Without it, Core queues events on the SQLAlchemy transaction and +publishes them to the process-local bus only after the outer commit. A rollback, +including a nested savepoint rollback, discards the corresponding events. This +fallback is suitable for local or non-critical reactions, but it is not a +durable multi-worker automation source. Deployments that rely on event-triggered +work must enable the outbox provider and run the `events` worker queue and +periodic dispatcher. diff --git a/docs/DEPLOYMENT_OPERATOR_GUIDE.md b/docs/DEPLOYMENT_OPERATOR_GUIDE.md index 3f751f6..b4e6ac2 100644 --- a/docs/DEPLOYMENT_OPERATOR_GUIDE.md +++ b/docs/DEPLOYMENT_OPERATOR_GUIDE.md @@ -209,9 +209,13 @@ prefer `FILE_STORAGE_*`. Interactive password login is enabled with fixed-window limits of 10 failures per normalized identity and 100 failures per direct client over 900 seconds. `AUTH_LOGIN_THROTTLE_*` settings change those limits. Counters use `REDIS_URL` -when Redis is reachable so replicas share state; a bounded process-local -fallback keeps development and Redis outages functional, with per-process -enforcement until Redis recovers. +when Redis is reachable so replicas share state. Production-like startup fails +when throttling is enabled without `REDIS_URL`. Set +`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 diff --git a/docs/EVENTS_AND_AUDIT.md b/docs/EVENTS_AND_AUDIT.md index 2c0d61a..c60d4e4 100644 --- a/docs/EVENTS_AND_AUDIT.md +++ b/docs/EVENTS_AND_AUDIT.md @@ -8,25 +8,32 @@ module reactions, and operator diagnostics. ## Production Transport Decision -The first production target is a **database outbox plus in-process immediate -dispatch**: +The production transport is a **transactional database outbox plus retrying +dispatcher**: - Use `govoplan_core.core.events.PlatformEvent` for domain and platform events. -- Use `EventBus` as the in-process dispatch contract for same-process module - reactions that are safe to run inline. +- Call `emit_platform_event(session, event)` to bind event delivery to the + 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 - 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. - 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`. -- Persist durable integration/workflow events through a database outbox before - acknowledging the state change that produced them. -- Drain the outbox through a small dispatcher process. The dispatcher may call - in-process handlers in the same deployment first, but its storage contract is - database-backed. +- Drain the outbox with the Celery `govoplan.events.dispatch_outbox` task on the + `events` queue. The periodic schedule also retries pending rows. +- The dispatcher invokes Dataflow event ingestion when that capability is + active, then publishes to the process-local bus. - 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` envelope so a future message broker can be added without changing event 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 outbox dispatcher can retry after a crash or timeout. -Recommended first outbox columns: +The current outbox stores: - `event_id`, `event_type`, `module_id` - `correlation_id`, `causation_id` -- `payload`, `occurred_at` -- `available_at`, `attempt_count`, `claimed_at`, `claim_token` -- `processed_at`, `last_error` +- `classification`, serialized event `payload` +- `status`, `attempts`, `next_attempt_at` +- `dispatched_at`, `last_error`, timestamps -Inline `EventBus` handlers are allowed only for non-critical local reactions. -Anything that must survive process failure, restart, package update, or worker -redeployment belongs in the outbox. +Handlers must be idempotent: a worker may complete an external effect and fail +before marking its outbox row dispatched. Anything that must survive process +failure, restart, package update, or worker redeployment requires the outbox +provider and dispatcher. ## Trace IDs diff --git a/docs/THROTTLING.md b/docs/THROTTLING.md index 9f11c52..a474e24 100644 --- a/docs/THROTTLING.md +++ b/docs/THROTTLING.md @@ -3,10 +3,13 @@ Core exposes `govoplan_core.core.throttling` for security-sensitive endpoints that need bounded fixed-window counters. Subjects are SHA-256 hashed before they become store keys. A configured Redis instance provides atomic counters shared -across API workers; development, a missing Redis configuration, and temporary -Redis outages use a bounded process-local fallback. When Redis fails, local -attempts are still mirrored so losing the distributed store does not reset the -active worker's protection window. +across API workers. Development and temporary Redis outages use a bounded +process-local fallback. Production-like startup rejects an enabled login +throttle without `REDIS_URL` unless +`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 namespace, a subject and a positive limit. They must call `check` before an diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index d915660..d54bebb 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -39,6 +39,22 @@ class DeltaCollectionResponse(BaseModel): 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): model_config = ConfigDict(extra="forbid") diff --git a/src/govoplan_core/audit/logging.py b/src/govoplan_core/audit/logging.py index 30f4563..de1b0ff 100644 --- a/src/govoplan_core/audit/logging.py +++ b/src/govoplan_core/audit/logging.py @@ -16,7 +16,7 @@ from govoplan_core.core.events import ( PlatformEvent, current_event_trace, normalize_trace_id, - publish_platform_event, + emit_platform_event, ) from govoplan_core.core.runtime import get_registry 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) - publish_platform_event( + emit_platform_event( + session, PlatformEvent( type=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, 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: session.commit() return item diff --git a/src/govoplan_core/auth/__init__.py b/src/govoplan_core/auth/__init__.py index 67a0fd8..c844c60 100644 --- a/src/govoplan_core/auth/__init__.py +++ b/src/govoplan_core/auth/__init__.py @@ -118,7 +118,7 @@ def _registry_from_request(request: Request) -> PlatformRegistry | None: def _api_principal_provider_from_request(request: Request) -> ApiPrincipalProvider: registry = _registry_from_request(request) 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) if not isinstance(capability, ApiPrincipalProvider): raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid auth provider capability") diff --git a/src/govoplan_core/celery_app.py b/src/govoplan_core/celery_app.py index c6d663a..4f4a44c 100644 --- a/src/govoplan_core/celery_app.py +++ b/src/govoplan_core/celery_app.py @@ -8,6 +8,12 @@ from govoplan_core.core.dataflows import ( CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER, 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.modules import ModuleContext from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider @@ -34,6 +40,7 @@ celery.conf.update( "govoplan.notifications.deliver_pending": {"queue": "notifications"}, "govoplan.calendar.dispatch_outbox": {"queue": "calendar"}, "govoplan.dataflow.dispatch_triggers": {"queue": "dataflow"}, + "govoplan.events.dispatch_outbox": {"queue": "events"}, }, worker_prefetch_multiplier=1, task_acks_late=True, @@ -49,6 +56,11 @@ celery.conf.update( "schedule": 60.0, "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 -def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None: - registry = _platform_registry() +def _dataflow_trigger_dispatcher( + registry: PlatformRegistry | None = None, +) -> DataflowTriggerDispatcher | None: + registry = registry or _platform_registry() if not registry.has_capability(CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER): return None capability = registry.require_capability( @@ -108,6 +122,18 @@ def _dataflow_trigger_dispatcher() -> DataflowTriggerDispatcher | None: 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) def send_email(self, job_id: str): """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)) session.commit() return result + + +@celery.task( + name="govoplan.events.dispatch_outbox", + bind=True, + max_retries=0, +) +def dispatch_platform_events(self, limit: int = 100): + """Deliver committed platform events 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 diff --git a/src/govoplan_core/core/access.py b/src/govoplan_core/core/access.py index 2d57e16..1b66361 100644 --- a/src/govoplan_core/core/access.py +++ b/src/govoplan_core/core/access.py @@ -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"] AccessSubjectKind = Literal[ "identity", diff --git a/src/govoplan_core/core/change_sequence.py b/src/govoplan_core/core/change_sequence.py index fadd0d1..54102f9 100644 --- a/src/govoplan_core/core/change_sequence.py +++ b/src/govoplan_core/core/change_sequence.py @@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func 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 WATERMARK_PREFIX = "seq:" @@ -96,13 +96,14 @@ def record_change( payload=payload or {}, ) session.add(entry) - _publish_change_event(entry) + _publish_change_event(session, 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) - publish_platform_event( + emit_platform_event( + session, PlatformEvent( type=event_type, module_id=entry.module_id, diff --git a/src/govoplan_core/core/definition_graphs.py b/src/govoplan_core/core/definition_graphs.py index 3344d23..4439800 100644 --- a/src/govoplan_core/core/definition_graphs.py +++ b/src/govoplan_core/core/definition_graphs.py @@ -111,96 +111,189 @@ def validate_definition_graph( nodes: Sequence[DefinitionNode], edges: Sequence[DefinitionEdge], ) -> tuple[DefinitionDiagnostic, ...]: - diagnostics: list[DefinitionDiagnostic] = [] - constraints = library.constraints if not nodes: 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( _error( "graph.node_limit", f"Definitions are limited to {constraints.max_nodes:,} nodes.", ) ) - if len(edges) > constraints.max_edges: + if edge_count > constraints.max_edges: diagnostics.append( _error( "graph.edge_limit", 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.")) if len({edge.id for edge in edges}) != len(edges): diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique.")) + return diagnostics + +def _validate_definition_edges( + *, + edges: Sequence[DefinitionEdge], + node_by_id: Mapping[str, DefinitionNode], + definitions: Mapping[str, DefinitionNodeType], +) -> tuple[ + dict[str, list[DefinitionEdge]], + dict[str, set[str]], + list[DefinitionEdge], + list[DefinitionDiagnostic], +]: incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id} undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id} topology_edges: list[DefinitionEdge] = [] + diagnostics: list[DefinitionDiagnostic] = [] for edge in edges: - if edge.source not in node_by_id: - diagnostics.append( - _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, - ) - ) + structural_error = _edge_structure_diagnostic(edge, node_by_id) + if structural_error is not None: + diagnostics.append(structural_error) continue topology_edges.append(edge) - source_definition = library.node_type(node_by_id[edge.source].type) - target_definition = library.node_type(node_by_id[edge.target].type) - if source_definition is not None and edge.source_port not in { - port.id for port in source_definition.output_ports - }: - diagnostics.append( - _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, - ) - ) + port_error = _edge_port_diagnostic( + edge, + source_definition=definitions.get(node_by_id[edge.source].type), + target_definition=definitions.get(node_by_id[edge.target].type), + ) + if port_error is not None: + diagnostics.append(port_error) continue incoming[edge.target].append(edge) undirected[edge.source].add(edge.target) undirected[edge.target].add(edge.source) + return incoming, undirected, topology_edges, diagnostics + +def _edge_structure_diagnostic( + edge: DefinitionEdge, + node_by_id: Mapping[str, DefinitionNode], +) -> DefinitionDiagnostic | None: + if edge.source not in node_by_id: + return _error( + "edge.unknown_source", + f"Edge {edge.id!r} references an unknown source node.", + ) + if edge.target not in node_by_id: + return _error( + "edge.unknown_target", + f"Edge {edge.id!r} references an unknown target node.", + ) + if edge.source == edge.target: + return _error( + "edge.self_reference", + "A node cannot connect to itself.", + node_id=edge.source, + ) + return None + + +def _edge_port_diagnostic( + edge: DefinitionEdge, + *, + source_definition: DefinitionNodeType | None, + target_definition: DefinitionNodeType | None, +) -> DefinitionDiagnostic | None: + if source_definition is not None and edge.source_port not in { + port.id for port in source_definition.output_ports + }: + return _error( + "edge.unknown_source_port", + f"Node {edge.source!r} has no output port {edge.source_port!r}.", + node_id=edge.source, + ) + if target_definition is not None and edge.target_port not in { + port.id for port in target_definition.input_ports + }: + return _error( + "edge.unknown_target_port", + f"Node {edge.target!r} has no input port {edge.target_port!r}.", + node_id=edge.target, + ) + return None + + +def _node_port_diagnostics( + *, + nodes: Sequence[DefinitionNode], + definitions: Mapping[str, DefinitionNodeType], + incoming: Mapping[str, Sequence[DefinitionEdge]], + library_id: str, +) -> list[DefinitionDiagnostic]: + diagnostics: list[DefinitionDiagnostic] = [] for node in nodes: - definition = library.node_type(node.type) + definition = definitions.get(node.type) if definition is None: diagnostics.append( _error( "node.unsupported_type", - f"Node type {node.type!r} is not in the {library.id!r} library.", + f"Node type {node.type!r} is not in the {library_id!r} library.", node_id=node.id, field="type", ) @@ -231,10 +324,19 @@ def validate_definition_graph( node_id=node.id, ) ) + return diagnostics + +def _node_count_diagnostics( + *, + nodes: Sequence[DefinitionNode], + definitions: Mapping[str, DefinitionNodeType], + constraints: DefinitionGraphConstraints, +) -> list[DefinitionDiagnostic]: + diagnostics: list[DefinitionDiagnostic] = [] for count_constraint in constraints.node_counts: count = sum( - count_constraint.matches(node, library.node_type(node.type)) + count_constraint.matches(node, definitions.get(node.type)) for node in nodes ) 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) if cyclic and not constraints.allow_cycles: diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph.")) @@ -269,7 +382,7 @@ def validate_definition_graph( diagnostics.append( _error("graph.disconnected", "Every node must belong to one connected definition.") ) - return tuple(diagnostics) + return diagnostics def definition_topological_order( diff --git a/src/govoplan_core/core/events.py b/src/govoplan_core/core/events.py index 412f5f7..f7b38e8 100644 --- a/src/govoplan_core/core/events.py +++ b/src/govoplan_core/core/events.py @@ -7,11 +7,16 @@ from contextvars import ContextVar from dataclasses import dataclass, field from datetime import datetime, timezone import re -from typing import Any, Literal +from typing import Any, Literal, Protocol, runtime_checkable 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}$") +_PENDING_EVENTS_KEY = "govoplan.pending_platform_events" +CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox" def new_event_id() -> str: @@ -102,6 +107,21 @@ class PlatformEvent: 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: return _current_trace.get() @@ -189,5 +209,85 @@ def publish_platform_event(event: PlatformEvent) -> None: 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]: return {key: item for key, item in value.items() if item is not None} diff --git a/src/govoplan_core/core/idm.py b/src/govoplan_core/core/idm.py index 88d3a9b..934136a 100644 --- a/src/govoplan_core/core/idm.py +++ b/src/govoplan_core/core/idm.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime from typing import Literal, Protocol, runtime_checkable @@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable IDM_MODULE_ID = "idm" CAPABILITY_IDM_DIRECTORY = "idm.directory" +CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments" IdmStatus = Literal["active", "inactive", "suspended"] OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"] @@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef: 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 class IdmDirectory(Protocol): def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None: @@ -40,6 +53,7 @@ class IdmDirectory(Protocol): identity_id: str, *, tenant_id: str | None = None, + effective_at: datetime | None = None, ) -> Sequence[OrganizationFunctionAssignmentRef]: ... @@ -48,5 +62,47 @@ class IdmDirectory(Protocol): account_id: str, *, tenant_id: str | None = None, + effective_at: datetime | None = None, ) -> 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]: + ... diff --git a/src/govoplan_core/core/lifecycle.py b/src/govoplan_core/core/lifecycle.py index 08850ed..e0c2814 100644 --- a/src/govoplan_core/core/lifecycle.py +++ b/src/govoplan_core/core/lifecycle.py @@ -67,6 +67,17 @@ class ModuleLifecycleManager: def mounted_module_ids(self) -> tuple[str, ...]: 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( self, requested_enabled: Sequence[str], diff --git a/src/govoplan_core/core/module_management.py b/src/govoplan_core/core/module_management.py index e7728cb..b082239 100644 --- a/src/govoplan_core/core/module_management.py +++ b/src/govoplan_core/core/module_management.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from govoplan_core.admin.models import SystemSettings 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.modules import ModuleManifest 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" INSTALL_PLAN_KEY = "install_plan" -REQUIRED_PLATFORM_MODULES = ("access",) -PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin") +REQUIRED_PLATFORM_MODULES: tuple[str, ...] = () +PROTECTED_MODULES = ("admin",) INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall") INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked") INSTALL_PLAN_SOURCES = ("manual", "catalog") @@ -109,6 +110,7 @@ def startup_candidate_module_ids( if desired is not None: candidates.extend(str(item).strip() for item in desired if str(item).strip()) candidates.extend(REQUIRED_PLATFORM_MODULES) + candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values()) if "admin" in fallback: candidates.append("admin") return tuple(dict.fromkeys(candidates)) @@ -404,46 +406,148 @@ def plan_desired_enabled_modules( *, protected_modules: Iterable[str] = PROTECTED_MODULES, ) -> ModuleStatePlan: - requested = {str(item).strip() for item in requested_enabled if str(item).strip()} - protected = {str(item).strip() for item in protected_modules if str(item).strip()} - requested.update(protected) + requested = _normalized_module_ids(requested_enabled) + requested.update(_normalized_module_ids(protected_modules)) missing = sorted(module_id for module_id in requested if module_id not in available) if missing: raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing)) - added_dependencies: set[str] = set() - visiting: set[str] = set() - 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) + added_dependencies = _expand_enabled_module_closure(requested, available) + ordered = _order_enabled_modules(requested, available, added_dependencies) return ModuleStatePlan( enabled_modules=tuple(dict.fromkeys(ordered)), 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, ...]]: dependents: dict[str, list[str]] = {module_id: [] for module_id in available} for manifest in available.values(): diff --git a/src/govoplan_core/core/postbox.py b/src/govoplan_core/core/postbox.py new file mode 100644 index 0000000..264cc76 --- /dev/null +++ b/src/govoplan_core/core/postbox.py @@ -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 diff --git a/src/govoplan_core/core/references.py b/src/govoplan_core/core/references.py new file mode 100644 index 0000000..989684e --- /dev/null +++ b/src/govoplan_core/core/references.py @@ -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", +] diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 5dcb98b..7a6afc0 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -514,59 +514,86 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None: if frontend is None: return root_id = module_view_surface_id(manifest.id) - _validate_view_surface_id(manifest.id, root_id) known_ids = {root_id} + _validate_view_surface_id(manifest.id, root_id) for item in frontend.nav_items: surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path) - _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) + _register_view_surface_id(manifest.id, surface_id, known_ids) for route in (*frontend.routes, *frontend.settings_routes): surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path) - _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) + _register_view_surface_id(manifest.id, surface_id, known_ids) for surface in frontend.view_surfaces: - if surface.module_id != manifest.id: - raise RegistryError( - 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) + _validate_declared_view_surface(manifest.id, surface) + _register_view_surface_id(manifest.id, surface.id, known_ids) for surface in frontend.view_surfaces: - 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 {manifest.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 {manifest.id!r} " - "must be visible by default" - ) + _validate_view_surface_definition( + manifest.id, + surface, + root_id=root_id, + known_ids=known_ids, + ) parent_by_id = { surface.id: surface.parent_id or root_id for surface in frontend.view_surfaces } + _validate_view_surface_hierarchy(manifest.id, parent_by_id) + + +def _register_view_surface_id( + module_id: str, + surface_id: str, + known_ids: set[str], +) -> None: + _validate_view_surface_id(module_id, surface_id) + if surface_id in known_ids: + raise RegistryError( + f"Duplicate view surface id {surface_id!r} in module {module_id!r}" + ) + known_ids.add(surface_id) + + +def _validate_declared_view_surface( + module_id: str, + surface: ViewSurface, +) -> None: + if surface.module_id != module_id: + raise RegistryError( + f"View surface {surface.id!r} belongs to {surface.module_id!r}, " + f"not module {module_id!r}" + ) + + +def _validate_view_surface_definition( + module_id: str, + surface: ViewSurface, + *, + root_id: str, + known_ids: set[str], +) -> None: + parent_id = surface.parent_id or root_id + if parent_id not in known_ids: + raise RegistryError( + f"View surface {surface.id!r} in module {module_id!r} " + f"references unknown parent {parent_id!r}" + ) + if surface.required and not surface.default_visible: + raise RegistryError( + f"Required view surface {surface.id!r} in module {module_id!r} " + "must be visible by default" + ) + + +def _validate_view_surface_hierarchy( + module_id: str, + parent_by_id: Mapping[str, str], +) -> None: for surface_id in parent_by_id: path: set[str] = set() current_id: str | None = surface_id while current_id in parent_by_id: if current_id in path: raise RegistryError( - f"View surface hierarchy in module {manifest.id!r} " + f"View surface hierarchy in module {module_id!r} " f"contains a cycle at {current_id!r}" ) path.add(current_id) diff --git a/src/govoplan_core/core/tabular_sources.py b/src/govoplan_core/core/tabular_sources.py index 7d83dbc..ccfd0b5 100644 --- a/src/govoplan_core/core/tabular_sources.py +++ b/src/govoplan_core/core/tabular_sources.py @@ -41,39 +41,73 @@ def parse_tabular_csv( raise TabularSourceValidationError("CSV delimiter must be one character.") try: 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): - 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.") + original_headers, normalized_headers = _csv_headers(reader.fieldnames) rows: list[dict[str, object]] = [] for row in reader: - extras = row.get(None) - if extras and any(str(value or "").strip() for value in extras): - 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): + _validate_csv_row_shape(row) + if _csv_row_is_empty(row, original_headers): continue if len(rows) >= max_rows: raise TabularSourceValidationError( f"CSV snapshots are limited to {max_rows:,} rows." ) - rows.append( - { - normalized_headers[position]: _csv_scalar(row.get(original)) - for position, original in enumerate(reader.fieldnames) - } - ) + rows.append(_csv_row(row, original_headers, normalized_headers)) return tuple(rows) except csv.Error as exc: raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc +def _csv_headers( + fieldnames: Sequence[str | None] | None, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + if not fieldnames: + raise TabularSourceValidationError("CSV input requires a non-empty header row.") + original = tuple(str(name or "") for name in fieldnames) + normalized = tuple( + name.strip().lstrip("\ufeff") if position == 0 else name.strip() + for position, name in enumerate(original) + ) + if any(not name for name in normalized): + raise TabularSourceValidationError("CSV input requires a non-empty header row.") + if len(set(normalized)) != len(normalized): + raise TabularSourceValidationError("CSV column names must be unique.") + return original, normalized + + +def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None: + extras = row.get(None) + if isinstance(extras, list) and any(str(value or "").strip() for value in extras): + raise TabularSourceValidationError( + "A CSV row contains more values than the header defines." + ) + + +def _csv_row_is_empty( + row: Mapping[str | None, str | list[str] | None], + headers: Sequence[str], +) -> bool: + return all( + value is None or isinstance(value, list) or not value.strip() + for value in (row.get(header) for header in headers) + ) + + +def _csv_row( + row: Mapping[str | None, str | list[str] | None], + original_headers: Sequence[str], + normalized_headers: Sequence[str], +) -> dict[str, object]: + return { + normalized: _csv_scalar(value if isinstance(value, str) else None) + for original, normalized in zip( + original_headers, + normalized_headers, + strict=True, + ) + for value in (row.get(original),) + } + + def _csv_scalar(value: str | None) -> object: if value is None: return None diff --git a/src/govoplan_core/db/session.py b/src/govoplan_core/db/session.py index 07e312c..6b08159 100644 --- a/src/govoplan_core/db/session.py +++ b/src/govoplan_core/db/session.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Generator, Mapping +import os from typing import Any from sqlalchemy import create_engine @@ -10,6 +11,14 @@ from sqlalchemy.orm import Session, sessionmaker 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]: 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)) if 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)) +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: def __init__(self, database_url: str, *, engine: Engine | None = None) -> None: self.database_url = database_url diff --git a/src/govoplan_core/server/fastapi.py b/src/govoplan_core/server/fastapi.py index 712d81a..467111c 100644 --- a/src/govoplan_core/server/fastapi.py +++ b/src/govoplan_core/server/fastapi.py @@ -72,8 +72,19 @@ def _validate_production_startup() -> None: "off", } 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( - "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" ) diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index e407eaa..fdb5b0f 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -1,10 +1,11 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.exc import SQLAlchemyError from govoplan_core.admin.models import SystemSettings 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.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute 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") - def modules(request: Request): + def modules( + request: Request, + _principal: ApiPrincipal = Depends(get_api_principal), + ): registry = _registry(request) return { "modules": [ @@ -190,7 +194,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter: } @router.get("/permissions") - def permissions(request: Request): + def permissions( + request: Request, + _principal: ApiPrincipal = Depends(get_api_principal), + ): registry = _registry(request) return { "permissions": [ @@ -210,7 +217,10 @@ def create_platform_router(settings: object | None = None) -> APIRouter: } @router.get("/navigation") - def navigation(request: Request): + def navigation( + request: Request, + _principal: ApiPrincipal = Depends(get_api_principal), + ): registry = _registry(request) return { "items": [_nav_item_payload(item) for item in registry.nav_items()] diff --git a/src/govoplan_core/server/registry.py b/src/govoplan_core/server/registry.py index 84c0430..25a1a9d 100644 --- a/src/govoplan_core/server/registry.py +++ b/src/govoplan_core/server/registry.py @@ -3,6 +3,9 @@ from __future__ import annotations from collections.abc import Callable, Iterable, Sequence import importlib +from govoplan_core.core.access import ( + DEFAULT_CAPABILITY_PROVIDERS, +) from govoplan_core.core.discovery import discover_module_manifests from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.registry import PlatformRegistry, RegistryError @@ -15,7 +18,6 @@ _BUILTIN_MANIFESTS = { "tenancy": "govoplan_tenancy.backend.manifest:get_manifest", } - def parse_enabled_modules(value: str | Iterable[str]) -> list[str]: if isinstance(value, str): items = [item.strip() for item in value.split(",")] @@ -57,22 +59,68 @@ def available_module_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) - if "access" not in requested: - requested.insert(0, "access") - - available = available_module_manifests(manifest_factories, enabled_modules=requested) + available = _resolve_required_manifests( + requested, + manifest_factories=manifest_factories, + ) registry = PlatformRegistry() - for module_id in requested: - manifest = available.get(module_id) - if manifest is None: - raise RegistryError(f"Enabled module is not available: {module_id}") + for module_id, manifest in available.items(): registry.register(manifest) registry.validate() 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: target = _BUILTIN_MANIFESTS[module_id] module_name, function_name = validate_object_path(target) diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index b2dee97..5ce8b20 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -6,18 +6,46 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=None, extra="ignore") 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( default="postgresql+psycopg://govoplan_dev@127.0.0.1:5432/govoplan_dev", 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_db_schema: str | None = Field(default=None, alias="ACCESS_DB_SCHEMA") access_table_prefix: str = Field(default="access_", alias="ACCESS_TABLE_PREFIX") 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_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") redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") 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_domain: str | None = Field(default=None, alias="AUTH_COOKIE_DOMAIN") 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_identity_limit: int = Field( default=10, diff --git a/src/govoplan_core/tenancy/service.py b/src/govoplan_core/tenancy/service.py index 5655c96..1486068 100644 --- a/src/govoplan_core/tenancy/service.py +++ b/src/govoplan_core/tenancy/service.py @@ -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.") -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() if registry is None or not hasattr(registry, "tenant_summary_providers"): return {} 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) counts.update({str(key): int(value) for key, value in provided.items()}) return counts @@ -67,17 +79,27 @@ def _access_administration() -> AccessAdministration | None: return capability -def tenant_counts(session: Session, tenant_id: str) -> dict[str, int]: - module_counts = _tenant_module_counts(session, tenant_id) +def tenant_counts( + 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_counts = access_administration.tenant_counts(session, tenant_id) if access_administration is not None else {} return { + **module_counts, "users": int(access_counts.get("users", 0)), "active_users": int(access_counts.get("active_users", 0)), "groups": int(access_counts.get("groups", 0)), - "campaigns": module_counts.get("campaigns", 0), - "files": module_counts.get("files", 0), + "campaigns": int(module_counts.get("campaigns", 0)), + "files": int(module_counts.get("files", 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))), } diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index 6a69411..1b4e97e 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -25,6 +25,7 @@ from govoplan_core.core.access import ( CAPABILITY_AUDIT_RECORDER, CAPABILITY_AUDIT_RETENTION, CAPABILITY_AUDIT_SINK, + CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_TENANCY_TENANT_RESOLVER, AccessDecision, AccessAdministration, @@ -1082,16 +1083,62 @@ class AccessContractTests(unittest.TestCase): def get_organization_function_assignment(self, requested_assignment_id: str): 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): return (assignment,) 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): return (assignment,) 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): def get_organization_unit(self, requested_organization_unit_id: str): if requested_organization_unit_id != organization_unit_id: @@ -1317,7 +1364,7 @@ class AccessContractTests(unittest.TestCase): clear_runtime() 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-")) try: account_id = "account-capability" @@ -1346,6 +1393,31 @@ class AccessContractTests(unittest.TestCase): def explain_scope(self, principal: PrincipalRef, required_scope: str) -> AccessDecision: 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.get("/capability-principal") @@ -1365,6 +1437,7 @@ class AccessContractTests(unittest.TestCase): capability_factories={ CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: lambda context: CapabilityPrincipalResolver(), 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", version="test", settings=SimpleNamespace(database_url=f"sqlite:///{root / 'test.db'}"), - enabled_modules=(), + enabled_modules=(ACCESS_MODULE_ID,), manifest_factories=(access_manifest,), base_routers=(router,), post_module_routers=(), diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 8b4b96a..97d79ce 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -52,6 +52,7 @@ engine = _database.engine SessionLocal = _database.SessionLocal from govoplan_core.server.app import app +from govoplan_campaign.backend.sending.execution import SNAPSHOT_VERSION class ApiSmokeTests(unittest.TestCase): @@ -764,7 +765,7 @@ class ApiSmokeTests(unittest.TestCase): assert version is not None self.assertEqual(version.raw_json["server"], {"mail_profile_id": profile_id}) 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.assertNotIn("smtp", 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) self.assertEqual(deleted.status_code, 200, deleted.text) self.assertFalse(deleted.json()["is_active"]) - self.assertFalse(deleted.json()["smtp_password_configured"]) - self.assertFalse(deleted.json()["imap_password_configured"]) + # Reusable hierarchy credentials outlive a deactivated profile. The + # 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 @@ -864,7 +867,10 @@ class ApiSmokeTests(unittest.TestCase): self.assertIsNotNone(version) assert version is not None 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.assertNotIn("smtp", 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]["last_error"], - "SMTP delivery outcome requires operator reconciliation.", + "Delivery outcome requires operator reconciliation.", ) self.assertEqual(delta_payload["counts"]["send"]["outcome_unknown"], 1) 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_mail.backend.db.models import MailServerProfile + from govoplan_mail.backend.db.models import MailServerEndpoint, MailServerProfile with SessionLocal() as session: version = session.get(CampaignVersion, version_id) @@ -4180,7 +4186,7 @@ class ApiSmokeTests(unittest.TestCase): assert version is not None snapshot = version.execution_snapshot 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["queueable_job_count"], 1) self.assertTrue(snapshot["job_manifest_sha256"]) @@ -4195,9 +4201,17 @@ class ApiSmokeTests(unittest.TestCase): profile = session.get(MailServerProfile, snapshot["mail_profile_id"]) self.assertIsNotNone(profile) assert profile is not None - profile.smtp_config = {**profile.smtp_config, "host": "smtp.example.invalid"} - profile.smtp_transport_revision = "manually-rotated-transport-revision" - session.add(profile) + smtp_server_id = snapshot.get("smtp_server_id") + self.assertTrue(smtp_server_id) + 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() sent = self.client.post( diff --git a/tests/test_change_sequence.py b/tests/test_change_sequence.py index 34d9f83..430ea4d 100644 --- a/tests/test_change_sequence.py +++ b/tests/test_change_sequence.py @@ -19,10 +19,25 @@ from govoplan_core.core.change_sequence import ( sequence_watermark_is_expired, ) 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 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: engine = create_engine("sqlite:///:memory:") SessionLocal = sessionmaker(bind=engine) @@ -93,6 +108,8 @@ class ChangeSequenceTests(unittest.TestCase): actor_id="user-1", payload={"scope_type": "tenant"}, ) + self.assertEqual([], seen) + session.commit() self.assertEqual([case[-1] for case in cases], [event.type for event in seen]) event = seen[1] @@ -105,6 +122,36 @@ class ChangeSequenceTests(unittest.TestCase): finally: 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: engine = create_engine("sqlite:///:memory:") SessionLocal = sessionmaker(bind=engine) diff --git a/tests/test_conditional_requests.py b/tests/test_conditional_requests.py index cb89214..6a47dee 100644 --- a/tests/test_conditional_requests.py +++ b/tests/test_conditional_requests.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Response from fastapi.responses import PlainTextResponse from fastapi.testclient import TestClient +from govoplan_core.auth import get_api_principal from govoplan_core.core.registry import PlatformRegistry from govoplan_core.server.fastapi import create_govoplan_app from govoplan_core.server.platform import create_platform_router @@ -85,6 +86,7 @@ class ConditionalRequestTests(unittest.TestCase): registry=PlatformRegistry(), api_router=api_router, ) + app.dependency_overrides[get_api_principal] = lambda: object() with TestClient(app) as client: for path in ( diff --git a/tests/test_core_events.py b/tests/test_core_events.py index b661d03..1d8886d 100644 --- a/tests/test_core_events.py +++ b/tests/test_core_events.py @@ -8,6 +8,8 @@ from unittest.mock import patch from fastapi import APIRouter, Request 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.core.events import ( @@ -17,6 +19,7 @@ from govoplan_core.core.events import ( EventTenantRef, PlatformEvent, current_event_trace, + emit_platform_event, event_bus_context, event_context, normalize_trace_id, @@ -31,13 +34,60 @@ from tests.db_isolation import temporary_database def _configure_audit_runtime() -> None: - registry = build_platform_registry(("audit",)) + registry = build_platform_registry(("access", "audit")) context = ModuleContext(registry=registry, settings=object()) registry.configure_capability_context(context) configure_runtime(context) 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: bus = EventBus() seen: list[PlatformEvent] = [] @@ -267,6 +317,11 @@ class CoreEventTests(unittest.TestCase): object_id="user-2", 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"] self.assertEqual(1, len(action_events)) @@ -315,6 +370,11 @@ class CoreEventTests(unittest.TestCase): object_type="demo", 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} self.assertEqual(cases, by_type) diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index ebef5db..d8b13bb 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -131,8 +131,15 @@ class DatabaseMigrationTests(unittest.TestCase): with tempfile.TemporaryDirectory(prefix="govoplan-core-baseline-test-") as directory: database = Path(directory) / "core.db" 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) try: @@ -157,7 +164,13 @@ class DatabaseMigrationTests(unittest.TestCase): ).scalar_one() 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.assertNotIn("tenancy_tenants", tables) self.assertIn("access_accounts", tables) diff --git a/tests/test_identity_organization_contracts.py b/tests/test_identity_organization_contracts.py index 17fbfa2..9d1885e 100644 --- a/tests/test_identity_organization_contracts.py +++ b/tests/test_identity_organization_contracts.py @@ -119,20 +119,66 @@ class _FakeIdmDirectory: def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | 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: return () if tenant_id is not None and tenant_id != self.assignment.tenant_id: return () 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: return () if tenant_id is not None and tenant_id != self.assignment.tenant_id: return () 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): def test_protocol_shapes_match_reference_implementations(self) -> None: diff --git a/tests/test_module_system.py b/tests/test_module_system.py index 0b1665e..b855a19 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -230,7 +230,10 @@ class ModuleSystemTests(unittest.TestCase): self.assertTrue({"access", "admin", "tenancy", "policy", "audit", "dashboard", "files", "mail", "campaigns", "docs", "ops"}.issubset(manifests)) self.assertEqual(manifests["campaigns"].dependencies, ()) 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.assertTrue(manifests["dashboard"].required_capabilities) self.assertEqual(manifests["docs"].dependencies, ()) @@ -587,7 +590,7 @@ class ModuleSystemTests(unittest.TestCase): def test_enabled_module_permutations_register_expected_routes(self) -> None: cases = ( - ("core_only", (), {"access"}, set()), + ("core_only", (), set(), set()), ("files_only", ("files",), {"access", "files"}, {"/api/v1/files"}), ("mail_only", ("mail",), {"access", "mail"}, {"/api/v1/mail"}), ("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) route_paths = _route_paths(app) self.assertIn("/api/v1/platform/modules", route_paths) - self.assertIn("/api/v1/auth/login", route_paths) - self.assertIn("/api/v1/admin/users", route_paths) + self.assertEqual( + "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"): 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}") @@ -613,12 +622,19 @@ class ModuleSystemTests(unittest.TestCase): self.assertEqual(health.status_code, 200, health.text) self.assertEqual({"status": "ok"}, health.json()) response = client.get("/api/v1/platform/modules") - self.assertEqual(response.status_code, 200, response.text) - payload_modules = {item["id"] for item in response.json()["modules"]} + self.assertEqual( + 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) 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) self.assertIn("/api/v1/admin/users", 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"): 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: registry = PlatformRegistry() registry.register(ModuleManifest( @@ -903,7 +950,7 @@ finally: plan = plan_desired_enabled_modules(("campaigns",), manifests) 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: root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-", dir=_TEST_ROOT)) @@ -3300,7 +3347,10 @@ finally: "version_min": "0.2.0", "version_max_exclusive": "0.3.0", }, 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.assertIn("migration", modules["files"]["migration_notes"].lower()) self.assertEqual("0.1.9", modules["files"]["version"]) @@ -3946,8 +3996,10 @@ finally: with TestClient(app) as client: response = client.get("/api/v1/platform/modules") - self.assertEqual(response.status_code, 200, response.text) - payload_modules = {item["id"] for item in response.json()["modules"]} + self.assertEqual(response.status_code, 401, response.text) + payload_modules = { + item.id for item in app.state.govoplan_registry.manifests() + } self.assertEqual({"access", "files"}, payload_modules) def test_create_app_ignores_saved_desired_modules_that_are_not_installed(self) -> None: @@ -3979,8 +4031,10 @@ finally: with TestClient(app) as client: response = client.get("/api/v1/platform/modules") - self.assertEqual(response.status_code, 200, response.text) - payload_modules = {item["id"] for item in response.json()["modules"]} + self.assertEqual(response.status_code, 401, response.text) + payload_modules = { + item.id for item in app.state.govoplan_registry.manifests() + } self.assertEqual({"access", "files"}, payload_modules) 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: response = client.get("/api/v1/platform/modules") - self.assertEqual(response.status_code, 200, response.text) - payload_modules = {item["id"] for item in response.json()["modules"]} + self.assertEqual(response.status_code, 401, response.text) + payload_modules = { + item.id for item in app.state.govoplan_registry.manifests() + } self.assertEqual({"access", "admin", "files"}, payload_modules) def test_module_lifecycle_enables_and_disables_routes_without_restart(self) -> None: @@ -4023,27 +4079,37 @@ finally: with TestClient(app) as client: response = client.get("/api/v1/platform/modules") - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]}) + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual((), lifecycle.active_module_ids()) self.assertFalse("/api/v1/files" in _route_paths(app)) 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") - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual({"access", "files"}, {item["id"] for item in response.json()["modules"]}) + self.assertEqual(response.status_code, 401, response.text) + self.assertEqual(("access", "files"), lifecycle.active_module_ids()) self.assertTrue("/api/v1/files" in _route_paths(app)) self.assertEqual(401, client.get("/api/v1/files").status_code) 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") - self.assertEqual(response.status_code, 200, response.text) - self.assertEqual({"access"}, {item["id"] for item in response.json()["modules"]}) + self.assertEqual(response.status_code, 503, response.text) + self.assertEqual((), lifecycle.active_module_ids()) disabled_response = client.get("/api/v1/files") self.assertEqual(404, disabled_response.status_code) 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: cases = ( @@ -4073,10 +4139,9 @@ finally: def test_module_migrations_are_registered_only_for_enabled_modules(self) -> None: core_plan = migration_metadata_plan(build_platform_registry(())) - self.assertEqual(1, len(core_plan.script_locations)) - self.assertTrue(core_plan.script_locations[0].endswith("govoplan_access/backend/migrations/versions")) - self.assertEqual(1, len(core_plan.metadata)) - self.assertEqual(("access",), tuple(item.module_id for item in core_plan.modules)) + self.assertEqual((), core_plan.script_locations) + self.assertEqual((), core_plan.metadata) + self.assertEqual((), core_plan.modules) files_plan = migration_metadata_plan(build_platform_registry(("files",))) self.assertEqual(2, len(files_plan.script_locations)) diff --git a/tests/test_postbox_contract.py b/tests/test_postbox_contract.py new file mode 100644 index 0000000..1f46b29 --- /dev/null +++ b/tests/test_postbox_contract.py @@ -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() diff --git a/tests/test_query_metrics.py b/tests/test_query_metrics.py index 7eecc70..c89fc26 100644 --- a/tests/test_query_metrics.py +++ b/tests/test_query_metrics.py @@ -15,6 +15,55 @@ from govoplan_core.server.fastapi import create_govoplan_app 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: engine = create_database_engine("sqlite:///:memory:") try: diff --git a/tests/test_reference_options.py b/tests/test_reference_options.py new file mode 100644 index 0000000..a60f680 --- /dev/null +++ b/tests/test_reference_options.py @@ -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() diff --git a/tests/test_view_surfaces.py b/tests/test_view_surfaces.py index ae7764c..7c2ea45 100644 --- a/tests/test_view_surfaces.py +++ b/tests/test_view_surfaces.py @@ -5,6 +5,7 @@ import unittest from fastapi import FastAPI from fastapi.testclient import TestClient +from govoplan_core.auth import get_api_principal from govoplan_core.core.modules import ( FrontendModule, FrontendRoute, @@ -218,6 +219,7 @@ class ViewSurfaceContractTests(unittest.TestCase): app = FastAPI() app.state.govoplan_registry = registry app.include_router(create_platform_router(), prefix="/api/v1") + app.dependency_overrides[get_api_principal] = lambda: object() with TestClient(app) as client: response = client.get("/api/v1/platform/modules") diff --git a/tests/test_wheel_runtime.py b/tests/test_wheel_runtime.py index 7a8a507..5050227 100644 --- a/tests/test_wheel_runtime.py +++ b/tests/test_wheel_runtime.py @@ -13,8 +13,6 @@ import unittest class WheelRuntimeTests(unittest.TestCase): def test_built_wheel_contains_and_runs_core_migrations_without_source_checkout(self) -> None: repository_root = Path(__file__).resolve().parents[1] - access_repository = repository_root.parent / "govoplan-access" - self.assertTrue(access_repository.is_dir(), "wheel runtime check requires the mandatory Access repository") with tempfile.TemporaryDirectory(prefix="govoplan-core-wheel-") as directory: temporary_root = Path(directory) wheel_directory = temporary_root / "wheels" @@ -30,21 +28,8 @@ class WheelRuntimeTests(unittest.TestCase): str(repository_root), cwd=temporary_root, ) - self._run( - sys.executable, - "-m", - "pip", - "wheel", - "--no-deps", - "--wheel-dir", - str(wheel_directory), - str(access_repository), - cwd=temporary_root, - ) core_wheels = tuple(wheel_directory.glob("govoplan_core-*.whl")) - access_wheels = tuple(wheel_directory.glob("govoplan_access-*.whl")) self.assertEqual(1, len(core_wheels)) - self.assertEqual(1, len(access_wheels)) virtual_environment = temporary_root / "venv" self._run(sys.executable, "-m", "venv", str(virtual_environment), cwd=temporary_root) @@ -56,7 +41,6 @@ class WheelRuntimeTests(unittest.TestCase): "install", "--no-deps", str(core_wheels[0]), - str(access_wheels[0]), cwd=temporary_root, ) @@ -85,7 +69,7 @@ class WheelRuntimeTests(unittest.TestCase): self.assertNotEqual(repository_root, runtime_root) self.assertTrue((runtime_root / "alembic.ini").is_file()) self.assertTrue((runtime_root / "alembic" / "env.py").is_file()) - self.assertIn("4f2a9c8e7b6d", result["heads"]) + self.assertEqual(["c91f0a72be34"], result["heads"]) self.assertIn("core_scopes", result["tables"]) self.assertIn("core_system_settings", result["tables"]) diff --git a/webui/package-lock.json b/webui/package-lock.json index 694793f..9930e39 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -25,6 +25,7 @@ "@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui", + "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui" @@ -37,7 +38,7 @@ "lucide-react": "^1.23.0", "react": "^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", "typescript": "^5.7.2", "vite": "^6.0.6" @@ -46,7 +47,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" } }, "../../govoplan-access/webui": { @@ -60,7 +61,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -76,7 +77,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -95,7 +96,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -111,7 +112,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -128,7 +129,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -140,7 +141,7 @@ }, "../../govoplan-campaign/webui": { "name": "@govoplan/campaign-webui", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "read-excel-file": "9.2.0" }, @@ -152,7 +153,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -168,7 +169,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -185,7 +186,7 @@ "lucide-react": "^1.23.0", "react": "^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" }, "peerDependenciesMeta": { @@ -202,7 +203,7 @@ "lucide-react": "^1.23.0", "react": "^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" }, "peerDependenciesMeta": { @@ -220,7 +221,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -239,7 +240,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -258,7 +259,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -279,7 +280,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -296,7 +297,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -315,7 +316,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -334,7 +335,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -352,7 +353,23 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, + "../../govoplan-postbox/webui": { + "name": "@govoplan/postbox-webui", + "version": "0.1.1", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": ">=7.18.2 <8" }, "peerDependenciesMeta": { "@govoplan/core-webui": { @@ -369,7 +386,7 @@ "lucide-react": "^1.23.0", "react": "^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", "vite": "^6.0.6" }, @@ -387,7 +404,7 @@ "lucide-react": "^1.23.0", "react": "^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": { @@ -404,7 +421,7 @@ "lucide-react": "^1.23.0", "react": "^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" }, "peerDependenciesMeta": { @@ -1205,6 +1222,10 @@ "resolved": "../../govoplan-policy/webui", "link": true }, + "node_modules/@govoplan/postbox-webui": { + "resolved": "../../govoplan-postbox/webui", + "link": true + }, "node_modules/@govoplan/scheduling-webui": { "resolved": "../../govoplan-scheduling/webui", "link": true @@ -2395,9 +2416,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "dev": true, "license": "MIT", "dependencies": { @@ -2418,13 +2439,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" diff --git a/webui/package.json b/webui/package.json index 15fd266..b62163d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -56,6 +56,7 @@ "@govoplan/organizations-webui": "file:../../govoplan-organizations/webui", "@govoplan/ops-webui": "file:../../govoplan-ops/webui", "@govoplan/policy-webui": "file:../../govoplan-policy/webui", + "@govoplan/postbox-webui": "file:../../govoplan-postbox/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui" @@ -68,7 +69,7 @@ "lucide-react": "^1.23.0", "react": "^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", "typescript": "^5.7.2", "vite": "^6.0.6" @@ -77,7 +78,7 @@ "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" }, "allowScripts": { "esbuild@0.25.12": true diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 81838d0..22c9918 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -18,6 +18,7 @@ const packageByModule = { organizations: "@govoplan/organizations-webui", ops: "@govoplan/ops-webui", policy: "@govoplan/policy-webui", + postbox: "@govoplan/postbox-webui", scheduling: "@govoplan/scheduling-webui", views: "@govoplan/views-webui", workflow: "@govoplan/workflow-webui" @@ -44,13 +45,15 @@ const cases = [ { name: "notifications-only", modules: ["notifications"] }, { name: "organizations-only", modules: ["organizations"] }, { name: "idm-with-organizations", modules: ["organizations", "idm"] }, + { name: "postbox-only", modules: ["postbox"] }, { name: "campaign-only", modules: ["campaigns"] }, + { name: "campaign-with-postbox", modules: ["campaigns", "postbox"] }, { name: "campaign-with-files-no-mail", modules: ["campaigns", "files"] }, { name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] }, { name: "scheduling-only", modules: ["scheduling"] }, { name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] }, { 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; diff --git a/webui/src/api/notificationSummary.ts b/webui/src/api/notificationSummary.ts new file mode 100644 index 0000000..bcc0826 --- /dev/null +++ b/webui/src/api/notificationSummary.ts @@ -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; + snapshot: SharedNotificationSummarySnapshot; + inFlight: Promise | null; + lastFetchedAt: number; + timerId: number | null; + focusListener: () => void; + changeListener: () => void; +}; + +const stores = new Map(); +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 { + 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( + 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(EMPTY_SNAPSHOT); + const activeStore = useRef(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; +} diff --git a/webui/src/components/CredentialEnvelopeManager.tsx b/webui/src/components/CredentialEnvelopeManager.tsx index 6a44f34..3e62328 100644 --- a/webui/src/components/CredentialEnvelopeManager.tsx +++ b/webui/src/components/CredentialEnvelopeManager.tsx @@ -20,10 +20,26 @@ import DismissibleAlert from "./DismissibleAlert"; import FormField from "./FormField"; import LoadingFrame from "./LoadingFrame"; import PasswordField from "./PasswordField"; +import { + ReferenceMultiSelect, + combineReferenceOptionProviders, + customReferenceOption, + staticReferenceOptionProvider, + type ReferenceOption +} from "./ReferenceSelect"; +import { platformModuleReferenceProvider } from "../platform/referenceProviders"; import StatusBadge from "./StatusBadge"; import TableActionGroup from "./table/TableActionGroup"; import ToggleSwitch from "./ToggleSwitch"; import { useUnsavedDraftGuard } from "./UnsavedChangesGuard"; +import { + usePlatformModules, + usePlatformUiCapabilities +} from "../platform/ModuleContext"; +import { usePlatformLanguage } from "../i18n/LanguageContext"; +import type { + CredentialReferenceSelectorsUiCapability +} from "./ReferenceSelect"; export type CredentialEnvelopeTargetOption = { id: string; @@ -31,11 +47,19 @@ export type CredentialEnvelopeTargetOption = { secondary?: string | null; }; +export type CredentialEnvelopeServerOption = { + id: string; + label: string; + secondary?: string | null; + disabled?: boolean; +}; + export type CredentialEnvelopeManagerProps = { settings: ApiSettings; scopeType: Extract; scopeId?: string | null; targetOptions?: CredentialEnvelopeTargetOption[]; + serverOptions?: CredentialEnvelopeServerOption[]; targetLabel?: string; title?: string; canWrite: boolean; @@ -76,10 +100,13 @@ export default function CredentialEnvelopeManager({ scopeType, scopeId, targetOptions = [], + serverOptions = [], targetLabel = "Target", title = "Credential envelopes", canWrite }: CredentialEnvelopeManagerProps) { + const modules = usePlatformModules(); + const { translateText } = usePlatformLanguage(); const [selectedTargetId, setSelectedTargetId] = useState(scopeId ?? targetOptions[0]?.id ?? ""); const [credentials, setCredentials] = useState([]); const [loading, setLoading] = useState(false); @@ -95,6 +122,56 @@ export default function CredentialEnvelopeManager({ : selectedTargetId || null; const scopeReady = scopeType === "system" || scopeType === "tenant" || Boolean(activeScopeId); const draftDirty = Boolean(editing) && credentialDraftKey(draft) !== savedDraftKey; + const referenceCapabilities = + usePlatformUiCapabilities( + "core.credentialReferenceSelectors" + ); + const moduleReferenceOptions = useMemo( + () => 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( + () => 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({ dirty: draftDirty, @@ -436,11 +513,26 @@ export default function CredentialEnvelopeManager({

Empty module or server lists mean every module or server allowed by scope.

- - setDraft({ ...draft, allowedModules: event.target.value })} /> + + setDraft({ ...draft, allowedModules: values.join(", ") })} + provider={moduleReferenceProvider} + aria-label="Credential modules" + placeholder="Add a module" + disabled={saving} + /> - - setDraft({ ...draft, allowedServerRefs: event.target.value })} /> + + setDraft({ ...draft, allowedServerRefs: values.join(", ") })} + provider={serverReferenceProvider} + createCustomOption={(value) => customReferenceOption(value, "server")} + aria-label="Credential servers" + placeholder="Add a server reference" + disabled={saving} + /> setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" /> setDraft({ ...draft, isActive })} label="Active" /> diff --git a/webui/src/components/DashboardWidgetContent.tsx b/webui/src/components/DashboardWidgetContent.tsx new file mode 100644 index 0000000..137cf55 --- /dev/null +++ b/webui/src/components/DashboardWidgetContent.tsx @@ -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 = { + data: T | null; + loading: boolean; + error: string; +}; + +export function useDashboardWidgetData( + load: () => Promise, + refreshKey: number +): DashboardWidgetDataState { + const [state, setState] = useState>({ + 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

{emptyText}

; + } + + return ( +
    + {items.map((item) => { + const content = ( + <> + {item.leading && ( + + {item.leading} + + )} + + + {item.title} + + {item.detail && ( + + {item.detail} + + )} + + {item.meta && ( + + {item.meta} + + )} + {item.trailing && ( + + {item.trailing} + + )} + + ); + return ( +
  • + {item.to ? ( + + {content} + + ) : ( +
    {content}
    + )} +
  • + ); + })} +
+ ); +} diff --git a/webui/src/components/ReferenceSelect.tsx b/webui/src/components/ReferenceSelect.tsx new file mode 100644 index 0000000..3fb44dc --- /dev/null +++ b/webui/src/components/ReferenceSelect.tsx @@ -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; + custom?: boolean; +}; + +export type ReferenceProviderContext = { + limit: number; + selectedValues: readonly string[]; + signal: AbortSignal; +}; + +export type ReferenceOptionProvider = { + search: ( + query: string, + context: ReferenceProviderContext + ) => Promise; + resolve?: ( + values: readonly string[], + context: Omit & { limit?: number } + ) => Promise; +}; + +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( + 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 ( + + 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 ( +
+ {selected.length ? ( +
+ {selected.map((option) => ( +
+ + {option.label} + {referenceOptionDetail(option)} + +
+ ))} +
+ ) : null} + { + 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} + /> +
+ ); +} + +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 + ): Promise { + const results = await Promise.allSettled(providers.map(operation)); + const successful = results.filter( + ( + result + ): result is PromiseFulfilledResult => + 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(); + 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"); +} diff --git a/webui/src/components/SearchableSelect.tsx b/webui/src/components/SearchableSelect.tsx new file mode 100644 index 0000000..d7f980e --- /dev/null +++ b/webui/src/components/SearchableSelect.tsx @@ -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; + +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(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(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) { + 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) { + 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 ( +
+
+
+ {open && !disabled && ( +
+ {visibleResults.map((option, index) => ( + + ))} + {visibleStatus && ( +

+ {translateText(visibleStatus)} +

+ )} +
+ )} + + {translateText(visibleStatus)} + +
+ ); +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 50b7976..0ca7014 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -3,6 +3,7 @@ export * from "./types"; export * from "./api/client"; export * from "./api/auth"; export * from "./api/platform"; +export * from "./api/notificationSummary"; export * from "./api/adminCommon"; export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "./api/mailContracts"; export type { @@ -60,7 +61,7 @@ export { default as ColorPickerField } from "./components/ColorPickerField"; export { default as CredentialPanel, CredentialFields } from "./components/CredentialPanel"; export type { CredentialFieldsProps, CredentialPanelProps, CredentialValues } from "./components/CredentialPanel"; export { default as CredentialEnvelopeManager } from "./components/CredentialEnvelopeManager"; -export type { CredentialEnvelopeManagerProps, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager"; +export type { CredentialEnvelopeManagerProps, CredentialEnvelopeServerOption, CredentialEnvelopeTargetOption } from "./components/CredentialEnvelopeManager"; export { createCredentialEnvelope, deleteCredentialEnvelope, @@ -112,6 +113,34 @@ export { default as PolicySourcePath } from "./components/PolicySourcePath"; export type { PolicySourcePathItem, PolicySourcePathProps } from "./components/PolicySourcePath"; export { default as PolicyLockedHint } 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 type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl"; export { default as SelectionList, SelectionListItem } from "./components/SelectionList"; diff --git a/webui/src/layout/Titlebar.tsx b/webui/src/layout/Titlebar.tsx index 377ba7e..59828d1 100644 --- a/webui/src/layout/Titlebar.tsx +++ b/webui/src/layout/Titlebar.tsx @@ -6,18 +6,13 @@ import LanguageMenu from "./LanguageMenu"; import LoginModal from "../features/auth/LoginModal"; import DismissibleAlert from "../components/DismissibleAlert"; import { useGuardedNavigate, useUnsavedChanges } from "../components/UnsavedChangesGuard"; -import { apiFetch, isApiError } from "../api/client"; +import { useSharedNotificationSummary } from "../api/notificationSummary"; import { logout, switchTenant } from "../api/auth"; import { hasAnyScope } from "../utils/permissions"; import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext"; import { useEffectiveView } from "../platform/ViewContext"; -type NotificationSummary = { - unread: number; - show_unread_badge?: boolean; -}; - type Props = { settings: ApiSettings; auth: AuthInfo | null; @@ -35,7 +30,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode const [loginOpen, setLoginOpen] = useState(false); const [switchingTenantId, setSwitchingTenantId] = useState(null); const [tenantError, setTenantError] = useState(""); - const [unreadNotificationCount, setUnreadNotificationCount] = useState(0); const accountRef = useRef(null); const tenantRef = useRef(null); const { translateText } = usePlatformLanguage(); @@ -55,7 +49,19 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode "system:tenants:suspend"] ); 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 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); useEffect(() => { @@ -72,41 +78,6 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode return () => window.removeEventListener("mousedown", onPointerDown); }, []); - useEffect(() => { - if (!auth || !notificationsAvailable) { - setUnreadNotificationCount(0); - return; - } - - let cancelled = false; - async function refreshNotificationSummary() { - try { - const summary = await apiFetch(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) { onAuthChange(response, ""); } @@ -185,8 +156,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode } - {auth && activeTenant && showTenantControl && -
+ {auth && showContextSelectors && +
+ {activeTenant && showTenantControl && +
{translateText("i18n:govoplan-core.tenant_label_prefix")} {canSwitchTenant ? <> @@ -218,13 +191,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode {activeTenant.name} }
+ } + {ViewSelector && + + } +
}
- {auth && ViewSelector && - - } diff --git a/webui/src/platform/ModuleContext.tsx b/webui/src/platform/ModuleContext.tsx index f25c0e1..b4ee0f4 100644 --- a/webui/src/platform/ModuleContext.tsx +++ b/webui/src/platform/ModuleContext.tsx @@ -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 { moduleInstalled, uiCapabilities, uiCapability } from "./modules"; import { useEffectiveView, useViewSurfaces } from "./ViewContext"; @@ -19,22 +19,34 @@ export function usePlatformModuleInstalled(moduleId: string): boolean { } export function usePlatformUiCapability(capabilityName: string): T | null { - return uiCapability(capabilityName, useViewVisibleModules()); + const modules = useViewVisibleModules(); + return useMemo( + () => uiCapability(capabilityName, modules), + [capabilityName, modules] + ); } export function usePlatformUiCapabilities(capabilityName: string): T[] { - return uiCapabilities(capabilityName, useViewVisibleModules()); + const modules = useViewVisibleModules(); + return useMemo( + () => uiCapabilities(capabilityName, modules), + [capabilityName, modules] + ); } function useViewVisibleModules(): PlatformWebModule[] { const modules = usePlatformModules(); const projection = useEffectiveView(); const surfaces = useViewSurfaces(); - return modules.filter((module) => - isViewSurfaceVisible( - projection, - moduleViewSurfaceId(module.id), - surfaces - ) + return useMemo( + () => + modules.filter((module) => + isViewSurfaceVisible( + projection, + moduleViewSurfaceId(module.id), + surfaces + ) + ), + [modules, projection, surfaces] ); } diff --git a/webui/src/platform/referenceProviders.ts b/webui/src/platform/referenceProviders.ts new file mode 100644 index 0000000..553b1b2 --- /dev/null +++ b/webui/src/platform/referenceProviders.ts @@ -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; +}; + +export function apiReferenceOptionProvider( + settings: ApiSettings, + path: string, + params: ApiQueryParams = {} +): ReferenceOptionProvider { + async function load( + query: string, + selectedValues: readonly string[], + limit: number, + signal: AbortSignal + ): Promise { + 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 | null = null; + + async function catalogue(signal: AbortSignal): Promise { + 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 +): Promise { + 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"); +} diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 3b899bd..248e5fa 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -1547,6 +1547,150 @@ .people-picker-search-control:focus-within > svg { 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 { display: grid; max-height: 360px; @@ -3440,6 +3584,63 @@ gap: 8px; 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) { .admin-details-grid, .admin-tenant-assignment-row { diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index 764bf31..d0c0d06 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -27,6 +27,7 @@ .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); } .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-label, .tenant-caret, .muted { color: var(--muted); } .block { display: block; } diff --git a/webui/src/types.ts b/webui/src/types.ts index 3290cfe..2bd4239 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -410,11 +410,43 @@ export type DashboardWidgetSize = "small" | "medium" | "wide" | "full"; 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 & { modules: PlatformWebModule[]; + effectiveView: EffectiveViewProjection | null; + instanceId: string; widgetId: string; refreshKey: number; size: DashboardWidgetSize; + configuration: DashboardWidgetConfiguration; }; export type DashboardWidgetContribution = { @@ -428,6 +460,9 @@ export type DashboardWidgetContribution = { defaultVisible?: boolean; defaultSize?: DashboardWidgetSize; supportedSizes?: DashboardWidgetSize[]; + allowMultiple?: boolean; + defaultConfiguration?: DashboardWidgetConfiguration; + configurationFields?: DashboardWidgetConfigurationField[]; refreshIntervalMs?: number; anyOf?: string[]; allOf?: string[]; diff --git a/webui/tests/module-capabilities.test.ts b/webui/tests/module-capabilities.test.ts index 98f1133..f86407c 100644 --- a/webui/tests/module-capabilities.test.ts +++ b/webui/tests/module-capabilities.test.ts @@ -1,4 +1,5 @@ import type { + DashboardWidgetsUiCapability, OrganizationFunctionActionContext, OrganizationFunctionActionContribution, 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("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( + "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); assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order"); diff --git a/webui/tests/selection-list.test.tsx b/webui/tests/selection-list.test.tsx index 315ac05..599b03a 100644 --- a/webui/tests/selection-list.test.tsx +++ b/webui/tests/selection-list.test.tsx @@ -3,6 +3,14 @@ function assert(condition: unknown, message = "assertion failed"): void { } 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 { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; @@ -50,3 +58,87 @@ const nativeLabelMarkup = renderToStaticMarkup( ); 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( + + undefined} + /> + +); +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( + + undefined} + selectedOptions={[ + { + value: "group-1", + label: "Finance", + description: "Access group" + } + ]} + provider={staticReferenceOptionProvider([ + { + value: "group-1", + label: "Finance", + description: "Access group" + } + ])} + createCustomOption={(value) => wildcardReferenceOption(value)} + /> + +); +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" +); diff --git a/webui/tsconfig.component-tests.json b/webui/tsconfig.component-tests.json index 71bed4f..2385560 100644 --- a/webui/tsconfig.component-tests.json +++ b/webui/tsconfig.component-tests.json @@ -35,6 +35,8 @@ "src/components/people/PeoplePicker.tsx", "src/components/people/peoplePickerTypes.ts", "src/components/ResourceAccessExplanation.tsx", + "src/components/ReferenceSelect.tsx", + "src/components/SearchableSelect.tsx", "src/components/SelectionList.tsx", "src/components/mail/MailServerSettingsPanel.tsx", "src/components/Button.tsx", diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 0839f95..42a7021 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -28,6 +28,7 @@ const defaultWebModulePackages = [ "@govoplan/organizations-webui", "@govoplan/ops-webui", "@govoplan/policy-webui", + "@govoplan/postbox-webui", "@govoplan/scheduling-webui", "@govoplan/views-webui", "@govoplan/workflow-webui" @@ -91,12 +92,14 @@ export default defineConfig({ chunkSizeWarningLimit: 1200 }, resolve: { - preserveSymlinks: true, + preserveSymlinks: false, dedupe: [ "@xyflow/react", + "lucide-react", "react", "react-dom", - "react-router-dom" + "react-router-dom", + "read-excel-file" ], alias: [ { 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-ops/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-views/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))