feat: implement governed service directory
This commit is contained in:
@@ -3,3 +3,30 @@
|
|||||||
<!-- govoplan-repository-type:start -->
|
<!-- govoplan-repository-type:start -->
|
||||||
**Repository type:** module (domain).
|
**Repository type:** module (domain).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
Portal owns service discovery, presentation, and channel entry. Its
|
||||||
|
`portal.service_directory` capability projects provider-owned, versioned
|
||||||
|
service definitions into available, explainably unavailable, or undiscoverable
|
||||||
|
entries. It does not persist the institutional service promise or import Cases,
|
||||||
|
Forms, Workflow, Access, or Policy tables.
|
||||||
|
|
||||||
|
The capability works in a reduced composition without a Services provider by
|
||||||
|
returning an empty directory. When a provider is present, provider-level access
|
||||||
|
filtering remains authoritative and Portal adds only presentation-oriented
|
||||||
|
availability checks for publication, effective time, audience, module, and
|
||||||
|
capability requirements.
|
||||||
|
|
||||||
|
The tenant-scoped `/api/v1/portal/services` endpoint and `/portal` WebUI expose
|
||||||
|
the projection. Audience visibility is derived from trusted principal and
|
||||||
|
active function-assignment state on the server.
|
||||||
|
|
||||||
|
`POST /api/v1/portal/services/{service_id}/launch` re-fetches and re-evaluates
|
||||||
|
the exact Service revision before any effect. URL entries return a validated
|
||||||
|
redirect. Case, form, and workflow bindings delegate through the optional
|
||||||
|
`cases.service_launcher`, `forms_runtime.service_launcher`, and
|
||||||
|
`workflow_engine.service_launcher` contracts; each is tenant-bound and
|
||||||
|
replay-safe. A missing owner launcher makes the entry explainably unavailable.
|
||||||
|
Form launch resolves an exact published `<form-id>/<revision>` and returns the
|
||||||
|
owner's Form-instance route rather than persisting values in Portal.
|
||||||
|
|
||||||
|
See [docs/SERVICE_DIRECTORY_CONCEPT.md](docs/SERVICE_DIRECTORY_CONCEPT.md).
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ GovOPlaN Portal should expose a role-aware service directory. The directory is
|
|||||||
not a marketing catalogue; it is the user-facing map of what a person can do in
|
not a marketing catalogue; it is the user-facing map of what a person can do in
|
||||||
the configured institution.
|
the configured institution.
|
||||||
|
|
||||||
|
Portal owns presentation, discovery, entry, and channel-specific availability.
|
||||||
|
It must not become the long-term owner of the institutional service definition.
|
||||||
|
A shared Services contract should own the versioned promise independently so
|
||||||
|
Cases, Forms, Workflow Engine, Reporting, external publication, and more than
|
||||||
|
one portal surface can consume the same definition.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
The service directory should help users find the right administrative action
|
The service directory should help users find the right administrative action
|
||||||
@@ -34,11 +40,22 @@ A service entry should describe:
|
|||||||
- policy and availability blockers
|
- policy and availability blockers
|
||||||
- user-facing explanation and required documents
|
- user-facing explanation and required documents
|
||||||
- audit and evidence expectations
|
- audit and evidence expectations
|
||||||
|
- version and effective interval
|
||||||
|
- legal or organizational basis, deadlines, fees, remedies, and service-level
|
||||||
|
expectations
|
||||||
|
- responsible organization/function, mandate/jurisdiction, result/decision,
|
||||||
|
publication, and records bindings
|
||||||
|
|
||||||
The portal should request service contributions through core-mediated
|
The portal should request service contributions through core-mediated
|
||||||
capability and UI contribution contracts. It must not import domain module
|
capability and UI contribution contracts. It must not import domain module
|
||||||
internals.
|
internals.
|
||||||
|
|
||||||
|
The first implementation may consume module-contributed service definitions.
|
||||||
|
Create a separate `govoplan-services` repository only when definitions require
|
||||||
|
independent persistence/lifecycle and are consumed outside Portal by at least
|
||||||
|
one other procedure or publication surface. Extraction must keep stable service
|
||||||
|
keys and compatibility for existing Portal links.
|
||||||
|
|
||||||
Service visibility must consume access semantics through kernel capabilities:
|
Service visibility must consume access semantics through kernel capabilities:
|
||||||
|
|
||||||
- `access.semanticDirectory` resolves the actor's identity, account,
|
- `access.semanticDirectory` resolves the actor's identity, account,
|
||||||
@@ -56,3 +73,54 @@ The directory should explain unavailable services when the reason is useful:
|
|||||||
missing role, disabled module, tenant policy, missing connector, maintenance
|
missing role, disabled module, tenant policy, missing connector, maintenance
|
||||||
mode, or unavailable external provider. It should hide only services that are
|
mode, or unavailable external provider. It should hide only services that are
|
||||||
irrelevant or intentionally undiscoverable by policy.
|
irrelevant or intentionally undiscoverable by policy.
|
||||||
|
|
||||||
|
## Implemented Slice
|
||||||
|
|
||||||
|
`portal.service_directory` consumes the optional `services.definitions`
|
||||||
|
capability. It returns only tenant-scoped definitions and distinguishes
|
||||||
|
`available`, `unavailable`, and `hidden` states. Draft, retired,
|
||||||
|
outside-effective-time, and audience-inapplicable services are hidden. A
|
||||||
|
suspended service or missing required module/capability remains discoverable
|
||||||
|
with stable reason codes and its configured explanation reference.
|
||||||
|
|
||||||
|
The definition provider remains responsible for access and policy filtering.
|
||||||
|
Portal cannot widen its result and returns an empty directory when no provider
|
||||||
|
is installed. No service definition is persisted in Portal.
|
||||||
|
|
||||||
|
Tenant/package specializations are derived before presentation through Core's
|
||||||
|
restrictive Service rule. They carry their parent version and may narrow
|
||||||
|
audience, channels, effective time, and publication or add prerequisites,
|
||||||
|
required evidence, legal bases, and bindings. They cannot silently loosen the
|
||||||
|
system/package definition.
|
||||||
|
|
||||||
|
Policy, Mandate, connector, maintenance, and configuration requirements are
|
||||||
|
evaluated through the optional `services.availability` capability. Module and
|
||||||
|
capability requirements are checked against the active registry, and audience
|
||||||
|
requirements against the supplied actor projection. Unknown requirements fail
|
||||||
|
closed. Each requirement declares whether failure remains visible with an
|
||||||
|
explanation or makes the entry undiscoverable.
|
||||||
|
|
||||||
|
`GET /api/v1/portal/services` and the `/portal` WebUI expose this projection.
|
||||||
|
The API derives audience tokens from the authenticated principal instead of
|
||||||
|
accepting caller-provided audience claims. Generic `public` and `authenticated`
|
||||||
|
tokens are combined with stable account, identity, group, role, active function,
|
||||||
|
and organization-unit tokens. Function slugs are included only after the
|
||||||
|
optional Access semantic directory confirms an active, effective, same-tenant
|
||||||
|
assignment. Missing or failing directory providers narrow the result.
|
||||||
|
|
||||||
|
The WebUI provides bounded search, availability filtering, explanations, and
|
||||||
|
one launch action. `POST /api/v1/portal/services/{service_id}/launch` resolves
|
||||||
|
the entry again at launch time so a stale browser projection cannot bypass a
|
||||||
|
new audience, publication, effective-time, module, or capability restriction.
|
||||||
|
Direct URL entries return only validated local or credential-free HTTP(S)
|
||||||
|
redirects.
|
||||||
|
|
||||||
|
Case, form, and workflow bindings delegate to owner capabilities. Their launch result
|
||||||
|
must retain the exact Service reference, tenant, binding kind, target reference,
|
||||||
|
and replay-safe idempotency key. Portal never writes their tables. Missing
|
||||||
|
launchers produce `service.launcher.missing:<kind>` and make the entry visibly
|
||||||
|
unavailable. Forms Runtime now provides a definition-aware launcher when Forms
|
||||||
|
and Forms Runtime are active. It resolves an exact published
|
||||||
|
`<form-id>/<revision>`, validates launch values, and retains Service/binding
|
||||||
|
provenance. Reduced installations still fail closed rather than simulating a
|
||||||
|
submission in Portal.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-portal"
|
||||||
|
version = "0.1.8"
|
||||||
|
description = "GovOPlaN service discovery and public portal module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = ["govoplan-core>=0.1.8"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
govoplan_portal = ["py.typed"]
|
||||||
|
|
||||||
|
[project.entry-points."govoplan.modules"]
|
||||||
|
"portal" = "govoplan_portal.backend.manifest:get_manifest"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""GovOPlaN Portal module."""
|
||||||
|
|
||||||
|
from govoplan_portal.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
__all__ = ["get_manifest"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Portal backend contracts."""
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
service_launch_capability,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
RoleTemplate,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.provider_governance import (
|
||||||
|
ModuleArchitectureDeclaration,
|
||||||
|
ModuleArchitectureDocumentation,
|
||||||
|
ModuleMaturityEvidence,
|
||||||
|
)
|
||||||
|
from govoplan_portal.backend.service_directory import (
|
||||||
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY,
|
||||||
|
PortalServiceDirectory,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_ID = "portal"
|
||||||
|
MODULE_VERSION = "0.1.8"
|
||||||
|
READ_SCOPE = "portal:service:read"
|
||||||
|
SERVICE_LAUNCH_CAPABILITIES = tuple(
|
||||||
|
service_launch_capability(kind) for kind in ("case", "form", "workflow")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_directory(context: ModuleContext) -> PortalServiceDirectory:
|
||||||
|
return PortalServiceDirectory(context.registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _router(context: ModuleContext):
|
||||||
|
from govoplan_portal.backend.router import configure_registry, router
|
||||||
|
|
||||||
|
configure_registry(context.registry)
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name="Portal",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
optional_dependencies=(
|
||||||
|
"access",
|
||||||
|
"services",
|
||||||
|
"cases",
|
||||||
|
"forms",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
),
|
||||||
|
optional_capabilities=(
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
*SERVICE_LAUNCH_CAPABILITIES,
|
||||||
|
),
|
||||||
|
permissions=(
|
||||||
|
PermissionDefinition(
|
||||||
|
scope=READ_SCOPE,
|
||||||
|
label="View service directory",
|
||||||
|
description="Discover services available to the current account and function assignments.",
|
||||||
|
category="Portal",
|
||||||
|
level="tenant",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
resource="service",
|
||||||
|
action="read",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
role_templates=(
|
||||||
|
RoleTemplate(
|
||||||
|
slug="portal_user",
|
||||||
|
name="Portal user",
|
||||||
|
description="Discover and enter available institutional services.",
|
||||||
|
permissions=(READ_SCOPE,),
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
provides_interfaces=(
|
||||||
|
ModuleInterfaceProvider(name="portal.service_directory", version="0.1.0"),
|
||||||
|
),
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(name="services.definition", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
ModuleInterfaceRequirement(name="services.availability", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||||
|
*(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=capability,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
)
|
||||||
|
for capability in SERVICE_LAUNCH_CAPABILITIES
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
||||||
|
},
|
||||||
|
route_factory=_router,
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/portal",
|
||||||
|
label="Services",
|
||||||
|
icon="landmark",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
package_name="@govoplan/portal-webui",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/portal",
|
||||||
|
component="PortalPage",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/portal",
|
||||||
|
label="Services",
|
||||||
|
icon="landmark",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="portal.navigation",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="navigation",
|
||||||
|
label="Services navigation",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="portal.directory",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Service directory",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_documentation={
|
||||||
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY: CapabilityDocumentation(
|
||||||
|
label="Portal service directory",
|
||||||
|
summary="Projects governed service definitions into role-aware availability entries.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.service-directory",
|
||||||
|
title="Service directory",
|
||||||
|
summary="Find services available in the configured institution and understand relevant availability limits.",
|
||||||
|
body=(
|
||||||
|
"Portal presents provider-owned, versioned service definitions. "
|
||||||
|
"Published services may be available, unavailable with a reason, "
|
||||||
|
"or undiscoverable when they do not apply to the current audience. "
|
||||||
|
"Opening a service re-evaluates that exact revision and delegates case, "
|
||||||
|
"form, or workflow startup to an installed owner capability."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Service directory architecture",
|
||||||
|
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
architecture=ModuleArchitectureDeclaration(
|
||||||
|
layer="communication_participation",
|
||||||
|
kind="domain",
|
||||||
|
maturity="vertical_slice",
|
||||||
|
evidence=(
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="test",
|
||||||
|
reference="tests/test_service_directory.py",
|
||||||
|
summary="Proves provider-neutral service discovery and explained availability.",
|
||||||
|
),
|
||||||
|
ModuleMaturityEvidence(
|
||||||
|
kind="documentation",
|
||||||
|
reference="docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||||
|
summary="Defines Portal presentation and Services ownership boundaries.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
known_limits=(
|
||||||
|
"Portal does not persist service definitions; the Services provider remains authoritative.",
|
||||||
|
"Case, Forms Runtime, and Workflow Engine own launch effects. Portal keeps entries unavailable whenever the selected owner capability is absent.",
|
||||||
|
),
|
||||||
|
owned_concepts=("service discovery", "service presentation", "channel entry"),
|
||||||
|
non_owned_concepts=("institutional service definition", "case lifecycle"),
|
||||||
|
reference_packages=("product.service-to-decision",),
|
||||||
|
documentation=ModuleArchitectureDocumentation(
|
||||||
|
security=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||||
|
operations=("docs/SERVICE_DIRECTORY_CONCEPT.md",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_manifest() -> ModuleManifest:
|
||||||
|
return manifest
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_portal.backend.schemas import (
|
||||||
|
PortalServiceLaunchRequest,
|
||||||
|
PortalServiceLaunchResponse,
|
||||||
|
PortalServiceListResponse,
|
||||||
|
)
|
||||||
|
from govoplan_portal.backend.service_directory import (
|
||||||
|
PortalServiceDirectory,
|
||||||
|
PortalServiceLaunchError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
READ_SCOPE = "portal:service:read"
|
||||||
|
router = APIRouter(prefix="/portal", tags=["portal"])
|
||||||
|
_registry: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def configure_registry(registry: object | None) -> None:
|
||||||
|
global _registry
|
||||||
|
_registry = registry
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services", response_model=PortalServiceListResponse)
|
||||||
|
def api_list_portal_services(
|
||||||
|
q: str = Query(default="", max_length=200),
|
||||||
|
include_unavailable: bool = True,
|
||||||
|
effective_at: datetime | None = None,
|
||||||
|
limit: int = Query(default=100, ge=1, le=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PortalServiceListResponse:
|
||||||
|
if not has_scope(principal, READ_SCOPE):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||||
|
observed_at = effective_at or datetime.now(tz=UTC)
|
||||||
|
if observed_at.tzinfo is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Portal service effective_at must include a timezone.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
entries = PortalServiceDirectory(_registry).list_entries(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
effective_at=observed_at,
|
||||||
|
query=q.strip(),
|
||||||
|
limit=limit,
|
||||||
|
include_unavailable=include_unavailable,
|
||||||
|
)
|
||||||
|
except InstitutionalContextError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return PortalServiceListResponse(
|
||||||
|
services=[entry.to_dict() for entry in entries]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/services/{service_id}/launch",
|
||||||
|
response_model=PortalServiceLaunchResponse,
|
||||||
|
)
|
||||||
|
def api_launch_portal_service(
|
||||||
|
service_id: str,
|
||||||
|
payload: PortalServiceLaunchRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PortalServiceLaunchResponse:
|
||||||
|
if not has_scope(principal, READ_SCOPE):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||||
|
if payload.requested_at.tzinfo is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Portal service launch requested_at must include a timezone.",
|
||||||
|
)
|
||||||
|
reference = InstitutionalReference(
|
||||||
|
kind="service",
|
||||||
|
owner_module="services",
|
||||||
|
object_id=service_id,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
version=payload.service_version,
|
||||||
|
valid_at=payload.requested_at,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = PortalServiceDirectory(_registry).launch_service(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=reference,
|
||||||
|
requested_at=payload.requested_at,
|
||||||
|
idempotency_key=payload.idempotency_key,
|
||||||
|
parameters=payload.parameters,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except LookupError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
except PortalServiceLaunchError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
except (InstitutionalContextError, ValueError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
status_code = 409 if "conflict" in str(exc).casefold() else 400
|
||||||
|
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
||||||
|
return PortalServiceLaunchResponse(**result.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["READ_SCOPE", "configure_registry", "router"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceEntryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
definition: dict[str, Any]
|
||||||
|
state: str
|
||||||
|
reason_codes: list[str] = Field(default_factory=list)
|
||||||
|
entry_binding: dict[str, Any] | None = None
|
||||||
|
availability_evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceListResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
services: list[PortalServiceEntryResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceLaunchRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
service_version: str = Field(min_length=1, max_length=120)
|
||||||
|
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||||
|
requested_at: datetime
|
||||||
|
parameters: dict[str, Any] = Field(default_factory=dict, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceLaunchResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
service_ref: dict[str, Any]
|
||||||
|
binding: dict[str, Any]
|
||||||
|
state: str
|
||||||
|
target_ref: dict[str, Any] | None = None
|
||||||
|
href: str | None = None
|
||||||
|
replayed: bool = False
|
||||||
|
evidence: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PortalServiceEntryResponse",
|
||||||
|
"PortalServiceLaunchRequest",
|
||||||
|
"PortalServiceLaunchResponse",
|
||||||
|
"PortalServiceListResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
AccessSemanticDirectory,
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceBinding,
|
||||||
|
ServiceAvailabilityAssessment,
|
||||||
|
ServiceAvailabilityEvaluator,
|
||||||
|
ServiceDefinition,
|
||||||
|
ServiceDefinitionProvider,
|
||||||
|
ServiceLaunchRequest,
|
||||||
|
ServiceLaunchResult,
|
||||||
|
ServiceLauncher,
|
||||||
|
service_launch_capability,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY = "portal.service_directory"
|
||||||
|
ServiceDiscoveryState = Literal["available", "unavailable", "hidden"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PortalServiceEntry:
|
||||||
|
definition: ServiceDefinition
|
||||||
|
state: ServiceDiscoveryState
|
||||||
|
reason_codes: tuple[str, ...] = ()
|
||||||
|
entry_binding: ServiceBinding | None = None
|
||||||
|
availability_evidence: tuple[EvidenceReference, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discoverable(self) -> bool:
|
||||||
|
return self.state != "hidden"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return self.state == "available"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"definition": self.definition.to_dict(include_inspection=False),
|
||||||
|
"state": self.state,
|
||||||
|
"reason_codes": list(self.reason_codes),
|
||||||
|
"entry_binding": (
|
||||||
|
self.entry_binding.to_dict() if self.entry_binding else None
|
||||||
|
),
|
||||||
|
"availability_evidence": [
|
||||||
|
item.to_dict(include_inspection=False)
|
||||||
|
for item in self.availability_evidence
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceDirectory:
|
||||||
|
"""Role-aware projection over a provider-owned service definition catalogue."""
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def list_entries(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime,
|
||||||
|
audiences: tuple[str, ...] | None = None,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
include_unavailable: bool = True,
|
||||||
|
) -> tuple[PortalServiceEntry, ...]:
|
||||||
|
if not tenant_id.strip():
|
||||||
|
raise InstitutionalContextError("Portal service tenant id is required.")
|
||||||
|
if not 1 <= limit <= 200:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Portal service directory limit must be between 1 and 200."
|
||||||
|
)
|
||||||
|
effective_audiences = (
|
||||||
|
audiences
|
||||||
|
if audiences is not None
|
||||||
|
else principal_audiences(
|
||||||
|
self._registry,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
|
||||||
|
if not isinstance(provider, ServiceDefinitionProvider):
|
||||||
|
return ()
|
||||||
|
definitions = provider.list_service_definitions(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
query=query,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
evaluator = _capability(
|
||||||
|
self._registry,
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
)
|
||||||
|
entries: list[PortalServiceEntry] = []
|
||||||
|
for definition in definitions[:limit]:
|
||||||
|
assessment = None
|
||||||
|
if isinstance(evaluator, ServiceAvailabilityEvaluator):
|
||||||
|
try:
|
||||||
|
assessment = evaluator.evaluate_service_availability(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=definition,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - isolate optional evaluators.
|
||||||
|
assessment = ServiceAvailabilityAssessment(
|
||||||
|
requirement_states={},
|
||||||
|
reason_codes=("service.availability.evaluator_failed",),
|
||||||
|
)
|
||||||
|
entries.append(
|
||||||
|
self.project(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
audiences=effective_audiences,
|
||||||
|
assessment=assessment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(
|
||||||
|
entry
|
||||||
|
for entry in entries
|
||||||
|
if entry.discoverable
|
||||||
|
and (include_unavailable or entry.available)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_entry(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: InstitutionalReference,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> PortalServiceEntry | None:
|
||||||
|
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
|
||||||
|
if not isinstance(provider, ServiceDefinitionProvider):
|
||||||
|
return None
|
||||||
|
definition = provider.get_service_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=reference,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
if definition is None:
|
||||||
|
return None
|
||||||
|
if definition.reference != reference:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Service provider did not return the requested exact revision."
|
||||||
|
)
|
||||||
|
evaluator = _capability(
|
||||||
|
self._registry,
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
)
|
||||||
|
assessment = None
|
||||||
|
if isinstance(evaluator, ServiceAvailabilityEvaluator):
|
||||||
|
try:
|
||||||
|
assessment = evaluator.evaluate_service_availability(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=definition,
|
||||||
|
effective_at=effective_at,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 - optional evaluator isolation.
|
||||||
|
assessment = ServiceAvailabilityAssessment(
|
||||||
|
requirement_states={},
|
||||||
|
reason_codes=("service.availability.evaluator_failed",),
|
||||||
|
)
|
||||||
|
return self.project(
|
||||||
|
definition,
|
||||||
|
tenant_id=reference.tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
audiences=principal_audiences(
|
||||||
|
self._registry,
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=reference.tenant_id,
|
||||||
|
effective_at=effective_at,
|
||||||
|
),
|
||||||
|
assessment=assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def launch_service(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: InstitutionalReference,
|
||||||
|
requested_at: datetime,
|
||||||
|
idempotency_key: str,
|
||||||
|
parameters: dict[str, object],
|
||||||
|
) -> ServiceLaunchResult:
|
||||||
|
entry = self.get_entry(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
reference=reference,
|
||||||
|
effective_at=requested_at,
|
||||||
|
)
|
||||||
|
if entry is None or not entry.discoverable:
|
||||||
|
raise LookupError("Service not found.")
|
||||||
|
if not entry.available:
|
||||||
|
raise PortalServiceLaunchError(
|
||||||
|
"Service is currently unavailable: "
|
||||||
|
+ ", ".join(entry.reason_codes)
|
||||||
|
)
|
||||||
|
binding = entry.entry_binding
|
||||||
|
if binding is None:
|
||||||
|
raise PortalServiceLaunchError(
|
||||||
|
"Service has no supported entry binding."
|
||||||
|
)
|
||||||
|
request = ServiceLaunchRequest(
|
||||||
|
service_ref=entry.definition.reference,
|
||||||
|
binding=binding,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
requested_at=requested_at,
|
||||||
|
parameters=parameters,
|
||||||
|
)
|
||||||
|
if binding.kind in {"url", "external"}:
|
||||||
|
return ServiceLaunchResult(
|
||||||
|
service_ref=entry.definition.reference,
|
||||||
|
binding=binding,
|
||||||
|
state="redirect",
|
||||||
|
href=binding.reference,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
capability_name = service_launch_capability(binding.kind)
|
||||||
|
launcher = _capability(self._registry, capability_name)
|
||||||
|
if not isinstance(launcher, ServiceLauncher):
|
||||||
|
raise PortalServiceLaunchError(
|
||||||
|
f"Service launcher is unavailable: {capability_name}."
|
||||||
|
)
|
||||||
|
result = launcher.launch_service(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=entry.definition,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
result.service_ref != entry.definition.reference
|
||||||
|
or result.binding != binding
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Service launcher returned a result for another definition or binding."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def project(
|
||||||
|
self,
|
||||||
|
definition: ServiceDefinition,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime,
|
||||||
|
audiences: tuple[str, ...] = (),
|
||||||
|
assessment: ServiceAvailabilityAssessment | None = None,
|
||||||
|
) -> PortalServiceEntry:
|
||||||
|
if definition.reference.tenant_id != tenant_id:
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Portal cannot project a service from another tenant."
|
||||||
|
)
|
||||||
|
if assessment is not None and any(
|
||||||
|
item.tenant_id != tenant_id for item in assessment.evidence
|
||||||
|
):
|
||||||
|
raise InstitutionalContextError(
|
||||||
|
"Service availability evidence belongs to another tenant."
|
||||||
|
)
|
||||||
|
hidden_reasons: list[str] = []
|
||||||
|
unavailable_reasons: list[str] = []
|
||||||
|
if definition.publication_state in {"draft", "retired"}:
|
||||||
|
hidden_reasons.append(
|
||||||
|
f"service.publication.{definition.publication_state}"
|
||||||
|
)
|
||||||
|
elif definition.publication_state == "suspended":
|
||||||
|
unavailable_reasons.append("service.publication.suspended")
|
||||||
|
if not definition.temporal.effective_at(effective_at):
|
||||||
|
hidden_reasons.append("service.outside_effective_interval")
|
||||||
|
if not set(audiences).intersection(definition.audience):
|
||||||
|
hidden_reasons.append("service.audience.not_applicable")
|
||||||
|
|
||||||
|
for binding in definition.bindings:
|
||||||
|
if not binding.required:
|
||||||
|
continue
|
||||||
|
if binding.kind == "module" and not _has_module(
|
||||||
|
self._registry, binding.reference
|
||||||
|
):
|
||||||
|
unavailable_reasons.append(
|
||||||
|
f"service.required_module.missing:{binding.reference}"
|
||||||
|
)
|
||||||
|
elif binding.kind == "capability" and _capability(
|
||||||
|
self._registry, binding.reference
|
||||||
|
) is None:
|
||||||
|
unavailable_reasons.append(
|
||||||
|
f"service.required_capability.missing:{binding.reference}"
|
||||||
|
)
|
||||||
|
assessment_states = (
|
||||||
|
assessment.requirement_states if assessment is not None else {}
|
||||||
|
)
|
||||||
|
for requirement in definition.availability_requirements:
|
||||||
|
if requirement.kind == "module":
|
||||||
|
resolved: bool | None = _has_module(
|
||||||
|
self._registry,
|
||||||
|
requirement.reference,
|
||||||
|
)
|
||||||
|
elif requirement.kind == "capability":
|
||||||
|
resolved = (
|
||||||
|
_capability(self._registry, requirement.reference) is not None
|
||||||
|
)
|
||||||
|
elif requirement.kind == "audience":
|
||||||
|
resolved = requirement.reference in audiences
|
||||||
|
else:
|
||||||
|
resolved = assessment_states.get(requirement.key)
|
||||||
|
if resolved is True:
|
||||||
|
continue
|
||||||
|
reason = (
|
||||||
|
f"service.requirement.failed:{requirement.key}"
|
||||||
|
if resolved is False
|
||||||
|
else f"service.requirement.unknown:{requirement.key}"
|
||||||
|
)
|
||||||
|
reasons = (
|
||||||
|
hidden_reasons
|
||||||
|
if requirement.failure_state == "hidden"
|
||||||
|
else unavailable_reasons
|
||||||
|
)
|
||||||
|
reasons.append(reason)
|
||||||
|
if requirement.explanation_ref:
|
||||||
|
reasons.append(
|
||||||
|
f"service.explanation:{requirement.explanation_ref}"
|
||||||
|
)
|
||||||
|
if assessment is not None and (
|
||||||
|
hidden_reasons or unavailable_reasons
|
||||||
|
):
|
||||||
|
unavailable_reasons.extend(assessment.reason_codes)
|
||||||
|
if definition.availability_explanation_ref and unavailable_reasons:
|
||||||
|
unavailable_reasons.append(
|
||||||
|
f"service.explanation:{definition.availability_explanation_ref}"
|
||||||
|
)
|
||||||
|
entry_binding = next(
|
||||||
|
(
|
||||||
|
binding
|
||||||
|
for binding in definition.bindings
|
||||||
|
if binding.kind in {"form", "case", "workflow", "external", "url"}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if entry_binding is not None and entry_binding.kind in {
|
||||||
|
"form",
|
||||||
|
"case",
|
||||||
|
"workflow",
|
||||||
|
}:
|
||||||
|
capability_name = service_launch_capability(entry_binding.kind)
|
||||||
|
if _capability(self._registry, capability_name) is None:
|
||||||
|
unavailable_reasons.append(
|
||||||
|
f"service.launcher.missing:{capability_name}"
|
||||||
|
)
|
||||||
|
state: ServiceDiscoveryState = (
|
||||||
|
"hidden"
|
||||||
|
if hidden_reasons
|
||||||
|
else "unavailable"
|
||||||
|
if unavailable_reasons
|
||||||
|
else "available"
|
||||||
|
)
|
||||||
|
return PortalServiceEntry(
|
||||||
|
definition=definition,
|
||||||
|
state=state,
|
||||||
|
reason_codes=tuple(
|
||||||
|
dict.fromkeys((*hidden_reasons, *unavailable_reasons))
|
||||||
|
),
|
||||||
|
entry_binding=entry_binding,
|
||||||
|
availability_evidence=(assessment.evidence if assessment else ()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceLaunchError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def principal_audiences(
|
||||||
|
registry: object | None,
|
||||||
|
_session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
effective_at: datetime,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
"""Build bounded audience tokens from trusted principal state.
|
||||||
|
|
||||||
|
Definitions can target generic authenticated users and stable account,
|
||||||
|
identity, group, role, organization-unit, or function identifiers. Function
|
||||||
|
slugs are included only after resolving an active tenant-bound assignment.
|
||||||
|
Provider failures fail closed to the tokens already present on the signed-in
|
||||||
|
principal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ref = _principal_ref(principal)
|
||||||
|
if ref is None or ref.tenant_id != tenant_id:
|
||||||
|
return ("public",)
|
||||||
|
tokens = {"public", "authenticated", f"account:{ref.account_id}"}
|
||||||
|
if ref.identity_id:
|
||||||
|
tokens.add(f"identity:{ref.identity_id}")
|
||||||
|
tokens.update(f"group:{item}" for item in ref.group_ids)
|
||||||
|
tokens.update(f"role:{item}" for item in ref.role_ids)
|
||||||
|
tokens.update(
|
||||||
|
f"function-assignment:{item}" for item in ref.function_assignment_ids
|
||||||
|
)
|
||||||
|
directory = _capability(registry, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY)
|
||||||
|
if not isinstance(directory, AccessSemanticDirectory):
|
||||||
|
return tuple(sorted(tokens))
|
||||||
|
for assignment_id in sorted(ref.function_assignment_ids):
|
||||||
|
try:
|
||||||
|
assignment = directory.get_function_assignment(assignment_id)
|
||||||
|
if (
|
||||||
|
assignment is None
|
||||||
|
or assignment.tenant_id != tenant_id
|
||||||
|
or assignment.account_id != ref.account_id
|
||||||
|
or assignment.status != "active"
|
||||||
|
or (
|
||||||
|
assignment.valid_from is not None
|
||||||
|
and effective_at < assignment.valid_from
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
assignment.valid_until is not None
|
||||||
|
and effective_at >= assignment.valid_until
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
function = directory.get_function(assignment.function_id)
|
||||||
|
if (
|
||||||
|
function is None
|
||||||
|
or function.tenant_id != tenant_id
|
||||||
|
or function.status != "active"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
except Exception: # noqa: BLE001 - optional directory failures fail closed.
|
||||||
|
continue
|
||||||
|
tokens.update(
|
||||||
|
{
|
||||||
|
f"function:{function.id}",
|
||||||
|
f"function:{function.slug}",
|
||||||
|
f"organization-unit:{assignment.organization_unit_id}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(sorted(tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_ref(principal: object) -> PrincipalRef | None:
|
||||||
|
if isinstance(principal, PrincipalRef):
|
||||||
|
return principal
|
||||||
|
converter = getattr(principal, "to_platform_principal", None)
|
||||||
|
if not callable(converter):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = converter()
|
||||||
|
except Exception: # noqa: BLE001 - untrusted provider object.
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, PrincipalRef) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_module(registry: object | None, module_id: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
registry is not None
|
||||||
|
and hasattr(registry, "has")
|
||||||
|
and registry.has(module_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capability(registry: object | None, name: str) -> object | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(name)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return registry.capability(name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_PORTAL_SERVICE_DIRECTORY",
|
||||||
|
"PortalServiceDirectory",
|
||||||
|
"PortalServiceEntry",
|
||||||
|
"PortalServiceLaunchError",
|
||||||
|
"ServiceDiscoveryState",
|
||||||
|
"principal_audiences",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
EvidenceReference,
|
||||||
|
InstitutionalContextError,
|
||||||
|
InstitutionalReference,
|
||||||
|
ServiceBinding,
|
||||||
|
ServiceAvailabilityAssessment,
|
||||||
|
ServiceAvailabilityRequirement,
|
||||||
|
ServiceDefinition,
|
||||||
|
ServiceLaunchResult,
|
||||||
|
TemporalRevision,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||||
|
FunctionAssignmentRef,
|
||||||
|
FunctionRef,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
|
from govoplan_portal.backend.manifest import get_manifest
|
||||||
|
from govoplan_portal.backend.service_directory import (
|
||||||
|
PortalServiceDirectory,
|
||||||
|
principal_audiences,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def service(
|
||||||
|
*,
|
||||||
|
state: str = "published",
|
||||||
|
requirements: tuple[ServiceAvailabilityRequirement, ...] = (),
|
||||||
|
) -> ServiceDefinition:
|
||||||
|
return ServiceDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="service",
|
||||||
|
owner_module="portal",
|
||||||
|
object_id="permit",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="3",
|
||||||
|
),
|
||||||
|
key="permit.apply",
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision="3",
|
||||||
|
valid_from=NOW - timedelta(days=1),
|
||||||
|
valid_to=NOW + timedelta(days=1),
|
||||||
|
recorded_at=NOW - timedelta(days=2),
|
||||||
|
),
|
||||||
|
title="Apply for a permit",
|
||||||
|
audience=("resident",),
|
||||||
|
bindings=(
|
||||||
|
ServiceBinding("capability", "cases.service_intake"),
|
||||||
|
ServiceBinding("case", "permit-application"),
|
||||||
|
),
|
||||||
|
availability_requirements=requirements,
|
||||||
|
publication_state=state, # type: ignore[arg-type]
|
||||||
|
availability_explanation_ref="docs:permit-requirements",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
def __init__(self, definition: ServiceDefinition | None = None) -> None:
|
||||||
|
self.definition = definition or service()
|
||||||
|
|
||||||
|
def get_service_definition(self, session, principal, *, reference, effective_at=None):
|
||||||
|
return self.definition
|
||||||
|
|
||||||
|
def list_service_definitions(self, session, principal, *, tenant_id, query="", limit=100):
|
||||||
|
return (self.definition,)
|
||||||
|
|
||||||
|
|
||||||
|
class Launcher:
|
||||||
|
def launch_service(self, session, principal, *, definition, request):
|
||||||
|
return ServiceLaunchResult(
|
||||||
|
service_ref=definition.reference,
|
||||||
|
binding=request.binding,
|
||||||
|
state="started",
|
||||||
|
target_ref=InstitutionalReference(
|
||||||
|
kind="case",
|
||||||
|
owner_module="cases",
|
||||||
|
object_id="case-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="1",
|
||||||
|
),
|
||||||
|
href="/cases/case-1",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
intake: bool = False,
|
||||||
|
evaluator: object | None = None,
|
||||||
|
definition: ServiceDefinition | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.capabilities = {
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS: Provider(definition)
|
||||||
|
}
|
||||||
|
if intake:
|
||||||
|
self.capabilities["cases.service_intake"] = object()
|
||||||
|
self.capabilities["cases.service_launcher"] = Launcher()
|
||||||
|
if evaluator is not None:
|
||||||
|
self.capabilities[CAPABILITY_SERVICE_AVAILABILITY] = evaluator
|
||||||
|
|
||||||
|
def has(self, module_id: str) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self.capabilities
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
return self.capabilities[name]
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticDirectory:
|
||||||
|
def get_account(self, account_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user(self, user_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_users(self, user_ids):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def users_for_tenant(self, tenant_id):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_group(self, group_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_groups(self, group_ids):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def groups_for_tenant(self, tenant_id):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def groups_for_user(self, user_id, *, tenant_id):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def display_label(self, subject):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_identity(self, identity_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def accounts_for_identity(self, identity_id):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_organization_unit(self, organization_unit_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def organization_units_for_tenant(self, tenant_id):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_function(self, function_id):
|
||||||
|
return FunctionRef(
|
||||||
|
id=function_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
slug="permit-clerk",
|
||||||
|
name="Permit clerk",
|
||||||
|
)
|
||||||
|
|
||||||
|
def functions_for_organization_unit(
|
||||||
|
self, organization_unit_id, *, include_subunits=False
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_function_assignment(self, assignment_id):
|
||||||
|
return FunctionAssignmentRef(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
function_id="function-1",
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def function_assignments_for_account(self, account_id, *, tenant_id=None):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class PortalServiceDirectoryTests(unittest.TestCase):
|
||||||
|
def test_provider_definition_is_available_when_requirements_exist(self) -> None:
|
||||||
|
entries = PortalServiceDirectory(Registry(intake=True)).list_entries(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(entries))
|
||||||
|
self.assertTrue(entries[0].available)
|
||||||
|
self.assertEqual("case", entries[0].entry_binding.kind)
|
||||||
|
|
||||||
|
def test_missing_capability_is_explained_and_filterable(self) -> None:
|
||||||
|
directory = PortalServiceDirectory(Registry())
|
||||||
|
entry = directory.project(
|
||||||
|
service(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("unavailable", entry.state)
|
||||||
|
self.assertIn(
|
||||||
|
"service.required_capability.missing:cases.service_intake",
|
||||||
|
entry.reason_codes,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
directory.list_entries(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
include_unavailable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_available_service_launch_uses_owner_launcher(self) -> None:
|
||||||
|
definition = replace(service(), audience=("public",))
|
||||||
|
result = PortalServiceDirectory(
|
||||||
|
Registry(intake=True, definition=definition)
|
||||||
|
).launch_service(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
reference=definition.reference,
|
||||||
|
requested_at=NOW,
|
||||||
|
idempotency_key="launch-1",
|
||||||
|
parameters={},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("started", result.state)
|
||||||
|
self.assertEqual("case-1", result.target_ref.object_id)
|
||||||
|
|
||||||
|
def test_non_applicable_or_unpublished_service_is_hidden(self) -> None:
|
||||||
|
directory = PortalServiceDirectory(Registry(intake=True))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
"hidden",
|
||||||
|
directory.project(
|
||||||
|
service(state="draft"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
).state,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"hidden",
|
||||||
|
directory.project(
|
||||||
|
service(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("operator",),
|
||||||
|
).state,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_tenant_projection_fails_closed(self) -> None:
|
||||||
|
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||||
|
PortalServiceDirectory().project(
|
||||||
|
service(),
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
effective_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_governed_requirements_explain_or_conceal_failures(self) -> None:
|
||||||
|
definition = service(
|
||||||
|
requirements=(
|
||||||
|
ServiceAvailabilityRequirement(
|
||||||
|
"policy",
|
||||||
|
"permit-access",
|
||||||
|
explanation_ref="docs:permit-policy",
|
||||||
|
),
|
||||||
|
ServiceAvailabilityRequirement(
|
||||||
|
"maintenance",
|
||||||
|
"permit-backend",
|
||||||
|
failure_state="hidden",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assessment = ServiceAvailabilityAssessment(
|
||||||
|
requirement_states={"policy:permit-access": False},
|
||||||
|
reason_codes=("policy.denied",),
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = PortalServiceDirectory(Registry(intake=True)).project(
|
||||||
|
definition,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
assessment=assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("hidden", entry.state)
|
||||||
|
self.assertIn(
|
||||||
|
"service.requirement.failed:policy:permit-access",
|
||||||
|
entry.reason_codes,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"service.requirement.unknown:maintenance:permit-backend",
|
||||||
|
entry.reason_codes,
|
||||||
|
)
|
||||||
|
self.assertIn("policy.denied", entry.reason_codes)
|
||||||
|
|
||||||
|
def test_availability_evidence_cannot_cross_tenants(self) -> None:
|
||||||
|
assessment = ServiceAvailabilityAssessment(
|
||||||
|
requirement_states={},
|
||||||
|
evidence=(
|
||||||
|
EvidenceReference(
|
||||||
|
kind="record",
|
||||||
|
owner_module="policy",
|
||||||
|
evidence_id="decision-1",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(InstitutionalContextError, "another tenant"):
|
||||||
|
PortalServiceDirectory(Registry(intake=True)).project(
|
||||||
|
service(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
assessment=assessment,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_principal_audiences_resolve_active_function_semantics(self) -> None:
|
||||||
|
registry = Registry(intake=True)
|
||||||
|
registry.capabilities[CAPABILITY_ACCESS_SEMANTIC_DIRECTORY] = (
|
||||||
|
SemanticDirectory()
|
||||||
|
)
|
||||||
|
principal = PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
identity_id="identity-1",
|
||||||
|
group_ids=frozenset({"group-1"}),
|
||||||
|
role_ids=frozenset({"role-1"}),
|
||||||
|
function_assignment_ids=frozenset({"assignment-1"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
audiences = principal_audiences(
|
||||||
|
registry,
|
||||||
|
None,
|
||||||
|
principal,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("authenticated", audiences)
|
||||||
|
self.assertIn("function:permit-clerk", audiences)
|
||||||
|
self.assertIn("organization-unit:unit-1", audiences)
|
||||||
|
self.assertIn("role:role-1", audiences)
|
||||||
|
|
||||||
|
def test_empty_audience_projection_fails_closed(self) -> None:
|
||||||
|
entry = PortalServiceDirectory(Registry(intake=True)).project(
|
||||||
|
service(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("hidden", entry.state)
|
||||||
|
self.assertIn("service.audience.not_applicable", entry.reason_codes)
|
||||||
|
|
||||||
|
def test_manifest_exposes_api_and_service_directory_webui(self) -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
|
||||||
|
self.assertEqual("portal", manifest.id)
|
||||||
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertEqual("@govoplan/portal-webui", manifest.frontend.package_name)
|
||||||
|
self.assertIn(
|
||||||
|
"portal:service:read",
|
||||||
|
{item.scope for item in manifest.permissions},
|
||||||
|
)
|
||||||
|
self.assertIn("portal.service_directory", manifest.capability_factories)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/portal-webui",
|
||||||
|
"version": "0.1.8",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/portal.css": "./src/styles/portal.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
export type PortalServiceBinding = {
|
||||||
|
kind: string;
|
||||||
|
reference: string;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalServiceDefinition = {
|
||||||
|
reference: {
|
||||||
|
object_id: string;
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
audience: string[];
|
||||||
|
prerequisites: string[];
|
||||||
|
required_evidence_types: string[];
|
||||||
|
fee_refs: string[];
|
||||||
|
deadline_refs: string[];
|
||||||
|
channels: string[];
|
||||||
|
publication_state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalServiceEntry = {
|
||||||
|
definition: PortalServiceDefinition;
|
||||||
|
state: "available" | "unavailable";
|
||||||
|
reason_codes: string[];
|
||||||
|
entry_binding?: PortalServiceBinding | null;
|
||||||
|
availability_evidence: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalServiceListResponse = {
|
||||||
|
services: PortalServiceEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalServiceLaunchResult = {
|
||||||
|
service_ref: PortalServiceDefinition["reference"];
|
||||||
|
binding: PortalServiceBinding;
|
||||||
|
state: "started" | "redirect";
|
||||||
|
target_ref?: Record<string, unknown> | null;
|
||||||
|
href?: string | null;
|
||||||
|
replayed: boolean;
|
||||||
|
evidence: Array<Record<string, unknown>>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listPortalServices(
|
||||||
|
settings: ApiSettings,
|
||||||
|
options: {
|
||||||
|
query?: string;
|
||||||
|
includeUnavailable?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalServiceListResponse> {
|
||||||
|
return apiFetch<PortalServiceListResponse>(
|
||||||
|
settings,
|
||||||
|
apiPath("/api/v1/portal/services", {
|
||||||
|
q: options.query,
|
||||||
|
include_unavailable: options.includeUnavailable,
|
||||||
|
limit: options.limit
|
||||||
|
}),
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchPortalService(
|
||||||
|
settings: ApiSettings,
|
||||||
|
serviceId: string,
|
||||||
|
payload: {
|
||||||
|
service_version: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
requested_at: string;
|
||||||
|
parameters?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
): Promise<PortalServiceLaunchResult> {
|
||||||
|
return apiFetch<PortalServiceLaunchResult>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/portal/services/${encodeURIComponent(serviceId)}/launch`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { ArrowUpRight, Search } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
DismissibleAlert,
|
||||||
|
Button,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
useGuardedNavigate,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
launchPortalService,
|
||||||
|
listPortalServices,
|
||||||
|
type PortalServiceEntry
|
||||||
|
} from "../../api/portal";
|
||||||
|
|
||||||
|
|
||||||
|
export default function PortalPage({ settings }: PlatformRouteContext) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [submittedQuery, setSubmittedQuery] = useState("");
|
||||||
|
const [includeUnavailable, setIncludeUnavailable] = useState(true);
|
||||||
|
const [services, setServices] = useState<PortalServiceEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [launchingId, setLaunchingId] = useState("");
|
||||||
|
const launchAttempts = useRef(new Map<string, {
|
||||||
|
idempotencyKey: string;
|
||||||
|
requestedAt: string;
|
||||||
|
}>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
listPortalServices(
|
||||||
|
settings,
|
||||||
|
{
|
||||||
|
query: submittedQuery,
|
||||||
|
includeUnavailable,
|
||||||
|
limit: 200
|
||||||
|
},
|
||||||
|
controller.signal
|
||||||
|
).
|
||||||
|
then((response) => setServices(response.services)).
|
||||||
|
catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Services could not be loaded.");
|
||||||
|
}
|
||||||
|
}).
|
||||||
|
finally(() => setLoading(false));
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [includeUnavailable, settings, submittedQuery]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => ({
|
||||||
|
available: services.filter((entry) => entry.state === "available").length,
|
||||||
|
unavailable: services.filter((entry) => entry.state === "unavailable").length
|
||||||
|
}), [services]);
|
||||||
|
|
||||||
|
function submit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSubmittedQuery(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launch(entry: PortalServiceEntry) {
|
||||||
|
const serviceId = entry.definition.reference.object_id;
|
||||||
|
const version = entry.definition.reference.version;
|
||||||
|
if (!version) {
|
||||||
|
setError("This service has no exact launchable revision.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attemptKey = `${serviceId}:${version}`;
|
||||||
|
const attempt = launchAttempts.current.get(attemptKey) ?? {
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
requestedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
launchAttempts.current.set(attemptKey, attempt);
|
||||||
|
setLaunchingId(attemptKey);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await launchPortalService(settings, serviceId, {
|
||||||
|
service_version: version,
|
||||||
|
idempotency_key: attempt.idempotencyKey,
|
||||||
|
requested_at: attempt.requestedAt
|
||||||
|
});
|
||||||
|
launchAttempts.current.delete(attemptKey);
|
||||||
|
if (!result.href) {
|
||||||
|
throw new Error("The service started without returning a destination.");
|
||||||
|
}
|
||||||
|
if (result.href.startsWith("/") && !result.href.startsWith("//")) {
|
||||||
|
navigate(result.href);
|
||||||
|
} else {
|
||||||
|
window.location.assign(result.href);
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Service could not be started.");
|
||||||
|
} finally {
|
||||||
|
setLaunchingId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="portal-page">
|
||||||
|
<div className="portal-shell">
|
||||||
|
<div className="portal-toolbar">
|
||||||
|
<form className="portal-search" onSubmit={submit}>
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
aria-label="Search services"
|
||||||
|
placeholder="Search services"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary">Search</button>
|
||||||
|
</form>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show unavailable services"
|
||||||
|
checked={includeUnavailable}
|
||||||
|
onChange={setIncludeUnavailable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="portal-result-summary" aria-live="polite">
|
||||||
|
<strong>{counts.available}</strong> available
|
||||||
|
{includeUnavailable && <><span aria-hidden="true">/</span><strong>{counts.unavailable}</strong> unavailable</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PageScrollViewport className="portal-results">
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
{loading && <LoadingIndicator label="Loading services" />}
|
||||||
|
{!loading && !error && services.length === 0 &&
|
||||||
|
<div className="portal-empty">No matching services.</div>
|
||||||
|
}
|
||||||
|
{!loading && services.length > 0 &&
|
||||||
|
<div className="portal-service-list">
|
||||||
|
{services.map((entry) =>
|
||||||
|
<ServiceEntry
|
||||||
|
key={`${entry.definition.reference.object_id}:${entry.definition.reference.version ?? "current"}`}
|
||||||
|
entry={entry}
|
||||||
|
launching={launchingId === `${entry.definition.reference.object_id}:${entry.definition.reference.version ?? ""}`}
|
||||||
|
onLaunch={() => void launch(entry)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceEntry({
|
||||||
|
entry,
|
||||||
|
launching,
|
||||||
|
onLaunch
|
||||||
|
}: {
|
||||||
|
entry: PortalServiceEntry;
|
||||||
|
launching: boolean;
|
||||||
|
onLaunch: () => void;
|
||||||
|
}) {
|
||||||
|
const reasons = userFacingReasons(entry.reason_codes);
|
||||||
|
return (
|
||||||
|
<article className={`portal-service-entry is-${entry.state}`}>
|
||||||
|
<div className="portal-service-heading">
|
||||||
|
<div>
|
||||||
|
<h2>{entry.definition.title}</h2>
|
||||||
|
<span className="portal-service-key">{entry.definition.key}</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge
|
||||||
|
status={entry.state === "available" ? "active" : "warning"}
|
||||||
|
label={entry.state === "available" ? "Available" : "Unavailable"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="portal-service-metadata">
|
||||||
|
{entry.definition.channels.map((channel) =>
|
||||||
|
<span key={channel}>{humanize(channel)}</span>
|
||||||
|
)}
|
||||||
|
{entry.definition.required_evidence_types.map((evidence) =>
|
||||||
|
<span key={evidence}>{humanize(evidence)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reasons.length > 0 &&
|
||||||
|
<ul className="portal-service-reasons">
|
||||||
|
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
<div className="portal-service-actions">
|
||||||
|
{entry.entry_binding && entry.state === "available" ?
|
||||||
|
<Button variant="primary" disabled={launching} onClick={onLaunch}>
|
||||||
|
{launching ? "Starting" : "Open"}
|
||||||
|
<ArrowUpRight size={15} aria-hidden="true" />
|
||||||
|
</Button> :
|
||||||
|
entry.entry_binding &&
|
||||||
|
<span className="portal-entry-kind">{humanize(entry.entry_binding.kind)}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFacingReasons(codes: string[]): string[] {
|
||||||
|
const values = codes.
|
||||||
|
filter((code) => !code.startsWith("service.explanation:")).
|
||||||
|
map((code) => {
|
||||||
|
if (code === "service.publication.suspended") return "This service is temporarily suspended.";
|
||||||
|
if (code.includes("required_module.missing") || code.includes("required_capability.missing")) {
|
||||||
|
return "A required system component is unavailable.";
|
||||||
|
}
|
||||||
|
if (code.includes("evaluator_failed")) return "Availability could not be confirmed.";
|
||||||
|
if (code.includes("requirement.failed")) return "An availability requirement is not met.";
|
||||||
|
if (code.includes("requirement.unknown")) return "An availability requirement could not be confirmed.";
|
||||||
|
return "This service is currently unavailable.";
|
||||||
|
});
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.\-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, portalModule } from "./module";
|
||||||
|
export * from "./api/portal";
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/portal.css";
|
||||||
|
|
||||||
|
|
||||||
|
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
|
||||||
|
|
||||||
|
export const portalModule: PlatformWebModule = {
|
||||||
|
id: "portal",
|
||||||
|
label: "Services",
|
||||||
|
version: "0.1.8",
|
||||||
|
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/portal",
|
||||||
|
anyOf: ["portal:service:read"],
|
||||||
|
order: 25,
|
||||||
|
surfaceId: "portal.directory",
|
||||||
|
render: (context) => createElement(PortalPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/portal",
|
||||||
|
label: "Services",
|
||||||
|
iconName: "landmark",
|
||||||
|
anyOf: ["portal:service:read"],
|
||||||
|
order: 25,
|
||||||
|
surfaceId: "portal.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "portal.navigation",
|
||||||
|
moduleId: "portal",
|
||||||
|
kind: "navigation",
|
||||||
|
label: "Services navigation",
|
||||||
|
order: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "portal.directory",
|
||||||
|
moduleId: "portal",
|
||||||
|
kind: "route",
|
||||||
|
label: "Service directory",
|
||||||
|
order: 20
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default portalModule;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
.portal-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(620px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search input {
|
||||||
|
min-width: 120px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-result-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-results {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(360px, 100%), 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-entry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 190px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-entry.is-unavailable {
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-key,
|
||||||
|
.portal-entry-kind {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-metadata {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-metadata span {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-reasons {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--warning-text-strong);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 34px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-service-actions .btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-empty {
|
||||||
|
padding: 36px 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.portal-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user