15 Commits
Author SHA1 Message Date
zemion f0a28dd8ec docs(projects): add German workflow guidance
Module Package Release / publish-packages (push) Successful in 10s
2026-08-23 01:54:20 +02:00
zemion de8ec81c47 feat(projects): add governed DSAR coverage 2026-08-21 13:28:07 +02:00
zemion 4bebb4211a refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 2618532377 feat: align projects with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion 139dc0cd75 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion e4663d545a Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion 801b11016c Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:51 +02:00
zemion eccf7dc207 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:13 +02:00
zemion 80795ef706 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:27 +02:00
zemion 7fade1b173 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 12s
2026-08-04 15:10:24 +02:00
zemion 260f87a6a6 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 20b8aa080c Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 4e7e972415 Adopt the shared interface pattern language 2026-08-04 08:21:50 +02:00
zemion 2f53dc44dc Add protected package release workflow 2026-08-04 04:14:07 +02:00
zemion 87e05af025 Correct Access package version floor 2026-08-04 04:13:50 +02:00
13 changed files with 1317 additions and 208 deletions
+270
View File
@@ -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
+2
View File
@@ -22,6 +22,8 @@ Connectors; Projects stores only the native planning object and canonical
external reference. external reference.
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md). 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 ## Development
+27
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/projects", "name": "@govoplan/projects",
"version": "0.1.14", "version": "0.1.19",
"private": true, "private": true,
"description": "GovOPlaN Projects domain module.", "description": "GovOPlaN Projects domain module.",
"type": "module", "type": "module",
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-projects" name = "govoplan-projects"
version = "0.1.14" version = "0.1.19"
description = "GovOPlaN Projects domain module." description = "GovOPlaN Projects domain module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.14", "govoplan-core>=0.1.37",
"govoplan-access>=0.1.14", "govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [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"]
+215 -26
View File
@@ -14,6 +14,7 @@ from govoplan_core.core.module_guards import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -24,6 +25,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import ( 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_core.db.base import Base
from govoplan_projects.backend.acl import ProjectScopeAclProvider from govoplan_projects.backend.acl import ProjectScopeAclProvider
from govoplan_projects.backend.db import models as project_models 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.search_source import create_projects_search_source
from govoplan_projects.backend.service import ( from govoplan_projects.backend.service import (
CAPABILITY_PROJECTS_REGISTRY, CAPABILITY_PROJECTS_REGISTRY,
@@ -45,7 +51,7 @@ from govoplan_projects.backend.service import (
MODULE_ID = "projects" MODULE_ID = "projects"
MODULE_NAME = "Projects" MODULE_NAME = "Projects"
MODULE_VERSION = "0.1.14" MODULE_VERSION = "0.1.19"
READ_SCOPE = "projects:project:read" READ_SCOPE = "projects:project:read"
WRITE_SCOPE = "projects:project:write" WRITE_SCOPE = "projects:project:write"
ADMIN_SCOPE = "projects:project:admin" ADMIN_SCOPE = "projects:project:admin"
@@ -129,6 +135,10 @@ def _registry(context: ModuleContext) -> SqlProjectRegistry:
return SqlProjectRegistry() return SqlProjectRegistry()
def _dsar_provider(_context: ModuleContext) -> ProjectsDsarProvider:
return ProjectsDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
counts = { counts = {
kind: count kind: count
@@ -200,21 +210,23 @@ ROLE_TEMPLATES = (
DOCUMENTATION = ( DOCUMENTATION = (
DocumentationTopic( DocumentationTopic(
id="projects.module-boundary", id="projects.module-boundary",
title="Projects module boundary", title="Plan and coordinate projects",
summary=( summary=(
"Portfolios, projects, versioned goals and outcomes, milestones, " "Coordinate portfolios, projects, milestones, participants, dependencies, "
"dependencies, capacity, benefits, participants, status, and references." "capacity assumptions, outcomes, and benefit reviews without absorbing work-item ownership."
), ),
body=( body=(
"Projects owns native project context. Tasks and Tickets own " "Projects owns native portfolio and project context, immutable revisions of goals and intended outcomes, "
"actionable work, Cases owns formal procedures, and Connectors " "milestones, participants, status, dependencies, capacity assumptions, benefit reviews, and governed resource references. "
"owns OpenProject synchronization. Reporting owns measured indicators; " "Create or revise the planning context, review its impact, and link provider-owned work and evidence without copying it. "
"Risk Compliance owns risks and controls; Projects links those facts to " "Tasks and Tickets retain actionable work, Cases retains formal procedures, and Connectors retains OpenProject transport. "
"planning, change impact, and benefit review." "Reporting owns measured indicators and Risk Compliance owns risks and controls; Projects links those facts to planning, "
"change impact, and benefit review."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"), audience=("user", "operator", "module_admin", "product_owner"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
DocumentationLink( DocumentationLink(
@@ -222,25 +234,112 @@ DOCUMENTATION = (
href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md", href="govoplan-projects/docs/PROJECTS_DOMAIN_BOUNDARY.md",
kind="repository", kind="repository",
), ),
DocumentationLink(
label="Projects interface pattern audit",
href="govoplan-projects/docs/INTERFACE_PATTERN_MIGRATION.md",
kind="repository",
),
), ),
metadata={ metadata={
"seed": True, "kind": "workflow",
"domain_objects": [ "help_contexts": [
"portfolio", "projects.workspace",
"project", "projects.project",
"milestone", "projects.milestone",
"versioned goal and intended outcome",
"dependency and capacity assumption",
"benefit review",
"project participant",
"project resource link",
"external project reference",
], ],
"first_slice": ( "purpose": (
"Implement project and portfolio identity, status, milestones, " "Maintain a revisioned planning context and connect provider-owned work, evidence, and measurements to it."
"participants, outcome/benefit intent, dependency/resource links, "
"and OpenProject reference mapping."
), ),
"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": (
"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 +386,17 @@ manifest = ModuleManifest(
order=36, 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=( view_surfaces=(
ViewSurface( ViewSurface(
id="projects.navigation", id="projects.navigation",
@@ -322,8 +432,12 @@ manifest = ModuleManifest(
), ),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="projects.registry", version="0.1.0"), 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_documentation={
CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation( CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation(
label="Projects registry", label="Projects registry",
@@ -333,6 +447,14 @@ manifest = ModuleManifest(
), ),
contract_version="0.1.0", 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=( search_sources=(
SearchSourceProviderRegistration( SearchSourceProviderRegistration(
@@ -372,7 +494,74 @@ manifest = ModuleManifest(
ProjectScopeAclProvider("milestone"), ProjectScopeAclProvider("milestone"),
), ),
tenant_summary_providers=(_tenant_summary,), 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, architecture=ARCHITECTURE,
) )
+31
View File
@@ -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()
+213
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/projects-webui", "name": "@govoplan/projects-webui",
"version": "0.1.14", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,8 +13,11 @@
}, },
"./styles/projects.css": "./src/styles/projects.css" "./styles/projects.css": "./src/styles/projects.css"
}, },
"scripts": {
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.14", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+18
View File
@@ -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.");
+66 -43
View File
@@ -13,15 +13,24 @@ import {
useState, useState,
type FormEvent type FormEvent
} from "react"; } from "react";
import { import { Button,
Button,
Dialog, Dialog,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FieldLabel,
FilterBar,
FormLayout,
IconButton, IconButton,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
hasScope, hasScope,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -69,6 +78,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [editorError, setEditorError] = useState("");
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<ProjectRecord | null>(null); const [editing, setEditing] = useState<ProjectRecord | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -123,12 +133,14 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
function openCreate() { function openCreate() {
setEditing(null); setEditing(null);
setEditorError("");
setEditorOpen(true); setEditorOpen(true);
} }
function openEdit() { function openEdit() {
if (!selected) return; if (!selected) return;
setEditing(selected); setEditing(selected);
setEditorError("");
setEditorOpen(true); setEditorOpen(true);
} }
@@ -188,10 +200,11 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
} }
setEditorOpen(false); setEditorOpen(false);
setEditing(null); setEditing(null);
setEditorError("");
await reload(); await reload();
setSelectedKey(objectKey(saved)); setSelectedKey(objectKey(saved));
} catch (reason) { } 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 { } finally {
setSaving(false); setSaving(false);
} }
@@ -199,9 +212,15 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
return ( return (
<main className="projects-page"> <main className="projects-page">
<div className="projects-shell"> <WorkspaceFrame className="projects-shell" label="Projects workspace" interfaceId="projects.workspace" helpContextId="projects.page.workspace" helpModuleId="projects">
<div className="projects-toolbar"> <WorkspaceActionBar
<form className="projects-search" onSubmit={submitSearch}> 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" /> <Search size={17} aria-hidden="true" />
<input <input
value={query} value={query}
@@ -210,7 +229,7 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
placeholder="Search portfolios, projects, and milestones" placeholder="Search portfolios, projects, and milestones"
/> />
<Button type="submit" variant="primary">Search</Button> <Button type="submit" variant="primary">Search</Button>
</form> </FilterBar>
<label className="projects-kind-filter"> <label className="projects-kind-filter">
<span>Type</span> <span>Type</span>
<select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}> <select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}>
@@ -221,12 +240,17 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
</select> </select>
</label> </label>
<span className="projects-count">{total} objects</span> <span className="projects-count">{total} objects</span>
{canWrite && </>}
helpAction={<DocumentationHelpLink
reference={{ topicId: "projects.module-boundary", documentationType: "user" }}
label="Open Projects documentation"
/>}
createAction={canWrite ?
<Button type="button" variant="primary" onClick={openCreate}> <Button type="button" variant="primary" onClick={openCreate}>
<Plus size={16} aria-hidden="true" /> New <Plus size={16} aria-hidden="true" /> New
</Button> </Button>
} : undefined}
</div> />
{error && {error &&
<DismissibleAlert tone="error" onDismiss={() => setError("")}> <DismissibleAlert tone="error" onDismiss={() => setError("")}>
{error} {error}
@@ -236,41 +260,38 @@ export default function ProjectsPage({ settings, auth }: PlatformRouteContext) {
<PageScrollViewport className="projects-list-viewport"> <PageScrollViewport className="projects-list-viewport">
{loading && <LoadingIndicator label="Loading projects" />} {loading && <LoadingIndicator label="Loading projects" />}
{!loading && objects.length === 0 && {!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) => {objects.map((item) =>
<button <SelectionListItem
type="button"
role="listitem"
key={objectKey(item)} key={objectKey(item)}
className={`project-row${objectKey(item) === selectedKey ? " is-selected" : ""}`} selected={objectKey(item) === selectedKey}
onClick={() => setSelectedKey(objectKey(item))}> onClick={() => setSelectedKey(objectKey(item))}>
<span className="project-row-icon">{kindIcon(item.object_kind)}</span> <SelectionListItemContent leading={kindIcon(item.object_kind)} title={item.title} description={`${item.object_key} · ${formatDate(item.due_at)}`} />
<span className="project-row-main">
<strong>{item.title}</strong>
<small>{item.object_key}</small>
</span>
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} /> <StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
<span className="project-row-date">{formatDate(item.due_at)}</span> </SelectionListItem>
</button>
)} )}
</div> </SelectionList>
</PageScrollViewport> </PageScrollViewport>
<PageScrollViewport className="project-detail-viewport"> <PageScrollViewport className="project-detail-viewport">
{selected ? {selected ?
<ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> : <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> </PageScrollViewport>
</div> </div>
</div> </WorkspaceFrame>
<ProjectEditorDialog <ProjectEditorDialog
open={editorOpen} open={editorOpen}
record={editing} record={editing}
objects={parentOptions} objects={parentOptions}
saving={saving} saving={saving}
onClose={() => setEditorOpen(false)} error={editorError}
onClose={() => {
setEditorOpen(false);
setEditorError("");
}}
onSave={save} onSave={save}
/> />
</main> </main>
@@ -337,11 +358,12 @@ function PlanningStat({ label, value }: { label: string; value: number }) {
return <div><span>{label}</span><strong>{value}</strong></div>; 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; open: boolean;
record: ProjectRecord | null; record: ProjectRecord | null;
objects: ProjectRecord[]; objects: ProjectRecord[];
saving: boolean; saving: boolean;
error: string;
onClose: () => void; onClose: () => void;
onSave: (values: EditorValues) => Promise<void>; onSave: (values: EditorValues) => Promise<void>;
}) { }) {
@@ -381,9 +403,10 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</Button> </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> <label>
<span>Type</span> <FieldLabel>Type</FieldLabel>
<select <select
value={values.kind} value={values.kind}
disabled={Boolean(record)} disabled={Boolean(record)}
@@ -397,8 +420,8 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</select> </select>
</label> </label>
{values.kind !== "portfolio" && {values.kind !== "portfolio" &&
<label className="project-editor-wide"> <label className="wide">
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span> <FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
<select <select
value={values.parentRef} value={values.parentRef}
required={values.kind === "milestone"} required={values.kind === "milestone"}
@@ -418,43 +441,43 @@ function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }:
</label> </label>
} }
<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)} /> <input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="wide">
<span>Title</span> <FieldLabel>Title</FieldLabel>
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} /> <input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
</label> </label>
<label> <label>
<span>State</span> <FieldLabel>State</FieldLabel>
<select value={values.state} onChange={(event) => set("state", event.target.value)}> <select value={values.state} onChange={(event) => set("state", event.target.value)}>
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)} {STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
</select> </select>
</label> </label>
<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"])}> <select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
<option value="tenant">Tenant</option> <option value="tenant">Tenant</option>
<option value="restricted">Restricted</option> <option value="restricted">Restricted</option>
</select> </select>
</label> </label>
<label> <label>
<span>Starts</span> <FieldLabel>Starts</FieldLabel>
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} /> <input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
</label> </label>
<label> <label>
<span>Due</span> <FieldLabel>Due</FieldLabel>
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} /> <input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <label className="wide">
<span>Description</span> <FieldLabel>Description</FieldLabel>
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} /> <textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
</label> </label>
<label className="project-editor-wide"> <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)} /> <input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
</label> </label>
</form> </FormLayout>
</Dialog> </Dialog>
); );
} }
+7 -130
View File
@@ -1,36 +1,16 @@
.projects-page, .projects-page {
.projects-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.projects-shell { .projects-page :where(button, input, select, textarea, a[href]):focus-visible {
display: flex; outline: var(--focus-outline);
flex-direction: column; outline-offset: 2px;
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-search { .projects-search {
display: flex; flex: 1 1 560px;
align-items: center;
gap: 8px;
width: min(560px, 48vw);
}
.projects-search input {
min-width: 160px;
flex: 1;
} }
.projects-kind-filter { .projects-kind-filter {
@@ -67,69 +47,6 @@
background: var(--surface-subtle, var(--surface)); 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 { .project-detail {
width: min(100%, 980px); width: min(100%, 980px);
margin: 0 auto; margin: 0 auto;
@@ -175,7 +92,7 @@
gap: 1px; gap: 1px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--border); background: var(--border);
} }
@@ -228,22 +145,10 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.projects-empty {
padding: 36px 10px;
color: var(--text-soft);
text-align: center;
}
.project-editor-dialog { .project-editor-dialog {
width: min(720px, calc(100vw - 32px)); 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 { .project-editor-form label {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -256,20 +161,7 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.project-editor-wide { @media (max-width: 900px) {
grid-column: 1 / -1;
}
@media (max-width: 920px) {
.projects-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.projects-search {
width: 100%;
}
.projects-count { .projects-count {
margin-left: 0; margin-left: 0;
} }
@@ -291,19 +183,4 @@
} }
@media (max-width: 560px) { @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;
}
} }