Compare commits
17
Commits
8bfcf2d6b4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad3418a7c | ||
|
|
a8bfdbf20c | ||
|
|
f0a28dd8ec | ||
|
|
de8ec81c47 | ||
|
|
4bebb4211a | ||
|
|
2618532377 | ||
|
|
139dc0cd75 | ||
|
|
e4663d545a | ||
|
|
801b11016c | ||
|
|
eccf7dc207 | ||
|
|
80795ef706 | ||
|
|
7fade1b173 | ||
|
|
260f87a6a6 | ||
|
|
20b8aa080c | ||
|
|
4e7e972415 | ||
|
|
2f53dc44dc | ||
|
|
87e05af025 |
@@ -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
|
||||
@@ -22,6 +22,8 @@ Connectors; Projects stores only the native planning object and canonical
|
||||
external reference.
|
||||
|
||||
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md).
|
||||
The list-detail, editor, state, consequence, and accessibility mapping is in
|
||||
[docs/INTERFACE_PATTERN_MIGRATION.md](docs/INTERFACE_PATTERN_MIGRATION.md).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -34,3 +36,21 @@ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
|
||||
The module migration is applied through the platform migration runner when the
|
||||
module is enabled. Destructive retirement requires the normal snapshot and
|
||||
uninstall-guard process because it removes planning history and events.
|
||||
|
||||
## Git-source WebUI package
|
||||
|
||||
The repository root exposes `@govoplan/projects-webui` for Git-tagged release
|
||||
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||
development or install scripts. The source archive contains `webui/src`, this
|
||||
README and any repository license file. Run module development checks from `webui/`; Python
|
||||
installation remains governed by `pyproject.toml`.
|
||||
|
||||
Das Repository stellt `@govoplan/projects-webui` am Wurzelpfad für versionierte
|
||||
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||
`pyproject.toml` definiert.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Projects Interface Pattern Migration
|
||||
|
||||
Projects uses a full-height list-detail workspace. It owns native portfolio,
|
||||
project, and milestone context; Tasks and Tickets own actionable work,
|
||||
Connectors owns OpenProject transport, and optional integrations remain
|
||||
capability-based.
|
||||
|
||||
| Surface | Task and archetype | Consequence and state contract |
|
||||
| --- | --- | --- |
|
||||
| `/projects` catalogue | Search/filter and select a planning object | Loading, empty, failed, filtered, and selected states retain the list context. Restricted records are removed by backend authorization rather than cosmetically hidden. |
|
||||
| Object detail | Inspect status, dates, ownership/membership evidence, outcomes, benefits, dependencies, and links | The selected identity, key, state, and revision remain visible while detail changes. |
|
||||
| Create/edit dialog | Adaptive create/edit | Core Dialog supplies focus containment and return. Stable key, state, visibility, parent, dates, and change reason have labelled controls; save errors remain attached to the dialog. |
|
||||
| Revisioned save | Consequential corrective action | Every successful save creates immutable revision and lifecycle evidence under optimistic concurrency. The required change reason explains the new record. |
|
||||
|
||||
Creating and editing require the Projects write permission. Restricted
|
||||
visibility changes who may read an object, so its consequence is explained at
|
||||
the field and enforced by the backend ACL. The workspace reflows to list then
|
||||
detail at narrow widths, preserves semantic button/list behavior, and uses Core
|
||||
buttons, icon buttons, dialog, alerts, loading, status, field labels, scrolling,
|
||||
and documentation help.
|
||||
|
||||
Verification:
|
||||
|
||||
- `npm run test:interface-pattern`
|
||||
- Projects service, migration, and manifest tests
|
||||
- the Core TypeScript graph, structural localization audit, theme check, module
|
||||
permutations, and full-product bundle budget
|
||||
+29
-3
@@ -1,8 +1,34 @@
|
||||
{
|
||||
"name": "@govoplan/projects",
|
||||
"version": "0.1.14",
|
||||
"name": "@govoplan/projects-webui",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"description": "GovOPlaN Projects domain module.",
|
||||
"type": "module",
|
||||
"peerDependencies": {}
|
||||
"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"
|
||||
},
|
||||
"main": "webui/src/index.ts",
|
||||
"module": "webui/src/index.ts",
|
||||
"types": "webui/src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./webui/src/index.ts",
|
||||
"import": "./webui/src/index.ts"
|
||||
},
|
||||
"./styles/projects.css": "./webui/src/styles/projects.css"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-projects"
|
||||
version = "0.1.14"
|
||||
version = "0.1.20"
|
||||
description = "GovOPlaN Projects domain module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.14",
|
||||
"govoplan-access>=0.1.14",
|
||||
"govoplan-core>=0.1.37",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_projects.backend.db.models import (
|
||||
ProjectMembershipGrant,
|
||||
ProjectObjectEvent,
|
||||
ProjectObjectIdentity,
|
||||
ProjectObjectRevision,
|
||||
)
|
||||
|
||||
|
||||
PROJECTS_DSAR_CAPABILITY = dsar_capability_name("projects")
|
||||
_MAX_RECORDS = 5_000
|
||||
_MAX_PERMISSIONS = 100
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Selectors:
|
||||
actor_ids: tuple[str, ...]
|
||||
membership_subjects: tuple[tuple[str, str], ...]
|
||||
object_kind: str | None
|
||||
object_id: str | None
|
||||
|
||||
|
||||
class ProjectsDsarProvider:
|
||||
provider_id = "projects"
|
||||
module_id = "projects"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
records: list[DsarRecordRef] = []
|
||||
|
||||
if selectors.membership_subjects:
|
||||
membership_conditions = tuple(
|
||||
and_(
|
||||
ProjectMembershipGrant.subject_kind == kind,
|
||||
ProjectMembershipGrant.subject_id == subject_id,
|
||||
)
|
||||
for kind, subject_id in selectors.membership_subjects
|
||||
)
|
||||
query = db.query(ProjectMembershipGrant).filter(
|
||||
ProjectMembershipGrant.tenant_id == tenant_id,
|
||||
or_(*membership_conditions),
|
||||
)
|
||||
query = _object_filter(query, ProjectMembershipGrant, selectors)
|
||||
records.extend(
|
||||
_membership_record(row)
|
||||
for row in _limited(
|
||||
query,
|
||||
ProjectMembershipGrant.created_at,
|
||||
ProjectMembershipGrant.id,
|
||||
label="membership",
|
||||
)
|
||||
)
|
||||
|
||||
if selectors.actor_ids:
|
||||
identities = db.query(ProjectObjectIdentity).filter(
|
||||
ProjectObjectIdentity.tenant_id == tenant_id,
|
||||
ProjectObjectIdentity.created_by.in_(selectors.actor_ids),
|
||||
)
|
||||
identities = _object_filter(identities, ProjectObjectIdentity, selectors)
|
||||
records.extend(
|
||||
_identity_actor_record(row)
|
||||
for row in _limited(
|
||||
identities,
|
||||
ProjectObjectIdentity.created_at,
|
||||
ProjectObjectIdentity.id,
|
||||
label="identity attribution",
|
||||
)
|
||||
)
|
||||
|
||||
revisions = db.query(ProjectObjectRevision).filter(
|
||||
ProjectObjectRevision.tenant_id == tenant_id,
|
||||
ProjectObjectRevision.changed_by.in_(selectors.actor_ids),
|
||||
)
|
||||
revisions = _object_filter(revisions, ProjectObjectRevision, selectors)
|
||||
records.extend(
|
||||
_revision_actor_record(row)
|
||||
for row in _limited(
|
||||
revisions,
|
||||
ProjectObjectRevision.recorded_at,
|
||||
ProjectObjectRevision.id,
|
||||
label="revision attribution",
|
||||
)
|
||||
)
|
||||
|
||||
events = db.query(ProjectObjectEvent).filter(
|
||||
ProjectObjectEvent.tenant_id == tenant_id,
|
||||
ProjectObjectEvent.actor_id.in_(selectors.actor_ids),
|
||||
)
|
||||
events = _object_filter(events, ProjectObjectEvent, selectors)
|
||||
records.extend(
|
||||
_event_actor_record(row)
|
||||
for row in _limited(
|
||||
events,
|
||||
ProjectObjectEvent.occurred_at,
|
||||
ProjectObjectEvent.id,
|
||||
label="event attribution",
|
||||
)
|
||||
)
|
||||
|
||||
if len(records) > _MAX_RECORDS:
|
||||
raise ValueError("Projects DSAR result limit exceeded; narrow selectors.")
|
||||
return tuple(
|
||||
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
|
||||
)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Projects DSAR subject selectors conflict.")
|
||||
actions = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
membership = record.resource_type == "project_membership"
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=(
|
||||
f"projects:{'manual_review' if membership else 'retain'}:"
|
||||
f"{record.resource_type}:{record.resource_id}"
|
||||
),
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="manual_review" if membership else "retain",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=("Review " if membership else "Retain ") + record.title,
|
||||
rationale=(
|
||||
"Membership removal must preserve access continuity, project "
|
||||
"ownership, and retained decision history."
|
||||
if membership
|
||||
else record.retention_reason
|
||||
or "Project lifecycle attribution remains governance evidence."
|
||||
),
|
||||
executable=False,
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del tenant_id
|
||||
_session(session)
|
||||
if _selectors(subject) is None:
|
||||
raise ValueError("Projects DSAR subject selectors conflict.")
|
||||
results = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if action.executable or action.kind not in {"manual_review", "retain"}:
|
||||
raise ValueError("Projects DSAR publishes non-executable actions only.")
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Project membership remains unchanged pending access and "
|
||||
"ownership review."
|
||||
if action.kind == "manual_review"
|
||||
else "Project lifecycle attribution remains governance evidence."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _selectors(subject: DsarSubjectRef) -> _Selectors | None:
|
||||
references = subject.external_references
|
||||
values = {
|
||||
"account_id": _coalesce(
|
||||
subject.account_id,
|
||||
references.get("projects.account"),
|
||||
references.get("access.account"),
|
||||
),
|
||||
"identity_id": _coalesce(
|
||||
subject.identity_id,
|
||||
references.get("projects.identity"),
|
||||
references.get("identity.id"),
|
||||
),
|
||||
"membership_id": _coalesce(
|
||||
subject.membership_id,
|
||||
references.get("projects.membership"),
|
||||
references.get("tenancy.membership"),
|
||||
),
|
||||
"function_assignment_id": _coalesce(
|
||||
references.get("projects.function_assignment"),
|
||||
references.get("projects.function_assignment_id"),
|
||||
),
|
||||
"project_id": _coalesce(
|
||||
references.get("projects.project"), references.get("projects.project_id")
|
||||
),
|
||||
"portfolio_id": _coalesce(
|
||||
references.get("projects.portfolio"),
|
||||
references.get("projects.portfolio_id"),
|
||||
),
|
||||
"milestone_id": _coalesce(
|
||||
references.get("projects.milestone"),
|
||||
references.get("projects.milestone_id"),
|
||||
),
|
||||
}
|
||||
if any(value is _CONFLICT for value in values.values()):
|
||||
return None
|
||||
object_values = tuple(
|
||||
(kind, value)
|
||||
for kind, value in (
|
||||
("project", _optional(values["project_id"])),
|
||||
("portfolio", _optional(values["portfolio_id"])),
|
||||
("milestone", _optional(values["milestone_id"])),
|
||||
)
|
||||
if value
|
||||
)
|
||||
if len(object_values) > 1:
|
||||
return None
|
||||
account_id = _optional(values["account_id"])
|
||||
identity_id = _optional(values["identity_id"])
|
||||
membership_id = _optional(values["membership_id"])
|
||||
function_assignment_id = _optional(values["function_assignment_id"])
|
||||
actor_ids = tuple(
|
||||
dict.fromkeys(
|
||||
value for value in (account_id, identity_id, membership_id) if value
|
||||
)
|
||||
)
|
||||
membership_subjects = tuple(
|
||||
item
|
||||
for item in (
|
||||
("account", account_id) if account_id else None,
|
||||
("identity", identity_id) if identity_id else None,
|
||||
(
|
||||
("function_assignment", function_assignment_id)
|
||||
if function_assignment_id
|
||||
else None
|
||||
),
|
||||
)
|
||||
if item is not None
|
||||
)
|
||||
if not actor_ids and not membership_subjects:
|
||||
return None
|
||||
return _Selectors(
|
||||
actor_ids=actor_ids,
|
||||
membership_subjects=membership_subjects,
|
||||
object_kind=object_values[0][0] if object_values else None,
|
||||
object_id=object_values[0][1] if object_values else None,
|
||||
)
|
||||
|
||||
|
||||
def _object_filter(query, model, selectors: _Selectors):
|
||||
if selectors.object_kind:
|
||||
query = query.filter(model.object_kind == selectors.object_kind)
|
||||
if selectors.object_id:
|
||||
query = query.filter(model.object_id == selectors.object_id)
|
||||
return query
|
||||
|
||||
|
||||
def _membership_record(row: ProjectMembershipGrant) -> DsarRecordRef:
|
||||
permissions = row.permissions
|
||||
if not isinstance(permissions, list) or len(permissions) > _MAX_PERMISSIONS:
|
||||
raise ValueError("Projects DSAR membership permissions exceed their bound.")
|
||||
return DsarRecordRef(
|
||||
provider_id="projects",
|
||||
module_id="projects",
|
||||
resource_type="project_membership",
|
||||
resource_id=row.id,
|
||||
category="project_participation",
|
||||
title="Project membership",
|
||||
data={
|
||||
"membership_grant_id": row.id,
|
||||
"object_kind": row.object_kind,
|
||||
"object_id": row.object_id,
|
||||
"subject_kind": row.subject_kind,
|
||||
"subject_id": row.subject_id,
|
||||
"role": row.role,
|
||||
"permissions": [str(item)[:120] for item in permissions],
|
||||
"active": row.active,
|
||||
"source_revision": row.source_revision,
|
||||
"created_at": _iso(row.created_at),
|
||||
"updated_at": _iso(row.updated_at),
|
||||
},
|
||||
observed_at=_aware(row.updated_at),
|
||||
retention_reason=(
|
||||
"Membership removal requires project access, ownership, and history review."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _identity_actor_record(row: ProjectObjectIdentity) -> DsarRecordRef:
|
||||
return _attribution_record(
|
||||
resource_type="project_identity_actor_attribution",
|
||||
resource_id=row.id,
|
||||
title="Project identity actor attribution",
|
||||
data={
|
||||
"object_kind": row.object_kind,
|
||||
"object_id": row.object_id,
|
||||
"activity": "created_project_identity",
|
||||
"created_at": _iso(row.created_at),
|
||||
},
|
||||
observed_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _revision_actor_record(row: ProjectObjectRevision) -> DsarRecordRef:
|
||||
return _attribution_record(
|
||||
resource_type="project_revision_actor_attribution",
|
||||
resource_id=row.id,
|
||||
title="Project revision actor attribution",
|
||||
data={
|
||||
"object_kind": row.object_kind,
|
||||
"object_id": row.object_id,
|
||||
"revision_id": row.id,
|
||||
"revision": row.revision,
|
||||
"state": row.state,
|
||||
"visibility": row.visibility,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"activity": "recorded_project_revision",
|
||||
},
|
||||
observed_at=row.recorded_at,
|
||||
)
|
||||
|
||||
|
||||
def _event_actor_record(row: ProjectObjectEvent) -> DsarRecordRef:
|
||||
return _attribution_record(
|
||||
resource_type="project_event_actor_attribution",
|
||||
resource_id=row.id,
|
||||
title="Project event actor attribution",
|
||||
data={
|
||||
"object_kind": row.object_kind,
|
||||
"object_id": row.object_id,
|
||||
"object_revision": row.object_revision,
|
||||
"event_id": row.event_id,
|
||||
"event_type": row.event_type,
|
||||
"occurred_at": _iso(row.occurred_at),
|
||||
},
|
||||
observed_at=row.occurred_at,
|
||||
)
|
||||
|
||||
|
||||
def _attribution_record(
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
title: str,
|
||||
data: dict[str, object],
|
||||
observed_at: datetime | None,
|
||||
) -> DsarRecordRef:
|
||||
return DsarRecordRef(
|
||||
provider_id="projects",
|
||||
module_id="projects",
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
category="project_governance_attribution",
|
||||
title=title,
|
||||
data=data,
|
||||
observed_at=_aware(observed_at),
|
||||
immutable_evidence=True,
|
||||
retention_reason=(
|
||||
"Project lifecycle attribution is retained for governance and accountability."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _limited(query, first, second, *, label: str):
|
||||
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(f"Projects DSAR {label} limit exceeded; narrow selectors.")
|
||||
return rows
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
aware = _aware(value)
|
||||
return aware.isoformat() if aware else None
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Projects DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
_RESOURCE_TYPES = {
|
||||
"project_membership",
|
||||
"project_identity_actor_attribution",
|
||||
"project_revision_actor_attribution",
|
||||
"project_event_actor_attribution",
|
||||
}
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "projects" or record.module_id != "projects":
|
||||
raise ValueError("Projects DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
|
||||
raise ValueError("Projects DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "projects" or action.module_id != "projects":
|
||||
raise ValueError("Projects DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("projects:"):
|
||||
raise ValueError("Projects DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["PROJECTS_DSAR_CAPABILITY", "ProjectsDsarProvider"]
|
||||
@@ -14,6 +14,7 @@ from govoplan_core.core.module_guards import (
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
@@ -24,6 +25,7 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
@@ -36,6 +38,10 @@ from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_projects.backend.acl import ProjectScopeAclProvider
|
||||
from govoplan_projects.backend.db import models as project_models
|
||||
from govoplan_projects.backend.dsar_provider import (
|
||||
PROJECTS_DSAR_CAPABILITY,
|
||||
ProjectsDsarProvider,
|
||||
)
|
||||
from govoplan_projects.backend.search_source import create_projects_search_source
|
||||
from govoplan_projects.backend.service import (
|
||||
CAPABILITY_PROJECTS_REGISTRY,
|
||||
@@ -45,7 +51,7 @@ from govoplan_projects.backend.service import (
|
||||
|
||||
MODULE_ID = "projects"
|
||||
MODULE_NAME = "Projects"
|
||||
MODULE_VERSION = "0.1.14"
|
||||
MODULE_VERSION = "0.1.20"
|
||||
READ_SCOPE = "projects:project:read"
|
||||
WRITE_SCOPE = "projects:project:write"
|
||||
ADMIN_SCOPE = "projects:project:admin"
|
||||
@@ -129,6 +135,10 @@ def _registry(context: ModuleContext) -> SqlProjectRegistry:
|
||||
return SqlProjectRegistry()
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> ProjectsDsarProvider:
|
||||
return ProjectsDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
counts = {
|
||||
kind: count
|
||||
@@ -200,21 +210,25 @@ ROLE_TEMPLATES = (
|
||||
DOCUMENTATION = (
|
||||
DocumentationTopic(
|
||||
id="projects.module-boundary",
|
||||
title="Projects module boundary",
|
||||
title="Plan and coordinate projects",
|
||||
summary=(
|
||||
"Portfolios, projects, versioned goals and outcomes, milestones, "
|
||||
"dependencies, capacity, benefits, participants, status, and references."
|
||||
"Coordinate portfolios, projects, milestones, participants, dependencies, "
|
||||
"capacity assumptions, outcomes, and benefit reviews without absorbing work-item ownership."
|
||||
),
|
||||
body=(
|
||||
"Projects owns native project context. Tasks and Tickets own "
|
||||
"actionable work, Cases owns formal procedures, and Connectors "
|
||||
"owns OpenProject synchronization. Reporting owns measured indicators; "
|
||||
"Risk Compliance owns risks and controls; Projects links those facts to "
|
||||
"planning, change impact, and benefit review."
|
||||
"Documentation books sit immediately beside the visible heading or contextual label for "
|
||||
"Projects, not among operational action buttons. Field help remains beside its label. "
|
||||
"Projects owns native portfolio and project context, immutable revisions of goals and intended outcomes, "
|
||||
"milestones, participants, status, dependencies, capacity assumptions, benefit reviews, and governed resource references. "
|
||||
"Create or revise the planning context, review its impact, and link provider-owned work and evidence without copying it. "
|
||||
"Tasks and Tickets retain actionable work, Cases retains formal procedures, and Connectors retains OpenProject transport. "
|
||||
"Reporting owns measured indicators and Risk Compliance owns risks and controls; Projects links those facts to planning, "
|
||||
"change impact, and benefit review."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
@@ -222,25 +236,115 @@ DOCUMENTATION = (
|
||||
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Projects interface pattern audit",
|
||||
href="govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"seed": True,
|
||||
"domain_objects": [
|
||||
"portfolio",
|
||||
"project",
|
||||
"milestone",
|
||||
"versioned goal and intended outcome",
|
||||
"dependency and capacity assumption",
|
||||
"benefit review",
|
||||
"project participant",
|
||||
"project resource link",
|
||||
"external project reference",
|
||||
"kind": "workflow",
|
||||
"help_contexts": [
|
||||
"projects.workspace",
|
||||
"projects.project",
|
||||
"projects.milestone",
|
||||
],
|
||||
"first_slice": (
|
||||
"Implement project and portfolio identity, status, milestones, "
|
||||
"participants, outcome/benefit intent, dependency/resource links, "
|
||||
"and OpenProject reference mapping."
|
||||
"purpose": (
|
||||
"Maintain a revisioned planning context and connect provider-owned work, evidence, and measurements to it."
|
||||
),
|
||||
"prerequisites": [
|
||||
"The actor can read the project; revisions require project write access.",
|
||||
"Referenced work, cases, files, risks, reports, and external systems remain authorized by their owner providers.",
|
||||
],
|
||||
"steps": [
|
||||
"Select a portfolio or create the project identity, status, participants, and planning horizon.",
|
||||
"Record versioned goals, intended outcomes, milestones, dependencies, and capacity assumptions.",
|
||||
"Link Tasks, Tickets, Cases, files, risks, controls, reports, or external project references without copying owner data.",
|
||||
"Review status, change impact, milestone progress, and benefit evidence before appending a revision.",
|
||||
"Use owner-module links to manage actionable work or formal procedures in their authoritative surface.",
|
||||
],
|
||||
"fields": {
|
||||
"portfolio": "Groups projects for planning and oversight without changing project authority.",
|
||||
"project": "The native revisioned context for outcome, status, participation, and resource links.",
|
||||
"milestone": "A dated project checkpoint, not an actionable Task or formal Case state.",
|
||||
"participant": "An explicit project access grant and role, independent of object ownership elsewhere.",
|
||||
"external_reference": "A governed link to a provider-owned project or resource, including OpenProject mappings.",
|
||||
},
|
||||
"limitations": [
|
||||
"Projects does not own Tasks, Tickets, Cases, source measurements, risks, controls, files, or external synchronization transport.",
|
||||
"A linked object remains unavailable when its owner provider denies current access.",
|
||||
],
|
||||
"operational_consequences": {
|
||||
"revise_plan": "Appends a new planning revision while preserving prior goals, status, and evidence links.",
|
||||
"change_participation": "Changes project access only and does not grant access to linked provider-owned objects.",
|
||||
"link_external_resource": "Stores a governed reference; synchronization remains a Connector responsibility.",
|
||||
},
|
||||
"verification": [
|
||||
"The project revision names its portfolio, status, participants, milestones, and intended outcomes.",
|
||||
"Every linked resource identifies its owner module or external provider and remains independently authorized.",
|
||||
"Benefit and change-impact review preserves the revision and evidence references used for the decision.",
|
||||
],
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Projekte planen und koordinieren",
|
||||
"summary": (
|
||||
"Portfolios, Projekte, Meilensteine, Beteiligte, Abhängigkeiten, Kapazitätsannahmen, "
|
||||
"Ergebnisse und Nutzenprüfungen koordinieren, ohne die Eigentümerschaft an Arbeitselementen zu übernehmen."
|
||||
),
|
||||
"body": (
|
||||
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||
"Kontextbezeichnung für Projekte, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe "
|
||||
"bleibt neben der Feldbezeichnung. "
|
||||
"Projects führt den nativen Portfolio- und Projektkontext, unveränderliche Revisionen von Zielen und "
|
||||
"beabsichtigten Ergebnissen, Meilensteine, Beteiligte, Status, Abhängigkeiten, Kapazitätsannahmen, "
|
||||
"Nutzenprüfungen und gesteuerte Ressourcenreferenzen. Planungskontext wird angelegt oder revidiert, "
|
||||
"seine Auswirkung geprüft und anbietergeführte Arbeit und Nachweise werden verknüpft, ohne sie zu kopieren. "
|
||||
"Tasks und Tickets behalten ausführbare Arbeit, Cases formelle Verfahren und Connectors den OpenProject-Transport. "
|
||||
"Reporting führt gemessene Kennzahlen und Risk Compliance Risiken und Kontrollen; Projects verknüpft diese Fakten "
|
||||
"mit Planung, Änderungsfolgen und Nutzenprüfung."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"purpose": (
|
||||
"Einen revisionierten Planungskontext pflegen und anbietergeführte Arbeit, Nachweise und Messwerte damit verknüpfen."
|
||||
),
|
||||
"prerequisites": [
|
||||
"Die handelnde Person darf das Projekt lesen; Revisionen erfordern Projektschreibberechtigung.",
|
||||
"Verknüpfte Arbeit, Verfahren, Dateien, Risiken, Berichte und externe Systeme bleiben durch ihre Eigentümeranbieter autorisiert.",
|
||||
],
|
||||
"steps": [
|
||||
"Ein Portfolio auswählen oder Projektidentität, Status, Beteiligte und Planungshorizont anlegen.",
|
||||
"Revisionierte Ziele, beabsichtigte Ergebnisse, Meilensteine, Abhängigkeiten und Kapazitätsannahmen erfassen.",
|
||||
"Tasks, Tickets, Cases, Dateien, Risiken, Kontrollen, Berichte oder externe Projektreferenzen verknüpfen, ohne Eigentümerdaten zu kopieren.",
|
||||
"Status, Änderungsfolgen, Meilensteinfortschritt und Nutzennachweise prüfen, bevor eine Revision angefügt wird.",
|
||||
"Verknüpfungen zu Eigentümermodulen verwenden, um ausführbare Arbeit oder formelle Verfahren in deren führender Oberfläche zu verwalten.",
|
||||
],
|
||||
"fields": {
|
||||
"portfolio": "Gruppiert Projekte für Planung und Aufsicht, ohne die Projektzuständigkeit zu verändern.",
|
||||
"project": "Der native revisionierte Kontext für Ergebnis, Status, Beteiligung und Ressourcenverknüpfungen.",
|
||||
"milestone": "Ein datierter Projektprüfpunkt, kein ausführbarer Task und kein formeller Case-Status.",
|
||||
"participant": "Eine ausdrückliche Projektzugriffsfreigabe und Rolle, unabhängig von Eigentümerschaft in anderen Modulen.",
|
||||
"external_reference": "Eine gesteuerte Verknüpfung zu einem anbietergeführten Projekt oder einer Ressource, einschließlich OpenProject-Zuordnungen.",
|
||||
},
|
||||
"limitations": [
|
||||
"Projects führt weder Tasks, Tickets, Cases, Quellmesswerte, Risiken, Kontrollen, Dateien noch externen Synchronisationstransport.",
|
||||
"Ein verknüpftes Objekt bleibt unverfügbar, wenn sein Eigentümeranbieter den aktuellen Zugriff verweigert.",
|
||||
],
|
||||
"operational_consequences": {
|
||||
"revise_plan": "Fügt eine neue Planungsrevision an und bewahrt frühere Ziele, Status und Nachweisverknüpfungen.",
|
||||
"change_participation": "Ändert nur den Projektzugriff und gewährt keinen Zugriff auf verknüpfte anbietergeführte Objekte.",
|
||||
"link_external_resource": "Speichert eine gesteuerte Referenz; Synchronisation bleibt Aufgabe eines Connectors.",
|
||||
},
|
||||
"verification": [
|
||||
"Die Projektrevision nennt Portfolio, Status, Beteiligte, Meilensteine und beabsichtigte Ergebnisse.",
|
||||
"Jede verknüpfte Ressource nennt Eigentümermodul oder externen Anbieter und bleibt unabhängig autorisiert.",
|
||||
"Nutzen- und Änderungsfolgenprüfung bewahrt die für die Entscheidung verwendete Revision und Nachweisreferenzen.",
|
||||
],
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -287,6 +391,17 @@ manifest = ModuleManifest(
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
product_areas=(
|
||||
ProductAreaContribution(
|
||||
id="work",
|
||||
module_id=MODULE_ID,
|
||||
label="i18n:govoplan-core.product_area.work",
|
||||
icon="list-checks",
|
||||
description="i18n:govoplan-core.product_area.work_description",
|
||||
surface_ids=("projects.nav.projects", "projects.route.projects"),
|
||||
order=10,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="projects.navigation",
|
||||
@@ -322,8 +437,12 @@ manifest = ModuleManifest(
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="projects.registry", version="0.1.0"),
|
||||
ModuleInterfaceProvider(name=PROJECTS_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
capability_factories={CAPABILITY_PROJECTS_REGISTRY: _registry},
|
||||
capability_factories={
|
||||
CAPABILITY_PROJECTS_REGISTRY: _registry,
|
||||
PROJECTS_DSAR_CAPABILITY: _dsar_provider,
|
||||
},
|
||||
capability_documentation={
|
||||
CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation(
|
||||
label="Projects registry",
|
||||
@@ -333,6 +452,14 @@ manifest = ModuleManifest(
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
PROJECTS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Projects data-subject request provider",
|
||||
summary=(
|
||||
"Exports exact project memberships and minimized lifecycle attribution "
|
||||
"without project payload content."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
@@ -372,7 +499,74 @@ manifest = ModuleManifest(
|
||||
ProjectScopeAclProvider("milestone"),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=DOCUMENTATION,
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="projects.data-subject-requests",
|
||||
title="Project data-subject requests",
|
||||
summary=(
|
||||
"Export project participation and accountable activity without project content."
|
||||
),
|
||||
body=(
|
||||
"Projects correlates exact account and identity membership grants, plus "
|
||||
"an explicitly supplied function-assignment reference. It separately "
|
||||
"matches exact account, identity, and membership identifiers used for "
|
||||
"creation and lifecycle attribution. Searches can narrow to one project, "
|
||||
"portfolio, or milestone, but an object identifier alone never establishes "
|
||||
"a subject. Membership exports include role, permissions, active state, "
|
||||
"and source revision. Titles, search text, planning payloads, event payloads, "
|
||||
"request hashes, idempotency keys, and unrelated members are excluded. "
|
||||
"Membership removal requires manual access and ownership review; immutable "
|
||||
"creation, revision, and event attribution remains retained governance evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("core", "access", "tasks", "audit"),
|
||||
order=90,
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"projects.workspace",
|
||||
"privacy.data-subject-requests",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"export_project_membership": "Returns exact subject grants without unrelated members.",
|
||||
"review_membership_removal": "Requires ownership and access-continuity review.",
|
||||
"retain_project_attribution": "Preserves immutable lifecycle accountability.",
|
||||
},
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Datenschutzanfragen zu Projekten",
|
||||
"summary": (
|
||||
"Projektbeteiligung und verantwortbare Aktivitäten ohne Projektinhalte exportieren."
|
||||
),
|
||||
"body": (
|
||||
"Projects gleicht exakte Konto- und Identitätsfreigaben sowie eine ausdrücklich angegebene "
|
||||
"Funktionszuweisungsreferenz ab. Exakte Konto-, Identitäts- und Mitgliedschaftskennungen für "
|
||||
"Anlage- und Lebenszykluszuschreibung werden getrennt ermittelt. Suchen können auf ein Projekt, "
|
||||
"Portfolio oder einen Meilenstein eingegrenzt werden; eine Objektkennung allein begründet niemals "
|
||||
"eine betroffene Person. Beteiligungsexporte enthalten Rolle, Berechtigungen, Aktivstatus und "
|
||||
"Quellrevision. Titel, Suchtext, Planungs- und Ereignisinhalte, Anforderungsprüfsummen, "
|
||||
"Idempotenzschlüssel und unbeteiligte Mitglieder bleiben ausgeschlossen. Die Entfernung einer "
|
||||
"Beteiligung erfordert eine manuelle Prüfung von Zugriff und Eigentumsfortbestand; unveränderliche "
|
||||
"Zuschreibungen zu Anlage, Revision und Ereignissen bleiben als Steuerungsnachweise erhalten."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"export_project_membership": "Gibt exakte Betroffenenfreigaben ohne unbeteiligte Mitglieder zurück.",
|
||||
"review_membership_removal": "Erfordert eine Prüfung von Eigentümerschaft und Zugriffskontinuität.",
|
||||
"retain_project_attribution": "Bewahrt unveränderliche Lebenszyklusverantwortung auf.",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
*DOCUMENTATION,
|
||||
),
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
documentation_structured_translation_issues,
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_projects.backend.manifest import manifest
|
||||
|
||||
|
||||
class ProjectsDocumentationTests(unittest.TestCase):
|
||||
def test_public_topics_have_complete_german_reference_content(self) -> None:
|
||||
self.assertEqual(2, len(manifest.documentation))
|
||||
for topic in manifest.documentation:
|
||||
translation = topic.translations.get("de", {})
|
||||
self.assertTrue(
|
||||
all(translation.get(key) for key in ("title", "summary", "body"))
|
||||
)
|
||||
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||
|
||||
def test_documentation_has_scope_conditioned_workflow_and_reference(self) -> None:
|
||||
kinds = {topic.metadata.get("kind") for topic in manifest.documentation}
|
||||
self.assertIn("workflow", kinds)
|
||||
self.assertIn("reference", kinds)
|
||||
for topic in manifest.documentation:
|
||||
self.assertEqual((), user_workflow_scope_condition_issues(topic))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_projects.backend.db.models import (
|
||||
ProjectMembershipGrant,
|
||||
ProjectObjectEvent,
|
||||
ProjectObjectIdentity,
|
||||
ProjectObjectRevision,
|
||||
)
|
||||
from govoplan_projects.backend.dsar_provider import (
|
||||
PROJECTS_DSAR_CAPABILITY,
|
||||
ProjectsDsarProvider,
|
||||
)
|
||||
from govoplan_projects.backend.manifest import manifest
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 22, 15, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
class ProjectsDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = ProjectsDsarProvider()
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
self.session.add_all(
|
||||
(
|
||||
ProjectObjectIdentity(
|
||||
id="identity-row-1",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
object_key="secret-object-key-do-not-export",
|
||||
created_by="account-1",
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
),
|
||||
ProjectObjectRevision(
|
||||
id="revision-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-row-1",
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
revision=2,
|
||||
state="active",
|
||||
title="Sensitive project title do not export",
|
||||
visibility="restricted",
|
||||
recorded_at=NOW,
|
||||
search_text="project-search-content-do-not-export",
|
||||
payload={"secret": "project-payload-do-not-export"},
|
||||
changed_by="account-1",
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
),
|
||||
ProjectMembershipGrant(
|
||||
id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
subject_kind="account",
|
||||
subject_id="account-1",
|
||||
role="member",
|
||||
permissions=["read", "write"],
|
||||
active=True,
|
||||
source_revision=2,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
),
|
||||
ProjectMembershipGrant(
|
||||
id="membership-other-person",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
subject_kind="account",
|
||||
subject_id="account-other-do-not-export",
|
||||
role="owner",
|
||||
permissions=["admin"],
|
||||
active=True,
|
||||
source_revision=2,
|
||||
),
|
||||
ProjectObjectEvent(
|
||||
id="event-row-1",
|
||||
tenant_id="tenant-1",
|
||||
object_kind="project",
|
||||
object_id="project-1",
|
||||
object_revision=2,
|
||||
event_id="event-1",
|
||||
event_type="projects.project.updated",
|
||||
occurred_at=NOW,
|
||||
actor_id="account-1",
|
||||
idempotency_key="event-idempotency-do-not-export",
|
||||
request_sha256="event-request-hash-do-not-export",
|
||||
payload={"secret": "event-payload-do-not-export"},
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
),
|
||||
ProjectMembershipGrant(
|
||||
id="membership-other-tenant",
|
||||
tenant_id="tenant-2",
|
||||
object_kind="project",
|
||||
object_id="project-other",
|
||||
subject_kind="account",
|
||||
subject_id="account-1",
|
||||
role="member",
|
||||
permissions=["read"],
|
||||
active=True,
|
||||
source_revision=1,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def test_search_exports_membership_and_minimized_attribution(self) -> None:
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"project_membership",
|
||||
"project_identity_actor_attribution",
|
||||
"project_revision_actor_attribution",
|
||||
"project_event_actor_attribution",
|
||||
},
|
||||
{record.resource_type for record in records},
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertIn("membership-1", exported)
|
||||
for excluded in (
|
||||
"account-other-do-not-export",
|
||||
"secret-object-key-do-not-export",
|
||||
"Sensitive project title do not export",
|
||||
"project-search-content-do-not-export",
|
||||
"project-payload-do-not-export",
|
||||
"event-idempotency-do-not-export",
|
||||
"event-request-hash-do-not-export",
|
||||
"event-payload-do-not-export",
|
||||
):
|
||||
self.assertNotIn(excluded, exported)
|
||||
|
||||
def test_requires_subject_identity_and_enforces_narrowing(self) -> None:
|
||||
self.assertEqual(
|
||||
(),
|
||||
self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(email="member@example.test"),
|
||||
),
|
||||
)
|
||||
narrowed = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"projects.project": "project-1"},
|
||||
),
|
||||
)
|
||||
self.assertEqual(4, len(narrowed))
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={
|
||||
"projects.project": "project-1",
|
||||
"projects.portfolio": "portfolio-1",
|
||||
},
|
||||
),
|
||||
)
|
||||
self.assertEqual((), conflict)
|
||||
|
||||
def test_membership_requires_review_and_attribution_is_retained(self) -> None:
|
||||
subject = DsarSubjectRef(account_id="account-1")
|
||||
records = self.provider.search_subject(
|
||||
self.session, tenant_id="tenant-1", subject=subject
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
by_type = {action.resource_type: action.kind for action in actions}
|
||||
self.assertEqual("manual_review", by_type["project_membership"])
|
||||
self.assertEqual("retain", by_type["project_event_actor_attribution"])
|
||||
|
||||
def test_manifest_registers_provider_and_documentation(self) -> None:
|
||||
self.assertIn(PROJECTS_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(
|
||||
"projects.data-subject-requests",
|
||||
{topic.id for topic in manifest.documentation},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/projects-webui",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,8 +13,11 @@
|
||||
},
|
||||
"./styles/projects.css": "./src/styles/projects.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
|
||||
const page = fs.readFileSync("src/features/projects/ProjectsPage.tsx", "utf8");
|
||||
const styles = fs.readFileSync("src/styles/projects.css", "utf8");
|
||||
|
||||
assert.ok(page.includes("DocumentationHelpLink"), "Projects exposes configured-system help");
|
||||
assert.ok(page.includes("PageScrollViewport"), "Projects owns bounded list and detail scrolling");
|
||||
assert.ok(page.includes("<Dialog"), "Projects uses the shared focus-contained editor dialog");
|
||||
assert.ok(page.includes("FieldLabel"), "Project editor fields use the shared label/help contract");
|
||||
assert.ok(page.includes('DismissibleAlert tone="danger" resetKey={error}'), "Save failures remain attached to the editor");
|
||||
assert.ok(page.includes("StatusBadge"), "Project lifecycle state is not conveyed by color alone");
|
||||
assert.ok(!page.includes("window.alert("), "Projects must not use browser alerts");
|
||||
assert.ok(!/<(div|span|li|tr)\b[^>]*\bonClick\s*=/.test(page), "Projects uses semantic interactive elements");
|
||||
assert.ok(styles.includes("@media (max-width: 560px)"), "Projects retains a narrow-viewport editor layout");
|
||||
assert.ok(styles.includes(":focus-visible"), "Projects retains visible keyboard focus");
|
||||
|
||||
console.log("Projects interface pattern contract passed.");
|
||||
@@ -13,15 +13,24 @@ import {
|
||||
useState,
|
||||
type FormEvent
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
import { Button,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FieldLabel,
|
||||
FilterBar,
|
||||
FormLayout,
|
||||
IconButton,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
WorkspaceActionBar,
|
||||
WorkspaceFrame,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
@@ -69,6 +78,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [editorError, setEditorError] = useState("");
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ProjectRecord | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -123,12 +133,14 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
setEditorError("");
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function openEdit() {
|
||||
if (!selected) return;
|
||||
setEditing(selected);
|
||||
setEditorError("");
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
@@ -188,10 +200,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
}
|
||||
setEditorOpen(false);
|
||||
setEditing(null);
|
||||
setEditorError("");
|
||||
await reload();
|
||||
setSelectedKey(objectKey(saved));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The project object could not be saved.");
|
||||
setEditorError(reason instanceof Error ? reason.message : "The project object could not be saved.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -199,9 +212,15 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
|
||||
return (
|
||||
<main className="projects-page">
|
||||
<div className="projects-shell">
|
||||
<div className="projects-toolbar">
|
||||
<form className="projects-search" onSubmit={submitSearch}>
|
||||
<WorkspaceFrame className="projects-shell" label="Projects workspace" interfaceId="projects.workspace" helpContextId="projects.page.workspace" helpModuleId="projects">
|
||||
<WorkspaceActionBar
|
||||
scope="workspace"
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reload(), loading }}
|
||||
className="projects-toolbar"
|
||||
contextActions={<>
|
||||
<FilterBar as="form" surface="control" wrap="never" width="default" className="projects-search" onSubmit={submitSearch}>
|
||||
<Search size={17} aria-hidden="true" />
|
||||
<input
|
||||
value={query}
|
||||
@@ -210,8 +229,8 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
placeholder="Search portfolios, projects, and milestones"
|
||||
/>
|
||||
<Button type="submit" variant="primary">Search</Button>
|
||||
</form>
|
||||
<label className="projects-kind-filter">
|
||||
</FilterBar>
|
||||
<label className="projects-kind-filter">
|
||||
<span>Type</span>
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}>
|
||||
<option value="">All planning objects</option>
|
||||
@@ -219,14 +238,21 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
<option value="project">Projects</option>
|
||||
<option value="milestone">Milestones</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="projects-count">{total} objects</span>
|
||||
{canWrite &&
|
||||
</label>
|
||||
<span className="projects-count">{total} objects</span>
|
||||
</>}
|
||||
title="Projects"
|
||||
titleLevel={1}
|
||||
titleHelp={<DocumentationHelpLink
|
||||
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
|
||||
label="Open Projects documentation"
|
||||
/>}
|
||||
createAction={canWrite ?
|
||||
<Button type="button" variant="primary" onClick={openCreate}>
|
||||
<Plus size={16} aria-hidden="true" /> New
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
: undefined}
|
||||
/>
|
||||
{error &&
|
||||
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
|
||||
{error}
|
||||
@@ -236,41 +262,38 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
|
||||
<PageScrollViewport className="projects-list-viewport">
|
||||
{loading && <LoadingIndicator label="Loading projects" />}
|
||||
{!loading && objects.length === 0 &&
|
||||
<div className="projects-empty">No matching planning objects.</div>
|
||||
<StatePanel size="compact" description="No matching planning objects." />
|
||||
}
|
||||
<div className="projects-list" role="list">
|
||||
<SelectionList variant="navigation" label="Planning objects">
|
||||
{objects.map((item) =>
|
||||
<button
|
||||
type="button"
|
||||
role="listitem"
|
||||
<SelectionListItem
|
||||
key={objectKey(item)}
|
||||
className={`project-row${objectKey(item) === selectedKey ? " is-selected" : ""}`}
|
||||
selected={objectKey(item) === selectedKey}
|
||||
onClick={() => setSelectedKey(objectKey(item))}>
|
||||
<span className="project-row-icon">{kindIcon(item.object_kind)}</span>
|
||||
<span className="project-row-main">
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.object_key}</small>
|
||||
</span>
|
||||
<SelectionListItemContent leading={kindIcon(item.object_kind)} title={item.title} description={`${item.object_key} · ${formatDate(item.due_at)}`} />
|
||||
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||
<span className="project-row-date">{formatDate(item.due_at)}</span>
|
||||
</button>
|
||||
</SelectionListItem>
|
||||
)}
|
||||
</div>
|
||||
</SelectionList>
|
||||
</PageScrollViewport>
|
||||
<PageScrollViewport className="project-detail-viewport">
|
||||
{selected ?
|
||||
<ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> :
|
||||
<div className="projects-empty">Select a portfolio, project, or milestone.</div>
|
||||
<StatePanel size="fill" title="Planning objects" description="Select a portfolio, project, or milestone." />
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceFrame>
|
||||
<ProjectEditorDialog
|
||||
open={editorOpen}
|
||||
record={editing}
|
||||
objects={parentOptions}
|
||||
saving={saving}
|
||||
onClose={() => setEditorOpen(false)}
|
||||
error={editorError}
|
||||
onClose={() => {
|
||||
setEditorOpen(false);
|
||||
setEditorError("");
|
||||
}}
|
||||
onSave={save}
|
||||
/>
|
||||
</main>
|
||||
@@ -288,7 +311,7 @@ function ProjectDetail({ record, canWrite, onEdit }: {
|
||||
<header className="project-detail-header">
|
||||
<div>
|
||||
<span className="project-eyebrow">{humanize(record.object_kind)} · {record.object_key}</span>
|
||||
<h1>{record.title}</h1>
|
||||
<h2>{record.title}</h2>
|
||||
</div>
|
||||
<div className="project-detail-actions">
|
||||
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
|
||||
@@ -337,11 +360,12 @@ function PlanningStat({ label, value }: { label: string; value: number }) {
|
||||
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||
}
|
||||
|
||||
function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: {
|
||||
function ProjectEditorDialog({ open, record, objects, saving, error, onClose, onSave }: {
|
||||
open: boolean;
|
||||
record: ProjectRecord | null;
|
||||
objects: ProjectRecord[];
|
||||
saving: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
onSave: (values: EditorValues) => Promise<void>;
|
||||
}) {
|
||||
@@ -381,9 +405,10 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<FormLayout id="project-editor-form" columns={2} gap="compact" collapseAt="narrow" className="project-editor-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<FieldLabel>Type</FieldLabel>
|
||||
<select
|
||||
value={values.kind}
|
||||
disabled={Boolean(record)}
|
||||
@@ -397,8 +422,8 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
|
||||
</select>
|
||||
</label>
|
||||
{values.kind !== "portfolio" &&
|
||||
<label className="project-editor-wide">
|
||||
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span>
|
||||
<label className="wide">
|
||||
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
|
||||
<select
|
||||
value={values.parentRef}
|
||||
required={values.kind === "milestone"}
|
||||
@@ -418,43 +443,43 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
<span>Key</span>
|
||||
<FieldLabel help="Stable identifier used in links and external mappings.">Key</FieldLabel>
|
||||
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<span>Title</span>
|
||||
<label className="wide">
|
||||
<FieldLabel>Title</FieldLabel>
|
||||
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>State</span>
|
||||
<FieldLabel>State</FieldLabel>
|
||||
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
|
||||
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Visibility</span>
|
||||
<FieldLabel help="Restricted objects require an owner or explicit membership grant.">Visibility</FieldLabel>
|
||||
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
|
||||
<option value="tenant">Tenant</option>
|
||||
<option value="restricted">Restricted</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Starts</span>
|
||||
<FieldLabel>Starts</FieldLabel>
|
||||
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Due</span>
|
||||
<FieldLabel>Due</FieldLabel>
|
||||
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<span>Description</span>
|
||||
<label className="wide">
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<span>Change reason</span>
|
||||
<FieldLabel help="Recorded with the immutable project revision and lifecycle evidence.">Change reason</FieldLabel>
|
||||
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
|
||||
</label>
|
||||
</form>
|
||||
</FormLayout>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
/** Module-owned translations for contextual headings. */
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
en: {
|
||||
"Projects": "Projects",
|
||||
},
|
||||
de: {
|
||||
"Projects": "Projekte",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/projects.css";
|
||||
|
||||
|
||||
const ProjectsPage = lazy(() => import("./features/projects/ProjectsPage"));
|
||||
|
||||
export const projectsModule: PlatformWebModule = {
|
||||
translations: generatedTranslations,
|
||||
id: "projects",
|
||||
label: "Projects",
|
||||
version: "0.1.14",
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
.projects-page,
|
||||
.projects-shell {
|
||||
.projects-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.projects-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.projects-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 58px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
.projects-page :where(button, input, select, textarea, a[href]):focus-visible {
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.projects-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: min(560px, 48vw);
|
||||
}
|
||||
|
||||
.projects-search input {
|
||||
min-width: 160px;
|
||||
flex: 1;
|
||||
flex: 1 1 560px;
|
||||
}
|
||||
|
||||
.projects-kind-filter {
|
||||
@@ -67,69 +47,6 @@
|
||||
background: var(--surface-subtle, var(--surface));
|
||||
}
|
||||
|
||||
.projects-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.project-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto minmax(96px, auto);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.project-row:hover,
|
||||
.project-row:focus-visible,
|
||||
.project-row.is-selected {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.project-row.is-selected {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.project-row-icon {
|
||||
display: grid;
|
||||
color: var(--text-soft);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.project-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-row-main strong,
|
||||
.project-row-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-row-main small,
|
||||
.project-row-date {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.project-detail {
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
@@ -145,7 +62,7 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-detail-header h1 {
|
||||
.project-detail-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 1.4rem;
|
||||
letter-spacing: 0;
|
||||
@@ -175,7 +92,7 @@
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-compact);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
@@ -228,22 +145,10 @@
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.projects-empty {
|
||||
padding: 36px 10px;
|
||||
color: var(--text-soft);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-editor-dialog {
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.project-editor-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.project-editor-form label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -256,20 +161,7 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.project-editor-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.projects-toolbar {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.projects-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.projects-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -291,19 +183,4 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.project-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.project-row-date {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-editor-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-editor-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user