feat: implement governed service directory
This commit is contained in:
@@ -3,3 +3,30 @@
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (domain).
|
||||
<!-- 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
|
||||
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
|
||||
|
||||
The service directory should help users find the right administrative action
|
||||
@@ -34,11 +40,22 @@ A service entry should describe:
|
||||
- policy and availability blockers
|
||||
- user-facing explanation and required documents
|
||||
- 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
|
||||
capability and UI contribution contracts. It must not import domain module
|
||||
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:
|
||||
|
||||
- `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
|
||||
mode, or unavailable external provider. It should hide only services that are
|
||||
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