Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f5a332a8 | ||
|
|
6f671f60f5 | ||
|
|
ed0357b146 | ||
|
|
89a142ead1 | ||
|
|
a15f599756 | ||
|
|
df1a7d3bb1 | ||
|
|
ca30928380 | ||
|
|
fea8e4b5e6 | ||
|
|
1bc367a454 | ||
|
|
5e22ce3f34 | ||
|
|
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,48 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
The public `/portal/status/:trackingId` surface presents the bounded
|
||||||
|
`application_status.projection` owned by Forms Runtime. It supports the
|
||||||
|
configured authenticated, short-lived email-link, and permanent-link modes,
|
||||||
|
while Portal owns only the accessible presentation and reload/request actions.
|
||||||
|
|
||||||
|
Portal deliberately does not publish a DSAR provider because it persists no
|
||||||
|
service-directory, launch, Postbox, application-status, applicant, or session
|
||||||
|
records. Services owns definitions, each launch target owns its effects,
|
||||||
|
Postbox owns mailbox data, and Forms Runtime owns status grants and submission
|
||||||
|
data. Core and the deployment operator remain responsible for request/security
|
||||||
|
logs. This reviewed boundary avoids duplicate or contradictory privacy exports.
|
||||||
|
|
||||||
|
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,174 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Applicant Status Presentation
|
||||||
|
|
||||||
|
Portal also presents Forms Runtime's bounded applicant-status projection at
|
||||||
|
`/portal/status/:trackingId`. It does not persist a status, inspect a Form
|
||||||
|
submission, or decide the disclosure policy. Forms Runtime resolves the tenant
|
||||||
|
through the Core `application_status.projection` contract and remains
|
||||||
|
authoritative for all access decisions.
|
||||||
|
|
||||||
|
The page adapts to the configured grant:
|
||||||
|
|
||||||
|
- authenticated-only access offers sign-in and then uses the applicant-bound
|
||||||
|
status endpoint;
|
||||||
|
- email-link access accepts the linked email address and always reports the
|
||||||
|
same request outcome, whether or not it matched; a delivered link carries a
|
||||||
|
short-lived secret that can be resent and replaces its predecessor; and
|
||||||
|
- permanent-link access loads from the high-entropy tracking URL without
|
||||||
|
authentication.
|
||||||
|
|
||||||
|
All modes render only title, current lifecycle state, update time, receipt
|
||||||
|
identifier, and the bounded public timeline supplied by Forms Runtime. Portal
|
||||||
|
must not infer missing milestones or expose values, people, evidence, internal
|
||||||
|
notes, or handoff details. A reload action re-fetches the authoritative
|
||||||
|
projection. Missing, disabled, revoked, expired, or unauthorized grants share
|
||||||
|
a non-enumerating unavailable state.
|
||||||
|
|
||||||
|
## Data-subject request ownership
|
||||||
|
|
||||||
|
Portal has no module-owned persistence and therefore does not contribute a
|
||||||
|
`privacy.dsar.portal` provider. Its routes resolve and render bounded
|
||||||
|
provider-owned projections during each request; they do not copy service
|
||||||
|
definitions, launch parameters or results, Postbox entries, status grants,
|
||||||
|
submission values, email addresses, or applicant identities into Portal.
|
||||||
|
|
||||||
|
Data-subject request coverage follows the authoritative owner:
|
||||||
|
|
||||||
|
- Services covers configuration-author attribution for versioned service
|
||||||
|
definitions;
|
||||||
|
- Cases, Forms Runtime, and Workflow Engine cover launch effects and domain
|
||||||
|
instances;
|
||||||
|
- Postbox covers mailbox records; and
|
||||||
|
- Forms Runtime covers status-access grants, token lifecycle, confirmations,
|
||||||
|
acknowledgements, and submitted Form data.
|
||||||
|
|
||||||
|
Authentication state, request/security logs, and infrastructure telemetry are
|
||||||
|
Core or deployment-operator concerns, not Portal records. If Portal later gains
|
||||||
|
durable personalization, analytics, saved searches, contact data, or session
|
||||||
|
persistence, that change must add a tenant-scoped DSAR provider before release.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "govoplan-portal"
|
||||||
|
version = "0.1.19"
|
||||||
|
description = "GovOPlaN service discovery and public portal module."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
authors = [{ name = "GovOPlaN" }]
|
||||||
|
dependencies = ["govoplan-core>=0.1.18"]
|
||||||
|
|
||||||
|
[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,436 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.application_status import (
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
|
application_status_projection_provider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.institutional import (
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
service_launch_capability,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.postbox import CAPABILITY_POSTBOX_PORTAL
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
|
DocumentationLink,
|
||||||
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
|
ModuleInterfaceRequirement,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
PublicFrontendRoute,
|
||||||
|
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.19"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _public_tenant_resolver(request: object, session: object) -> str | None:
|
||||||
|
path = str(getattr(getattr(request, "url", None), "path", ""))
|
||||||
|
if "/portal/status/" not in path:
|
||||||
|
return None
|
||||||
|
path_params = getattr(request, "path_params", {})
|
||||||
|
tracking_id = str(
|
||||||
|
path_params.get("trackingId")
|
||||||
|
or path_params.get("tracking_id")
|
||||||
|
or path.rsplit("/", 1)[-1]
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not tracking_id:
|
||||||
|
return None
|
||||||
|
app = getattr(request, "app", None)
|
||||||
|
registry = getattr(getattr(app, "state", None), "govoplan_registry", None)
|
||||||
|
provider = application_status_projection_provider(registry)
|
||||||
|
if provider is None:
|
||||||
|
return None
|
||||||
|
return provider.tenant_id_for_tracking_id(session, tracking_id=tracking_id)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = ModuleManifest(
|
||||||
|
id=MODULE_ID,
|
||||||
|
name="Portal",
|
||||||
|
version=MODULE_VERSION,
|
||||||
|
optional_dependencies=(
|
||||||
|
"access",
|
||||||
|
"services",
|
||||||
|
"cases",
|
||||||
|
"forms",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
"postbox",
|
||||||
|
),
|
||||||
|
optional_capabilities=(
|
||||||
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
CAPABILITY_SERVICE_AVAILABILITY,
|
||||||
|
*SERVICE_LAUNCH_CAPABILITIES,
|
||||||
|
CAPABILITY_POSTBOX_PORTAL,
|
||||||
|
CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
|
),
|
||||||
|
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
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_POSTBOX_PORTAL,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_APPLICATION_STATUS_PROJECTION,
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
CAPABILITY_PORTAL_SERVICE_DIRECTORY: _service_directory,
|
||||||
|
},
|
||||||
|
route_factory=_router,
|
||||||
|
public_tenant_resolver=_public_tenant_resolver,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/portal/status/:trackingId",
|
||||||
|
component="PortalStatusPage",
|
||||||
|
order=12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/portal",
|
||||||
|
label="Services",
|
||||||
|
icon="landmark",
|
||||||
|
required_any=(READ_SCOPE,),
|
||||||
|
order=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="services-cases",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.services_cases",
|
||||||
|
icon="landmark",
|
||||||
|
description="i18n:govoplan-core.product_area.services_cases_description",
|
||||||
|
surface_ids=("portal.nav.portal", "portal.route.portal"),
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="portal.application-status",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
kind="route",
|
||||||
|
label="Applicant status",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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.data-subject-requests",
|
||||||
|
title="Portal data-subject request boundary",
|
||||||
|
summary="Understand why Portal has no separate privacy export and which authoritative modules own the projected data.",
|
||||||
|
body=(
|
||||||
|
"Portal persists no service-directory, service-launch, Postbox, application-status, applicant, or session records, so it deliberately publishes no duplicate data-subject request provider. Services owns definition attribution; Cases, Forms Runtime, and Workflow Engine own launch effects; Postbox owns mailbox records; and Forms Runtime owns submission and status-access data. Core and the deployment operator own authentication state, request/security logs, and infrastructure telemetry. "
|
||||||
|
"If durable personalization, analytics, saved searches, contact data, or sessions are added to Portal, a tenant-scoped privacy provider is required before release."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin", "auditor"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Datenschutzgrenze des Portals",
|
||||||
|
"summary": "Verstehen, warum das Portal keinen eigenen Datenschutzexport bereitstellt und welche maßgeblichen Module die projizierten Daten verwalten.",
|
||||||
|
"body": (
|
||||||
|
"Das Portal speichert weder Dienstverzeichnis-, Dienststart-, Postfach-, Antragsstatus-, Antragsteller- noch Sitzungsdaten und stellt deshalb bewusst keinen doppelten Auskunftsanbieter bereit. "
|
||||||
|
"Services verwaltet die Zuordnung von Dienstdefinitionen; Cases, Forms Runtime und Workflow Engine verwalten die Wirkungen eines Starts; Postbox verwaltet Postfachdaten; Forms Runtime verwaltet Einreichungs- und Statuszugriffsdaten. "
|
||||||
|
"Core und der Betriebsverantwortliche verwalten Authentifizierungszustand, Anfrage- und Sicherheitsprotokolle sowie Infrastrukturtelemetrie. "
|
||||||
|
"Werden dem Portal dauerhafte Personalisierung, Analysen, gespeicherte Suchen, Kontaktdaten oder Sitzungen hinzugefügt, ist vor der Freigabe ein mandantenbezogener Datenschutzanbieter erforderlich."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Portal ownership boundary",
|
||||||
|
href="govoplan-portal/docs/SERVICE_DIRECTORY_CONCEPT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=(
|
||||||
|
"core",
|
||||||
|
"services",
|
||||||
|
"cases",
|
||||||
|
"forms_runtime",
|
||||||
|
"workflow_engine",
|
||||||
|
"postbox",
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["portal.data-subject-requests"],
|
||||||
|
"dsar_coverage": "not_applicable_no_persistence",
|
||||||
|
},
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.function-postboxes",
|
||||||
|
title="Portal-facing function Postboxes",
|
||||||
|
summary="Open explicitly published function Postboxes without moving their access rules into Portal.",
|
||||||
|
body=(
|
||||||
|
"When Postbox is installed, Portal can display Postboxes whose exact definition or published template revision is marked portal-visible. "
|
||||||
|
"Postbox re-evaluates the current function assignment, classification, and read authority for every projection. Portal stores no Postbox ACL, "
|
||||||
|
"does not expose vacant or inaccessible addresses, and links back to the authoritative Postbox surface."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "operator", "module_admin"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Portal-sichtbare Funktionspostfächer",
|
||||||
|
"summary": "Ausdrücklich veröffentlichte Funktionspostfächer öffnen, ohne ihre Zugriffsregeln in das Portal zu verlagern.",
|
||||||
|
"body": (
|
||||||
|
"Ist Postbox installiert, kann das Portal Postfächer anzeigen, deren exakte Definition oder veröffentlichte Vorlagenrevision als portalsichtbar markiert ist. "
|
||||||
|
"Postbox prüft für jede Projektion die aktuelle Funktionszuordnung, Klassifikation und Leseberechtigung erneut. Das Portal speichert keine Postfach-ACL, "
|
||||||
|
"zeigt keine unbesetzten oder nicht zugänglichen Adressen und verweist auf die maßgebliche Postbox-Oberfläche."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(DocumentationLink(label="Portal", href="/portal", kind="runtime"),),
|
||||||
|
related_modules=("postbox", "idm", "organizations"),
|
||||||
|
metadata={"kind": "guide", "help_contexts": ["portal.postboxes"]},
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="portal.application-status",
|
||||||
|
title="Track an application",
|
||||||
|
summary="View the bounded public lifecycle through the access profile configured for the exact submitted Form revision.",
|
||||||
|
body=(
|
||||||
|
"Portal presents the applicant status projection owned by Forms Runtime. The service administrator chooses authenticated-only access, a short-lived email link, or a permanent public bearer link for each exact published Form revision. "
|
||||||
|
"Authenticated access is checked against the applicant account. Email-link requests return the same response for matching and non-matching details, revoke the previous link when a new one is sent, and depend on configured Notifications and Mail delivery. A permanent link does not expire or require sign-in and must therefore be handled like a bearer secret. "
|
||||||
|
"The page exposes only lifecycle states, update times, a tracking identifier, and the submission receipt. It does not display Form values, evidence, internal notes, actors, decision reasoning, or module handoff details."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("public", "user", "operator", "module_admin"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Antrag verfolgen",
|
||||||
|
"summary": "Den begrenzten öffentlichen Verlauf über das für die genaue veröffentlichte Formularrevision konfigurierte Zugriffsprofil einsehen.",
|
||||||
|
"body": (
|
||||||
|
"Das Portal zeigt die von Forms Runtime verwaltete Antragsstatusprojektion. Für jede genaue veröffentlichte Formularrevision wählt die Dienstadministration zwischen ausschließlich authentifiziertem Zugriff, einem kurzlebigen E-Mail-Link und einem dauerhaften öffentlichen Inhaberlink. "
|
||||||
|
"Beim authentifizierten Zugriff wird das Antragstellerkonto geprüft. Anforderungen eines E-Mail-Links liefern für passende und unpassende Angaben dieselbe Antwort, widerrufen beim erneuten Versand den vorherigen Link und setzen konfigurierte Notifications- und Mail-Zustellung voraus. Ein dauerhafter Link läuft nicht ab und erfordert keine Anmeldung; er ist deshalb wie ein Inhabergeheimnis zu behandeln. "
|
||||||
|
"Die Seite zeigt ausschließlich Lebenszyklusstatus, Aktualisierungszeiten, eine Vorgangskennung und die Einreichungsbestätigung. Formularwerte, Nachweise, interne Notizen, handelnde Personen, Entscheidungsbegründungen und Details von Modulübergaben bleiben verborgen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Applicant status",
|
||||||
|
href="/portal/status/:trackingId",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("forms_runtime", "notifications", "mail"),
|
||||||
|
metadata={
|
||||||
|
"kind": "guide",
|
||||||
|
"help_contexts": ["portal.application-status"],
|
||||||
|
"privacy_notes": [
|
||||||
|
"Possession of a permanent status link grants access to the bounded projection until its owner suspends the policy or revokes the grant.",
|
||||||
|
"The email request surface does not reveal whether the tracking identifier, email address, provider, or delivery attempt matched."
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
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"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("portal",),
|
||||||
|
required_scopes=(READ_SCOPE,),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Dienstverzeichnis",
|
||||||
|
"summary": "Verfügbare Dienste der konfigurierten Institution finden und ihre maßgeblichen Verfügbarkeitsgrenzen verstehen.",
|
||||||
|
"body": (
|
||||||
|
"Das Portal zeigt versionierte Dienstdefinitionen ihrer jeweils verantwortlichen Anbieter. Veröffentlichte Dienste können verfügbar sein, mit einer Begründung als nicht verfügbar erscheinen oder unauffindbar bleiben, wenn sie für die aktuelle Zielgruppe nicht gelten. "
|
||||||
|
"Beim Öffnen wird die genaue Revision erneut geprüft und der Start eines Falls, Formulars oder Workflows an die installierte Besitzerfunktion übergeben."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["portal.service-directory"],
|
||||||
|
"steps": [
|
||||||
|
"Open the service directory and select an applicable published service.",
|
||||||
|
"Review any explained availability restriction before continuing.",
|
||||||
|
"Open the service so Portal rechecks the exact revision and hands the start to its owning module.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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.",
|
||||||
|
"Forms Runtime owns applicant-status access, redaction, and timeline semantics; Portal only presents that projection. Payment and decision-document actions are not yet included in the first status surface.",
|
||||||
|
"Portal owns no durable subject records and therefore has no DSAR provider; adding persistence requires introducing one before release.",
|
||||||
|
),
|
||||||
|
owned_concepts=("service discovery", "service presentation", "channel entry", "applicant status presentation"),
|
||||||
|
non_owned_concepts=("institutional service definition", "case lifecycle", "applicant status access decision"),
|
||||||
|
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,146 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
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.core.postbox import postbox_portal_projection_provider
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_portal.backend.schemas import (
|
||||||
|
PortalServiceLaunchRequest,
|
||||||
|
PortalServiceLaunchResponse,
|
||||||
|
PortalServiceListResponse,
|
||||||
|
PortalPostboxListResponse,
|
||||||
|
)
|
||||||
|
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.get("/postboxes", response_model=PortalPostboxListResponse)
|
||||||
|
def api_list_portal_postboxes(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> PortalPostboxListResponse:
|
||||||
|
if not has_scope(principal, READ_SCOPE):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}")
|
||||||
|
provider = postbox_portal_projection_provider(_registry)
|
||||||
|
if provider is None:
|
||||||
|
return PortalPostboxListResponse(
|
||||||
|
provider_available=False,
|
||||||
|
postboxes=[],
|
||||||
|
)
|
||||||
|
entries = provider.list_portal_entries(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return PortalPostboxListResponse(
|
||||||
|
provider_available=True,
|
||||||
|
postboxes=[asdict(entry) 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,70 @@
|
|||||||
|
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 PortalPostboxEntryResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
postbox: dict[str, Any]
|
||||||
|
unread_count: int = Field(default=0, ge=0)
|
||||||
|
latest_message_at: datetime | None = None
|
||||||
|
route_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class PortalPostboxListResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
provider_available: bool
|
||||||
|
postboxes: list[PortalPostboxEntryResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
"PortalPostboxEntryResponse",
|
||||||
|
"PortalPostboxListResponse",
|
||||||
|
]
|
||||||
@@ -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,483 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
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_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxPortalEntryRef
|
||||||
|
from govoplan_portal.backend.manifest import get_manifest
|
||||||
|
from govoplan_portal.backend.router import api_list_portal_postboxes
|
||||||
|
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_portal_postbox_endpoint_projects_optional_provider_entries(self) -> None:
|
||||||
|
principal = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset({"portal:service:read"}),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="membership-1"),
|
||||||
|
)
|
||||||
|
entry = PostboxPortalEntryRef(
|
||||||
|
postbox=PostboxDirectoryEntryRef(
|
||||||
|
id="postbox-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
address="clerk.district",
|
||||||
|
address_key="clerk.district",
|
||||||
|
name="District / Clerk",
|
||||||
|
status="active",
|
||||||
|
classification="internal",
|
||||||
|
),
|
||||||
|
unread_count=3,
|
||||||
|
)
|
||||||
|
provider = SimpleNamespace(
|
||||||
|
list_portal_entries=lambda *_args, **_kwargs: (entry,)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_portal.backend.router.postbox_portal_projection_provider",
|
||||||
|
return_value=provider,
|
||||||
|
):
|
||||||
|
response = api_list_portal_postboxes(
|
||||||
|
limit=100,
|
||||||
|
session=object(),
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(response.provider_available)
|
||||||
|
self.assertEqual("postbox-1", response.postboxes[0].postbox["id"])
|
||||||
|
self.assertEqual(3, response.postboxes[0].unread_count)
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.assertIsNotNone(manifest.public_tenant_resolver)
|
||||||
|
self.assertIn(
|
||||||
|
"/portal/status/:trackingId",
|
||||||
|
{route.path for route in manifest.frontend.public_routes},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"application_status.projection",
|
||||||
|
{item.name for item in manifest.requires_interfaces},
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
name.startswith("privacy.dsar.")
|
||||||
|
for name in manifest.capability_factories
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dsar_topic = next(
|
||||||
|
item
|
||||||
|
for item in manifest.documentation
|
||||||
|
if item.id == "portal.data-subject-requests"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"not_applicable_no_persistence",
|
||||||
|
dsar_topic.metadata["dsar_coverage"],
|
||||||
|
)
|
||||||
|
self.assertTrue({"admin", "user"}.issubset(dsar_topic.documentation_types))
|
||||||
|
|
||||||
|
topics = {topic.id: topic for topic in manifest.documentation}
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
all(
|
||||||
|
topic.translations.get("de", {}).get(field)
|
||||||
|
for field in ("title", "summary", "body")
|
||||||
|
)
|
||||||
|
for topic in topics.values()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
workflow = topics["portal.service-directory"]
|
||||||
|
self.assertEqual("workflow", workflow.metadata["kind"])
|
||||||
|
self.assertTrue(workflow.conditions)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
condition.required_scopes or condition.any_scopes
|
||||||
|
for condition in workflow.conditions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual("reference", dsar_topic.metadata["kind"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/portal-webui",
|
||||||
|
"version": "0.1.19",
|
||||||
|
"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.18",
|
||||||
|
"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,16 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const page = fs.readFileSync("src/features/portal/PortalPage.tsx", "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(page.includes("<WorkspaceActionBar"), "Portal delegates responsive toolbar layout to the shared semantic action bar");
|
||||||
|
|
||||||
|
console.log("Portal interface pattern contract passed.");
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
|
||||||
|
function publicSettings(settings: ApiSettings): ApiSettings {
|
||||||
|
return { ...settings, accessToken: "", apiKey: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 PortalPostboxEntry = {
|
||||||
|
postbox: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
organization_unit_name?: string | null;
|
||||||
|
function_name?: string | null;
|
||||||
|
classification: string;
|
||||||
|
};
|
||||||
|
unread_count: number;
|
||||||
|
latest_message_at?: string | null;
|
||||||
|
route_path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalPostboxListResponse = {
|
||||||
|
provider_available: boolean;
|
||||||
|
postboxes: PortalPostboxEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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 type PortalApplicationStatusAccess = {
|
||||||
|
tracking_id: string;
|
||||||
|
mode: "authenticated" | "email_link" | "permanent_link";
|
||||||
|
authenticated_available: boolean;
|
||||||
|
email_link_available: boolean;
|
||||||
|
token_ttl_seconds?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortalApplicationStatus = {
|
||||||
|
tracking_id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
updated_at: string;
|
||||||
|
receipt_id?: string | null;
|
||||||
|
timeline: Array<{
|
||||||
|
status: string;
|
||||||
|
occurred_at: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 listPortalPostboxes(
|
||||||
|
settings: ApiSettings,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalPostboxListResponse> {
|
||||||
|
return apiFetch<PortalPostboxListResponse>(
|
||||||
|
settings,
|
||||||
|
"/api/v1/portal/postboxes",
|
||||||
|
{ 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)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApplicationStatusAccess(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalApplicationStatusAccess> {
|
||||||
|
return apiFetch(
|
||||||
|
publicSettings(settings),
|
||||||
|
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/access`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicApplicationStatus(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
token?: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalApplicationStatus> {
|
||||||
|
return apiFetch(
|
||||||
|
publicSettings(settings),
|
||||||
|
apiPath(`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}`, { token }),
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthenticatedApplicationStatus(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PortalApplicationStatus> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/forms-runtime/status/${encodeURIComponent(trackingId)}`,
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestApplicationStatusEmailLink(
|
||||||
|
settings: ApiSettings,
|
||||||
|
trackingId: string,
|
||||||
|
email: string
|
||||||
|
): Promise<{ accepted: boolean; message: string }> {
|
||||||
|
return apiFetch(
|
||||||
|
publicSettings(settings),
|
||||||
|
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/email-links`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
email
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { ArrowUpRight, Inbox, Search } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent
|
||||||
|
} from "react";
|
||||||
|
import { ActionBlockerHint,
|
||||||
|
CountBadge,
|
||||||
|
DismissibleAlert,
|
||||||
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
|
FilterBar,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatePanel,
|
||||||
|
StatusBadge,
|
||||||
|
ToggleSwitch,
|
||||||
|
useGuardedNavigate,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
launchPortalService,
|
||||||
|
listPortalPostboxes,
|
||||||
|
listPortalServices,
|
||||||
|
type PortalPostboxEntry,
|
||||||
|
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 [postboxes, setPostboxes] = useState<PortalPostboxEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [launchingId, setLaunchingId] = useState("");
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
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, reloadKey, settings, submittedQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
listPortalPostboxes(settings, controller.signal)
|
||||||
|
.then((response) => setPostboxes(response.postboxes))
|
||||||
|
.catch((reason) => {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Postboxes could not be loaded.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [reloadKey, settings]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<WorkspaceFrame className="portal-shell" label="Service directory" interfaceId="portal.service-directory" helpContextId="portal.page.directory" helpModuleId="portal">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="workspace"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => setReloadKey((value) => value + 1), loading }}
|
||||||
|
className="portal-toolbar"
|
||||||
|
contextActions={<>
|
||||||
|
<FilterBar as="form" surface="control" wrap="never" width="wide" 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>
|
||||||
|
</FilterBar>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Show unavailable services"
|
||||||
|
checked={includeUnavailable}
|
||||||
|
onChange={setIncludeUnavailable}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
helpAction={<DocumentationHelpLink
|
||||||
|
reference={{ topicId: "portal.service-directory", documentationType: "user" }}
|
||||||
|
label="Open service directory documentation"
|
||||||
|
/>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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" />}
|
||||||
|
{postboxes.length > 0 && (
|
||||||
|
<section className="portal-postboxes" aria-labelledby="portal-postboxes-heading">
|
||||||
|
<div className="portal-section-heading">
|
||||||
|
<Inbox size={18} aria-hidden="true" />
|
||||||
|
<h2 id="portal-postboxes-heading">My function postboxes</h2>
|
||||||
|
</div>
|
||||||
|
<div className="portal-postbox-list">
|
||||||
|
{postboxes.map((entry) => (
|
||||||
|
<button
|
||||||
|
key={entry.postbox.id}
|
||||||
|
type="button"
|
||||||
|
className="portal-postbox-entry"
|
||||||
|
onClick={() => navigate(entry.route_path)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{entry.postbox.name}</strong>
|
||||||
|
<small>
|
||||||
|
{[entry.postbox.organization_unit_name, entry.postbox.function_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ") || entry.postbox.address}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<CountBadge className="portal-postbox-count" aria-label={`${entry.unread_count} unread messages`}>
|
||||||
|
{entry.unread_count > 99 ? "99+" : entry.unread_count}
|
||||||
|
</CountBadge>
|
||||||
|
<ArrowUpRight size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{!loading && !error && services.length === 0 &&
|
||||||
|
<StatePanel size="compact" description="No matching services." />
|
||||||
|
}
|
||||||
|
{!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>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
</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,208 @@
|
|||||||
|
import { Clock3, LogIn, Send } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useParams, useSearchParams } from "react-router";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DescriptionItem,
|
||||||
|
DescriptionList,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
LoadingIndicator,
|
||||||
|
PageScrollViewport,
|
||||||
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
type PlatformRouteContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
getApplicationStatusAccess,
|
||||||
|
getAuthenticatedApplicationStatus,
|
||||||
|
getPublicApplicationStatus,
|
||||||
|
requestApplicationStatusEmailLink,
|
||||||
|
type PortalApplicationStatus,
|
||||||
|
type PortalApplicationStatusAccess
|
||||||
|
} from "../../api/portal";
|
||||||
|
|
||||||
|
|
||||||
|
export default function PortalStatusPage({ settings }: PlatformRouteContext) {
|
||||||
|
const { trackingId = "" } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get("token") ?? "";
|
||||||
|
const [access, setAccess] = useState<PortalApplicationStatusAccess | null>(null);
|
||||||
|
const [status, setStatus] = useState<PortalApplicationStatus | null>(null);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async (signal?: AbortSignal) => {
|
||||||
|
if (!trackingId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const nextAccess = await getApplicationStatusAccess(settings, trackingId, signal);
|
||||||
|
setAccess(nextAccess);
|
||||||
|
if (token) {
|
||||||
|
setStatus(await getPublicApplicationStatus(settings, trackingId, token, signal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextAccess.mode === "permanent_link") {
|
||||||
|
setStatus(await getPublicApplicationStatus(settings, trackingId, undefined, signal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settings.accessToken || settings.apiKey) {
|
||||||
|
try {
|
||||||
|
setStatus(await getAuthenticatedApplicationStatus(settings, trackingId, signal));
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
} catch (reason) {
|
||||||
|
if ((reason as Error).name !== "AbortError") {
|
||||||
|
setError(reason instanceof Error ? reason.message : "Application status could not be loaded.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [settings, token, trackingId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
void load(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function requestLink() {
|
||||||
|
if (!email.trim()) return;
|
||||||
|
setSending(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
try {
|
||||||
|
const response = await requestApplicationStatusEmailLink(settings, trackingId, email.trim());
|
||||||
|
setNotice(response.message);
|
||||||
|
} catch {
|
||||||
|
setNotice("If the application and email address match, a new short-lived status link will be sent.");
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="portal-status-page">
|
||||||
|
<WorkspaceFrame
|
||||||
|
height="container"
|
||||||
|
label="Application status"
|
||||||
|
interfaceId="portal.application-status"
|
||||||
|
helpModuleId="portal"
|
||||||
|
helpTopicId="portal.application-status"
|
||||||
|
helpContextId="portal.application-status">
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="workspace"
|
||||||
|
variant="detail"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void load(), loading }}
|
||||||
|
className="portal-status-toolbar"
|
||||||
|
contextActions={<strong>Application status</strong>}
|
||||||
|
/>
|
||||||
|
<PageScrollViewport className="portal-status-viewport">
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
{notice && <DismissibleAlert tone="info" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||||
|
{loading && <LoadingIndicator label="Loading application status" />}
|
||||||
|
{!loading && status && <StatusProjection status={status} />}
|
||||||
|
{!loading && !status && access?.mode === "authenticated" &&
|
||||||
|
<Card title="Sign in to view status" className="portal-status-access-card">
|
||||||
|
<p>This application is configured for authenticated access only. Sign in with the account linked to the submission.</p>
|
||||||
|
<a className="btn btn-primary" href={`/login?next=${encodeURIComponent(window.location.pathname)}`}>
|
||||||
|
<LogIn size={16} aria-hidden="true" />
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
{!loading && !status && access?.mode === "email_link" &&
|
||||||
|
<Card title="Request a short-lived status link" className="portal-status-access-card">
|
||||||
|
<p>Enter the email address linked to the application. The response is identical whether or not the details match.</p>
|
||||||
|
<form onSubmit={(event) => { event.preventDefault(); void requestLink(); }}>
|
||||||
|
<FormField label="Linked email address">
|
||||||
|
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} disabled={sending} required autoComplete="email" />
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" variant="primary" disabled={sending || !email.trim()}>
|
||||||
|
<Send size={16} aria-hidden="true" />
|
||||||
|
Send new link
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{access.token_ttl_seconds &&
|
||||||
|
<p className="portal-status-expiry"><Clock3 size={15} aria-hidden="true" />The link remains valid for {durationLabel(access.token_ttl_seconds)} and replaces the previous link.</p>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</WorkspaceFrame>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusProjection({ status }: { status: PortalApplicationStatus }) {
|
||||||
|
return (
|
||||||
|
<div className="portal-status-content">
|
||||||
|
<Card
|
||||||
|
title={status.title}
|
||||||
|
actions={<StatusBadge status={statusTone(status.status)} label={statusLabel(status.status)} />}>
|
||||||
|
<DescriptionList columns={3} collapseAt="workspace" density="compact">
|
||||||
|
<DescriptionItem term="Tracking ID"><code>{status.tracking_id}</code></DescriptionItem>
|
||||||
|
<DescriptionItem term="Last updated">{formatDate(status.updated_at)}</DescriptionItem>
|
||||||
|
{status.receipt_id && <DescriptionItem term="Submission receipt"><code>{status.receipt_id}</code></DescriptionItem>}
|
||||||
|
</DescriptionList>
|
||||||
|
</Card>
|
||||||
|
<Card title="Timeline">
|
||||||
|
{status.timeline.length === 0 && <p>No public status event is available yet.</p>}
|
||||||
|
<ol className="portal-status-timeline">
|
||||||
|
{status.timeline.map((item, index) =>
|
||||||
|
<li key={`${item.status}:${item.occurred_at}:${index}`}>
|
||||||
|
<span className="portal-status-marker" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{statusLabel(item.status)}</strong>
|
||||||
|
<time dateTime={item.occurred_at}>{formatDate(item.occurred_at)}</time>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
|
<DismissibleAlert tone="info">
|
||||||
|
This page intentionally shows only the public lifecycle, update times, and receipt reference. Form values, evidence, internal notes, actors, and handoff details remain private.
|
||||||
|
</DismissibleAlert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(value: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
submitted: "Application received",
|
||||||
|
validated: "Completeness checked",
|
||||||
|
needs_review: "Under review",
|
||||||
|
accepted: "Approved",
|
||||||
|
rejected: "Decision issued",
|
||||||
|
handed_off: "Further processing",
|
||||||
|
archived: "Procedure closed"
|
||||||
|
};
|
||||||
|
return labels[value] ?? value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(value: string): string {
|
||||||
|
if (value === "accepted" || value === "archived") return "active";
|
||||||
|
if (value === "rejected") return "warning";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationLabel(seconds: number): string {
|
||||||
|
if (seconds % 3600 === 0) return `${seconds / 3600} hour${seconds === 3600 ? "" : "s"}`;
|
||||||
|
return `${Math.round(seconds / 60)} minutes`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, portalModule } from "./module";
|
||||||
|
export * from "./api/portal";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/portal.css";
|
||||||
|
|
||||||
|
|
||||||
|
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
|
||||||
|
const PortalStatusPage = lazy(() => import("./features/portal/PortalStatusPage"));
|
||||||
|
|
||||||
|
export const portalModule: PlatformWebModule = {
|
||||||
|
id: "portal",
|
||||||
|
label: "Services",
|
||||||
|
version: "0.1.19",
|
||||||
|
optionalDependencies: ["access", "services", "cases", "forms", "forms_runtime", "workflow_engine"],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/portal",
|
||||||
|
anyOf: ["portal:service:read"],
|
||||||
|
order: 25,
|
||||||
|
surfaceId: "portal.directory",
|
||||||
|
render: (context) => createElement(PortalPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
publicRoutes: [
|
||||||
|
{
|
||||||
|
path: "/portal/status/:trackingId",
|
||||||
|
order: 12,
|
||||||
|
render: (context) => createElement(PortalStatusPage, 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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "portal.application-status",
|
||||||
|
moduleId: "portal",
|
||||||
|
kind: "route",
|
||||||
|
label: "Applicant status",
|
||||||
|
order: 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default portalModule;
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
.portal-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-toolbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-viewport {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-content {
|
||||||
|
display: grid;
|
||||||
|
width: min(920px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-access-card {
|
||||||
|
width: min(620px, 100%);
|
||||||
|
margin: 32px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-access-card form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-access-card .btn,
|
||||||
|
.portal-status-access-card .btn-primary {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-expiry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 11px;
|
||||||
|
bottom: 11px;
|
||||||
|
left: 7px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-strong);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-marker {
|
||||||
|
z-index: 1;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
border: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline li > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-status-timeline time {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-search { flex: 1 1 620px; }
|
||||||
|
|
||||||
|
.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-postboxes {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-section-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-compact);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry:hover,
|
||||||
|
.portal-postbox-entry:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry > span:first-child {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-entry small {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-postbox-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--radius-compact);
|
||||||
|
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: var(--radius-sm);
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user