feat: strengthen module contracts and shared WebUI runtime
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]:
|
||||
...
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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():
|
||||
|
||||
386
src/govoplan_core/core/postbox.py
Normal file
386
src/govoplan_core/core/postbox.py
Normal file
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
POSTBOX_MODULE_ID = "postbox"
|
||||
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
|
||||
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
|
||||
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
|
||||
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
|
||||
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
|
||||
|
||||
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
|
||||
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxActorRef:
|
||||
account_id: str
|
||||
identity_id: str | None = None
|
||||
selected_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
authorized_actions: frozenset[PostboxAction] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxTargetRef:
|
||||
postbox_id: str | None = None
|
||||
address_key: str | None = None
|
||||
template_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
context_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryTemplateRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None
|
||||
published_revision_id: str
|
||||
function_type_id: str | None
|
||||
scope_kind: str
|
||||
scope_id: str | None
|
||||
classification: str
|
||||
allow_vacant_delivery: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxOrganizationFunctionTargetRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
function_type_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxOrganizationUnitTargetRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
unit_type_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryCatalogRef:
|
||||
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
|
||||
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
|
||||
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxAccessDecisionRef:
|
||||
allowed: bool
|
||||
action: PostboxAction
|
||||
postbox_id: str
|
||||
reason_code: str
|
||||
explanation: str
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
assignment_ids: tuple[str, ...] = ()
|
||||
assignment_sources: tuple[str, ...] = ()
|
||||
selected_assignment_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDirectoryEntryRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
address: str
|
||||
address_key: str
|
||||
name: str
|
||||
status: str
|
||||
classification: str
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
context_key: str | None = None
|
||||
template_revision_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
access: PostboxAccessDecisionRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxParticipantRef:
|
||||
kind: str
|
||||
reference_type: str
|
||||
reference_id: str | None = None
|
||||
label: str | None = None
|
||||
address: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxAttachmentRef:
|
||||
reference_type: str
|
||||
reference_id: str
|
||||
name: str | None = None
|
||||
media_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
digest: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxMessageRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
postbox_id: str
|
||||
subject: str
|
||||
body_text: str | None
|
||||
status: str
|
||||
classification: str
|
||||
sender_label: str | None
|
||||
delivered_at: datetime
|
||||
read_at: datetime | None = None
|
||||
acknowledged_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
withdrawn_at: datetime | None = None
|
||||
producer_module: str | None = None
|
||||
producer_resource_type: str | None = None
|
||||
producer_resource_id: str | None = None
|
||||
encryption_profile: str = "plaintext_v1"
|
||||
key_epoch: int = 1
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryRequest:
|
||||
tenant_id: str
|
||||
target: PostboxTargetRef
|
||||
producer_module: str
|
||||
producer_resource_type: str
|
||||
producer_resource_id: str | None
|
||||
idempotency_key: str
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
sender_label: str | None = None
|
||||
classification: str = "internal"
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryResult:
|
||||
delivery_id: str
|
||||
postbox_id: str
|
||||
message_id: str
|
||||
address: str
|
||||
status: str
|
||||
vacant: bool
|
||||
holder_count: int
|
||||
duplicate: bool = False
|
||||
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxDeliveryRejected(RuntimeError):
|
||||
"""A delivery was rejected before the provider accepted any effect."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.temporary = temporary
|
||||
|
||||
|
||||
class PostboxDeliveryOutcomeUnknown(RuntimeError):
|
||||
"""The provider may have accepted an effect and must not be bypassed."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxDirectoryProvider(Protocol):
|
||||
def list_visible_postboxes(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor: PostboxActorRef,
|
||||
) -> Sequence[PostboxDirectoryEntryRef]:
|
||||
...
|
||||
|
||||
def resolve_postbox(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
target: PostboxTargetRef,
|
||||
materialize: bool = False,
|
||||
) -> PostboxDirectoryEntryRef | None:
|
||||
...
|
||||
|
||||
def delivery_catalog(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> PostboxDeliveryCatalogRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxAccessProvider(Protocol):
|
||||
def explain_access(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor: PostboxActorRef,
|
||||
action: PostboxAction,
|
||||
) -> PostboxAccessDecisionRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxMessagesProvider(Protocol):
|
||||
def list_messages(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
actor: PostboxActorRef,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
query: str | None = None,
|
||||
state: PostboxMessageListState = "all",
|
||||
) -> Sequence[PostboxMessageRef]:
|
||||
...
|
||||
|
||||
def get_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
) -> PostboxMessageRef | None:
|
||||
...
|
||||
|
||||
def mark_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
state: Literal["read", "acknowledged"],
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxDeliveryProvider(Protocol):
|
||||
def deliver(
|
||||
self,
|
||||
session: object,
|
||||
request: PostboxDeliveryRequest,
|
||||
) -> PostboxDeliveryResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxEvidenceProvider(Protocol):
|
||||
def link_evidence(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
attachment: PostboxAttachmentRef,
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
|
||||
def _postbox_provider(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_name: str,
|
||||
provider_type: type,
|
||||
) -> object | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(capability_name):
|
||||
return None
|
||||
capability = registry.capability(capability_name)
|
||||
return capability if isinstance(capability, provider_type) else None
|
||||
|
||||
|
||||
def postbox_directory_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxDirectoryProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
|
||||
provider_type=PostboxDirectoryProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxDirectoryProvider) else None
|
||||
|
||||
|
||||
def postbox_access_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxAccessProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_ACCESS,
|
||||
provider_type=PostboxAccessProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxAccessProvider) else None
|
||||
|
||||
|
||||
def postbox_messages_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxMessagesProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_MESSAGES,
|
||||
provider_type=PostboxMessagesProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxMessagesProvider) else None
|
||||
|
||||
|
||||
def postbox_delivery_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxDeliveryProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
provider_type=PostboxDeliveryProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxDeliveryProvider) else None
|
||||
|
||||
|
||||
def postbox_evidence_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxEvidenceProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
|
||||
provider_type=PostboxEvidenceProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxEvidenceProvider) else None
|
||||
369
src/govoplan_core/core/references.py
Normal file
369
src/govoplan_core/core/references.py
Normal file
@@ -0,0 +1,369 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
AccessDirectory,
|
||||
GroupRef,
|
||||
UserRef,
|
||||
)
|
||||
|
||||
|
||||
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReferenceOption:
|
||||
"""A stable identifier paired with presentation-only directory metadata."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
kind: str | None = None
|
||||
availability: ReferenceAvailability = "available"
|
||||
disabled: bool = False
|
||||
source_module: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"value": self.value,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"kind": self.kind,
|
||||
"availability": self.availability,
|
||||
"disabled": self.disabled,
|
||||
"source_module": self.source_module,
|
||||
"provenance": dict(self.provenance),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReferenceSearchRequest:
|
||||
kind: str
|
||||
tenant_id: str | None
|
||||
query: str = ""
|
||||
selected_values: tuple[str, ...] = ()
|
||||
limit: int = 50
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReferenceOptionProvider(Protocol):
|
||||
"""Optional module-neutral provider for typed reference selectors."""
|
||||
|
||||
def search_reference_options(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReferenceSearchRequest,
|
||||
) -> Sequence[ReferenceOption]:
|
||||
...
|
||||
|
||||
|
||||
def reference_option_provider(
|
||||
registry: object | None,
|
||||
capability_name: str,
|
||||
) -> ReferenceOptionProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(capability_name)
|
||||
):
|
||||
return None
|
||||
provider = registry.capability(capability_name)
|
||||
return provider if isinstance(provider, ReferenceOptionProvider) else None
|
||||
|
||||
|
||||
def access_scope_reference_options(
|
||||
registry: object | None,
|
||||
principal: object,
|
||||
*,
|
||||
scope_type: str,
|
||||
query: str = "",
|
||||
selected_values: Sequence[str] = (),
|
||||
limit: int = 50,
|
||||
administrative: bool = False,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
"""Return canonical user-account or group references for definition scopes."""
|
||||
|
||||
clean_type = str(scope_type or "").strip().casefold()
|
||||
if clean_type not in {"user", "group"}:
|
||||
raise ValueError("Scope reference type must be user or group.")
|
||||
normalized_limit = max(1, min(int(limit), 200))
|
||||
selected = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip() for value in selected_values if str(value).strip()
|
||||
)
|
||||
)
|
||||
directory = _access_directory(registry)
|
||||
if directory is None:
|
||||
return _fallback_scope_options(
|
||||
principal,
|
||||
scope_type=clean_type,
|
||||
query=query,
|
||||
selected_values=selected,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||
if not tenant_id:
|
||||
return tuple(
|
||||
_unavailable_option(value, kind=clean_type) for value in selected
|
||||
)
|
||||
if clean_type == "user":
|
||||
candidates = _user_options(
|
||||
directory.users_for_tenant(tenant_id),
|
||||
principal=principal,
|
||||
administrative=administrative,
|
||||
)
|
||||
else:
|
||||
candidates = _group_options(
|
||||
directory.groups_for_tenant(tenant_id),
|
||||
principal=principal,
|
||||
administrative=administrative,
|
||||
)
|
||||
return _filter_and_retain_options(
|
||||
candidates,
|
||||
query=query,
|
||||
selected_values=selected,
|
||||
limit=normalized_limit,
|
||||
kind=clean_type,
|
||||
)
|
||||
|
||||
|
||||
def access_scope_reference_provider_available(registry: object | None) -> bool:
|
||||
return _access_directory(registry) is not None
|
||||
|
||||
|
||||
def validate_access_scope_reference(
|
||||
registry: object | None,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
preserve_existing: str | None = None,
|
||||
) -> str | None:
|
||||
"""Validate new directory-backed scope references while preserving history."""
|
||||
|
||||
clean_type = str(scope_type or "").strip().casefold()
|
||||
clean_id = str(scope_id or "").strip() or None
|
||||
if clean_type not in {"user", "group"}:
|
||||
return clean_id
|
||||
if not clean_id:
|
||||
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
|
||||
if clean_id == preserve_existing:
|
||||
return clean_id
|
||||
|
||||
directory = _access_directory(registry)
|
||||
if directory is None:
|
||||
return clean_id
|
||||
if not tenant_id:
|
||||
raise ValueError("Directory-backed scopes require an active tenant.")
|
||||
|
||||
if clean_type == "group":
|
||||
group = directory.get_group(clean_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
raise ValueError("The selected group is not available in the active tenant.")
|
||||
if group.status != "active":
|
||||
raise ValueError("Inactive groups cannot be selected for new definitions.")
|
||||
return group.id
|
||||
|
||||
matching_user = next(
|
||||
(
|
||||
user
|
||||
for user in directory.users_for_tenant(tenant_id)
|
||||
if user.account_id == clean_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_user is None:
|
||||
raise ValueError("The selected user account is not available in the active tenant.")
|
||||
if matching_user.status != "active":
|
||||
raise ValueError("Inactive users cannot be selected for new definitions.")
|
||||
account = directory.get_account(matching_user.account_id)
|
||||
if account is not None and account.status != "active":
|
||||
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
|
||||
return matching_user.account_id
|
||||
|
||||
|
||||
def _access_directory(registry: object | None) -> AccessDirectory | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
):
|
||||
return None
|
||||
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
return provider if isinstance(provider, AccessDirectory) else None
|
||||
|
||||
|
||||
def _user_options(
|
||||
users: Sequence[UserRef],
|
||||
*,
|
||||
principal: object,
|
||||
administrative: bool,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
principal_account_id = str(getattr(principal, "account_id", "") or "")
|
||||
options: dict[str, ReferenceOption] = {}
|
||||
for user in users:
|
||||
if not administrative and user.account_id != principal_account_id:
|
||||
continue
|
||||
inactive = user.status != "active"
|
||||
label = user.display_name or user.email or user.account_id
|
||||
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
|
||||
options[user.account_id] = ReferenceOption(
|
||||
value=user.account_id,
|
||||
label=label,
|
||||
description=" · ".join(detail_parts) or None,
|
||||
kind="user",
|
||||
availability="inactive" if inactive else "available",
|
||||
disabled=inactive,
|
||||
source_module="access",
|
||||
provenance={
|
||||
"membership_id": user.id,
|
||||
"tenant_id": user.tenant_id,
|
||||
"account_id": user.account_id,
|
||||
},
|
||||
)
|
||||
return tuple(options.values())
|
||||
|
||||
|
||||
def _group_options(
|
||||
groups: Sequence[GroupRef],
|
||||
*,
|
||||
principal: object,
|
||||
administrative: bool,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
permitted = {
|
||||
str(value)
|
||||
for value in getattr(principal, "group_ids", ())
|
||||
if str(value)
|
||||
}
|
||||
return tuple(
|
||||
ReferenceOption(
|
||||
value=group.id,
|
||||
label=group.name or group.id,
|
||||
description="Inactive" if group.status != "active" else None,
|
||||
kind="group",
|
||||
availability=(
|
||||
"inactive" if group.status != "active" else "available"
|
||||
),
|
||||
disabled=group.status != "active",
|
||||
source_module="access",
|
||||
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||
)
|
||||
for group in groups
|
||||
if administrative or group.id in permitted
|
||||
)
|
||||
|
||||
|
||||
def _filter_and_retain_options(
|
||||
options: Sequence[ReferenceOption],
|
||||
*,
|
||||
query: str,
|
||||
selected_values: Sequence[str],
|
||||
limit: int,
|
||||
kind: str,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
by_value = {option.value: option for option in options}
|
||||
needle = str(query or "").strip().casefold()
|
||||
matches = [
|
||||
option
|
||||
for option in options
|
||||
if not needle
|
||||
or needle
|
||||
in " ".join(
|
||||
(
|
||||
option.value,
|
||||
option.label,
|
||||
option.description or "",
|
||||
)
|
||||
).casefold()
|
||||
][:limit]
|
||||
returned = {option.value for option in matches}
|
||||
for value in selected_values:
|
||||
if value in returned:
|
||||
continue
|
||||
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
|
||||
returned.add(value)
|
||||
return tuple(matches)
|
||||
|
||||
|
||||
def _fallback_scope_options(
|
||||
principal: object,
|
||||
*,
|
||||
scope_type: str,
|
||||
query: str,
|
||||
selected_values: Sequence[str],
|
||||
limit: int,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
candidates: list[ReferenceOption] = []
|
||||
if scope_type == "user":
|
||||
account_id = str(getattr(principal, "account_id", "") or "")
|
||||
if account_id:
|
||||
candidates.append(
|
||||
ReferenceOption(
|
||||
value=account_id,
|
||||
label=(
|
||||
str(getattr(principal, "display_name", "") or "")
|
||||
or str(getattr(principal, "email", "") or "")
|
||||
or account_id
|
||||
),
|
||||
description="Current user · Access directory unavailable",
|
||||
kind="user",
|
||||
source_module="core",
|
||||
provenance={"fallback": True},
|
||||
)
|
||||
)
|
||||
else:
|
||||
candidates.extend(
|
||||
ReferenceOption(
|
||||
value=str(group_id),
|
||||
label=str(group_id),
|
||||
description="Permitted group · Access directory unavailable",
|
||||
kind="group",
|
||||
source_module="core",
|
||||
provenance={"fallback": True},
|
||||
)
|
||||
for group_id in getattr(principal, "group_ids", ())
|
||||
if str(group_id)
|
||||
)
|
||||
return _filter_and_retain_options(
|
||||
candidates,
|
||||
query=query,
|
||||
selected_values=selected_values,
|
||||
limit=limit,
|
||||
kind=scope_type,
|
||||
)
|
||||
|
||||
|
||||
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
|
||||
return ReferenceOption(
|
||||
value=value,
|
||||
label=f"Unavailable {kind}",
|
||||
description=value,
|
||||
kind=kind,
|
||||
availability="unavailable",
|
||||
disabled=True,
|
||||
source_module=None,
|
||||
provenance={"retained_reference": True},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReferenceAvailability",
|
||||
"ReferenceOption",
|
||||
"ReferenceOptionProvider",
|
||||
"ReferenceSearchRequest",
|
||||
"access_scope_reference_options",
|
||||
"access_scope_reference_provider_available",
|
||||
"reference_option_provider",
|
||||
"validate_access_scope_reference",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))),
|
||||
}
|
||||
|
||||
@@ -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=(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
119
tests/test_postbox_contract.py
Normal file
119
tests/test_postbox_contract.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_ACCESS,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
PostboxAccessProvider,
|
||||
PostboxDeliveryProvider,
|
||||
PostboxDirectoryProvider,
|
||||
PostboxEvidenceProvider,
|
||||
PostboxMessagesProvider,
|
||||
postbox_access_provider,
|
||||
postbox_delivery_provider,
|
||||
postbox_directory_provider,
|
||||
postbox_evidence_provider,
|
||||
postbox_messages_provider,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
class _PostboxProvider:
|
||||
def list_visible_postboxes(self, session, *, tenant_id, actor):
|
||||
del session, tenant_id, actor
|
||||
return ()
|
||||
|
||||
def resolve_postbox(self, session, *, tenant_id, target, materialize=False):
|
||||
del session, tenant_id, target, materialize
|
||||
return None
|
||||
|
||||
def delivery_catalog(self, session, *, tenant_id):
|
||||
del session, tenant_id
|
||||
return None
|
||||
|
||||
def explain_access(self, session, *, tenant_id, postbox_id, actor, action):
|
||||
del session, tenant_id, postbox_id, actor, action
|
||||
return None
|
||||
|
||||
def list_messages(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
tenant_id,
|
||||
postbox_ids,
|
||||
actor,
|
||||
limit=100,
|
||||
offset=0,
|
||||
):
|
||||
del session, tenant_id, postbox_ids, actor, limit, offset
|
||||
return ()
|
||||
|
||||
def get_message(self, session, *, tenant_id, message_id, actor):
|
||||
del session, tenant_id, message_id, actor
|
||||
return None
|
||||
|
||||
def mark_message(self, session, *, tenant_id, message_id, actor, state):
|
||||
del session, tenant_id, message_id, actor, state
|
||||
return None
|
||||
|
||||
def deliver(self, session, request):
|
||||
del session, request
|
||||
return None
|
||||
|
||||
def link_evidence(self, session, *, tenant_id, message_id, attachment):
|
||||
del session, tenant_id, message_id, attachment
|
||||
return None
|
||||
|
||||
|
||||
class PostboxContractTests(unittest.TestCase):
|
||||
def test_protocols_and_registry_helpers_resolve_without_module_imports(self) -> None:
|
||||
provider = _PostboxProvider()
|
||||
self.assertIsInstance(provider, PostboxDirectoryProvider)
|
||||
self.assertIsInstance(provider, PostboxAccessProvider)
|
||||
self.assertIsInstance(provider, PostboxMessagesProvider)
|
||||
self.assertIsInstance(provider, PostboxDeliveryProvider)
|
||||
self.assertIsInstance(provider, PostboxEvidenceProvider)
|
||||
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="postbox_contract_test",
|
||||
name="Postbox contract test",
|
||||
version="test",
|
||||
capability_factories={
|
||||
name: lambda context: provider
|
||||
for name in (
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_ACCESS,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
self.assertIs(provider, postbox_directory_provider(registry))
|
||||
self.assertIs(provider, postbox_access_provider(registry))
|
||||
self.assertIs(provider, postbox_messages_provider(registry))
|
||||
self.assertIs(provider, postbox_delivery_provider(registry))
|
||||
self.assertIs(provider, postbox_evidence_provider(registry))
|
||||
|
||||
empty = PlatformRegistry()
|
||||
self.assertIsNone(postbox_directory_provider(empty))
|
||||
self.assertIsNone(postbox_access_provider(empty))
|
||||
self.assertIsNone(postbox_messages_provider(empty))
|
||||
self.assertIsNone(postbox_delivery_provider(empty))
|
||||
self.assertIsNone(postbox_evidence_provider(empty))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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:
|
||||
|
||||
173
tests/test_reference_options.py
Normal file
173
tests/test_reference_options.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
AccountRef,
|
||||
GroupRef,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_core.core.references import (
|
||||
access_scope_reference_options,
|
||||
validate_access_scope_reference,
|
||||
)
|
||||
|
||||
|
||||
class _Directory:
|
||||
users = (
|
||||
UserRef(
|
||||
id="membership-1",
|
||||
account_id="account-1",
|
||||
tenant_id="tenant-1",
|
||||
email="ada@example.test",
|
||||
display_name="Ada",
|
||||
),
|
||||
UserRef(
|
||||
id="membership-2",
|
||||
account_id="account-2",
|
||||
tenant_id="tenant-1",
|
||||
email="inactive@example.test",
|
||||
display_name="Inactive",
|
||||
status="inactive",
|
||||
),
|
||||
)
|
||||
groups = (
|
||||
GroupRef(id="group-1", tenant_id="tenant-1", name="Finance"),
|
||||
GroupRef(
|
||||
id="group-2",
|
||||
tenant_id="tenant-1",
|
||||
name="Former team",
|
||||
status="inactive",
|
||||
),
|
||||
)
|
||||
|
||||
def get_account(self, account_id):
|
||||
if account_id not in {"account-1", "account-2"}:
|
||||
return None
|
||||
return AccountRef(id=account_id, email=f"{account_id}@example.test")
|
||||
|
||||
def get_user(self, user_id):
|
||||
return next((item for item in self.users if item.id == user_id), None)
|
||||
|
||||
def get_users(self, user_ids):
|
||||
return {item.id: item for item in self.users if item.id in set(user_ids)}
|
||||
|
||||
def users_for_tenant(self, tenant_id):
|
||||
return tuple(item for item in self.users if item.tenant_id == tenant_id)
|
||||
|
||||
def get_group(self, group_id):
|
||||
return next((item for item in self.groups if item.id == group_id), None)
|
||||
|
||||
def get_groups(self, group_ids):
|
||||
return {item.id: item for item in self.groups if item.id in set(group_ids)}
|
||||
|
||||
def groups_for_tenant(self, tenant_id):
|
||||
return tuple(item for item in self.groups if item.tenant_id == tenant_id)
|
||||
|
||||
def groups_for_user(self, user_id, *, tenant_id):
|
||||
del user_id
|
||||
return self.groups_for_tenant(tenant_id)
|
||||
|
||||
def display_label(self, subject):
|
||||
return subject.label
|
||||
|
||||
|
||||
class _Registry:
|
||||
directory = _Directory()
|
||||
|
||||
def has_capability(self, name):
|
||||
return name == CAPABILITY_ACCESS_DIRECTORY
|
||||
|
||||
def capability(self, name):
|
||||
return self.directory if self.has_capability(name) else None
|
||||
|
||||
|
||||
class ReferenceOptionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
display_name="Ada",
|
||||
email="ada@example.test",
|
||||
group_ids=frozenset({"group-1"}),
|
||||
)
|
||||
|
||||
def test_user_values_are_canonical_account_ids(self) -> None:
|
||||
options = access_scope_reference_options(
|
||||
_Registry(),
|
||||
self.principal,
|
||||
scope_type="user",
|
||||
administrative=True,
|
||||
)
|
||||
|
||||
self.assertEqual(["account-1", "account-2"], [item.value for item in options])
|
||||
self.assertEqual("membership-1", options[0].provenance["membership_id"])
|
||||
self.assertTrue(options[1].disabled)
|
||||
self.assertEqual("inactive", options[1].availability)
|
||||
|
||||
def test_unavailable_selected_values_remain_readable(self) -> None:
|
||||
options = access_scope_reference_options(
|
||||
_Registry(),
|
||||
self.principal,
|
||||
scope_type="group",
|
||||
query="missing",
|
||||
selected_values=("removed-group",),
|
||||
administrative=True,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(options))
|
||||
self.assertEqual("removed-group", options[0].value)
|
||||
self.assertEqual("unavailable", options[0].availability)
|
||||
self.assertTrue(options[0].disabled)
|
||||
|
||||
def test_non_administrators_only_receive_permitted_scope_targets(self) -> None:
|
||||
users = access_scope_reference_options(
|
||||
_Registry(),
|
||||
self.principal,
|
||||
scope_type="user",
|
||||
administrative=False,
|
||||
)
|
||||
groups = access_scope_reference_options(
|
||||
_Registry(),
|
||||
self.principal,
|
||||
scope_type="group",
|
||||
administrative=False,
|
||||
)
|
||||
|
||||
self.assertEqual(["account-1"], [item.value for item in users])
|
||||
self.assertEqual(["group-1"], [item.value for item in groups])
|
||||
|
||||
def test_inactive_targets_are_rejected_but_existing_values_survive(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Inactive groups"):
|
||||
validate_access_scope_reference(
|
||||
_Registry(),
|
||||
tenant_id="tenant-1",
|
||||
scope_type="group",
|
||||
scope_id="group-2",
|
||||
)
|
||||
self.assertEqual(
|
||||
"group-2",
|
||||
validate_access_scope_reference(
|
||||
_Registry(),
|
||||
tenant_id="tenant-1",
|
||||
scope_type="group",
|
||||
scope_id="group-2",
|
||||
preserve_existing="group-2",
|
||||
),
|
||||
)
|
||||
|
||||
def test_missing_optional_access_provider_keeps_reduced_mode(self) -> None:
|
||||
options = access_scope_reference_options(
|
||||
None,
|
||||
self.principal,
|
||||
scope_type="user",
|
||||
)
|
||||
|
||||
self.assertEqual("account-1", options[0].value)
|
||||
self.assertEqual("core", options[0].source_module)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
81
webui/package-lock.json
generated
81
webui/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
213
webui/src/api/notificationSummary.ts
Normal file
213
webui/src/api/notificationSummary.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../types";
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type SharedNotificationSummary = {
|
||||
total: number;
|
||||
unread: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
show_unread_badge?: boolean;
|
||||
};
|
||||
|
||||
export type SharedNotificationSummarySnapshot = {
|
||||
summary: SharedNotificationSummary | null;
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
type Listener = (snapshot: SharedNotificationSummarySnapshot) => void;
|
||||
|
||||
type SummaryStore = {
|
||||
settings: ApiSettings;
|
||||
listeners: Map<Listener, number>;
|
||||
snapshot: SharedNotificationSummarySnapshot;
|
||||
inFlight: Promise<void> | null;
|
||||
lastFetchedAt: number;
|
||||
timerId: number | null;
|
||||
focusListener: () => void;
|
||||
changeListener: () => void;
|
||||
};
|
||||
|
||||
const stores = new Map<string, SummaryStore>();
|
||||
const EMPTY_SNAPSHOT: SharedNotificationSummarySnapshot = {
|
||||
summary: null,
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
function storeKey(settings: ApiSettings, scopeKey: string): string {
|
||||
return [
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
settings.accessToken,
|
||||
scopeKey
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function notify(store: SummaryStore): void {
|
||||
for (const listener of store.listeners.keys()) {
|
||||
listener(store.snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function minimumInterval(store: SummaryStore): number {
|
||||
return Math.max(
|
||||
5_000,
|
||||
Math.min(...Array.from(store.listeners.values()))
|
||||
);
|
||||
}
|
||||
|
||||
function refresh(store: SummaryStore, force = false): Promise<void> {
|
||||
if (store.inFlight) return store.inFlight;
|
||||
if (
|
||||
!force &&
|
||||
store.lastFetchedAt > 0 &&
|
||||
Date.now() - store.lastFetchedAt < Math.min(minimumInterval(store), 5_000)
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (store.snapshot.summary === null) {
|
||||
store.snapshot = { ...store.snapshot, loading: true, error: null };
|
||||
notify(store);
|
||||
}
|
||||
const request = apiFetch<SharedNotificationSummary>(
|
||||
store.settings,
|
||||
"/api/v1/notifications/summary"
|
||||
)
|
||||
.then((summary) => {
|
||||
store.lastFetchedAt = Date.now();
|
||||
store.snapshot = { summary, loading: false, error: null };
|
||||
notify(store);
|
||||
})
|
||||
.catch((error) => {
|
||||
store.snapshot = {
|
||||
...store.snapshot,
|
||||
loading: false,
|
||||
error
|
||||
};
|
||||
notify(store);
|
||||
})
|
||||
.finally(() => {
|
||||
if (store.inFlight === request) store.inFlight = null;
|
||||
});
|
||||
store.inFlight = request;
|
||||
return request;
|
||||
}
|
||||
|
||||
function schedule(store: SummaryStore): void {
|
||||
if (store.timerId !== null) window.clearInterval(store.timerId);
|
||||
if (store.listeners.size === 0) {
|
||||
store.timerId = null;
|
||||
return;
|
||||
}
|
||||
store.timerId = window.setInterval(
|
||||
() => void refresh(store, true),
|
||||
minimumInterval(store)
|
||||
);
|
||||
}
|
||||
|
||||
function createStore(settings: ApiSettings): SummaryStore {
|
||||
const store = {} as SummaryStore;
|
||||
store.settings = settings;
|
||||
store.listeners = new Map();
|
||||
store.snapshot = { ...EMPTY_SNAPSHOT };
|
||||
store.inFlight = null;
|
||||
store.lastFetchedAt = 0;
|
||||
store.timerId = null;
|
||||
store.focusListener = () => void refresh(store);
|
||||
store.changeListener = () => void refresh(store, true);
|
||||
return store;
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
settings: ApiSettings,
|
||||
scopeKey: string,
|
||||
intervalMs: number,
|
||||
listener: Listener
|
||||
): { store: SummaryStore; unsubscribe: () => void } {
|
||||
const key = storeKey(settings, scopeKey);
|
||||
let store = stores.get(key);
|
||||
if (!store) {
|
||||
store = createStore(settings);
|
||||
stores.set(key, store);
|
||||
}
|
||||
const firstListener = store.listeners.size === 0;
|
||||
store.listeners.set(listener, Math.max(5_000, intervalMs));
|
||||
if (firstListener) {
|
||||
window.addEventListener("focus", store.focusListener);
|
||||
window.addEventListener(
|
||||
"govoplan:notifications-changed",
|
||||
store.changeListener
|
||||
);
|
||||
}
|
||||
listener(store.snapshot);
|
||||
schedule(store);
|
||||
void refresh(store);
|
||||
return {
|
||||
store,
|
||||
unsubscribe: () => {
|
||||
store?.listeners.delete(listener);
|
||||
if (store?.listeners.size === 0) {
|
||||
schedule(store);
|
||||
window.removeEventListener("focus", store.focusListener);
|
||||
window.removeEventListener(
|
||||
"govoplan:notifications-changed",
|
||||
store.changeListener
|
||||
);
|
||||
stores.delete(key);
|
||||
} else if (store) {
|
||||
schedule(store);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useSharedNotificationSummary(
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
enabled: boolean;
|
||||
scopeKey: string;
|
||||
intervalMs?: number;
|
||||
refreshKey?: string | number;
|
||||
}
|
||||
): SharedNotificationSummarySnapshot {
|
||||
const [snapshot, setSnapshot] =
|
||||
useState<SharedNotificationSummarySnapshot>(EMPTY_SNAPSHOT);
|
||||
const activeStore = useRef<SummaryStore | null>(null);
|
||||
const previousRefreshKey = useRef(options.refreshKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options.enabled) {
|
||||
activeStore.current = null;
|
||||
setSnapshot(EMPTY_SNAPSHOT);
|
||||
return;
|
||||
}
|
||||
const subscription = subscribe(
|
||||
settings,
|
||||
options.scopeKey,
|
||||
options.intervalMs ?? 60_000,
|
||||
setSnapshot
|
||||
);
|
||||
activeStore.current = subscription.store;
|
||||
return () => {
|
||||
activeStore.current = null;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [
|
||||
options.enabled,
|
||||
options.intervalMs,
|
||||
options.scopeKey,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousRefreshKey.current === options.refreshKey) return;
|
||||
previousRefreshKey.current = options.refreshKey;
|
||||
if (activeStore.current) void refresh(activeStore.current, true);
|
||||
}, [options.refreshKey]);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
@@ -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<MailProfileScope, "system" | "tenant" | "user" | "group">;
|
||||
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<CredentialEnvelopeSummary[]>([]);
|
||||
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<CredentialReferenceSelectorsUiCapability>(
|
||||
"core.credentialReferenceSelectors"
|
||||
);
|
||||
const moduleReferenceOptions = useMemo<ReferenceOption[]>(
|
||||
() => modules.map((module) => ({
|
||||
value: module.id,
|
||||
label: translateText(module.label),
|
||||
description: `${module.id} · version ${module.version}`,
|
||||
kind: "module",
|
||||
sourceModule: "core",
|
||||
provenance: { version: module.version }
|
||||
})),
|
||||
[modules, translateText]
|
||||
);
|
||||
const serverReferenceOptions = useMemo<ReferenceOption[]>(
|
||||
() => serverOptions.map((server) => ({
|
||||
value: server.id,
|
||||
label: server.label,
|
||||
description: server.secondary ?? server.id,
|
||||
kind: "server",
|
||||
disabled: server.disabled,
|
||||
availability: server.disabled ? "inactive" : "available",
|
||||
provenance: { announcedByConsumer: true }
|
||||
})),
|
||||
[serverOptions]
|
||||
);
|
||||
const moduleReferenceProvider = useMemo(
|
||||
() => platformModuleReferenceProvider(settings, moduleReferenceOptions),
|
||||
[moduleReferenceOptions, settings]
|
||||
);
|
||||
const serverReferenceProvider = useMemo(
|
||||
() =>
|
||||
combineReferenceOptionProviders([
|
||||
staticReferenceOptionProvider(serverReferenceOptions),
|
||||
...referenceCapabilities.map((capability) =>
|
||||
capability.serverProvider(settings, {
|
||||
scopeType,
|
||||
scopeId: activeScopeId
|
||||
})
|
||||
)
|
||||
]),
|
||||
[
|
||||
activeScopeId,
|
||||
referenceCapabilities,
|
||||
scopeType,
|
||||
serverReferenceOptions,
|
||||
settings
|
||||
]
|
||||
);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: draftDirty,
|
||||
@@ -436,11 +513,26 @@ export default function CredentialEnvelopeManager({
|
||||
<p>Empty module or server lists mean every module or server allowed by scope.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Modules" help="Comma-separated module IDs, for example mail, files, calendar, addresses.">
|
||||
<input value={draft.allowedModules} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedModules: event.target.value })} />
|
||||
<FormField label="Modules" help="Choose installed modules that may use this credential. Empty means every module allowed by scope.">
|
||||
<ReferenceMultiSelect
|
||||
values={splitValues(draft.allowedModules)}
|
||||
onChange={(values) => setDraft({ ...draft, allowedModules: values.join(", ") })}
|
||||
provider={moduleReferenceProvider}
|
||||
aria-label="Credential modules"
|
||||
placeholder="Add a module"
|
||||
disabled={saving}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Servers" help="Optional references such as mail:server-id, files:connection-id, calendar:source-id, or addresses:source-id.">
|
||||
<input value={draft.allowedServerRefs} disabled={saving} onChange={(event) => setDraft({ ...draft, allowedServerRefs: event.target.value })} />
|
||||
<FormField label="Servers" help="Choose announced servers or enter an explicit module-qualified reference. Empty means every permitted server.">
|
||||
<ReferenceMultiSelect
|
||||
values={splitValues(draft.allowedServerRefs)}
|
||||
onChange={(values) => setDraft({ ...draft, allowedServerRefs: values.join(", ") })}
|
||||
provider={serverReferenceProvider}
|
||||
createCustomOption={(value) => customReferenceOption(value, "server")}
|
||||
aria-label="Credential servers"
|
||||
placeholder="Add a server reference"
|
||||
disabled={saving}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch checked={draft.inheritToLowerScopes} disabled={saving} onChange={(inheritToLowerScopes) => setDraft({ ...draft, inheritToLowerScopes })} label="Visible to lower scopes" />
|
||||
<ToggleSwitch checked={draft.isActive} disabled={saving} onChange={(isActive) => setDraft({ ...draft, isActive })} label="Active" />
|
||||
|
||||
119
webui/src/components/DashboardWidgetContent.tsx
Normal file
119
webui/src/components/DashboardWidgetContent.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { adminErrorMessage } from "./admin/adminUtils";
|
||||
|
||||
export type DashboardWidgetDataState<T> = {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function useDashboardWidgetData<T>(
|
||||
load: () => Promise<T>,
|
||||
refreshKey: number
|
||||
): DashboardWidgetDataState<T> {
|
||||
const [state, setState] = useState<DashboardWidgetDataState<T>>({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
loading: true,
|
||||
error: ""
|
||||
}));
|
||||
void load()
|
||||
.then((data) => {
|
||||
if (active) setState({ data, loading: false, error: "" });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
loading: false,
|
||||
error: adminErrorMessage(error)
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load, refreshKey]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export type DashboardWidgetListItem = {
|
||||
id: string;
|
||||
title: ReactNode;
|
||||
detail?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
leading?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export function DashboardWidgetList({
|
||||
items,
|
||||
emptyText
|
||||
}: {
|
||||
items: DashboardWidgetListItem[];
|
||||
emptyText: ReactNode;
|
||||
}) {
|
||||
if (!items.length) {
|
||||
return <p className="muted dashboard-contribution-empty">{emptyText}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="dashboard-contribution-list">
|
||||
{items.map((item) => {
|
||||
const content = (
|
||||
<>
|
||||
{item.leading && (
|
||||
<span className="dashboard-contribution-leading">
|
||||
{item.leading}
|
||||
</span>
|
||||
)}
|
||||
<span className="dashboard-contribution-copy">
|
||||
<span className="dashboard-contribution-title">
|
||||
{item.title}
|
||||
</span>
|
||||
{item.detail && (
|
||||
<span className="dashboard-contribution-detail">
|
||||
{item.detail}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{item.meta && (
|
||||
<span className="dashboard-contribution-meta">
|
||||
{item.meta}
|
||||
</span>
|
||||
)}
|
||||
{item.trailing && (
|
||||
<span className="dashboard-contribution-trailing">
|
||||
{item.trailing}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<li key={item.id}>
|
||||
{item.to ? (
|
||||
<Link className="dashboard-contribution-row" to={item.to}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="dashboard-contribution-row">{content}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
472
webui/src/components/ReferenceSelect.tsx
Normal file
472
webui/src/components/ReferenceSelect.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import SearchableSelect, {
|
||||
filterSearchableSelectOptions,
|
||||
type SearchableSelectCreateCustomOption,
|
||||
type SearchableSelectOption,
|
||||
type SearchableSelectProps
|
||||
} from "./SearchableSelect";
|
||||
import IconButton from "./IconButton";
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
export type ReferenceAvailability =
|
||||
| "available"
|
||||
| "inactive"
|
||||
| "unavailable";
|
||||
|
||||
export type ReferenceOption = SearchableSelectOption & {
|
||||
kind?: string | null;
|
||||
availability?: ReferenceAvailability;
|
||||
sourceModule?: string | null;
|
||||
provenance?: Record<string, unknown>;
|
||||
custom?: boolean;
|
||||
};
|
||||
|
||||
export type ReferenceProviderContext = {
|
||||
limit: number;
|
||||
selectedValues: readonly string[];
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export type ReferenceOptionProvider = {
|
||||
search: (
|
||||
query: string,
|
||||
context: ReferenceProviderContext
|
||||
) => Promise<readonly ReferenceOption[]>;
|
||||
resolve?: (
|
||||
values: readonly string[],
|
||||
context: Omit<ReferenceProviderContext, "limit"> & { limit?: number }
|
||||
) => Promise<readonly ReferenceOption[]>;
|
||||
};
|
||||
|
||||
export type ConfigurationReferenceSelectorsUiCapability = {
|
||||
tenantProvider: (settings: ApiSettings) => ReferenceOptionProvider;
|
||||
changeRequestProvider: (
|
||||
settings: ApiSettings,
|
||||
options: {
|
||||
purpose: string;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
) => ReferenceOptionProvider;
|
||||
};
|
||||
|
||||
export type CredentialReferenceSelectorContext = {
|
||||
scopeType: "system" | "tenant" | "user" | "group";
|
||||
scopeId?: string | null;
|
||||
};
|
||||
|
||||
export type CredentialReferenceSelectorsUiCapability = {
|
||||
serverProvider: (
|
||||
settings: ApiSettings,
|
||||
context: CredentialReferenceSelectorContext
|
||||
) => ReferenceOptionProvider;
|
||||
};
|
||||
|
||||
export type ReferenceSelectProps = Omit<
|
||||
SearchableSelectProps,
|
||||
"options" | "loadOptions" | "selectedOption"
|
||||
> & {
|
||||
provider: ReferenceOptionProvider;
|
||||
selectedOption?: ReferenceOption | null;
|
||||
};
|
||||
|
||||
export type ReferenceMultiSelectProps = {
|
||||
id?: string;
|
||||
values: readonly string[];
|
||||
onChange: (values: string[]) => void;
|
||||
provider: ReferenceOptionProvider;
|
||||
selectedOptions?: readonly ReferenceOption[];
|
||||
createCustomOption?: SearchableSelectCreateCustomOption;
|
||||
"aria-label"?: string;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
minQueryLength?: number;
|
||||
searchLimit?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EMPTY_REFERENCE_OPTIONS: readonly ReferenceOption[] = [];
|
||||
|
||||
export default function ReferenceSelect({
|
||||
provider,
|
||||
value,
|
||||
selectedOption,
|
||||
searchLimit = 50,
|
||||
...props
|
||||
}: ReferenceSelectProps) {
|
||||
const [resolved, setResolved] = useState<ReferenceOption | null>(
|
||||
selectedOption?.value === value ? selectedOption : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setResolved(null);
|
||||
return;
|
||||
}
|
||||
setResolved(
|
||||
selectedOption?.value === value
|
||||
? selectedOption
|
||||
: unavailableReferenceOption(value)
|
||||
);
|
||||
}, [selectedOption, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value || selectedOption?.value === value || !provider.resolve) return;
|
||||
const controller = new AbortController();
|
||||
void provider
|
||||
.resolve([value], {
|
||||
selectedValues: [value],
|
||||
signal: controller.signal,
|
||||
limit: 1
|
||||
})
|
||||
.then((options) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setResolved(
|
||||
options.find((option) => option.value === value)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
!controller.signal.aborted
|
||||
&& !(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
setResolved(unavailableReferenceOption(value));
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [provider, selectedOption?.value, value]);
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
{...props}
|
||||
value={value}
|
||||
searchLimit={searchLimit}
|
||||
selectedOption={resolved}
|
||||
loadOptions={(query, context) =>
|
||||
provider.search(query, {
|
||||
...context,
|
||||
selectedValues: value ? [value] : []
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReferenceMultiSelect({
|
||||
id,
|
||||
values,
|
||||
onChange,
|
||||
provider,
|
||||
selectedOptions = EMPTY_REFERENCE_OPTIONS,
|
||||
createCustomOption,
|
||||
"aria-label": ariaLabel = "Select references",
|
||||
placeholder = "Add a reference",
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
disabled = false,
|
||||
minQueryLength,
|
||||
searchLimit = 50,
|
||||
className = ""
|
||||
}: ReferenceMultiSelectProps) {
|
||||
const normalizedValues = useMemo(
|
||||
() => [...new Set(values.map((value) => value.trim()).filter(Boolean))],
|
||||
[values]
|
||||
);
|
||||
const normalizedValuesKey = normalizedValues.join("\u0000");
|
||||
const [resolvedOptions, setResolvedOptions] = useState<
|
||||
readonly ReferenceOption[]
|
||||
>(selectedOptions);
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedOptions(selectedOptions);
|
||||
}, [selectedOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedValues.length || !provider.resolve) return;
|
||||
const controller = new AbortController();
|
||||
void provider
|
||||
.resolve(normalizedValues, {
|
||||
selectedValues: normalizedValues,
|
||||
signal: controller.signal,
|
||||
limit: normalizedValues.length
|
||||
})
|
||||
.then((options) => {
|
||||
if (!controller.signal.aborted) setResolvedOptions(options);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
!controller.signal.aborted
|
||||
&& !(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
setResolvedOptions(EMPTY_REFERENCE_OPTIONS);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [normalizedValuesKey, provider]);
|
||||
|
||||
const optionsByValue = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...selectedOptions, ...resolvedOptions].map((option) => [
|
||||
option.value,
|
||||
option
|
||||
])
|
||||
),
|
||||
[resolvedOptions, selectedOptions]
|
||||
);
|
||||
const selected = normalizedValues.map(
|
||||
(value) =>
|
||||
optionsByValue.get(value)
|
||||
?? (createCustomOption?.(value) as ReferenceOption | null | undefined)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
const rootClassName = ["reference-multi-select", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
function add(value: string, option: SearchableSelectOption | null) {
|
||||
if (!value || normalizedValues.includes(value)) return;
|
||||
if (option) {
|
||||
setResolvedOptions((current) => [
|
||||
...current.filter((item) => item.value !== value),
|
||||
option as ReferenceOption
|
||||
]);
|
||||
}
|
||||
onChange([...normalizedValues, value]);
|
||||
}
|
||||
|
||||
function remove(value: string) {
|
||||
onChange(normalizedValues.filter((item) => item !== value));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={rootClassName}>
|
||||
{selected.length ? (
|
||||
<div
|
||||
className="reference-multi-select-values"
|
||||
role="list"
|
||||
aria-label={`${ariaLabel} selected`}
|
||||
>
|
||||
{selected.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={[
|
||||
"reference-multi-select-value",
|
||||
option.availability
|
||||
? `is-${option.availability}`
|
||||
: "",
|
||||
option.custom ? "is-custom" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="listitem"
|
||||
>
|
||||
<span className="reference-multi-select-value-copy">
|
||||
<strong>{option.label}</strong>
|
||||
<small>{referenceOptionDetail(option)}</small>
|
||||
</span>
|
||||
<IconButton
|
||||
label={`Remove ${option.label}`}
|
||||
icon={<X size={15} aria-hidden="true" />}
|
||||
onClick={() => remove(option.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<SearchableSelect
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
value=""
|
||||
onChange={add}
|
||||
loadOptions={async (query, context) => {
|
||||
const options = await provider.search(query, {
|
||||
...context,
|
||||
selectedValues: normalizedValues
|
||||
});
|
||||
return options.filter(
|
||||
(option) => !normalizedValues.includes(option.value)
|
||||
);
|
||||
}}
|
||||
createCustomOption={(value) => {
|
||||
if (normalizedValues.includes(value)) return null;
|
||||
return createCustomOption?.(value) ?? null;
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
emptyText={emptyText}
|
||||
disabled={disabled}
|
||||
minQueryLength={minQueryLength}
|
||||
searchLimit={searchLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function staticReferenceOptionProvider(
|
||||
options: readonly ReferenceOption[]
|
||||
): ReferenceOptionProvider {
|
||||
const byValue = new Map(options.map((option) => [option.value, option]));
|
||||
return {
|
||||
async search(query, context) {
|
||||
if (context.signal.aborted) throw abortError();
|
||||
return filterSearchableSelectOptions(
|
||||
options,
|
||||
query,
|
||||
context.limit
|
||||
) as ReferenceOption[];
|
||||
},
|
||||
async resolve(values, context) {
|
||||
if (context.signal.aborted) throw abortError();
|
||||
return values.map(
|
||||
(value) => byValue.get(value) ?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function combineReferenceOptionProviders(
|
||||
providers: readonly ReferenceOptionProvider[]
|
||||
): ReferenceOptionProvider {
|
||||
if (providers.length === 1) return providers[0];
|
||||
|
||||
async function collect(
|
||||
operation: (
|
||||
provider: ReferenceOptionProvider
|
||||
) => Promise<readonly ReferenceOption[]>
|
||||
): Promise<ReferenceOption[]> {
|
||||
const results = await Promise.allSettled(providers.map(operation));
|
||||
const successful = results.filter(
|
||||
(
|
||||
result
|
||||
): result is PromiseFulfilledResult<readonly ReferenceOption[]> =>
|
||||
result.status === "fulfilled"
|
||||
);
|
||||
if (!successful.length) {
|
||||
const failure = results.find(
|
||||
(result): result is PromiseRejectedResult =>
|
||||
result.status === "rejected"
|
||||
);
|
||||
throw failure?.reason ?? new Error("Reference options are unavailable.");
|
||||
}
|
||||
return mergeReferenceOptions(
|
||||
successful.flatMap((result) => [...result.value])
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query, context) {
|
||||
const options = await collect((provider) =>
|
||||
provider.search(query, context)
|
||||
);
|
||||
return options.slice(0, context.limit);
|
||||
},
|
||||
async resolve(values, context) {
|
||||
const options = await collect((provider) =>
|
||||
provider.resolve
|
||||
? provider.resolve(values, context)
|
||||
: provider.search("", {
|
||||
...context,
|
||||
limit: context.limit ?? Math.max(values.length, 50)
|
||||
})
|
||||
);
|
||||
const byValue = new Map(options.map((option) => [option.value, option]));
|
||||
return values.map(
|
||||
(value) =>
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(value)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function wildcardReferenceOption(
|
||||
value: string,
|
||||
kind = "pattern"
|
||||
): ReferenceOption | null {
|
||||
const clean = value.trim();
|
||||
if (!clean || !/[*?[\]]/.test(clean)) return null;
|
||||
return {
|
||||
value: clean,
|
||||
label: clean,
|
||||
description: "Custom wildcard pattern",
|
||||
kind,
|
||||
availability: "available",
|
||||
custom: true,
|
||||
provenance: { input: "custom_pattern" }
|
||||
};
|
||||
}
|
||||
|
||||
export function customReferenceOption(
|
||||
value: string,
|
||||
kind = "custom"
|
||||
): ReferenceOption | null {
|
||||
const clean = value.trim();
|
||||
if (!clean) return null;
|
||||
return {
|
||||
value: clean,
|
||||
label: clean,
|
||||
description: "Custom reference",
|
||||
kind,
|
||||
availability: "available",
|
||||
custom: true,
|
||||
provenance: { input: "custom_reference" }
|
||||
};
|
||||
}
|
||||
|
||||
export function unavailableReferenceOption(
|
||||
value: string,
|
||||
label = "Unavailable reference"
|
||||
): ReferenceOption {
|
||||
return {
|
||||
value,
|
||||
label,
|
||||
description: value,
|
||||
availability: "unavailable",
|
||||
disabled: true,
|
||||
provenance: { retainedReference: true }
|
||||
};
|
||||
}
|
||||
|
||||
function mergeReferenceOptions(
|
||||
options: readonly ReferenceOption[]
|
||||
): ReferenceOption[] {
|
||||
const merged = new Map<string, ReferenceOption>();
|
||||
for (const option of options) {
|
||||
const current = merged.get(option.value);
|
||||
if (!current || referenceOptionPriority(option) > referenceOptionPriority(current)) {
|
||||
merged.set(option.value, option);
|
||||
}
|
||||
}
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function referenceOptionPriority(option: ReferenceOption): number {
|
||||
if (option.availability === "unavailable") return 0;
|
||||
if (option.availability === "inactive" || option.disabled) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function referenceOptionDetail(option: ReferenceOption): string {
|
||||
const state = option.custom
|
||||
? option.kind === "pattern"
|
||||
? "Pattern"
|
||||
: "Custom"
|
||||
: option.availability === "inactive"
|
||||
? "Inactive"
|
||||
: option.availability === "unavailable"
|
||||
? "Unavailable"
|
||||
: "";
|
||||
return [state, option.description, option.value !== option.label ? option.value : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
379
webui/src/components/SearchableSelect.tsx
Normal file
379
webui/src/components/SearchableSelect.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent
|
||||
} from "react";
|
||||
import { ChevronDown, Search } from "lucide-react";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
|
||||
export type SearchableSelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
searchText?: string | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type SearchableSelectLoadOptions = (
|
||||
query: string,
|
||||
options: { limit: number; signal: AbortSignal }
|
||||
) => Promise<readonly SearchableSelectOption[]>;
|
||||
|
||||
export type SearchableSelectCreateCustomOption = (
|
||||
value: string
|
||||
) => SearchableSelectOption | null;
|
||||
|
||||
export type SearchableSelectProps = {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (
|
||||
value: string,
|
||||
option: SearchableSelectOption | null
|
||||
) => void;
|
||||
options?: readonly SearchableSelectOption[];
|
||||
loadOptions?: SearchableSelectLoadOptions;
|
||||
createCustomOption?: SearchableSelectCreateCustomOption;
|
||||
selectedOption?: SearchableSelectOption | null;
|
||||
"aria-label"?: string;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
loadingText?: string;
|
||||
errorText?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
minQueryLength?: number;
|
||||
searchLimit?: number;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const EMPTY_OPTIONS: readonly SearchableSelectOption[] = [];
|
||||
|
||||
export function filterSearchableSelectOptions(
|
||||
options: readonly SearchableSelectOption[],
|
||||
query: string,
|
||||
limit = 50
|
||||
): SearchableSelectOption[] {
|
||||
const needle = query.trim().toLocaleLowerCase();
|
||||
const filtered = needle
|
||||
? options.filter((option) =>
|
||||
[
|
||||
option.label,
|
||||
option.description ?? "",
|
||||
option.searchText ?? "",
|
||||
option.value
|
||||
]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
: [...options];
|
||||
return filtered.slice(0, Math.max(1, Math.floor(limit)));
|
||||
}
|
||||
|
||||
export default function SearchableSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options = EMPTY_OPTIONS,
|
||||
loadOptions,
|
||||
createCustomOption,
|
||||
selectedOption,
|
||||
"aria-label": ariaLabel = "Select an option",
|
||||
placeholder = "Select an option",
|
||||
searchPlaceholder = "Search...",
|
||||
emptyText = "No matching options.",
|
||||
loadingText = "Loading...",
|
||||
errorText = "Options could not be loaded.",
|
||||
disabled = false,
|
||||
required = false,
|
||||
minQueryLength = 0,
|
||||
searchLimit = 50,
|
||||
debounceMs = 200,
|
||||
className = ""
|
||||
}: SearchableSelectProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
const pickerId = id || `searchable-select-${generatedId}`;
|
||||
const listboxId = `${pickerId}-results`;
|
||||
const statusId = `${pickerId}-status`;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const resolvedSelected = useMemo(
|
||||
() =>
|
||||
selectedOption ??
|
||||
options.find((option) => option.value === value) ??
|
||||
null,
|
||||
[options, selectedOption, value]
|
||||
);
|
||||
const selectedLabel = resolvedSelected?.label ?? value;
|
||||
const [inputValue, setInputValue] = useState(selectedLabel);
|
||||
const [results, setResults] =
|
||||
useState<readonly SearchableSelectOption[]>(EMPTY_OPTIONS);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const normalizedMinLength = Math.max(0, Math.floor(minQueryLength));
|
||||
const normalizedLimit = Math.max(1, Math.min(200, Math.floor(searchLimit)));
|
||||
const visibleResults = useMemo(() => {
|
||||
const customValue = inputValue.trim();
|
||||
const customOption = customValue ? createCustomOption?.(customValue) : null;
|
||||
if (
|
||||
!customOption
|
||||
|| results.some((option) => option.value === customOption.value)
|
||||
) {
|
||||
return results;
|
||||
}
|
||||
return [...results, customOption];
|
||||
}, [createCustomOption, inputValue, results]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setInputValue(selectedLabel);
|
||||
}, [open, selectedLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || disabled) {
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
setResults(EMPTY_OPTIONS);
|
||||
return;
|
||||
}
|
||||
const query = inputValue.trim();
|
||||
if (query.length < normalizedMinLength) {
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
setResults(EMPTY_OPTIONS);
|
||||
return;
|
||||
}
|
||||
if (!loadOptions) {
|
||||
setResults(filterSearchableSelectOptions(options, query, normalizedLimit));
|
||||
setLoading(false);
|
||||
setFailed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
void loadOptions(query, {
|
||||
limit: normalizedLimit,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then((nextOptions) => {
|
||||
if (!controller.signal.aborted) setResults(nextOptions);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setResults(EMPTY_OPTIONS);
|
||||
setFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
}, Math.max(0, Math.floor(debounceMs)));
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [
|
||||
debounceMs,
|
||||
disabled,
|
||||
inputValue,
|
||||
loadOptions,
|
||||
normalizedLimit,
|
||||
normalizedMinLength,
|
||||
open,
|
||||
options
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedIndex = visibleResults.findIndex(
|
||||
(option) => option.value === value && !option.disabled
|
||||
);
|
||||
const firstEnabledIndex = visibleResults.findIndex(
|
||||
(option) => !option.disabled
|
||||
);
|
||||
setActiveIndex(selectedIndex >= 0 ? selectedIndex : firstEnabledIndex);
|
||||
}, [value, visibleResults]);
|
||||
|
||||
function choose(option: SearchableSelectOption) {
|
||||
if (disabled || option.disabled) return;
|
||||
onChange(option.value, option);
|
||||
setInputValue(option.label);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
|
||||
function moveActive(delta: 1 | -1) {
|
||||
const enabledIndexes = visibleResults
|
||||
.map((option, index) => ({ option, index }))
|
||||
.filter(({ option }) => !option.disabled)
|
||||
.map(({ index }) => index);
|
||||
if (!enabledIndexes.length) {
|
||||
setActiveIndex(-1);
|
||||
return;
|
||||
}
|
||||
const currentPosition = enabledIndexes.indexOf(activeIndex);
|
||||
const nextPosition =
|
||||
currentPosition < 0
|
||||
? delta > 0
|
||||
? 0
|
||||
: enabledIndexes.length - 1
|
||||
: (currentPosition + delta + enabledIndexes.length) %
|
||||
enabledIndexes.length;
|
||||
setActiveIndex(enabledIndexes[nextPosition]);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
moveActive(-1);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && open && activeIndex >= 0) {
|
||||
const option = visibleResults[activeIndex];
|
||||
if (option && !option.disabled) {
|
||||
event.preventDefault();
|
||||
choose(option);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
setInputValue(selectedLabel);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}
|
||||
|
||||
function closeOnFocusLeave(event: FocusEvent<HTMLDivElement>) {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setInputValue(selectedLabel);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
|
||||
const minimumLengthMissing =
|
||||
inputValue.trim().length < normalizedMinLength;
|
||||
const visibleStatus = loading
|
||||
? loadingText
|
||||
: failed
|
||||
? errorText
|
||||
: minimumLengthMissing
|
||||
? `Type at least ${normalizedMinLength} characters.`
|
||||
: !visibleResults.length
|
||||
? emptyText
|
||||
: "";
|
||||
const rootClassName = ["searchable-select", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={rootClassName}
|
||||
onBlur={closeOnFocusLeave}
|
||||
>
|
||||
<div className="searchable-select-control">
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
id={pickerId}
|
||||
type="search"
|
||||
role="combobox"
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
required={required && !value}
|
||||
placeholder={translateText(
|
||||
open ? searchPlaceholder : placeholder
|
||||
)}
|
||||
aria-label={translateText(ariaLabel)}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={
|
||||
open && activeIndex >= 0
|
||||
? `${pickerId}-option-${activeIndex}`
|
||||
: undefined
|
||||
}
|
||||
aria-describedby={statusId}
|
||||
aria-busy={loading}
|
||||
onFocus={() => {
|
||||
if (!open) setInputValue("");
|
||||
setOpen(true);
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setInputValue(event.target.value);
|
||||
setOpen(true);
|
||||
setFailed(false);
|
||||
if (value) onChange("", null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<ChevronDown size={17} aria-hidden="true" />
|
||||
</div>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
id={listboxId}
|
||||
className="searchable-select-results"
|
||||
role="listbox"
|
||||
aria-label={translateText(ariaLabel)}
|
||||
>
|
||||
{visibleResults.map((option, index) => (
|
||||
<button
|
||||
key={option.value}
|
||||
id={`${pickerId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
className={[
|
||||
"searchable-select-option",
|
||||
option.value === value ? "is-selected" : "",
|
||||
index === activeIndex ? "is-active" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
disabled={option.disabled}
|
||||
onMouseEnter={() => {
|
||||
if (!option.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(option)}
|
||||
>
|
||||
<strong>{option.label}</strong>
|
||||
{option.description && <small>{option.description}</small>}
|
||||
</button>
|
||||
))}
|
||||
{visibleStatus && (
|
||||
<p className="searchable-select-message">
|
||||
{translateText(visibleStatus)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span id={statusId} className="sr-only" aria-live="polite">
|
||||
{translateText(visibleStatus)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||
const accountRef = useRef<HTMLDivElement>(null);
|
||||
const tenantRef = useRef<HTMLDivElement>(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<NotificationSummary>(settings, "/api/v1/notifications/summary", { cache: "no-store" });
|
||||
if (!cancelled) {
|
||||
setUnreadNotificationCount(summary.show_unread_badge === false ? 0 : Math.max(0, Number(summary.unread) || 0));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setUnreadNotificationCount(0);
|
||||
}
|
||||
if (!isApiError(error, 401, 403, 404)) {
|
||||
console.error("Failed to load notification summary", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void refreshNotificationSummary();
|
||||
const intervalId = window.setInterval(refreshNotificationSummary, 60_000);
|
||||
window.addEventListener("focus", refreshNotificationSummary);
|
||||
window.addEventListener("govoplan:notifications-changed", refreshNotificationSummary);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", refreshNotificationSummary);
|
||||
window.removeEventListener("govoplan:notifications-changed", refreshNotificationSummary);
|
||||
};
|
||||
}, [activeTenant?.id, auth?.user?.id, notificationsAvailable, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function handleLogin(response: LoginResponse) {
|
||||
onAuthChange(response, "");
|
||||
}
|
||||
@@ -185,8 +156,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
</button>
|
||||
}
|
||||
|
||||
{auth && activeTenant && showTenantControl &&
|
||||
<div className="tenant-selector" ref={tenantRef}>
|
||||
{auth && showContextSelectors &&
|
||||
<div className="titlebar-context-selectors">
|
||||
{activeTenant && showTenantControl &&
|
||||
<div className="tenant-selector" ref={tenantRef}>
|
||||
<span className="tenant-label">{translateText("i18n:govoplan-core.tenant_label_prefix")}</span>
|
||||
{canSwitchTenant ?
|
||||
<>
|
||||
@@ -218,13 +191,15 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
<strong>{activeTenant.name}</strong>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
{auth && ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
<LanguageMenu />
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
|
||||
@@ -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<T = unknown>(capabilityName: string): T | null {
|
||||
return uiCapability<T>(capabilityName, useViewVisibleModules());
|
||||
const modules = useViewVisibleModules();
|
||||
return useMemo(
|
||||
() => uiCapability<T>(capabilityName, modules),
|
||||
[capabilityName, modules]
|
||||
);
|
||||
}
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
|
||||
const modules = useViewVisibleModules();
|
||||
return useMemo(
|
||||
() => uiCapabilities<T>(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]
|
||||
);
|
||||
}
|
||||
|
||||
163
webui/src/platform/referenceProviders.ts
Normal file
163
webui/src/platform/referenceProviders.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiQueryParams
|
||||
} from "../api/client";
|
||||
import { fetchPlatformModules } from "../api/platform";
|
||||
import { filterSearchableSelectOptions } from "../components/SearchableSelect";
|
||||
import {
|
||||
unavailableReferenceOption,
|
||||
type ReferenceOption,
|
||||
type ReferenceOptionProvider
|
||||
} from "../components/ReferenceSelect";
|
||||
import type { ApiSettings } from "../types";
|
||||
|
||||
type ApiReferenceOptionPayload = {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
kind?: string | null;
|
||||
availability?: ReferenceOption["availability"];
|
||||
disabled?: boolean;
|
||||
source_module?: string | null;
|
||||
provenance?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function apiReferenceOptionProvider(
|
||||
settings: ApiSettings,
|
||||
path: string,
|
||||
params: ApiQueryParams = {}
|
||||
): ReferenceOptionProvider {
|
||||
async function load(
|
||||
query: string,
|
||||
selectedValues: readonly string[],
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
): Promise<ReferenceOption[]> {
|
||||
const response = await apiFetch<{
|
||||
options: ApiReferenceOptionPayload[];
|
||||
provider_available: boolean;
|
||||
}>(
|
||||
settings,
|
||||
apiPath(path, {
|
||||
...params,
|
||||
q: query,
|
||||
selected: selectedValues,
|
||||
limit
|
||||
}),
|
||||
{ signal }
|
||||
);
|
||||
return response.options.map(referenceOptionFromPayload);
|
||||
}
|
||||
|
||||
return {
|
||||
search: (query, context) =>
|
||||
load(query, context.selectedValues, context.limit, context.signal),
|
||||
resolve: (values, context) =>
|
||||
load("", values, context.limit ?? values.length, context.signal)
|
||||
};
|
||||
}
|
||||
|
||||
export function platformModuleReferenceProvider(
|
||||
settings: ApiSettings,
|
||||
presentedOptions: readonly ReferenceOption[] = []
|
||||
): ReferenceOptionProvider {
|
||||
const presentedByValue = new Map(
|
||||
presentedOptions.map((option) => [option.value, option])
|
||||
);
|
||||
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
|
||||
|
||||
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
|
||||
cataloguePromise ??= loadPlatformModuleOptions(
|
||||
settings,
|
||||
presentedOptions,
|
||||
presentedByValue
|
||||
).catch((error: unknown) => {
|
||||
cataloguePromise = null;
|
||||
throw error;
|
||||
});
|
||||
const options = await cataloguePromise;
|
||||
if (signal.aborted) throw abortError();
|
||||
return options;
|
||||
}
|
||||
|
||||
return {
|
||||
async search(query, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
return filterSearchableSelectOptions(
|
||||
options,
|
||||
query,
|
||||
context.limit
|
||||
) as ReferenceOption[];
|
||||
},
|
||||
async resolve(values, context) {
|
||||
const options = await catalogue(context.signal);
|
||||
const byValue = new Map(
|
||||
[...presentedOptions, ...options].map((option) => [
|
||||
option.value,
|
||||
option
|
||||
])
|
||||
);
|
||||
return values.map(
|
||||
(value) =>
|
||||
byValue.get(value)
|
||||
?? unavailableReferenceOption(value, "Unavailable module")
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPlatformModuleOptions(
|
||||
settings: ApiSettings,
|
||||
presentedOptions: readonly ReferenceOption[],
|
||||
presentedByValue: ReadonlyMap<string, ReferenceOption>
|
||||
): Promise<ReferenceOption[]> {
|
||||
let modules;
|
||||
try {
|
||||
modules = (await fetchPlatformModules(settings)).modules;
|
||||
} catch (error) {
|
||||
if (presentedOptions.length) return [...presentedOptions];
|
||||
throw error;
|
||||
}
|
||||
return modules
|
||||
.filter((module) => module.enabled)
|
||||
.map((module) => {
|
||||
const presented = presentedByValue.get(module.id);
|
||||
return {
|
||||
value: module.id,
|
||||
label: presented?.label ?? module.name ?? module.id,
|
||||
description: presented?.description
|
||||
?? `${module.id} · version ${module.version}`,
|
||||
searchText: presented?.searchText
|
||||
?? `${module.id} ${module.name} ${module.version}`,
|
||||
kind: "module",
|
||||
availability: presented?.availability ?? "available",
|
||||
disabled: presented?.disabled ?? false,
|
||||
sourceModule: "core",
|
||||
provenance: {
|
||||
version: module.version,
|
||||
activeRegistry: true,
|
||||
...(presented?.provenance ?? {})
|
||||
}
|
||||
} satisfies ReferenceOption;
|
||||
});
|
||||
}
|
||||
|
||||
function referenceOptionFromPayload(
|
||||
option: ApiReferenceOptionPayload
|
||||
): ReferenceOption {
|
||||
return {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
kind: option.kind,
|
||||
availability: option.availability,
|
||||
disabled: option.disabled,
|
||||
sourceModule: option.source_module,
|
||||
provenance: option.provenance
|
||||
};
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("The operation was aborted.", "AbortError");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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<DashboardWidgetsUiCapability>(
|
||||
"dashboard.widgets",
|
||||
[dashboardProvider]
|
||||
);
|
||||
assert(
|
||||
resolvedDashboardWidgets?.widgets[0].configurationFields?.length === 2,
|
||||
"dashboard widget capabilities should retain provider-defined configuration"
|
||||
);
|
||||
|
||||
const routes = routeContributionsForModules([access, files, mail, campaigns]).map((route) => route.path);
|
||||
assert(routes.join(",") === "/campaigns,/files,/mail", "routes should aggregate and sort by contribution order");
|
||||
|
||||
|
||||
@@ -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(
|
||||
</SelectionList>
|
||||
);
|
||||
assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged");
|
||||
|
||||
const directoryOptions = [
|
||||
{
|
||||
value: "account-1",
|
||||
label: "Ada Lovelace",
|
||||
description: "ada@example.test"
|
||||
},
|
||||
{
|
||||
value: "group-1",
|
||||
label: "Finance",
|
||||
searchText: "cost centre"
|
||||
}
|
||||
];
|
||||
assert(
|
||||
filterSearchableSelectOptions(directoryOptions, "example").length === 1,
|
||||
"searchable selects match option descriptions"
|
||||
);
|
||||
assert(
|
||||
filterSearchableSelectOptions(directoryOptions, "cost centre")[0]?.value ===
|
||||
"group-1",
|
||||
"searchable selects include explicit search aliases"
|
||||
);
|
||||
|
||||
const searchableMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<SearchableSelect
|
||||
id="assignment-target"
|
||||
aria-label="Assignment target"
|
||||
value="account-1"
|
||||
options={directoryOptions}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(
|
||||
searchableMarkup.includes('role="combobox"'),
|
||||
"searchable selects expose combobox semantics"
|
||||
);
|
||||
assert(
|
||||
searchableMarkup.includes('aria-controls="assignment-target-results"'),
|
||||
"searchable selects associate their input and result list"
|
||||
);
|
||||
assert(
|
||||
searchableMarkup.includes('value="Ada Lovelace"'),
|
||||
"searchable selects render labels instead of technical ids"
|
||||
);
|
||||
|
||||
const referenceMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
<ReferenceMultiSelect
|
||||
id="policy-references"
|
||||
aria-label="Policy references"
|
||||
values={["group-1", "legacy-*", "removed"]}
|
||||
onChange={() => undefined}
|
||||
selectedOptions={[
|
||||
{
|
||||
value: "group-1",
|
||||
label: "Finance",
|
||||
description: "Access group"
|
||||
}
|
||||
]}
|
||||
provider={staticReferenceOptionProvider([
|
||||
{
|
||||
value: "group-1",
|
||||
label: "Finance",
|
||||
description: "Access group"
|
||||
}
|
||||
])}
|
||||
createCustomOption={(value) => wildcardReferenceOption(value)}
|
||||
/>
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(
|
||||
referenceMarkup.includes("Finance"),
|
||||
"reference multi-selects render known labels"
|
||||
);
|
||||
assert(
|
||||
referenceMarkup.includes("Pattern"),
|
||||
"reference multi-selects distinguish wildcard patterns"
|
||||
);
|
||||
assert(
|
||||
referenceMarkup.includes("Unavailable reference"),
|
||||
"reference multi-selects retain unavailable values"
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user