Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63a64328ae | ||
|
|
eb17c7889a | ||
|
|
011c2f66c6 | ||
|
|
a7a272edae | ||
|
|
075b6df175 | ||
|
|
49cbe258e0 | ||
|
|
ff44ed5f4a | ||
|
|
cadac23162 | ||
|
|
b29318f254 | ||
|
|
03d168310f |
@@ -0,0 +1,270 @@
|
|||||||
|
name: Module Package Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: Existing protected version tag to publish
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Select and validate protected release tag
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||||
|
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||||
|
case "$tag" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||||
|
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||||
|
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||||
|
echo "Release tag is not contained in main" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
git checkout --detach "$tag"
|
||||||
|
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||||
|
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||||
|
- name: Validate package versions
|
||||||
|
run: |
|
||||||
|
python - <<'PY'
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
tag = os.environ["RELEASE_TAG"]
|
||||||
|
expected = tag.removeprefix("v")
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
if project.get("version") != expected:
|
||||||
|
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||||
|
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||||
|
webui = Path("webui/package.json")
|
||||||
|
if webui.is_file():
|
||||||
|
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||||
|
if package.get("version") != expected:
|
||||||
|
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||||
|
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||||
|
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||||
|
release = Path("webui/package.release.json")
|
||||||
|
if release.is_file():
|
||||||
|
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||||
|
if (
|
||||||
|
release_package.get("name") != package.get("name")
|
||||||
|
or release_package.get("version") != expected
|
||||||
|
):
|
||||||
|
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||||
|
PY
|
||||||
|
- name: Build immutable package artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||||
|
rm -rf dist .package-webui
|
||||||
|
python -m build --wheel --outdir dist
|
||||||
|
python -m twine check dist/*.whl
|
||||||
|
if [[ -f webui/package.json ]]; then
|
||||||
|
mkdir .package-webui
|
||||||
|
cp -a webui/. .package-webui/
|
||||||
|
rm -rf .package-webui/node_modules .package-webui/dist
|
||||||
|
if [[ -f .package-webui/package.release.json ]]; then
|
||||||
|
cp .package-webui/package.release.json .package-webui/package.json
|
||||||
|
fi
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = ".package-webui/package.json";
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||||
|
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||||
|
if (!name.startsWith("@govoplan/")) continue;
|
||||||
|
if (typeof specifier !== "string") {
|
||||||
|
throw new Error(`${group}.${name} must use a string version`);
|
||||||
|
}
|
||||||
|
const packageSlug = name.slice("@govoplan/".length);
|
||||||
|
if (!packageSlug.endsWith("-webui")) {
|
||||||
|
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||||
|
}
|
||||||
|
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||||
|
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const gitTag = specifier.match(
|
||||||
|
new RegExp(
|
||||||
|
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (gitTag) {
|
||||||
|
packageJson[group][name] = gitTag[1];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||||
|
throw new Error(
|
||||||
|
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete packageJson.private;
|
||||||
|
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
NODE
|
||||||
|
npm pkg delete private --prefix .package-webui
|
||||||
|
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||||
|
fi
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
artifacts = []
|
||||||
|
for path in sorted(Path("dist").iterdir()):
|
||||||
|
if path.suffix not in {".whl", ".tgz"}:
|
||||||
|
continue
|
||||||
|
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||||
|
payload = {
|
||||||
|
"schema_version": "1",
|
||||||
|
"repository": os.environ["GITEA_REPOSITORY"],
|
||||||
|
"tag": os.environ["RELEASE_TAG"],
|
||||||
|
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||||
|
"artifacts": artifacts,
|
||||||
|
}
|
||||||
|
Path("dist/package-artifacts.json").write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
- name: Retain package hash evidence
|
||||||
|
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||||
|
with:
|
||||||
|
name: module-packages-${{ gitea.ref_name }}
|
||||||
|
path: dist/package-artifacts.json
|
||||||
|
- name: Check immutable registry state
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
python - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||||
|
token = os.environ["PACKAGE_TOKEN"]
|
||||||
|
|
||||||
|
def should_publish(kind, name, version, path):
|
||||||
|
package_url = "/".join(
|
||||||
|
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||||
|
)
|
||||||
|
request = Request(
|
||||||
|
package_url,
|
||||||
|
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=30) as response:
|
||||||
|
files = json.load(response)
|
||||||
|
except HTTPError as exc:
|
||||||
|
if exc.code == 404:
|
||||||
|
print(f"{kind} package {name}=={version} is not published yet")
|
||||||
|
return True
|
||||||
|
raise
|
||||||
|
if not isinstance(files, list) or len(files) != 1:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||||
|
)
|
||||||
|
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
if files[0].get("sha256") != expected_sha256:
|
||||||
|
raise SystemExit(
|
||||||
|
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||||
|
)
|
||||||
|
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||||
|
return False
|
||||||
|
|
||||||
|
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||||
|
wheels = tuple(Path("dist").glob("*.whl"))
|
||||||
|
if len(wheels) != 1:
|
||||||
|
raise SystemExit("release build must contain exactly one wheel")
|
||||||
|
publish_pypi = should_publish(
|
||||||
|
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||||
|
if len(tarballs) > 1:
|
||||||
|
raise SystemExit("release build must contain at most one npm package")
|
||||||
|
publish_npm = False
|
||||||
|
if tarballs:
|
||||||
|
webui = json.loads(
|
||||||
|
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
publish_npm = should_publish(
|
||||||
|
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||||
|
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||||
|
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||||
|
PY
|
||||||
|
- name: Publish wheel and WebUI package
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$PACKAGE_USERNAME"
|
||||||
|
test -n "$PACKAGE_TOKEN"
|
||||||
|
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||||
|
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||||
|
python -m twine upload --non-interactive \
|
||||||
|
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||||
|
dist/*.whl
|
||||||
|
else
|
||||||
|
echo "Exact wheel is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
|
shopt -s nullglob
|
||||||
|
webui_packages=(dist/*.tgz)
|
||||||
|
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||||
|
npmrc="$(mktemp)"
|
||||||
|
trap 'rm -f "$npmrc"' EXIT
|
||||||
|
chmod 600 "$npmrc"
|
||||||
|
printf '%s\n' \
|
||||||
|
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||||
|
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||||
|
> "$npmrc"
|
||||||
|
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||||
|
--ignore-scripts --access public \
|
||||||
|
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||||
|
elif (( ${#webui_packages[@]} )); then
|
||||||
|
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||||
|
fi
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# vitepress build output
|
||||||
|
**/.vitepress/dist
|
||||||
|
|
||||||
|
# vitepress cache directory
|
||||||
|
**/.vitepress/cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Local WebUI test/build scratch directories
|
||||||
|
.component-test-build/
|
||||||
|
.module-test-build/
|
||||||
|
.policy-test-build/
|
||||||
|
.template-preview-test-build/
|
||||||
|
.import-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.policy-test-build/
|
||||||
|
webui/.template-preview-test-build/
|
||||||
|
webui/.import-test-build/
|
||||||
|
|
||||||
|
# ---> Python
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
|
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||||
|
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||||
|
# install all needed dependencies.
|
||||||
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# UV
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
#uv.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||||
|
.pdm.toml
|
||||||
|
.pdm-python
|
||||||
|
.pdm-build/
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# Ruff stuff:
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# PyPI configuration file
|
||||||
|
.pypirc
|
||||||
|
|
||||||
|
# ---> VisualStudioCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Built Visual Studio Code Extensions
|
||||||
|
*.vsix
|
||||||
|
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# GovOPlaN local runtime state
|
||||||
|
runtime/
|
||||||
|
|
||||||
|
# GovOPlaN WebUI test output
|
||||||
|
webui/.module-test-build/
|
||||||
|
webui/.component-test-build/
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# govoplan-portal
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (domain).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
The service-directory route, state, blocker, and accessibility mapping is
|
||||||
|
recorded in
|
||||||
|
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Portal Interface Pattern Migration
|
||||||
|
|
||||||
|
Portal is a public-service-style directory for authenticated institutional
|
||||||
|
users. Services owns definitions, Policy/provider capabilities own availability,
|
||||||
|
and Cases, Forms Runtime, or Workflow Engine owns the launch effect. Portal
|
||||||
|
does not import those modules or infer their state.
|
||||||
|
|
||||||
|
| Surface | Task and archetype | Consequence and state contract |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/portal` toolbar | Find and filter services | Search and the unavailable-service toggle affect only the directory result. Counts are announced as the result changes. |
|
||||||
|
| Service directory | Public service/entry catalogue | Loading, empty, failed, available, unavailable, and capability-unknown states remain distinct. Only discoverable service metadata is rendered. |
|
||||||
|
| Availability blocker | Explained disabled action | Open remains in its stable action position. The blocker states why launch is unavailable, what must happen, the responsible actor, and the destination. |
|
||||||
|
| Open service | Consequential handoff | Portal re-evaluates the exact service revision with a replay-safe request, then hands off to the provider-owned route. It never reports success without a destination. |
|
||||||
|
|
||||||
|
The directory uses Core buttons, toggle, status, loading, alerts, scrolling,
|
||||||
|
blocker explanation, guarded navigation, and documentation help. Availability
|
||||||
|
is expressed in text as well as color. The responsive toolbar stacks before the
|
||||||
|
service list and every action remains keyboard-operable. Optional owner modules
|
||||||
|
remain capability-based and an unavailable provider does not become a broken
|
||||||
|
link.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `npm run test:interface-pattern`
|
||||||
|
- Portal service-directory and manifest tests
|
||||||
|
- the Core TypeScript graph, structural localization audit, theme check, module
|
||||||
|
permutations, and full-product bundle budget
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Role-Based Service Directory Concept
|
||||||
|
|
||||||
|
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
|
||||||
|
without knowing the internal module layout. Available services depend on
|
||||||
|
tenant, role, organization unit, policies, installed modules, and configuration
|
||||||
|
packages.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- apply for a permit
|
||||||
|
- submit documents for an existing case
|
||||||
|
- request an appointment
|
||||||
|
- send a secure postbox message
|
||||||
|
- report an issue
|
||||||
|
- book a resource
|
||||||
|
- start an internal workflow
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
A service entry should describe:
|
||||||
|
|
||||||
|
- service key and label
|
||||||
|
- owning module or configuration package
|
||||||
|
- audience and role/function requirements
|
||||||
|
- required installed/enabled modules
|
||||||
|
- required capabilities
|
||||||
|
- form, workflow, postbox, task, or external connector entry point
|
||||||
|
- 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,
|
||||||
|
organization-unit function assignments, delegations, and role mappings.
|
||||||
|
- `access.explanation` provides the explanation shown when a service is
|
||||||
|
available or blocked because of a missing function, role, right, or policy.
|
||||||
|
|
||||||
|
Service entries should support function requirements directly, not only role
|
||||||
|
requirements. A function requirement may apply to one organization unit or to
|
||||||
|
that unit and all subunits, matching the access assignment scope.
|
||||||
|
|
||||||
|
## UX Rule
|
||||||
|
|
||||||
|
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.16"
|
||||||
|
description = "GovOPlaN service discovery and public portal module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = ["govoplan-core>=0.1.16"]
|
||||||
|
|
||||||
|
[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,225 @@
|
|||||||
|
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.16"
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Portal interface pattern audit",
|
||||||
|
href="govoplan-portal/docs/INTERFACE_PATTERN_MIGRATION.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,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/portal-webui",
|
||||||
|
"version": "0.1.16",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.16",
|
||||||
|
"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,17 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "utf8");
|
||||||
|
const styles = fs.readFileSync("src/styles/portal.css", "utf8");
|
||||||
|
|
||||||
|
assert.ok(page.includes("DocumentationHelpLink"), "Portal exposes configured-system help");
|
||||||
|
assert.ok(page.includes("ActionBlockerHint"), "Unavailable launches expose the shared structured blocker");
|
||||||
|
assert.ok(page.includes("disabledReason={entry.state !== \"available\" ? blocker.summary : undefined}"), "The launch action remains keyboard-explainable");
|
||||||
|
assert.ok(page.includes("PageScrollViewport"), "Portal owns bounded directory scrolling");
|
||||||
|
assert.ok(page.includes('aria-live="polite"'), "Changing result counts are announced");
|
||||||
|
assert.ok(page.includes("useGuardedNavigate"), "Internal launch handoffs respect unsaved-work navigation");
|
||||||
|
assert.ok(!page.includes("window.alert("), "Portal must not use browser alerts");
|
||||||
|
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Portal uses semantic interactive elements");
|
||||||
|
assert.ok(styles.includes("@media (max-width: 720px)"), "Portal retains a narrow-viewport toolbar layout");
|
||||||
|
|
||||||
|
console.log("Portal interface pattern contract passed.");
|
||||||
@@ -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,278 @@
|
|||||||
|
import { ArrowUpRight, Search } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
ActionBlockerHint,
|
||||||
|
DismissibleAlert,
|
||||||
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
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" variant="primary">Search</Button>
|
||||||
|
</form>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
||||||
|
label="Open service directory documentation"
|
||||||
|
/>
|
||||||
|
<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);
|
||||||
|
const blocker = serviceBlocker(entry.reason_codes, reasons);
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
{entry.entry_binding && entry.state !== "available" &&
|
||||||
|
<ActionBlockerHint reason={blocker} tone="warning" />
|
||||||
|
}
|
||||||
|
<div className="portal-service-actions">
|
||||||
|
{entry.entry_binding ?
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={launching || entry.state !== "available"}
|
||||||
|
disabledReason={entry.state !== "available" ? blocker.summary : undefined}
|
||||||
|
onClick={onLaunch}>
|
||||||
|
{launching ? "Starting" : "Open"}
|
||||||
|
<ArrowUpRight size={15} aria-hidden="true" />
|
||||||
|
</Button> :
|
||||||
|
<span className="portal-entry-kind">No launch destination</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceBlocker(codes: string[], reasons: string[]) {
|
||||||
|
const reasonCode = codes.find((code) => !code.startsWith("service.explanation:")) ?? "";
|
||||||
|
if (reasonCode === "service.publication.suspended") {
|
||||||
|
return {
|
||||||
|
summary: reasons[0] ?? "This service is temporarily suspended.",
|
||||||
|
requiredAction: "Resume the published service revision.",
|
||||||
|
actor: "Service owner",
|
||||||
|
target: "Service administration"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reasonCode.includes("required_module.missing") || reasonCode.includes("required_capability.missing")) {
|
||||||
|
return {
|
||||||
|
summary: reasons[0] ?? "A required system component is unavailable.",
|
||||||
|
requiredAction: "Install, enable, configure, or restore the required component.",
|
||||||
|
actor: "System or module administrator",
|
||||||
|
target: "Module administration"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (reasonCode.includes("evaluator_failed") || reasonCode.includes("requirement.unknown")) {
|
||||||
|
return {
|
||||||
|
summary: reasons[0] ?? "Availability could not be confirmed.",
|
||||||
|
requiredAction: "Restore the availability evaluator and check the service again.",
|
||||||
|
actor: "System operator",
|
||||||
|
target: "Operations and service diagnostics"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
summary: reasons[0] ?? "This service is currently unavailable.",
|
||||||
|
requiredAction: "Review and fulfil the service availability requirements.",
|
||||||
|
actor: "Service owner or responsible authority",
|
||||||
|
target: "Service details"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,150 @@
|
|||||||
|
.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-entry .action-blocker-hint {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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