From 8bfcf2d6b49e75104c34d69c760c3b9f9f0e7761 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 17:48:38 +0200 Subject: [PATCH] feat: implement governed project portfolio --- AGENTS.md | 6 + README.md | 18 +- docs/PROJECTS_DOMAIN_BOUNDARY.md | 65 +- src/govoplan_projects/backend/acl.py | 31 + src/govoplan_projects/backend/db/__init__.py | 15 + src/govoplan_projects/backend/db/models.py | 219 ++++ src/govoplan_projects/backend/domain.py | 886 ++++++++++++++++ src/govoplan_projects/backend/manifest.py | 267 ++++- .../backend/migrations/__init__.py | 1 + .../backend/migrations/versions/__init__.py | 1 + .../c4a1e8f2d6b9_v0114_projects_baseline.py | 259 +++++ src/govoplan_projects/backend/router.py | 227 ++++ src/govoplan_projects/backend/schemas.py | 47 + .../backend/search_source.py | 236 +++++ src/govoplan_projects/backend/service.py | 977 ++++++++++++++++++ tests/test_manifest.py | 12 +- tests/test_project_service.py | 314 ++++++ webui/package.json | 28 + webui/src/api/projects.ts | 107 ++ webui/src/features/projects/ProjectsPage.tsx | 517 +++++++++ webui/src/index.ts | 2 + webui/src/module.ts | 53 + webui/src/styles/projects.css | 309 ++++++ 23 files changed, 4577 insertions(+), 20 deletions(-) create mode 100644 src/govoplan_projects/backend/acl.py create mode 100644 src/govoplan_projects/backend/db/__init__.py create mode 100644 src/govoplan_projects/backend/db/models.py create mode 100644 src/govoplan_projects/backend/domain.py create mode 100644 src/govoplan_projects/backend/migrations/__init__.py create mode 100644 src/govoplan_projects/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_projects/backend/migrations/versions/c4a1e8f2d6b9_v0114_projects_baseline.py create mode 100644 src/govoplan_projects/backend/router.py create mode 100644 src/govoplan_projects/backend/schemas.py create mode 100644 src/govoplan_projects/backend/search_source.py create mode 100644 src/govoplan_projects/backend/service.py create mode 100644 tests/test_project_service.py create mode 100644 webui/package.json create mode 100644 webui/src/api/projects.ts create mode 100644 webui/src/features/projects/ProjectsPage.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/projects.css diff --git a/AGENTS.md b/AGENTS.md index 6fae14d..d361385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,11 @@ # GovOPlaN Projects Codex Guide +## Documentation Contract + +- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior. +- Keep feature content here; `govoplan-docs` projects it without importing Projects internals. +- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes. + ## Scope This repository owns native projects, portfolios, milestones, participation, diff --git a/README.md b/README.md index 51a6409..c84f8fa 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,18 @@ milestones, participants, work structure, and links to project evidence. It is the native GovOPlaN project context and the integration target for OpenProject. -The runtime module ID is `projects`. This initial scaffold registers the -boundary, permissions, roles, documentation, and module entry point. +The runtime module ID is `projects`. The module persists tenant-scoped +portfolios, projects, and milestones as immutable revisions with optimistic +concurrency, replay-safe lifecycle events, membership-based restricted access, +and permission-aware Search indexing. Planning records can retain outcomes, +benefits, dependencies, capacity assumptions, change impacts, benefit reviews, +module resource links, and canonical external-system references. + +The `/projects` workspace provides catalogue, detail, create, and core-field +editing views. The governed API exposes the complete planning record at +`/api/v1/projects/objects`. OpenProject transport and synchronization remain in +Connectors; Projects stores only the native planning object and canonical +external reference. See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md). @@ -20,3 +30,7 @@ cd /mnt/DATA/git/govoplan-projects PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests ``` + +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. diff --git a/docs/PROJECTS_DOMAIN_BOUNDARY.md b/docs/PROJECTS_DOMAIN_BOUNDARY.md index 6c721dd..fbd57c6 100644 --- a/docs/PROJECTS_DOMAIN_BOUNDARY.md +++ b/docs/PROJECTS_DOMAIN_BOUNDARY.md @@ -3,9 +3,13 @@ ## Owns - portfolio and project identity -- goals, status, dates, milestones, and project structure +- versioned goals, intended outcomes, benefits, status, dates, milestones, and + project structure +- portfolio dependencies, capacity assumptions, change impact, and benefit + review - participant and responsibility references -- links to tasks, tickets, cases, files, Wiki pages, and calendar objects +- links to mandates, services, risks, controls, contracts, grants, resources, + indicators, tasks, tickets, cases, files, Wiki pages, and calendar objects - project-level external references and synchronization provenance ## Does Not Own @@ -15,6 +19,8 @@ - file storage or Wiki content - workflow runtime - OpenProject transport, credentials, or synchronization jobs +- policy decisions, risk/control lifecycle, resource booking, contract/grant + obligations, or report calculation ## OpenProject Boundary @@ -24,8 +30,55 @@ mapping diagnostics, synchronization, and migration. A project may remain external-only, linked, partially synchronized, imported, or native according to the integration maturity level recorded on its external reference. -## First Slice +## Runtime Contract -Implement project identity, status, milestones, participants, resource links, -and canonical OpenProject reference mapping before implementing a full native -work-package model. +The implemented aggregate uses stable identities and immutable revisions for +`portfolio`, `project`, and `milestone` objects. Writes require an expected +revision, an idempotency key, an aware timestamp, and a change reason. The +module records a durable module event in the same transaction and publishes a +platform event after commit. + +Current normalized membership grants make restricted-object filtering and +authorization efficient while every historical revision retains the exact +owner and membership snapshot. Tenant-visible objects require the read scope; +restricted objects additionally require creator, owner, membership, or admin +access. Search stores only bounded document data and always rechecks the source +authorization before returning a result. + +Parent rules are explicit: portfolios have no parent, projects may belong to a +portfolio, and milestones belong to a project. State changes use a per-kind +transition map. Physical deletion is deliberately absent from the object API; +cancelled/completed lifecycle state and governed module retirement preserve the +audit trail. + +## User And Admin Operation + +Users work in `/projects`, where they can search and filter the planning +catalogue, inspect outcome/governance summaries, and create or edit core +planning fields. The API record additionally accepts first-class outcomes, +benefits, dependencies, capacity assumptions, change impacts, benefit reviews, +resource links, memberships, and external references. Administrators grant the +Projects role templates and use ordinary module activation/migration controls; +there is no Projects-specific credential or transport configuration. + +Use Reporting for measured indicators and Risk Compliance for risks and +controls; Projects retains planning intent and links. Use Tasks or Tickets for +actionable work. A canonical OpenProject reference may use any declared +integration maturity, but Connectors owns discovery, credentials, mapping, +synchronization, and migration. + +## Recovery And Limits + +Database backup and restore cover identities, immutable revisions, normalized +memberships, and events as one logical state set. Replaying a successful write +with its original idempotency key returns the recorded revision; reusing the +key with another request is rejected. A stale expected revision is rejected +without changing current state. + +The first WebUI edits the common object fields. Advanced planning collections +are fully validated and persisted through the API but still need specialized +editors. OpenProject synchronization is future Connectors work. Projects does +not implement a native work-package lifecycle. + +Do not create a separate Goals module until another domain proves a reusable, +independent goal lifecycle that Projects cannot own through references. diff --git a/src/govoplan_projects/backend/acl.py b/src/govoplan_projects/backend/acl.py new file mode 100644 index 0000000..c817c1b --- /dev/null +++ b/src/govoplan_projects/backend/acl.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from govoplan_core.auth import has_scope +from govoplan_core.core.modules import AccessDecision + + +class ProjectScopeAclProvider: + """Expose coarse scope checks; the Projects registry rechecks object grants.""" + + def __init__(self, resource_type: str) -> None: + self.resource_type = resource_type + + def can_read(self, principal: object, resource_id: str) -> bool: + del resource_id + return has_scope(principal, "projects:project:read") + + def can_write(self, principal: object, resource_id: str) -> bool: + del resource_id + return has_scope(principal, "projects:project:write") + + def explain(self, principal: object, resource_id: str) -> AccessDecision: + del resource_id + allowed = self.can_read(principal, "") + return AccessDecision( + allowed=allowed, + reason=None if allowed else "Missing scope: projects:project:read", + requirements=("projects:project:read",), + ) + + +__all__ = ["ProjectScopeAclProvider"] diff --git a/src/govoplan_projects/backend/db/__init__.py b/src/govoplan_projects/backend/db/__init__.py new file mode 100644 index 0000000..7236ff0 --- /dev/null +++ b/src/govoplan_projects/backend/db/__init__.py @@ -0,0 +1,15 @@ +"""Projects database models.""" + +from govoplan_projects.backend.db.models import ( + ProjectMembershipGrant, + ProjectObjectEvent, + ProjectObjectIdentity, + ProjectObjectRevision, +) + +__all__ = [ + "ProjectMembershipGrant", + "ProjectObjectEvent", + "ProjectObjectIdentity", + "ProjectObjectRevision", +] diff --git a/src/govoplan_projects/backend/db/models.py b/src/govoplan_projects/backend/db/models.py new file mode 100644 index 0000000..7b54596 --- /dev/null +++ b/src/govoplan_projects/backend/db/models.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + JSON, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class ProjectObjectIdentity(Base, TimestampMixin): + __tablename__ = "project_object_identities" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + name="uq_project_object_identity", + ), + UniqueConstraint( + "tenant_id", + "object_kind", + "object_key", + name="uq_project_object_key", + ), + Index( + "ix_project_object_identity_catalog", + "tenant_id", + "object_kind", + "object_key", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + object_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + created_by: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + + +class ProjectObjectRevision(Base, TimestampMixin): + __tablename__ = "project_object_revisions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + "revision", + name="uq_project_object_revision", + ), + Index( + "ix_project_object_current", + "tenant_id", + "object_kind", + "object_id", + "superseded_at", + ), + Index( + "ix_project_object_parent", + "tenant_id", + "parent_kind", + "parent_id", + "object_kind", + "state", + ), + Index( + "ix_project_object_catalog", + "tenant_id", + "object_kind", + "state", + "recorded_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + identity_id: Mapped[str] = mapped_column( + ForeignKey("project_object_identities.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[int] = mapped_column(Integer, nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("project_object_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + parent_kind: Mapped[str | None] = mapped_column( + String(30), nullable=True, index=True + ) + parent_id: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + state: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + title: Mapped[str] = mapped_column(String(500), nullable=False) + visibility: Mapped[str] = mapped_column( + String(30), nullable=False, default="tenant", index=True + ) + starts_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + due_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + recorded_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + superseded_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + search_text: Mapped[str] = mapped_column(Text, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + changed_by: Mapped[str | None] = mapped_column( + String(255), nullable=True, index=True + ) + + +class ProjectMembershipGrant(Base, TimestampMixin): + __tablename__ = "project_membership_grants" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + "subject_kind", + "subject_id", + name="uq_project_membership_subject", + ), + Index( + "ix_project_membership_lookup", + "tenant_id", + "subject_kind", + "subject_id", + "active", + ), + Index( + "ix_project_membership_object", + "tenant_id", + "object_kind", + "object_id", + "active", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + role: Mapped[str] = mapped_column(String(80), nullable=False) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) + active: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, index=True + ) + source_revision: Mapped[int] = mapped_column(Integer, nullable=False) + + +class ProjectObjectEvent(Base, TimestampMixin): + __tablename__ = "project_object_events" + __table_args__ = ( + UniqueConstraint("tenant_id", "event_id", name="uq_project_object_event"), + UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_project_object_idempotency", + ), + Index( + "ix_project_object_event_history", + "tenant_id", + "object_kind", + "object_id", + "occurred_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + object_revision: Mapped[int] = mapped_column(Integer, nullable=False) + event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_sha256: Mapped[str] = mapped_column(String(64), nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +__all__ = [ + "ProjectMembershipGrant", + "ProjectObjectEvent", + "ProjectObjectIdentity", + "ProjectObjectRevision", +] diff --git a/src/govoplan_projects/backend/domain.py b/src/govoplan_projects/backend/domain.py new file mode 100644 index 0000000..1d6608c --- /dev/null +++ b/src/govoplan_projects/backend/domain.py @@ -0,0 +1,886 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Literal, cast + +from govoplan_core.core.external_references import ExternalObjectReference + + +ProjectObjectKind = Literal["portfolio", "project", "milestone"] +ProjectVisibility = Literal["tenant", "restricted"] + +OBJECT_KINDS = frozenset({"portfolio", "project", "milestone"}) +VISIBILITIES = frozenset({"tenant", "restricted"}) +SUBJECT_KINDS = frozenset( + { + "account", + "identity", + "group", + "role", + "function", + "function_assignment", + "organization_unit", + "service_account", + } +) +PROJECT_STATES: Mapping[str, frozenset[str]] = { + "portfolio": frozenset({"draft", "active", "on_hold", "completed", "cancelled"}), + "project": frozenset( + { + "draft", + "proposed", + "approved", + "active", + "on_hold", + "completed", + "cancelled", + } + ), + "milestone": frozenset({"planned", "active", "achieved", "missed", "cancelled"}), +} +STATE_TRANSITIONS: Mapping[str, Mapping[str, frozenset[str]]] = { + "portfolio": { + "draft": frozenset({"active", "cancelled"}), + "active": frozenset({"on_hold", "completed", "cancelled"}), + "on_hold": frozenset({"active", "cancelled"}), + "completed": frozenset({"active"}), + "cancelled": frozenset(), + }, + "project": { + "draft": frozenset({"proposed", "cancelled"}), + "proposed": frozenset({"draft", "approved", "cancelled"}), + "approved": frozenset({"active", "on_hold", "cancelled"}), + "active": frozenset({"on_hold", "completed", "cancelled"}), + "on_hold": frozenset({"approved", "active", "cancelled"}), + "completed": frozenset({"active"}), + "cancelled": frozenset(), + }, + "milestone": { + "planned": frozenset({"active", "achieved", "missed", "cancelled"}), + "active": frozenset({"achieved", "missed", "cancelled"}), + "achieved": frozenset(), + "missed": frozenset({"active", "achieved", "cancelled"}), + "cancelled": frozenset(), + }, +} + + +class ProjectDomainError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ProjectSubjectRef: + kind: str + id: str + label: str | None = None + + def __post_init__(self) -> None: + if self.kind not in SUBJECT_KINDS: + raise ProjectDomainError( + f"Unsupported project subject kind: {self.kind!r}." + ) + _required(self.id, "Project subject identifier", maximum=255) + _optional(self.label, "Project subject label", maximum=500) + + def to_dict(self) -> dict[str, object]: + return {"kind": self.kind, "id": self.id, "label": self.label} + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectSubjectRef": + return cls( + kind=_required(value.get("kind"), "Project subject kind", maximum=40), + id=_required(value.get("id"), "Project subject identifier", maximum=255), + label=_optional(value.get("label"), "Project subject label", maximum=500), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectMembership: + subject: ProjectSubjectRef + role: str + permissions: tuple[str, ...] = ("read",) + + def __post_init__(self) -> None: + _required(self.role, "Project membership role", maximum=80) + normalized = _bounded_strings( + self.permissions, + "Project membership permissions", + maximum_items=20, + maximum_length=80, + ) + if not normalized: + raise ProjectDomainError( + "Project memberships require at least one permission." + ) + object.__setattr__(self, "permissions", normalized) + + def to_dict(self) -> dict[str, object]: + return { + "subject": self.subject.to_dict(), + "role": self.role, + "permissions": list(self.permissions), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectMembership": + return cls( + subject=ProjectSubjectRef.from_mapping(_mapping(value, "subject")), + role=_required(value.get("role"), "Project membership role", maximum=80), + permissions=_string_items(value.get("permissions")), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectOutcome: + key: str + title: str + description: str | None = None + success_indicators: tuple[str, ...] = () + + def __post_init__(self) -> None: + _key(self.key, "Project outcome key") + _required(self.title, "Project outcome title", maximum=500) + _optional(self.description, "Project outcome description", maximum=20_000) + object.__setattr__( + self, + "success_indicators", + _bounded_strings( + self.success_indicators, + "Project success indicators", + maximum_items=100, + maximum_length=2_000, + ), + ) + + def to_dict(self) -> dict[str, object]: + return { + "key": self.key, + "title": self.title, + "description": self.description, + "success_indicators": list(self.success_indicators), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectOutcome": + return cls( + key=_required(value.get("key"), "Project outcome key", maximum=120), + title=_required(value.get("title"), "Project outcome title", maximum=500), + description=_optional( + value.get("description"), + "Project outcome description", + maximum=20_000, + ), + success_indicators=_string_items(value.get("success_indicators")), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectBenefit: + key: str + title: str + description: str | None = None + owner: ProjectSubjectRef | None = None + target: str | None = None + measure_ref: str | None = None + + def __post_init__(self) -> None: + _key(self.key, "Project benefit key") + _required(self.title, "Project benefit title", maximum=500) + _optional(self.description, "Project benefit description", maximum=20_000) + _optional(self.target, "Project benefit target", maximum=2_000) + _optional(self.measure_ref, "Project benefit measure reference", maximum=500) + + def to_dict(self) -> dict[str, object]: + return { + "key": self.key, + "title": self.title, + "description": self.description, + "owner": self.owner.to_dict() if self.owner else None, + "target": self.target, + "measure_ref": self.measure_ref, + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectBenefit": + owner = value.get("owner") + return cls( + key=_required(value.get("key"), "Project benefit key", maximum=120), + title=_required(value.get("title"), "Project benefit title", maximum=500), + description=_optional( + value.get("description"), + "Project benefit description", + maximum=20_000, + ), + owner=( + ProjectSubjectRef.from_mapping(owner) + if isinstance(owner, Mapping) + else None + ), + target=_optional( + value.get("target"), "Project benefit target", maximum=2_000 + ), + measure_ref=_optional( + value.get("measure_ref"), + "Project benefit measure reference", + maximum=500, + ), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectDependency: + key: str + relationship: str + target_module: str + target_type: str + target_id: str + description: str | None = None + critical: bool = False + + def __post_init__(self) -> None: + _key(self.key, "Project dependency key") + _required(self.relationship, "Project dependency relationship", maximum=80) + _required(self.target_module, "Project dependency module", maximum=100) + _required(self.target_type, "Project dependency type", maximum=100) + _required(self.target_id, "Project dependency identifier", maximum=255) + _optional( + self.description, + "Project dependency description", + maximum=10_000, + ) + + def to_dict(self) -> dict[str, object]: + return { + "key": self.key, + "relationship": self.relationship, + "target_module": self.target_module, + "target_type": self.target_type, + "target_id": self.target_id, + "description": self.description, + "critical": self.critical, + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectDependency": + return cls( + key=_required(value.get("key"), "Project dependency key", maximum=120), + relationship=_required( + value.get("relationship"), + "Project dependency relationship", + maximum=80, + ), + target_module=_required( + value.get("target_module"), + "Project dependency module", + maximum=100, + ), + target_type=_required( + value.get("target_type"), + "Project dependency type", + maximum=100, + ), + target_id=_required( + value.get("target_id"), + "Project dependency identifier", + maximum=255, + ), + description=_optional( + value.get("description"), + "Project dependency description", + maximum=10_000, + ), + critical=bool(value.get("critical", False)), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectCapacityAssumption: + key: str + label: str + amount: float | None = None + unit: str | None = None + period_start: datetime | None = None + period_end: datetime | None = None + notes: str | None = None + + def __post_init__(self) -> None: + _key(self.key, "Project capacity key") + _required(self.label, "Project capacity label", maximum=500) + _optional(self.unit, "Project capacity unit", maximum=80) + _optional(self.notes, "Project capacity notes", maximum=10_000) + _aware(self.period_start, "Project capacity period_start") + _aware(self.period_end, "Project capacity period_end") + if ( + self.period_start is not None + and self.period_end is not None + and self.period_end < self.period_start + ): + raise ProjectDomainError( + "Project capacity period_end cannot precede period_start." + ) + + def to_dict(self) -> dict[str, object]: + return { + "key": self.key, + "label": self.label, + "amount": self.amount, + "unit": self.unit, + "period_start": _datetime_text(self.period_start), + "period_end": _datetime_text(self.period_end), + "notes": self.notes, + } + + @classmethod + def from_mapping( + cls, + value: Mapping[str, object], + ) -> "ProjectCapacityAssumption": + amount = value.get("amount") + return cls( + key=_required(value.get("key"), "Project capacity key", maximum=120), + label=_required(value.get("label"), "Project capacity label", maximum=500), + amount=float(amount) if amount is not None else None, + unit=_optional(value.get("unit"), "Project capacity unit", maximum=80), + period_start=_optional_datetime(value.get("period_start")), + period_end=_optional_datetime(value.get("period_end")), + notes=_optional( + value.get("notes"), "Project capacity notes", maximum=10_000 + ), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectChangeImpact: + key: str + audience: str + description: str + severity: str = "medium" + mitigation: str | None = None + + def __post_init__(self) -> None: + _key(self.key, "Project change-impact key") + _required(self.audience, "Project change-impact audience", maximum=500) + _required(self.description, "Project change-impact description", maximum=20_000) + if self.severity not in {"low", "medium", "high", "critical"}: + raise ProjectDomainError("Unsupported project change-impact severity.") + _optional(self.mitigation, "Project change-impact mitigation", maximum=20_000) + + def to_dict(self) -> dict[str, object]: + return { + "key": self.key, + "audience": self.audience, + "description": self.description, + "severity": self.severity, + "mitigation": self.mitigation, + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectChangeImpact": + return cls( + key=_required(value.get("key"), "Project change-impact key", maximum=120), + audience=_required( + value.get("audience"), + "Project change-impact audience", + maximum=500, + ), + description=_required( + value.get("description"), + "Project change-impact description", + maximum=20_000, + ), + severity=str(value.get("severity") or "medium"), + mitigation=_optional( + value.get("mitigation"), + "Project change-impact mitigation", + maximum=20_000, + ), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectBenefitReview: + benefit_key: str + status: str + observed_at: datetime + summary: str + evidence_refs: tuple[str, ...] = () + + def __post_init__(self) -> None: + _key(self.benefit_key, "Project benefit-review key") + _required(self.status, "Project benefit-review status", maximum=80) + _aware(self.observed_at, "Project benefit-review observed_at") + _required(self.summary, "Project benefit-review summary", maximum=20_000) + object.__setattr__( + self, + "evidence_refs", + _bounded_strings( + self.evidence_refs, + "Project benefit-review evidence", + maximum_items=100, + maximum_length=1_000, + ), + ) + + def to_dict(self) -> dict[str, object]: + return { + "benefit_key": self.benefit_key, + "status": self.status, + "observed_at": self.observed_at.isoformat(), + "summary": self.summary, + "evidence_refs": list(self.evidence_refs), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectBenefitReview": + observed_at = _optional_datetime(value.get("observed_at")) + if observed_at is None: + raise ProjectDomainError("Project benefit-review observed_at is required.") + return cls( + benefit_key=_required( + value.get("benefit_key"), + "Project benefit-review key", + maximum=120, + ), + status=_required( + value.get("status"), + "Project benefit-review status", + maximum=80, + ), + observed_at=observed_at, + summary=_required( + value.get("summary"), + "Project benefit-review summary", + maximum=20_000, + ), + evidence_refs=_string_items(value.get("evidence_refs")), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectResourceLink: + owner_module: str + resource_type: str + resource_id: str + relationship: str + label: str | None = None + href: str | None = None + + def __post_init__(self) -> None: + _required(self.owner_module, "Project resource owner", maximum=100) + _required(self.resource_type, "Project resource type", maximum=100) + _required(self.resource_id, "Project resource identifier", maximum=255) + _required(self.relationship, "Project resource relationship", maximum=80) + _optional(self.label, "Project resource label", maximum=500) + _optional(self.href, "Project resource link", maximum=1_500) + + def to_dict(self) -> dict[str, object]: + return { + "owner_module": self.owner_module, + "resource_type": self.resource_type, + "resource_id": self.resource_id, + "relationship": self.relationship, + "label": self.label, + "href": self.href, + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectResourceLink": + return cls( + owner_module=_required( + value.get("owner_module"), + "Project resource owner", + maximum=100, + ), + resource_type=_required( + value.get("resource_type"), + "Project resource type", + maximum=100, + ), + resource_id=_required( + value.get("resource_id"), + "Project resource identifier", + maximum=255, + ), + relationship=_required( + value.get("relationship"), + "Project resource relationship", + maximum=80, + ), + label=_optional(value.get("label"), "Project resource label", maximum=500), + href=_optional(value.get("href"), "Project resource link", maximum=1_500), + ) + + +@dataclass(frozen=True, slots=True) +class ProjectRecord: + tenant_id: str + object_kind: ProjectObjectKind + object_id: str + object_key: str + revision: int + title: str + state: str + recorded_at: datetime + change_reason: str + description: str | None = None + visibility: ProjectVisibility = "tenant" + parent_kind: ProjectObjectKind | None = None + parent_id: str | None = None + starts_at: datetime | None = None + due_at: datetime | None = None + owner: ProjectSubjectRef | None = None + memberships: tuple[ProjectMembership, ...] = () + outcomes: tuple[ProjectOutcome, ...] = () + benefits: tuple[ProjectBenefit, ...] = () + dependencies: tuple[ProjectDependency, ...] = () + capacity_assumptions: tuple[ProjectCapacityAssumption, ...] = () + change_impacts: tuple[ProjectChangeImpact, ...] = () + benefit_reviews: tuple[ProjectBenefitReview, ...] = () + resource_links: tuple[ProjectResourceLink, ...] = () + external_references: tuple[ExternalObjectReference, ...] = () + metadata: Mapping[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + _required(self.tenant_id, "Project tenant", maximum=255) + if self.object_kind not in OBJECT_KINDS: + raise ProjectDomainError( + f"Unsupported project object kind: {self.object_kind!r}." + ) + _required(self.object_id, "Project object identifier", maximum=255) + _key(self.object_key, "Project object key") + if self.revision < 1: + raise ProjectDomainError("Project revisions start at one.") + _required(self.title, "Project title", maximum=500) + if self.state not in PROJECT_STATES[self.object_kind]: + raise ProjectDomainError( + f"Unsupported {self.object_kind} state: {self.state!r}." + ) + if self.visibility not in VISIBILITIES: + raise ProjectDomainError( + f"Unsupported project visibility: {self.visibility!r}." + ) + _aware(self.recorded_at, "Project recorded_at") + _aware(self.starts_at, "Project starts_at") + _aware(self.due_at, "Project due_at") + _required(self.change_reason, "Project change reason", maximum=1_000) + _optional(self.description, "Project description", maximum=100_000) + if self.starts_at and self.due_at and self.due_at < self.starts_at: + raise ProjectDomainError("Project due_at cannot precede starts_at.") + _validate_parent(self.object_kind, self.parent_kind, self.parent_id) + _unique_keys(self.outcomes, "Project outcomes") + _unique_keys(self.benefits, "Project benefits") + _unique_keys(self.dependencies, "Project dependencies") + _unique_keys(self.capacity_assumptions, "Project capacity assumptions") + _unique_keys(self.change_impacts, "Project change impacts") + benefit_keys = {item.key for item in self.benefits} + missing_benefits = { + item.benefit_key for item in self.benefit_reviews + } - benefit_keys + if missing_benefits: + raise ProjectDomainError( + "Project benefit reviews reference unknown benefits: " + + ", ".join(sorted(missing_benefits)) + ) + member_keys = [ + (item.subject.kind, item.subject.id) for item in self.memberships + ] + if len(member_keys) != len(set(member_keys)): + raise ProjectDomainError( + "Project memberships must contain unique subjects." + ) + if len(self.metadata) > 200: + raise ProjectDomainError("Project metadata is limited to 200 entries.") + + def to_dict(self) -> dict[str, Any]: + return { + "tenant_id": self.tenant_id, + "object_kind": self.object_kind, + "object_id": self.object_id, + "object_key": self.object_key, + "revision": self.revision, + "title": self.title, + "state": self.state, + "description": self.description, + "visibility": self.visibility, + "parent_kind": self.parent_kind, + "parent_id": self.parent_id, + "starts_at": _datetime_text(self.starts_at), + "due_at": _datetime_text(self.due_at), + "recorded_at": self.recorded_at.isoformat(), + "change_reason": self.change_reason, + "owner": self.owner.to_dict() if self.owner else None, + "memberships": [item.to_dict() for item in self.memberships], + "outcomes": [item.to_dict() for item in self.outcomes], + "benefits": [item.to_dict() for item in self.benefits], + "dependencies": [item.to_dict() for item in self.dependencies], + "capacity_assumptions": [ + item.to_dict() for item in self.capacity_assumptions + ], + "change_impacts": [item.to_dict() for item in self.change_impacts], + "benefit_reviews": [item.to_dict() for item in self.benefit_reviews], + "resource_links": [item.to_dict() for item in self.resource_links], + "external_references": [ + item.to_dict() for item in self.external_references + ], + "metadata": dict(self.metadata), + } + + @classmethod + def from_mapping(cls, value: Mapping[str, object]) -> "ProjectRecord": + metadata = value.get("metadata") or {} + if not isinstance(metadata, Mapping): + raise ProjectDomainError("Project metadata must be an object.") + owner = value.get("owner") + recorded_at = _optional_datetime(value.get("recorded_at")) + if recorded_at is None: + raise ProjectDomainError("Project recorded_at is required.") + object_kind = str(value.get("object_kind") or "") + parent_kind = value.get("parent_kind") + return cls( + tenant_id=_required(value.get("tenant_id"), "Project tenant", maximum=255), + object_kind=cast(ProjectObjectKind, object_kind), + object_id=_required( + value.get("object_id"), + "Project object identifier", + maximum=255, + ), + object_key=_required( + value.get("object_key"), + "Project object key", + maximum=120, + ), + revision=int(value.get("revision") or 0), + title=_required(value.get("title"), "Project title", maximum=500), + state=_required(value.get("state"), "Project state", maximum=30), + description=_optional( + value.get("description"), + "Project description", + maximum=100_000, + ), + visibility=cast( + ProjectVisibility, + str(value.get("visibility") or "tenant"), + ), + parent_kind=( + cast(ProjectObjectKind, str(parent_kind)) + if parent_kind is not None + else None + ), + parent_id=_optional( + value.get("parent_id"), + "Project parent identifier", + maximum=255, + ), + starts_at=_optional_datetime(value.get("starts_at")), + due_at=_optional_datetime(value.get("due_at")), + recorded_at=recorded_at, + change_reason=_required( + value.get("change_reason"), + "Project change reason", + maximum=1_000, + ), + owner=( + ProjectSubjectRef.from_mapping(owner) + if isinstance(owner, Mapping) + else None + ), + memberships=_objects( + value.get("memberships"), ProjectMembership.from_mapping + ), + outcomes=_objects(value.get("outcomes"), ProjectOutcome.from_mapping), + benefits=_objects(value.get("benefits"), ProjectBenefit.from_mapping), + dependencies=_objects( + value.get("dependencies"), ProjectDependency.from_mapping + ), + capacity_assumptions=_objects( + value.get("capacity_assumptions"), + ProjectCapacityAssumption.from_mapping, + ), + change_impacts=_objects( + value.get("change_impacts"), ProjectChangeImpact.from_mapping + ), + benefit_reviews=_objects( + value.get("benefit_reviews"), ProjectBenefitReview.from_mapping + ), + resource_links=_objects( + value.get("resource_links"), ProjectResourceLink.from_mapping + ), + external_references=tuple( + _external_reference(item) + for item in _mapping_items( + value.get("external_references"), + "Project external references", + ) + ), + metadata=dict(metadata), + ) + + +def validate_state_transition( + object_kind: str, + current_state: str, + next_state: str, +) -> None: + if next_state == current_state: + return + allowed = STATE_TRANSITIONS.get(object_kind, {}).get(current_state, frozenset()) + if next_state not in allowed: + raise ProjectDomainError( + f"Cannot move {object_kind} from {current_state!r} to {next_state!r}." + ) + + +def _validate_parent( + object_kind: str, + parent_kind: str | None, + parent_id: str | None, +) -> None: + if object_kind == "portfolio": + if parent_kind is not None or parent_id is not None: + raise ProjectDomainError("Portfolios cannot have a parent object.") + return + expected = "portfolio" if object_kind == "project" else "project" + if parent_kind is None and parent_id is None and object_kind == "project": + return + if parent_kind != expected or not str(parent_id or "").strip(): + raise ProjectDomainError(f"A {object_kind} parent must identify a {expected}.") + + +def _external_reference(value: Mapping[str, object]) -> ExternalObjectReference: + payload = dict(value) + observed_at = payload.get("observed_at") + if observed_at is not None: + payload["observed_at"] = _optional_datetime(observed_at) + return ExternalObjectReference(**payload) # type: ignore[arg-type] + + +def _objects(value: object, factory): + return tuple(factory(item) for item in _mapping_items(value, "Project items")) + + +def _mapping_items( + value: object, + label: str, +) -> tuple[Mapping[str, object], ...]: + if value is None: + return () + if not isinstance(value, (list, tuple)) or any( + not isinstance(item, Mapping) for item in value + ): + raise ProjectDomainError(f"{label} must be a list of objects.") + return tuple(value) # type: ignore[return-value] + + +def _mapping(value: Mapping[str, object], key: str) -> Mapping[str, object]: + result = value.get(key) + if not isinstance(result, Mapping): + raise ProjectDomainError(f"Project {key} must be an object.") + return result + + +def _string_items(value: object) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, (list, tuple)): + raise ProjectDomainError("Project values must be a list of strings.") + return tuple(str(item) for item in value) + + +def _bounded_strings( + values: Sequence[str], + label: str, + *, + maximum_items: int, + maximum_length: int, +) -> tuple[str, ...]: + normalized = tuple(dict.fromkeys(str(item).strip() for item in values)) + if any(not item for item in normalized): + raise ProjectDomainError(f"{label} cannot contain blank values.") + if len(normalized) > maximum_items: + raise ProjectDomainError(f"{label} supports at most {maximum_items} values.") + if any(len(item) > maximum_length for item in normalized): + raise ProjectDomainError( + f"{label} values are limited to {maximum_length} characters." + ) + return normalized + + +def _unique_keys(items: Sequence[object], label: str) -> None: + keys = [str(getattr(item, "key")) for item in items] + if len(keys) != len(set(keys)): + raise ProjectDomainError(f"{label} require unique keys.") + + +def _key(value: object, label: str) -> str: + key = _required(value, label, maximum=120).casefold() + allowed = "abcdefghijklmnopqrstuvwxyz0123456789._-" + if any(character not in allowed for character in key): + raise ProjectDomainError( + f"{label} may contain only letters, digits, dot, underscore, and hyphen." + ) + return key + + +def _required(value: object, label: str, *, maximum: int) -> str: + result = str(value or "").strip() + if not result: + raise ProjectDomainError(f"{label} is required.") + if len(result) > maximum: + raise ProjectDomainError(f"{label} is limited to {maximum} characters.") + return result + + +def _optional(value: object, label: str, *, maximum: int) -> str | None: + result = str(value or "").strip() + if not result: + return None + if len(result) > maximum: + raise ProjectDomainError(f"{label} is limited to {maximum} characters.") + return result + + +def _aware(value: datetime | None, label: str) -> None: + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + raise ProjectDomainError(f"{label} must include a timezone.") + + +def _optional_datetime(value: object) -> datetime | None: + if value is None or value == "": + return None + if isinstance(value, datetime): + result = value + else: + try: + result = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as exc: + raise ProjectDomainError("Project timestamp is invalid.") from exc + _aware(result, "Project timestamp") + return result + + +def _datetime_text(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +__all__ = [ + "OBJECT_KINDS", + "PROJECT_STATES", + "ProjectBenefit", + "ProjectBenefitReview", + "ProjectCapacityAssumption", + "ProjectChangeImpact", + "ProjectDependency", + "ProjectDomainError", + "ProjectMembership", + "ProjectObjectKind", + "ProjectOutcome", + "ProjectRecord", + "ProjectResourceLink", + "ProjectSubjectRef", + "ProjectVisibility", + "STATE_TRANSITIONS", + "validate_state_transition", +] diff --git a/src/govoplan_projects/backend/manifest.py b/src/govoplan_projects/backend/manifest.py index 4447a76..b4e2829 100644 --- a/src/govoplan_projects/backend/manifest.py +++ b/src/govoplan_projects/backend/manifest.py @@ -1,16 +1,46 @@ from __future__ import annotations +from pathlib import Path + +from sqlalchemy import func + from govoplan_core.core.access import ( CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER, ) +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationLink, DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, ModuleManifest, + NavItem, PermissionDefinition, RoleTemplate, ) +from govoplan_core.core.provider_governance import ( + ModuleArchitectureDeclaration, + ModuleArchitectureDocumentation, + ModuleMaturityEvidence, +) +from govoplan_core.core.search import SearchSourceProviderRegistration +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.search_source import create_projects_search_source +from govoplan_projects.backend.service import ( + CAPABILITY_PROJECTS_REGISTRY, + SqlProjectRegistry, +) MODULE_ID = "projects" @@ -30,8 +60,97 @@ OPTIONAL_DEPENDENCIES = ( "connectors", "search", "notifications", + "reporting", + "risk_compliance", ) +ARCHITECTURE = ModuleArchitectureDeclaration( + layer="domain_capability", + kind="domain", + maturity="vertical_slice", + evidence=( + ModuleMaturityEvidence( + kind="documentation", + reference="docs/PROJECTS_DOMAIN_BOUNDARY.md", + summary=( + "Defines portfolio, project, outcome, governance, and integration " + "ownership." + ), + ), + ModuleMaturityEvidence( + kind="test", + reference="tests/test_project_service.py", + summary=( + "Proves immutable revisions, OCC, idempotency, tenant isolation, " + "restricted memberships, state transitions, and search rechecks." + ), + ), + ), + known_limits=( + "OpenProject transport and synchronization remain connector-owned provider work.", + "Task execution remains in Tasks or Tickets; Projects links work without duplicating its lifecycle.", + "The first WebUI edits core planning fields; advanced outcome, capacity, and benefit structures remain available through the governed API.", + ), + supported_authority_modes=( + "native_authoritative", + "external_authoritative", + "external_mirror", + "governed_sync", + "governance_overlay", + "linked_reference", + ), + owned_concepts=( + "portfolio and project identity", + "project goals and intended outcomes", + "project dependencies and capacity assumptions", + "project benefit and change-impact review", + ), + non_owned_concepts=( + "actionable task and ticket lifecycle", + "risk and control lifecycle", + "report calculation and presentation", + "external project-system transport", + ), + documentation=ModuleArchitectureDocumentation( + operations=("docs/PROJECTS_DOMAIN_BOUNDARY.md",), + ), +) + + +def _router(context: ModuleContext): + del context + from govoplan_projects.backend.router import router + + return router + + +def _registry(context: ModuleContext) -> SqlProjectRegistry: + del context + return SqlProjectRegistry() + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + counts = { + kind: count + for kind, count in ( + session.query( + project_models.ProjectObjectRevision.object_kind, + func.count(), + ) + .filter( + project_models.ProjectObjectRevision.tenant_id == tenant_id, + project_models.ProjectObjectRevision.superseded_at.is_(None), + ) + .group_by(project_models.ProjectObjectRevision.object_kind) + .all() + ) + } + return { + "portfolios": int(counts.get("portfolio", 0)), + "projects": int(counts.get("project", 0)), + "milestones": int(counts.get("milestone", 0)), + } + def _permission( scope: str, @@ -52,9 +171,15 @@ def _permission( PERMISSIONS = ( - _permission(READ_SCOPE, "View projects", "Read accessible projects and portfolios."), - _permission(WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."), - _permission(ADMIN_SCOPE, "Administer projects", "Configure project types and policies."), + _permission( + READ_SCOPE, "View projects", "Read accessible projects and portfolios." + ), + _permission( + WRITE_SCOPE, "Manage projects", "Create and update projects and milestones." + ), + _permission( + ADMIN_SCOPE, "Administer projects", "Configure project types and policies." + ), ) ROLE_TEMPLATES = ( @@ -77,17 +202,19 @@ DOCUMENTATION = ( id="projects.module-boundary", title="Projects module boundary", summary=( - "Portfolios, projects, goals, milestones, participants, status, " - "work structure, and project-level references." + "Portfolios, projects, versioned goals and outcomes, milestones, " + "dependencies, capacity, benefits, participants, status, and references." ), body=( "Projects owns native project context. Tasks and Tickets own " "actionable work, Cases owns formal procedures, and Connectors " - "owns OpenProject synchronization." + "owns OpenProject synchronization. Reporting owns measured indicators; " + "Risk Compliance owns risks and controls; Projects links those facts to " + "planning, change impact, and benefit review." ), layer="available", - documentation_types=("admin",), - audience=("operator", "module_admin", "product_owner"), + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "product_owner"), related_modules=OPTIONAL_DEPENDENCIES, links=( DocumentationLink( @@ -102,13 +229,17 @@ DOCUMENTATION = ( "portfolio", "project", "milestone", + "versioned goal and intended outcome", + "dependency and capacity assumption", + "benefit review", "project participant", "project resource link", "external project reference", ], "first_slice": ( - "Implement project identity, status, milestones, participants, " - "resource links, and OpenProject reference mapping." + "Implement project and portfolio identity, status, milestones, " + "participants, outcome/benefit intent, dependency/resource links, " + "and OpenProject reference mapping." ), }, ), @@ -126,7 +257,123 @@ manifest = ModuleManifest( ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, + route_factory=_router, + nav_items=( + NavItem( + path="/projects", + label="Projects", + icon="folder-kanban", + required_any=(READ_SCOPE,), + order=36, + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/projects-webui", + routes=( + FrontendRoute( + path="/projects", + component="ProjectsPage", + required_any=(READ_SCOPE,), + order=36, + ), + ), + nav_items=( + NavItem( + path="/projects", + label="Projects", + icon="folder-kanban", + required_any=(READ_SCOPE,), + order=36, + ), + ), + view_surfaces=( + ViewSurface( + id="projects.navigation", + module_id=MODULE_ID, + kind="navigation", + label="Projects navigation", + order=10, + ), + ViewSurface( + id="projects.workspace", + module_id=MODULE_ID, + kind="route", + label="Projects workspace", + order=20, + ), + ViewSurface( + id="projects.portfolios", + module_id=MODULE_ID, + kind="section", + label="Portfolio planning", + parent_id="projects.workspace", + order=30, + ), + ViewSurface( + id="projects.outcomes", + module_id=MODULE_ID, + kind="section", + label="Outcomes and benefits", + parent_id="projects.workspace", + order=40, + ), + ), + ), + provides_interfaces=( + ModuleInterfaceProvider(name="projects.registry", version="0.1.0"), + ), + capability_factories={CAPABILITY_PROJECTS_REGISTRY: _registry}, + capability_documentation={ + CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation( + label="Projects registry", + summary=( + "Persists portfolios, projects, milestones, planning intent, " + "membership grants, revisions, and lifecycle events." + ), + contract_version="0.1.0", + ), + }, + search_sources=( + SearchSourceProviderRegistration( + id="projects.objects", + factory=create_projects_search_source, + ), + ), + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + project_models.ProjectObjectEvent, + project_models.ProjectMembershipGrant, + project_models.ProjectObjectRevision, + project_models.ProjectObjectIdentity, + label="Projects", + ), + retirement_notes=( + "Destructive retirement requires a database snapshot and removes " + "Projects identities, immutable revisions, memberships, and events." + ), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + project_models.ProjectObjectIdentity, + project_models.ProjectObjectRevision, + project_models.ProjectMembershipGrant, + project_models.ProjectObjectEvent, + label="Projects", + ), + ), + resource_acl_providers=( + ProjectScopeAclProvider("portfolio"), + ProjectScopeAclProvider("project"), + ProjectScopeAclProvider("milestone"), + ), + tenant_summary_providers=(_tenant_summary,), documentation=DOCUMENTATION, + architecture=ARCHITECTURE, ) diff --git a/src/govoplan_projects/backend/migrations/__init__.py b/src/govoplan_projects/backend/migrations/__init__.py new file mode 100644 index 0000000..8ec207c --- /dev/null +++ b/src/govoplan_projects/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Projects Alembic revisions.""" diff --git a/src/govoplan_projects/backend/migrations/versions/__init__.py b/src/govoplan_projects/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..c67207a --- /dev/null +++ b/src/govoplan_projects/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Projects migration versions.""" diff --git a/src/govoplan_projects/backend/migrations/versions/c4a1e8f2d6b9_v0114_projects_baseline.py b/src/govoplan_projects/backend/migrations/versions/c4a1e8f2d6b9_v0114_projects_baseline.py new file mode 100644 index 0000000..33170fc --- /dev/null +++ b/src/govoplan_projects/backend/migrations/versions/c4a1e8f2d6b9_v0114_projects_baseline.py @@ -0,0 +1,259 @@ +"""v0.1.14 Projects persistent portfolio baseline. + +Revision ID: c4a1e8f2d6b9 +Revises: None +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c4a1e8f2d6b9" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "project_object_identities", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("object_kind", sa.String(length=30), nullable=False), + sa.Column("object_id", sa.String(length=255), nullable=False), + sa.Column("object_key", sa.String(length=120), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_project_object_identities")), + sa.UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + name="uq_project_object_identity", + ), + sa.UniqueConstraint( + "tenant_id", + "object_kind", + "object_key", + name="uq_project_object_key", + ), + ) + for column in ( + "tenant_id", + "object_kind", + "object_id", + "object_key", + "created_by", + ): + op.create_index( + op.f(f"ix_project_object_identities_{column}"), + "project_object_identities", + [column], + unique=False, + ) + op.create_index( + "ix_project_object_identity_catalog", + "project_object_identities", + ["tenant_id", "object_kind", "object_key"], + unique=False, + ) + + op.create_table( + "project_object_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("identity_id", sa.String(length=36), nullable=False), + sa.Column("object_kind", sa.String(length=30), nullable=False), + sa.Column("object_id", sa.String(length=255), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("parent_kind", sa.String(length=30), nullable=True), + sa.Column("parent_id", sa.String(length=255), nullable=True), + sa.Column("state", sa.String(length=30), nullable=False), + sa.Column("title", sa.String(length=500), nullable=False), + sa.Column("visibility", sa.String(length=30), nullable=False), + sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("search_text", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("changed_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["identity_id"], + ["project_object_identities.id"], + name=op.f( + "fk_project_object_revisions_identity_id_project_object_identities" + ), + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["previous_revision_id"], + ["project_object_revisions.id"], + name=op.f( + "fk_project_object_revisions_previous_revision_id_project_object_revisions" + ), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_project_object_revisions")), + sa.UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + "revision", + name="uq_project_object_revision", + ), + ) + for column in ( + "tenant_id", + "identity_id", + "object_kind", + "object_id", + "previous_revision_id", + "parent_kind", + "parent_id", + "state", + "visibility", + "starts_at", + "due_at", + "recorded_at", + "superseded_at", + "changed_by", + ): + op.create_index( + op.f(f"ix_project_object_revisions_{column}"), + "project_object_revisions", + [column], + unique=False, + ) + op.create_index( + "ix_project_object_current", + "project_object_revisions", + ["tenant_id", "object_kind", "object_id", "superseded_at"], + unique=False, + ) + op.create_index( + "ix_project_object_parent", + "project_object_revisions", + ["tenant_id", "parent_kind", "parent_id", "object_kind", "state"], + unique=False, + ) + op.create_index( + "ix_project_object_catalog", + "project_object_revisions", + ["tenant_id", "object_kind", "state", "recorded_at"], + unique=False, + ) + + op.create_table( + "project_membership_grants", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("object_kind", sa.String(length=30), nullable=False), + sa.Column("object_id", sa.String(length=255), nullable=False), + sa.Column("subject_kind", sa.String(length=40), nullable=False), + sa.Column("subject_id", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=80), nullable=False), + sa.Column("permissions", sa.JSON(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("source_revision", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_project_membership_grants")), + sa.UniqueConstraint( + "tenant_id", + "object_kind", + "object_id", + "subject_kind", + "subject_id", + name="uq_project_membership_subject", + ), + ) + for column in ( + "tenant_id", + "object_kind", + "object_id", + "subject_kind", + "subject_id", + "active", + ): + op.create_index( + op.f(f"ix_project_membership_grants_{column}"), + "project_membership_grants", + [column], + unique=False, + ) + op.create_index( + "ix_project_membership_lookup", + "project_membership_grants", + ["tenant_id", "subject_kind", "subject_id", "active"], + unique=False, + ) + op.create_index( + "ix_project_membership_object", + "project_membership_grants", + ["tenant_id", "object_kind", "object_id", "active"], + unique=False, + ) + + op.create_table( + "project_object_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("object_kind", sa.String(length=30), nullable=False), + sa.Column("object_id", sa.String(length=255), nullable=False), + sa.Column("object_revision", sa.Integer(), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("event_type", sa.String(length=120), nullable=False), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_id", sa.String(length=255), nullable=True), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_sha256", sa.String(length=64), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_project_object_events")), + sa.UniqueConstraint( + "tenant_id", + "event_id", + name="uq_project_object_event", + ), + sa.UniqueConstraint( + "tenant_id", + "idempotency_key", + name="uq_project_object_idempotency", + ), + ) + for column in ( + "tenant_id", + "object_kind", + "object_id", + "event_id", + "event_type", + "occurred_at", + "actor_id", + ): + op.create_index( + op.f(f"ix_project_object_events_{column}"), + "project_object_events", + [column], + unique=False, + ) + op.create_index( + "ix_project_object_event_history", + "project_object_events", + ["tenant_id", "object_kind", "object_id", "occurred_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("project_object_events") + op.drop_table("project_membership_grants") + op.drop_table("project_object_revisions") + op.drop_table("project_object_identities") diff --git a/src/govoplan_projects/backend/router.py b/src/govoplan_projects/backend/router.py new file mode 100644 index 0000000..60f845f --- /dev/null +++ b/src/govoplan_projects/backend/router.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.db.session import get_session +from govoplan_projects.backend.domain import ProjectDomainError, ProjectRecord +from govoplan_projects.backend.manifest import ( + READ_SCOPE, + WRITE_SCOPE, +) +from govoplan_projects.backend.schemas import ( + ProjectObjectEventsResponse, + ProjectObjectHistoryResponse, + ProjectObjectListResponse, + ProjectObjectUpdateRequest, + ProjectObjectWriteRequest, +) +from govoplan_projects.backend.service import ( + ProjectStoreError, + create_project_object, + get_project_object, + list_project_objects, + project_object_events, + project_object_history, + update_project_object, +) + + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + lowered = message.casefold() + if isinstance(exc, LookupError): + code = 404 + elif isinstance(exc, PermissionError): + code = 403 + elif any(word in lowered for word in ("conflict", "already", "stale")): + code = 409 + else: + code = 400 + return HTTPException(status_code=code, detail=message) + + +@router.get("/objects", response_model=ProjectObjectListResponse) +def api_list_project_objects( + object_kind: list[str] | None = Query(default=None), + state: list[str] | None = Query(default=None), + parent_kind: str | None = None, + parent_id: str | None = None, + query: str = "", + offset: int = Query(default=0, ge=0), + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> ProjectObjectListResponse: + _require(principal, READ_SCOPE) + try: + items, total = list_project_objects( + session, + principal, + object_kinds=object_kind, + states=state, + parent_kind=parent_kind, + parent_id=parent_id, + query=query, + offset=offset, + limit=limit, + ) + except (ProjectStoreError, ProjectDomainError) as exc: + raise _error(exc) from exc + return ProjectObjectListResponse( + objects=[item.to_dict() for item in items], + total=total, + offset=offset, + limit=limit, + ) + + +@router.post( + "/objects", + response_model=dict, + status_code=status.HTTP_201_CREATED, +) +def api_create_project_object( + payload: ProjectObjectWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict: + _require(principal, WRITE_SCOPE) + try: + item = create_project_object( + session, + principal, + record=ProjectRecord.from_mapping(payload.record), + idempotency_key=payload.idempotency_key, + ) + session.commit() + except (ProjectStoreError, ProjectDomainError, PermissionError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + +@router.get("/objects/{object_kind}/{object_id}", response_model=dict) +def api_get_project_object( + object_kind: str, + object_id: str, + revision: int | None = Query(default=None, ge=1), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict: + _require(principal, READ_SCOPE) + try: + item = get_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + revision=revision, + ) + except (ProjectStoreError, ProjectDomainError) as exc: + raise _error(exc) from exc + if item is None: + raise HTTPException(status_code=404, detail="Project object not found") + return item.to_dict() + + +@router.patch("/objects/{object_kind}/{object_id}", response_model=dict) +def api_update_project_object( + object_kind: str, + object_id: str, + payload: ProjectObjectUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict: + _require(principal, WRITE_SCOPE) + try: + item = update_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + expected_revision=payload.expected_revision, + changes=payload.changes, + recorded_at=payload.recorded_at, + change_reason=payload.change_reason, + idempotency_key=payload.idempotency_key, + ) + session.commit() + except ( + ProjectStoreError, + ProjectDomainError, + PermissionError, + LookupError, + ) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict() + + +@router.get( + "/objects/{object_kind}/{object_id}/history", + response_model=ProjectObjectHistoryResponse, +) +def api_project_object_history( + object_kind: str, + object_id: str, + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> ProjectObjectHistoryResponse: + _require(principal, READ_SCOPE) + revisions = project_object_history( + session, + principal, + object_kind=object_kind, + object_id=object_id, + limit=limit, + ) + if not revisions: + raise HTTPException(status_code=404, detail="Project object not found") + return ProjectObjectHistoryResponse( + revisions=[item.to_dict() for item in revisions] + ) + + +@router.get( + "/objects/{object_kind}/{object_id}/events", + response_model=ProjectObjectEventsResponse, +) +def api_project_object_events( + object_kind: str, + object_id: str, + limit: int = Query(default=200, ge=1, le=500), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> ProjectObjectEventsResponse: + _require(principal, READ_SCOPE) + events = project_object_events( + session, + principal, + object_kind=object_kind, + object_id=object_id, + limit=limit, + ) + if not events: + item = get_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + ) + if item is None: + raise HTTPException(status_code=404, detail="Project object not found") + return ProjectObjectEventsResponse(events=list(events)) + + +__all__ = ["router"] diff --git a/src/govoplan_projects/backend/schemas.py b/src/govoplan_projects/backend/schemas.py new file mode 100644 index 0000000..3729f22 --- /dev/null +++ b/src/govoplan_projects/backend/schemas.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ProjectObjectWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + record: dict[str, Any] + idempotency_key: str = Field(min_length=1, max_length=255) + + +class ProjectObjectUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + expected_revision: int = Field(ge=1) + recorded_at: datetime + change_reason: str = Field(min_length=1, max_length=1_000) + idempotency_key: str = Field(min_length=1, max_length=255) + changes: dict[str, Any] + + +class ProjectObjectListResponse(BaseModel): + objects: list[dict[str, Any]] + total: int + offset: int + limit: int + + +class ProjectObjectHistoryResponse(BaseModel): + revisions: list[dict[str, Any]] + + +class ProjectObjectEventsResponse(BaseModel): + events: list[dict[str, Any]] + + +__all__ = [ + "ProjectObjectEventsResponse", + "ProjectObjectHistoryResponse", + "ProjectObjectListResponse", + "ProjectObjectUpdateRequest", + "ProjectObjectWriteRequest", +] diff --git a/src/govoplan_projects/backend/search_source.py b/src/govoplan_projects/backend/search_source.py new file mode 100644 index 0000000..6f5680a --- /dev/null +++ b/src/govoplan_projects/backend/search_source.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Mapping, Sequence +from urllib.parse import quote + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillPage, + SearchBackfillRequest, + SearchDocument, + SearchResourceType, +) +from govoplan_projects.backend.db.models import ( + ProjectMembershipGrant, + ProjectObjectIdentity, + ProjectObjectRevision, +) +from govoplan_projects.backend.service import can_read_project_object + + +PROVIDER_ID = "projects.objects" +RESOURCE_TYPES: Mapping[str, str] = { + "project_portfolio": "portfolio", + "project": "project", + "project_milestone": "milestone", +} + + +class ProjectsSearchSource: + def resource_types(self) -> Sequence[SearchResourceType]: + labels = { + "project_portfolio": "Project portfolios", + "project": "Projects", + "project_milestone": "Project milestones", + } + return tuple( + SearchResourceType( + provider_id=PROVIDER_ID, + module_id="projects", + resource_type=resource_type, + label=labels[resource_type], + requires_authorization_recheck=True, + ) + for resource_type in RESOURCE_TYPES + ) + + def backfill( + self, + session: object, + *, + request: SearchBackfillRequest, + ) -> SearchBackfillPage: + if request.provider_id != PROVIDER_ID: + raise ValueError("Unsupported Projects search provider.") + object_kind = RESOURCE_TYPES.get(request.resource_type) + if object_kind is None: + raise ValueError("Unsupported Projects search resource type.") + db = _session(session) + query = db.query(ProjectObjectRevision).filter( + ProjectObjectRevision.tenant_id == request.tenant_id, + ProjectObjectRevision.object_kind == object_kind, + ProjectObjectRevision.superseded_at.is_(None), + ) + if request.cursor: + query = query.filter(ProjectObjectRevision.id > request.cursor) + rows = ( + query.order_by(ProjectObjectRevision.id.asc()) + .limit(request.limit + 1) + .all() + ) + has_more = len(rows) > request.limit + selected = rows[: request.limit] + acl_tokens = _acl_tokens(db, selected) + high_watermark = ( + db.query(func.max(ProjectObjectRevision.updated_at)) + .filter( + ProjectObjectRevision.tenant_id == request.tenant_id, + ProjectObjectRevision.object_kind == object_kind, + ProjectObjectRevision.superseded_at.is_(None), + ) + .scalar() + ) + return SearchBackfillPage( + documents=tuple( + _document( + row, + resource_type=request.resource_type, + acl_tokens=acl_tokens[(row.object_kind, row.object_id)], + ) + for row in selected + ), + next_cursor=selected[-1].id if has_more and selected else None, + complete=not has_more, + high_watermark=( + high_watermark.isoformat() if high_watermark is not None else None + ), + ) + + def authorize( + self, + session: object, + principal: object, + *, + requests: Sequence[SearchAuthorizationRequest], + ) -> Mapping[str, bool]: + decisions = {request.reference.key: False for request in requests} + db = _session(session) + tenant_id = str(getattr(principal, "tenant_id", "") or "") + for request in requests: + reference = request.reference + object_kind = RESOURCE_TYPES.get(reference.resource_type) + if ( + reference.tenant_id != tenant_id + or reference.module_id != "projects" + or object_kind is None + ): + continue + decisions[reference.key] = can_read_project_object( + db, + principal, + object_kind=object_kind, + object_id=reference.resource_id, + ) + return decisions + + +def create_projects_search_source( + context: ModuleContext, +) -> ProjectsSearchSource: + del context + return ProjectsSearchSource() + + +def _document( + row: ProjectObjectRevision, + *, + resource_type: str, + acl_tokens: tuple[str, ...], +) -> SearchDocument: + payload = dict(row.payload or {}) + description = str(payload.get("description") or "").strip() or None + keywords = [row.object_kind, row.state] + keywords.extend( + str(item.get("title") or "") + for item in payload.get("outcomes", ()) + if isinstance(item, Mapping) + ) + updated_at = row.updated_at or row.recorded_at + restricted_tokens = tuple( + dict.fromkeys((*acl_tokens, "scope:projects:project:admin")) + ) + return SearchDocument( + tenant_id=row.tenant_id, + module_id="projects", + provider_id=PROVIDER_ID, + resource_type=resource_type, + resource_id=row.object_id, + title=row.title, + url=( + f"/projects?kind={quote(row.object_kind, safe='')}" + f"&objectId={quote(row.object_id, safe='')}" + ), + summary=description, + body=row.search_text, + keywords=tuple(item for item in keywords if item), + visibility=row.visibility, + acl_tokens=restricted_tokens if row.visibility == "restricted" else (), + metadata={ + "object_kind": row.object_kind, + "state": row.state, + "parent_kind": row.parent_kind, + "parent_id": row.parent_id, + "due_at": row.due_at.isoformat() if row.due_at else None, + }, + source_revision=str(row.revision), + source_updated_at=updated_at, + requires_authorization_recheck=True, + ) + + +def _acl_tokens( + session: Session, + rows: Sequence[ProjectObjectRevision], +) -> Mapping[tuple[str, str], tuple[str, ...]]: + result: dict[tuple[str, str], list[str]] = defaultdict(list) + if not rows: + return result + identity_ids = {row.identity_id for row in rows} + identities = ( + session.query(ProjectObjectIdentity) + .filter(ProjectObjectIdentity.id.in_(identity_ids)) + .all() + ) + for identity in identities: + if identity.created_by: + result[(identity.object_kind, identity.object_id)].append( + f"account:{identity.created_by}" + ) + object_keys = {(row.object_kind, row.object_id) for row in rows} + grants = ( + session.query(ProjectMembershipGrant) + .filter( + ProjectMembershipGrant.tenant_id == rows[0].tenant_id, + ProjectMembershipGrant.active.is_(True), + ) + .all() + ) + token_prefix = { + "function_assignment": "function", + } + for grant in grants: + key = (grant.object_kind, grant.object_id) + if key not in object_keys: + continue + prefix = token_prefix.get(grant.subject_kind, grant.subject_kind) + result[key].append(f"{prefix}:{grant.subject_id}") + return {key: tuple(dict.fromkeys(tokens)) for key, tokens in result.items()} + + +def _session(value: object) -> Session: + if not isinstance(value, Session): + raise TypeError("Projects search requires a SQLAlchemy session.") + return value + + +__all__ = [ + "PROVIDER_ID", + "RESOURCE_TYPES", + "ProjectsSearchSource", + "create_projects_search_source", +] diff --git a/src/govoplan_projects/backend/service.py b/src/govoplan_projects/backend/service.py new file mode 100644 index 0000000..16378db --- /dev/null +++ b/src/govoplan_projects/backend/service.py @@ -0,0 +1,977 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +import hashlib +import json +from typing import Any +import uuid + +from sqlalchemy import and_, exists, func, or_ +from sqlalchemy.orm import Query, Session + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.security.module_permissions import scopes_grant_compatible +from govoplan_projects.backend.db.models import ( + ProjectMembershipGrant, + ProjectObjectEvent, + ProjectObjectIdentity, + ProjectObjectRevision, +) +from govoplan_projects.backend.domain import ( + OBJECT_KINDS, + ProjectDomainError, + ProjectRecord, + validate_state_transition, +) + + +CAPABILITY_PROJECTS_REGISTRY = "projects.registry" +READ_SCOPE = "projects:project:read" +WRITE_SCOPE = "projects:project:write" +ADMIN_SCOPE = "projects:project:admin" + + +class ProjectStoreError(ValueError): + pass + + +def create_project_object( + session: Session, + principal: object, + *, + record: ProjectRecord, + idempotency_key: str, +) -> ProjectRecord: + tenant_id = _principal_tenant(principal) + if record.tenant_id != tenant_id: + raise ProjectStoreError("Project objects cannot cross tenants.") + if record.revision != 1: + raise ProjectStoreError("A new project object must start at revision 1.") + request_sha256 = _request_sha256(record.to_dict()) + replay = _replay( + session, + tenant_id=tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + ) + if replay is not None: + return replay + _validate_parent_reference(session, principal, record) + identity = _identity( + session, + tenant_id=tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + ) + if identity is not None: + raise ProjectStoreError("A project object with this identifier exists.") + duplicate_key = ( + session.query(ProjectObjectIdentity.id) + .filter( + ProjectObjectIdentity.tenant_id == tenant_id, + ProjectObjectIdentity.object_kind == record.object_kind, + ProjectObjectIdentity.object_key == record.object_key, + ) + .first() + ) + if duplicate_key is not None: + raise ProjectStoreError("A project object with this key exists.") + identity = ProjectObjectIdentity( + tenant_id=tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + object_key=record.object_key, + created_by=_principal_actor(principal), + ) + session.add(identity) + session.flush() + return _record_revision( + session, + principal, + identity=identity, + record=record, + current=None, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + operation="created", + ) + + +def update_project_object( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, +) -> ProjectRecord: + tenant_id = _principal_tenant(principal) + _object_kind(object_kind) + current_row = _current_row( + session, + tenant_id=tenant_id, + object_kind=object_kind, + object_id=object_id, + lock=True, + ) + if current_row is None: + raise LookupError("Project object not found.") + if not can_write_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + ): + raise PermissionError("Project object write access is denied.") + current = _record_from_row(current_row) + normalized = _normalized_changes(changes) + _require_aware(recorded_at, "Project recorded_at") + clean_reason = _required_text( + change_reason, + "Project change reason", + maximum=1_000, + ) + request_sha256 = _request_sha256( + { + "object_kind": object_kind, + "object_id": object_id, + "expected_revision": expected_revision, + "changes": normalized, + "recorded_at": recorded_at, + "change_reason": clean_reason, + } + ) + replay = _replay( + session, + tenant_id=tenant_id, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + ) + if replay is not None: + return replay + if current.revision != expected_revision: + raise ProjectStoreError( + "Project revision conflict: the expected revision is stale." + ) + next_payload = current.to_dict() + next_payload.update(normalized) + next_payload.update( + { + "revision": current.revision + 1, + "recorded_at": recorded_at.isoformat(), + "change_reason": clean_reason, + } + ) + next_record = ProjectRecord.from_mapping(next_payload) + validate_state_transition( + current.object_kind, + current.state, + next_record.state, + ) + _validate_parent_reference(session, principal, next_record) + identity = _identity( + session, + tenant_id=tenant_id, + object_kind=object_kind, + object_id=object_id, + ) + if identity is None: + raise ProjectStoreError("Project object identity is missing.") + return _record_revision( + session, + principal, + identity=identity, + record=next_record, + current=current_row, + idempotency_key=idempotency_key, + request_sha256=request_sha256, + operation=( + "state_changed" if current.state != next_record.state else "updated" + ), + ) + + +def get_project_object( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + revision: int | None = None, +) -> ProjectRecord | None: + tenant_id = _principal_tenant(principal) + _object_kind(object_kind) + if not can_read_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + ): + return None + query = session.query(ProjectObjectRevision).filter( + ProjectObjectRevision.tenant_id == tenant_id, + ProjectObjectRevision.object_kind == object_kind, + ProjectObjectRevision.object_id == object_id, + ) + if revision is None: + query = query.filter(ProjectObjectRevision.superseded_at.is_(None)) + else: + query = query.filter(ProjectObjectRevision.revision == revision) + row = query.order_by(ProjectObjectRevision.revision.desc()).first() + return _record_from_row(row) if row is not None else None + + +def list_project_objects( + session: Session, + principal: object, + *, + object_kinds: Sequence[str] | None = None, + states: Sequence[str] | None = None, + parent_kind: str | None = None, + parent_id: str | None = None, + query: str = "", + offset: int = 0, + limit: int = 100, +) -> tuple[tuple[ProjectRecord, ...], int]: + tenant_id = _principal_tenant(principal) + if offset < 0 or not 1 <= limit <= 200: + raise ProjectStoreError( + "Project list offset must be non-negative and limit between 1 and 200." + ) + kinds = tuple(dict.fromkeys(object_kinds or tuple(OBJECT_KINDS))) + if any(item not in OBJECT_KINDS for item in kinds): + raise ProjectStoreError("Project list contains an unsupported object kind.") + statement = session.query(ProjectObjectRevision).filter( + ProjectObjectRevision.tenant_id == tenant_id, + ProjectObjectRevision.superseded_at.is_(None), + ProjectObjectRevision.object_kind.in_(kinds), + ) + statement = _filter_accessible(statement, principal) + if states: + statement = statement.filter( + ProjectObjectRevision.state.in_(tuple(dict.fromkeys(states))) + ) + if parent_kind is not None or parent_id is not None: + if parent_kind is None or parent_id is None: + raise ProjectStoreError( + "Project parent filters require both parent_kind and parent_id." + ) + _object_kind(parent_kind) + statement = statement.filter( + ProjectObjectRevision.parent_kind == parent_kind, + ProjectObjectRevision.parent_id == parent_id, + ) + clean_query = query.strip().casefold() + if clean_query: + statement = statement.filter( + ProjectObjectRevision.search_text.contains(clean_query) + ) + total = int(statement.with_entities(func.count()).scalar() or 0) + rows = ( + statement.order_by( + ProjectObjectRevision.due_at.asc().nullslast(), + ProjectObjectRevision.title.asc(), + ProjectObjectRevision.object_id.asc(), + ) + .offset(offset) + .limit(limit) + .all() + ) + return tuple(_record_from_row(row) for row in rows), total + + +def project_object_history( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + limit: int = 100, +) -> tuple[ProjectRecord, ...]: + if not 1 <= limit <= 200: + raise ProjectStoreError("Project history limit must be between 1 and 200.") + if not can_read_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + ): + return () + rows = ( + session.query(ProjectObjectRevision) + .filter( + ProjectObjectRevision.tenant_id == _principal_tenant(principal), + ProjectObjectRevision.object_kind == object_kind, + ProjectObjectRevision.object_id == object_id, + ) + .order_by(ProjectObjectRevision.revision.desc()) + .limit(limit) + .all() + ) + return tuple(_record_from_row(row) for row in rows) + + +def project_object_events( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + limit: int = 200, +) -> tuple[dict[str, object], ...]: + if not 1 <= limit <= 500: + raise ProjectStoreError("Project event limit must be between 1 and 500.") + if not can_read_project_object( + session, + principal, + object_kind=object_kind, + object_id=object_id, + ): + return () + rows = ( + session.query(ProjectObjectEvent) + .filter( + ProjectObjectEvent.tenant_id == _principal_tenant(principal), + ProjectObjectEvent.object_kind == object_kind, + ProjectObjectEvent.object_id == object_id, + ) + .order_by( + ProjectObjectEvent.occurred_at.desc(), + ProjectObjectEvent.id.desc(), + ) + .limit(limit) + .all() + ) + return tuple( + { + "event_id": row.event_id, + "event_type": row.event_type, + "object_revision": row.object_revision, + "occurred_at": _datetime_text(row.occurred_at), + "actor_id": row.actor_id, + "payload": dict(row.payload or {}), + } + for row in rows + ) + + +def can_read_project_object( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, +) -> bool: + if not _has_scope(principal, READ_SCOPE): + return False + return _object_access( + session, + principal, + object_kind=object_kind, + object_id=object_id, + permission="read", + ) + + +def can_write_project_object( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, +) -> bool: + if not _has_scope(principal, WRITE_SCOPE): + return False + return _object_access( + session, + principal, + object_kind=object_kind, + object_id=object_id, + permission="write", + ) + + +class SqlProjectRegistry: + def create( + self, + session: object, + principal: object, + *, + record: ProjectRecord, + idempotency_key: str, + ) -> ProjectRecord: + return create_project_object( + _session(session), + principal, + record=record, + idempotency_key=idempotency_key, + ) + + def get( + self, + session: object, + principal: object, + *, + object_kind: str, + object_id: str, + revision: int | None = None, + ) -> ProjectRecord | None: + return get_project_object( + _session(session), + principal, + object_kind=object_kind, + object_id=object_id, + revision=revision, + ) + + def list( + self, + session: object, + principal: object, + *, + object_kinds: Sequence[str] | None = None, + limit: int = 100, + ) -> Sequence[ProjectRecord]: + records, _total = list_project_objects( + _session(session), + principal, + object_kinds=object_kinds, + limit=limit, + ) + return records + + +def _record_revision( + session: Session, + principal: object, + *, + identity: ProjectObjectIdentity, + record: ProjectRecord, + current: ProjectObjectRevision | None, + idempotency_key: str, + request_sha256: str, + operation: str, +) -> ProjectRecord: + clean_key = _required_text( + idempotency_key, + "Project idempotency key", + maximum=255, + ) + if current is not None: + current.superseded_at = record.recorded_at + payload = record.to_dict() + row = ProjectObjectRevision( + tenant_id=identity.tenant_id, + identity_id=identity.id, + object_kind=record.object_kind, + object_id=record.object_id, + revision=record.revision, + previous_revision_id=current.id if current is not None else None, + parent_kind=record.parent_kind, + parent_id=record.parent_id, + state=record.state, + title=record.title, + visibility=record.visibility, + starts_at=record.starts_at, + due_at=record.due_at, + recorded_at=record.recorded_at, + search_text=_search_text(record), + payload=payload, + changed_by=_principal_actor(principal), + ) + event_id = str(uuid.uuid4()) + event_type = f"projects.{record.object_kind}.{operation}" + event_payload = { + "object_key": record.object_key, + "object_kind": record.object_kind, + "state": record.state, + "revision": record.revision, + "change_reason": record.change_reason, + } + event = ProjectObjectEvent( + tenant_id=identity.tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + object_revision=record.revision, + event_id=event_id, + event_type=event_type, + occurred_at=record.recorded_at, + actor_id=_principal_actor(principal), + idempotency_key=clean_key, + request_sha256=request_sha256, + payload=event_payload, + ) + session.add_all((row, event)) + session.flush() + _sync_memberships(session, record) + emit_platform_event( + session, + PlatformEvent( + event_id=event_id, + type=event_type, + module_id="projects", + payload=event_payload, + occurred_at=record.recorded_at, + actor=EventActorRef(type="account", id=_principal_actor(principal)), + tenant=EventTenantRef(id=identity.tenant_id), + resource=EventObjectRef( + type=record.object_kind, + id=record.object_id, + label=record.title, + ), + classification=( + "restricted" if record.visibility == "restricted" else "internal" + ), + ), + ) + return _record_from_row(row) + + +def _sync_memberships(session: Session, record: ProjectRecord) -> None: + rows = ( + session.query(ProjectMembershipGrant) + .filter( + ProjectMembershipGrant.tenant_id == record.tenant_id, + ProjectMembershipGrant.object_kind == record.object_kind, + ProjectMembershipGrant.object_id == record.object_id, + ) + .all() + ) + existing = {(row.subject_kind, row.subject_id): row for row in rows} + desired: dict[tuple[str, str], tuple[str, list[str]]] = {} + if record.owner is not None: + desired[(record.owner.kind, record.owner.id)] = ( + "owner", + ["read", "write", "admin"], + ) + for membership in record.memberships: + key = (membership.subject.kind, membership.subject.id) + desired[key] = (membership.role, list(membership.permissions)) + for key, row in existing.items(): + if key not in desired: + row.active = False + row.source_revision = record.revision + for key, (role, permissions) in desired.items(): + row = existing.get(key) + if row is None: + row = ProjectMembershipGrant( + tenant_id=record.tenant_id, + object_kind=record.object_kind, + object_id=record.object_id, + subject_kind=key[0], + subject_id=key[1], + role=role, + permissions=permissions, + active=True, + source_revision=record.revision, + ) + session.add(row) + else: + row.role = role + row.permissions = permissions + row.active = True + row.source_revision = record.revision + session.flush() + + +def _replay( + session: Session, + *, + tenant_id: str, + idempotency_key: str, + request_sha256: str, +) -> ProjectRecord | None: + clean_key = _required_text( + idempotency_key, + "Project idempotency key", + maximum=255, + ) + row = ( + session.query(ProjectObjectEvent) + .filter( + ProjectObjectEvent.tenant_id == tenant_id, + ProjectObjectEvent.idempotency_key == clean_key, + ) + .one_or_none() + ) + if row is None: + return None + if row.request_sha256 != request_sha256: + raise ProjectStoreError( + "Project idempotency conflict: the key belongs to another request." + ) + revision = ( + session.query(ProjectObjectRevision) + .filter( + ProjectObjectRevision.tenant_id == tenant_id, + ProjectObjectRevision.object_kind == row.object_kind, + ProjectObjectRevision.object_id == row.object_id, + ProjectObjectRevision.revision == row.object_revision, + ) + .one() + ) + return _record_from_row(revision) + + +def _validate_parent_reference( + session: Session, + principal: object, + record: ProjectRecord, +) -> None: + if record.parent_kind is None or record.parent_id is None: + return + parent = _current_row( + session, + tenant_id=record.tenant_id, + object_kind=record.parent_kind, + object_id=record.parent_id, + lock=False, + ) + if parent is None: + raise ProjectStoreError("Project parent object does not exist.") + if not can_read_project_object( + session, + principal, + object_kind=record.parent_kind, + object_id=record.parent_id, + ): + raise PermissionError("Project parent object access is denied.") + + +def _object_access( + session: Session, + principal: object, + *, + object_kind: str, + object_id: str, + permission: str, +) -> bool: + tenant_id = _principal_tenant(principal) + current = _current_row( + session, + tenant_id=tenant_id, + object_kind=object_kind, + object_id=object_id, + lock=False, + ) + if current is None: + return False + if _has_scope(principal, ADMIN_SCOPE): + return True + identity = _identity( + session, + tenant_id=tenant_id, + object_kind=object_kind, + object_id=object_id, + ) + actors = _principal_actor_ids(principal) + if identity is not None and identity.created_by in actors: + return True + if permission == "read" and current.visibility == "tenant": + return True + subjects = _principal_subjects(principal) + if not subjects: + return False + clauses = [ + and_( + ProjectMembershipGrant.subject_kind == kind, + ProjectMembershipGrant.subject_id == subject_id, + ) + for kind, subject_id in subjects + ] + rows = ( + session.query(ProjectMembershipGrant) + .filter( + ProjectMembershipGrant.tenant_id == tenant_id, + ProjectMembershipGrant.object_kind == object_kind, + ProjectMembershipGrant.object_id == object_id, + ProjectMembershipGrant.active.is_(True), + or_(*clauses), + ) + .all() + ) + return any( + permission == "read" + or permission in set(row.permissions or ()) + or "admin" in set(row.permissions or ()) + for row in rows + ) + + +def _filter_accessible(query: Query, principal: object) -> Query: + if _has_scope(principal, ADMIN_SCOPE): + return query + actor_ids = _principal_actor_ids(principal) + subjects = _principal_subjects(principal) + conditions = [ProjectObjectRevision.visibility == "tenant"] + if actor_ids: + conditions.append( + exists() + .where(ProjectObjectIdentity.id == ProjectObjectRevision.identity_id) + .where(ProjectObjectIdentity.created_by.in_(actor_ids)) + ) + if subjects: + subject_condition = or_( + *( + and_( + ProjectMembershipGrant.subject_kind == kind, + ProjectMembershipGrant.subject_id == subject_id, + ) + for kind, subject_id in subjects + ) + ) + conditions.append( + exists() + .where(ProjectMembershipGrant.tenant_id == ProjectObjectRevision.tenant_id) + .where( + ProjectMembershipGrant.object_kind == ProjectObjectRevision.object_kind + ) + .where(ProjectMembershipGrant.object_id == ProjectObjectRevision.object_id) + .where(ProjectMembershipGrant.active.is_(True)) + .where(subject_condition) + ) + return query.filter(or_(*conditions)) + + +def _normalized_changes(changes: Mapping[str, object]) -> dict[str, object]: + allowed = { + "title", + "state", + "description", + "visibility", + "parent_kind", + "parent_id", + "starts_at", + "due_at", + "owner", + "memberships", + "outcomes", + "benefits", + "dependencies", + "capacity_assumptions", + "change_impacts", + "benefit_reviews", + "resource_links", + "external_references", + "metadata", + } + unknown = set(changes) - allowed + if unknown: + raise ProjectStoreError( + "Unsupported project update fields: " + ", ".join(sorted(unknown)) + ) + return _json_value(dict(changes)) + + +def _record_from_row(row: ProjectObjectRevision) -> ProjectRecord: + payload = dict(row.payload or {}) + payload.update( + { + "revision": row.revision, + "state": row.state, + "title": row.title, + "visibility": row.visibility, + "parent_kind": row.parent_kind, + "parent_id": row.parent_id, + "starts_at": _datetime_text(row.starts_at), + "due_at": _datetime_text(row.due_at), + "recorded_at": _datetime_text(row.recorded_at), + } + ) + return ProjectRecord.from_mapping(payload) + + +def _search_text(record: ProjectRecord) -> str: + values = [ + record.object_key, + record.title, + record.description or "", + record.state, + *(item.title for item in record.outcomes), + *(item.title for item in record.benefits), + *(item.description or "" for item in record.dependencies), + ] + return " ".join(value for value in values if value).casefold() + + +def _current_row( + session: Session, + *, + tenant_id: str, + object_kind: str, + object_id: str, + lock: bool, +) -> ProjectObjectRevision | None: + query = session.query(ProjectObjectRevision).filter( + ProjectObjectRevision.tenant_id == tenant_id, + ProjectObjectRevision.object_kind == object_kind, + ProjectObjectRevision.object_id == object_id, + ProjectObjectRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _identity( + session: Session, + *, + tenant_id: str, + object_kind: str, + object_id: str, +) -> ProjectObjectIdentity | None: + return ( + session.query(ProjectObjectIdentity) + .filter( + ProjectObjectIdentity.tenant_id == tenant_id, + ProjectObjectIdentity.object_kind == object_kind, + ProjectObjectIdentity.object_id == object_id, + ) + .one_or_none() + ) + + +def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]: + values: list[tuple[str, str]] = [] + singular = { + "account": getattr(principal, "account_id", None), + "identity": getattr(principal, "identity_id", None), + "function_assignment": getattr(principal, "acting_assignment_id", None), + "service_account": getattr(principal, "service_account_id", None), + } + for kind, value in singular.items(): + if str(value or "").strip(): + values.append((kind, str(value))) + collections = { + "group": getattr(principal, "group_ids", ()), + "role": getattr(principal, "role_ids", ()), + "function_assignment": getattr(principal, "function_assignment_ids", ()), + } + for kind, items in collections.items(): + values.extend( + (kind, str(item)) for item in items or () if str(item or "").strip() + ) + return tuple(dict.fromkeys(values)) + + +def _principal_actor_ids(principal: object) -> tuple[str, ...]: + user = getattr(principal, "user", None) + values = ( + getattr(user, "id", None), + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(principal, "membership_id", None), + ) + return tuple( + dict.fromkeys(str(value) for value in values if str(value or "").strip()) + ) + + +def _principal_actor(principal: object) -> str | None: + user = getattr(principal, "user", None) + for value in ( + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(principal, "membership_id", None), + getattr(user, "id", None), + ): + if str(value or "").strip(): + return str(value) + return None + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise ProjectStoreError("Project operations require a tenant-bound principal.") + return tenant_id + + +def _has_scope(principal: object, scope: str) -> bool: + method = getattr(principal, "has", None) + if callable(method): + return bool(method(scope)) + scopes = frozenset(getattr(principal, "scopes", ()) or ()) + return scopes_grant_compatible(scopes, scope) + + +def _object_kind(value: str) -> str: + if value not in OBJECT_KINDS: + raise ProjectStoreError(f"Unsupported project object kind: {value!r}.") + return value + + +def _required_text(value: object, label: str, *, maximum: int) -> str: + result = str(value or "").strip() + if not result: + raise ProjectStoreError(f"{label} is required.") + if len(result) > maximum: + raise ProjectStoreError(f"{label} is limited to {maximum} characters.") + return result + + +def _require_aware(value: datetime, label: str) -> None: + if value.tzinfo is None or value.utcoffset() is None: + raise ProjectStoreError(f"{label} must include a timezone.") + + +def _request_sha256(value: object) -> str: + payload = json.dumps( + _json_value(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _json_value(value: object) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if hasattr(value, "to_dict"): + return _json_value(value.to_dict()) + if isinstance(value, Mapping): + return {str(key): _json_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_value(item) for item in value] + return value + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise ProjectDomainError("Project registry requires a database session.") + return value # type: ignore[return-value] + + +__all__ = [ + "CAPABILITY_PROJECTS_REGISTRY", + "ProjectStoreError", + "SqlProjectRegistry", + "can_read_project_object", + "can_write_project_object", + "create_project_object", + "get_project_object", + "list_project_objects", + "project_object_events", + "project_object_history", + "update_project_object", +] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 0558cef..2de7854 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -23,8 +23,16 @@ class ProjectsManifestTests(unittest.TestCase): self.assertIn("connectors", manifest.optional_dependencies) self.assertIn("tickets", manifest.optional_dependencies) self.assertTrue(manifest.documentation) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.frontend) + self.assertEqual("vertical_slice", manifest.architecture.maturity) + self.assertIn( + "project benefit and change-impact review", + manifest.architecture.owned_concepts, + ) + self.assertIsNotNone(manifest.route_factory) + self.assertEqual("@govoplan/projects-webui", manifest.frontend.package_name) + self.assertIsNotNone(manifest.migration_spec) + self.assertEqual("projects.registry", manifest.provides_interfaces[0].name) + self.assertEqual(1, len(manifest.search_sources)) if __name__ == "__main__": diff --git a/tests/test_project_service.py b/tests/test_project_service.py new file mode 100644 index 0000000..99d6905 --- /dev/null +++ b/tests/test_project_service.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from dataclasses import replace +from datetime import UTC, datetime +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.external_references import ExternalObjectReference +from govoplan_core.core.search import ( + SearchAuthorizationRequest, + SearchBackfillRequest, +) +from govoplan_core.db.base import Base +from govoplan_core.security.module_permissions import scopes_grant_compatible +from govoplan_projects.backend.domain import ( + ProjectDomainError, + ProjectMembership, + ProjectRecord, + ProjectSubjectRef, +) +from govoplan_projects.backend.search_source import ( + PROVIDER_ID, + ProjectsSearchSource, +) +from govoplan_projects.backend.service import ( + ADMIN_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + ProjectStoreError, + create_project_object, + get_project_object, + list_project_objects, + project_object_history, + update_project_object, +) + + +NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC) + + +class Principal: + def __init__( + self, + tenant_id: str, + account_id: str, + *, + scopes: tuple[str, ...] = (READ_SCOPE, WRITE_SCOPE), + group_ids: tuple[str, ...] = (), + ) -> None: + self.tenant_id = tenant_id + self.account_id = account_id + self.identity_id = f"identity-{account_id}" + self.membership_id = f"membership-{account_id}" + self.group_ids = frozenset(group_ids) + self.role_ids = frozenset() + self.function_assignment_ids = frozenset() + self.acting_assignment_id = None + self.service_account_id = None + self.scopes = frozenset(scopes) + self.user = SimpleNamespace(id=f"user-{account_id}") + + def has(self, scope: str) -> bool: + return scopes_grant_compatible(self.scopes, scope) + + +class ProjectServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.session = Session(self.engine, expire_on_commit=False) + self.owner = Principal("tenant-1", "owner-1") + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_lifecycle_is_versioned_idempotent_and_occ_guarded(self) -> None: + record = project_record() + created = create_project_object( + self.session, + self.owner, + record=record, + idempotency_key="create-project-1", + ) + replay = create_project_object( + self.session, + self.owner, + record=record, + idempotency_key="create-project-1", + ) + self.assertEqual(created.to_dict(), replay.to_dict()) + + updated = update_project_object( + self.session, + self.owner, + object_kind="project", + object_id="project-1", + expected_revision=1, + changes={"state": "proposed", "title": "Permit modernization"}, + recorded_at=NOW.replace(hour=11), + change_reason="Submitted for approval.", + idempotency_key="update-project-1", + ) + self.assertEqual(2, updated.revision) + self.assertEqual("proposed", updated.state) + self.assertEqual( + [2, 1], + [ + item.revision + for item in project_object_history( + self.session, + self.owner, + object_kind="project", + object_id="project-1", + ) + ], + ) + with self.assertRaisesRegex(ProjectStoreError, "stale"): + update_project_object( + self.session, + self.owner, + object_kind="project", + object_id="project-1", + expected_revision=1, + changes={"title": "Stale title"}, + recorded_at=NOW.replace(hour=12), + change_reason="Stale update.", + idempotency_key="update-project-stale", + ) + + def test_state_machine_rejects_invalid_transition(self) -> None: + create_project_object( + self.session, + self.owner, + record=project_record(), + idempotency_key="create-transition-project", + ) + with self.assertRaisesRegex(ProjectDomainError, "Cannot move"): + update_project_object( + self.session, + self.owner, + object_kind="project", + object_id="project-1", + expected_revision=1, + changes={"state": "completed"}, + recorded_at=NOW.replace(hour=11), + change_reason="Invalid shortcut.", + idempotency_key="invalid-transition", + ) + + def test_parent_hierarchy_and_tenant_boundary_are_enforced(self) -> None: + with self.assertRaisesRegex(ProjectStoreError, "parent"): + create_project_object( + self.session, + self.owner, + record=replace( + project_record(), + object_kind="milestone", + object_id="milestone-1", + object_key="milestone-1", + state="planned", + parent_kind="project", + parent_id="missing-project", + ), + idempotency_key="orphan-milestone", + ) + with self.assertRaisesRegex(ProjectStoreError, "cross tenants"): + create_project_object( + self.session, + Principal("tenant-2", "owner-2"), + record=project_record(), + idempotency_key="cross-tenant-project", + ) + + def test_restricted_memberships_filter_lists_and_reads(self) -> None: + record = replace( + project_record(), + visibility="restricted", + memberships=( + ProjectMembership( + subject=ProjectSubjectRef(kind="group", id="group-reviewers"), + role="reviewer", + permissions=("read",), + ), + ), + ) + create_project_object( + self.session, + self.owner, + record=record, + idempotency_key="create-restricted-project", + ) + outsider = Principal("tenant-1", "outsider") + reviewer = Principal( + "tenant-1", + "reviewer", + group_ids=("group-reviewers",), + ) + self.assertIsNone( + get_project_object( + self.session, + outsider, + object_kind="project", + object_id="project-1", + ) + ) + visible, total = list_project_objects(self.session, outsider) + self.assertEqual(((), 0), (visible, total)) + self.assertIsNotNone( + get_project_object( + self.session, + reviewer, + object_kind="project", + object_id="project-1", + ) + ) + admin = Principal( + "tenant-1", + "admin", + scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE), + ) + self.assertEqual(1, list_project_objects(self.session, admin)[1]) + + def test_search_backfill_has_acl_tokens_and_rechecks_access(self) -> None: + create_project_object( + self.session, + self.owner, + record=replace(project_record(), visibility="restricted"), + idempotency_key="create-search-project", + ) + source = ProjectsSearchSource() + page = source.backfill( + self.session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type="project", + rebuild_id="rebuild-1", + ), + ) + self.assertTrue(page.complete) + self.assertEqual(1, len(page.documents)) + document = page.documents[0] + self.assertIn("account:owner-1", document.acl_tokens) + request = SearchAuthorizationRequest( + reference=document.reference, + source_revision=document.source_revision, + ) + owner_decision = source.authorize( + self.session, + self.owner, + requests=(request,), + ) + outsider_decision = source.authorize( + self.session, + Principal("tenant-1", "outsider"), + requests=(request,), + ) + self.assertTrue(owner_decision[document.reference.key]) + self.assertFalse(outsider_decision[document.reference.key]) + + +def project_record( + *, + object_kind: str = "project", +) -> ProjectRecord: + return ProjectRecord.from_mapping( + { + "tenant_id": "tenant-1", + "object_kind": object_kind, + "object_id": "project-1", + "object_key": "permit-modernization", + "revision": 1, + "title": "Permit modernization", + "state": "draft", + "description": "Replace a fragmented permit process.", + "visibility": "tenant", + "recorded_at": NOW.isoformat(), + "change_reason": "Initial planning baseline.", + "owner": { + "kind": "account", + "id": "owner-1", + "label": "Project owner", + }, + "outcomes": [ + { + "key": "faster-decisions", + "title": "Faster permit decisions", + "success_indicators": ["Median processing time"], + } + ], + "benefits": [ + { + "key": "less-rework", + "title": "Less manual rework", + "target": "Reduce rework by 30 percent", + } + ], + "external_references": [ + ExternalObjectReference( + system="openproject", + object_type="project", + object_id="42", + ).to_dict() + ], + } + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..9b96c13 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,28 @@ +{ + "name": "@govoplan/projects-webui", + "version": "0.1.14", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/projects.css": "./src/styles/projects.css" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.14", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/src/api/projects.ts b/webui/src/api/projects.ts new file mode 100644 index 0000000..5ed0d15 --- /dev/null +++ b/webui/src/api/projects.ts @@ -0,0 +1,107 @@ +import { + apiFetch, + apiPath, + type ApiSettings +} from "@govoplan/core-webui"; + + +export type ProjectObjectKind = "portfolio" | "project" | "milestone"; + +export type ProjectSubjectRef = { + kind: string; + id: string; + label?: string | null; +}; + +export type ProjectRecord = { + tenant_id: string; + object_kind: ProjectObjectKind; + object_id: string; + object_key: string; + revision: number; + title: string; + state: string; + description?: string | null; + visibility: "tenant" | "restricted"; + parent_kind?: ProjectObjectKind | null; + parent_id?: string | null; + starts_at?: string | null; + due_at?: string | null; + recorded_at: string; + change_reason: string; + owner?: ProjectSubjectRef | null; + memberships: Array>; + outcomes: Array<{ key: string; title: string; description?: string | null }>; + benefits: Array<{ key: string; title: string; target?: string | null }>; + dependencies: Array>; + capacity_assumptions: Array>; + change_impacts: Array>; + benefit_reviews: Array>; + resource_links: Array>; + external_references: Array>; + metadata: Record; +}; + +export type ProjectListResponse = { + objects: ProjectRecord[]; + total: number; + offset: number; + limit: number; +}; + +export function listProjectObjects( + settings: ApiSettings, + options: { + objectKinds?: ProjectObjectKind[]; + states?: string[]; + parentKind?: ProjectObjectKind; + parentId?: string; + query?: string; + offset?: number; + limit?: number; + }, + signal?: AbortSignal +): Promise { + return apiFetch(settings, apiPath("/api/v1/projects/objects", { + object_kind: options.objectKinds, + state: options.states, + parent_kind: options.parentKind, + parent_id: options.parentId, + query: options.query, + offset: options.offset, + limit: options.limit + }), { signal }); +} + +export function createProjectObject( + settings: ApiSettings, + record: ProjectRecord, + idempotencyKey: string +): Promise { + return apiFetch(settings, "/api/v1/projects/objects", { + method: "POST", + body: JSON.stringify({ record, idempotency_key: idempotencyKey }) + }); +} + +export function updateProjectObject( + settings: ApiSettings, + record: ProjectRecord, + changes: Record, + changeReason: string +): Promise { + return apiFetch( + settings, + `/api/v1/projects/objects/${encodeURIComponent(record.object_kind)}/${encodeURIComponent(record.object_id)}`, + { + method: "PATCH", + body: JSON.stringify({ + expected_revision: record.revision, + recorded_at: new Date().toISOString(), + change_reason: changeReason, + idempotency_key: crypto.randomUUID(), + changes + }) + } + ); +} diff --git a/webui/src/features/projects/ProjectsPage.tsx b/webui/src/features/projects/ProjectsPage.tsx new file mode 100644 index 0000000..c26e851 --- /dev/null +++ b/webui/src/features/projects/ProjectsPage.tsx @@ -0,0 +1,517 @@ +import { + CalendarDays, + FolderKanban, + Milestone, + Pencil, + Plus, + Search, + Target +} from "lucide-react"; +import { + useEffect, + useMemo, + useState, + type FormEvent +} from "react"; +import { + Button, + Dialog, + DismissibleAlert, + IconButton, + LoadingIndicator, + PageScrollViewport, + StatusBadge, + hasScope, + type PlatformRouteContext +} from "@govoplan/core-webui"; +import { + createProjectObject, + listProjectObjects, + updateProjectObject, + type ProjectObjectKind, + type ProjectRecord +} from "../../api/projects"; + + +type EditorValues = { + kind: ProjectObjectKind; + key: string; + title: string; + state: string; + description: string; + visibility: "tenant" | "restricted"; + parentRef: string; + startsAt: string; + dueAt: string; + changeReason: string; +}; + +const STATES: Record = { + portfolio: ["draft", "active", "on_hold", "completed", "cancelled"], + project: ["draft", "proposed", "approved", "active", "on_hold", "completed", "cancelled"], + milestone: ["planned", "active", "achieved", "missed", "cancelled"] +}; + +const INITIAL_STATE: Record = { + portfolio: "draft", + project: "draft", + milestone: "planned" +}; + + +export default function ProjectsPage({ settings, auth }: PlatformRouteContext) { + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [kind, setKind] = useState(""); + const [objects, setObjects] = useState([]); + const [parentOptions, setParentOptions] = useState([]); + const [selectedKey, setSelectedKey] = useState(""); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [editorOpen, setEditorOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + const canWrite = hasScope(auth, "projects:project:write"); + + function reload(signal?: AbortSignal) { + setLoading(true); + setError(""); + return Promise.all([ + listProjectObjects(settings, { + objectKinds: kind ? [kind] : undefined, + query: submittedQuery, + limit: 200 + }, signal), + listProjectObjects(settings, { + objectKinds: ["portfolio", "project"], + limit: 200 + }, signal) + ]). + then(([result, parents]) => { + setObjects(result.objects); + setParentOptions(parents.objects); + setTotal(result.total); + setSelectedKey((current) => { + if (result.objects.some((item) => objectKey(item) === current)) return current; + return result.objects[0] ? objectKey(result.objects[0]) : ""; + }); + }). + catch((reason) => { + if ((reason as Error).name !== "AbortError") { + setError(reason instanceof Error ? reason.message : "Projects could not be loaded."); + } + }). + finally(() => setLoading(false)); + } + + useEffect(() => { + const controller = new AbortController(); + void reload(controller.signal); + return () => controller.abort(); + }, [settings, kind, submittedQuery]); + + const selected = useMemo( + () => objects.find((item) => objectKey(item) === selectedKey) ?? null, + [objects, selectedKey] + ); + + function submitSearch(event: FormEvent) { + event.preventDefault(); + setSubmittedQuery(query.trim()); + } + + function openCreate() { + setEditing(null); + setEditorOpen(true); + } + + function openEdit() { + if (!selected) return; + setEditing(selected); + setEditorOpen(true); + } + + async function save(values: EditorValues) { + setSaving(true); + setError(""); + try { + let saved: ProjectRecord; + if (editing) { + const parent = parseParentRef(values.parentRef); + saved = await updateProjectObject(settings, editing, { + title: values.title, + state: values.state, + description: values.description || null, + visibility: values.visibility, + parent_kind: parent?.kind ?? null, + parent_id: parent?.id ?? null, + starts_at: dateValue(values.startsAt), + due_at: dateValue(values.dueAt) + }, values.changeReason); + } else { + const tenantId = auth.active_tenant?.id ?? auth.tenant.id; + const objectId = crypto.randomUUID(); + const parent = parseParentRef(values.parentRef); + saved = await createProjectObject(settings, { + tenant_id: tenantId, + object_kind: values.kind, + object_id: objectId, + object_key: values.key, + revision: 1, + title: values.title, + state: values.state, + description: values.description || null, + visibility: values.visibility, + parent_kind: parent?.kind ?? null, + parent_id: parent?.id ?? null, + starts_at: dateValue(values.startsAt), + due_at: dateValue(values.dueAt), + recorded_at: new Date().toISOString(), + change_reason: values.changeReason, + owner: { + kind: "account", + id: auth.user.account_id, + label: auth.user.display_name ?? auth.user.email + }, + memberships: [], + outcomes: [], + benefits: [], + dependencies: [], + capacity_assumptions: [], + change_impacts: [], + benefit_reviews: [], + resource_links: [], + external_references: [], + metadata: {} + }, crypto.randomUUID()); + } + setEditorOpen(false); + setEditing(null); + await reload(); + setSelectedKey(objectKey(saved)); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "The project object could not be saved."); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+
+
+ {error && + setError("")}> + {error} + + } +
+ + {loading && } + {!loading && objects.length === 0 && +
No matching planning objects.
+ } +
+ {objects.map((item) => + + )} +
+
+ + {selected ? + : +
Select a portfolio, project, or milestone.
+ } +
+
+
+ setEditorOpen(false)} + onSave={save} + /> +
+ ); +} + + +function ProjectDetail({ record, canWrite, onEdit }: { + record: ProjectRecord; + canWrite: boolean; + onEdit: () => void; +}) { + return ( +
+
+
+ {humanize(record.object_kind)} ยท {record.object_key} +

{record.title}

+
+
+ + {canWrite && + } onClick={onEdit} /> + } +
+
+ {record.description &&

{record.description}

} +
+
Starts{formatDate(record.starts_at)}
+
Due{formatDate(record.due_at)}
+
Visibility{humanize(record.visibility)}
+
Revision{record.revision}
+
+
+

Outcomes and benefits

+
+ + + + +
+ {record.outcomes.length > 0 && +
+ {record.outcomes.map((item) => +
{item.title}{item.description || item.key}
+ )} +
+ } +
+
+

Governance links

+
+ + + + +
+
+
+ ); +} + +function PlanningStat({ label, value }: { label: string; value: number }) { + return
{label}{value}
; +} + +function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: { + open: boolean; + record: ProjectRecord | null; + objects: ProjectRecord[]; + saving: boolean; + onClose: () => void; + onSave: (values: EditorValues) => Promise; +}) { + const [values, setValues] = useState(() => editorValues(record)); + + useEffect(() => { + if (open) setValues(editorValues(record)); + }, [open, record]); + + function set(key: K, value: EditorValues[K]) { + setValues((current) => ({ ...current, [key]: value })); + } + + function submit(event: FormEvent) { + event.preventDefault(); + void onSave(values); + } + + const eligibleParents = objects.filter((item) => + values.kind === "project" + ? item.object_kind === "portfolio" + : values.kind === "milestone" && item.object_kind === "project" + ); + + return ( + + + + + }> +
+ + {values.kind !== "portfolio" && + + } + + + + + + +