Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad3418a7c | ||
|
|
a8bfdbf20c | ||
|
|
f0a28dd8ec | ||
|
|
de8ec81c47 | ||
|
|
4bebb4211a | ||
|
|
2618532377 | ||
|
|
139dc0cd75 | ||
|
|
e4663d545a |
@@ -36,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.
|
||||
|
||||
+29
-3
@@ -1,8 +1,34 @@
|
||||
{
|
||||
"name": "@govoplan/projects",
|
||||
"version": "0.1.18",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-projects"
|
||||
version = "0.1.18"
|
||||
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.18",
|
||||
"govoplan-core>=0.1.37",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.18"
|
||||
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(
|
||||
@@ -229,23 +243,108 @@ DOCUMENTATION = (
|
||||
),
|
||||
),
|
||||
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.",
|
||||
],
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -292,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",
|
||||
@@ -327,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",
|
||||
@@ -338,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(
|
||||
@@ -377,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()
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/projects-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -13,17 +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 {
|
||||
@@ -205,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}
|
||||
@@ -216,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>
|
||||
@@ -225,18 +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>
|
||||
<DocumentationHelpLink
|
||||
</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"
|
||||
/>
|
||||
{canWrite &&
|
||||
/>}
|
||||
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}
|
||||
@@ -246,35 +262,28 @@ 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}
|
||||
@@ -302,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)} />
|
||||
@@ -397,7 +406,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
|
||||
</>
|
||||
}>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
|
||||
<FormLayout id="project-editor-form" columns={2} gap="compact" collapseAt="narrow" className="project-editor-form" onSubmit={submit}>
|
||||
<label>
|
||||
<FieldLabel>Type</FieldLabel>
|
||||
<select
|
||||
@@ -413,7 +422,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
|
||||
</select>
|
||||
</label>
|
||||
{values.kind !== "portfolio" &&
|
||||
<label className="project-editor-wide">
|
||||
<label className="wide">
|
||||
<FieldLabel>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</FieldLabel>
|
||||
<select
|
||||
value={values.parentRef}
|
||||
@@ -437,7 +446,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
|
||||
<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">
|
||||
<label className="wide">
|
||||
<FieldLabel>Title</FieldLabel>
|
||||
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
|
||||
</label>
|
||||
@@ -462,7 +471,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
|
||||
<FieldLabel>Due</FieldLabel>
|
||||
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
|
||||
</label>
|
||||
<label className="project-editor-wide">
|
||||
<label className="wide">
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
|
||||
</label>
|
||||
@@ -470,7 +479,7 @@ function ProjectEditorDialog({ open, record, objects, saving, error, onClose, on
|
||||
<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