feat: implement governed project portfolio
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# GovOPlaN Projects Codex Guide
|
# 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
|
## Scope
|
||||||
|
|
||||||
This repository owns native projects, portfolios, milestones, participation,
|
This repository owns native projects, portfolios, milestones, participation,
|
||||||
|
|||||||
@@ -8,8 +8,18 @@
|
|||||||
milestones, participants, work structure, and links to project evidence. It is
|
milestones, participants, work structure, and links to project evidence. It is
|
||||||
the native GovOPlaN project context and the integration target for OpenProject.
|
the native GovOPlaN project context and the integration target for OpenProject.
|
||||||
|
|
||||||
The runtime module ID is `projects`. This initial scaffold registers the
|
The runtime module ID is `projects`. The module persists tenant-scoped
|
||||||
boundary, permissions, roles, documentation, and module entry point.
|
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).
|
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 \
|
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
|
||||||
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
/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.
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
## Owns
|
## Owns
|
||||||
|
|
||||||
- portfolio and project identity
|
- 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
|
- 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
|
- project-level external references and synchronization provenance
|
||||||
|
|
||||||
## Does Not Own
|
## Does Not Own
|
||||||
@@ -15,6 +19,8 @@
|
|||||||
- file storage or Wiki content
|
- file storage or Wiki content
|
||||||
- workflow runtime
|
- workflow runtime
|
||||||
- OpenProject transport, credentials, or synchronization jobs
|
- OpenProject transport, credentials, or synchronization jobs
|
||||||
|
- policy decisions, risk/control lifecycle, resource booking, contract/grant
|
||||||
|
obligations, or report calculation
|
||||||
|
|
||||||
## OpenProject Boundary
|
## OpenProject Boundary
|
||||||
|
|
||||||
@@ -24,8 +30,55 @@ mapping diagnostics, synchronization, and migration. A project may remain
|
|||||||
external-only, linked, partially synchronized, imported, or native according
|
external-only, linked, partially synchronized, imported, or native according
|
||||||
to the integration maturity level recorded on its external reference.
|
to the integration maturity level recorded on its external reference.
|
||||||
|
|
||||||
## First Slice
|
## Runtime Contract
|
||||||
|
|
||||||
Implement project identity, status, milestones, participants, resource links,
|
The implemented aggregate uses stable identities and immutable revisions for
|
||||||
and canonical OpenProject reference mapping before implementing a full native
|
`portfolio`, `project`, and `milestone` objects. Writes require an expected
|
||||||
work-package model.
|
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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Projects database models."""
|
||||||
|
|
||||||
|
from govoplan_projects.backend.db.models import (
|
||||||
|
ProjectMembershipGrant,
|
||||||
|
ProjectObjectEvent,
|
||||||
|
ProjectObjectIdentity,
|
||||||
|
ProjectObjectRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProjectMembershipGrant",
|
||||||
|
"ProjectObjectEvent",
|
||||||
|
"ProjectObjectIdentity",
|
||||||
|
"ProjectObjectRevision",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -1,16 +1,46 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
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 (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
MigrationSpec,
|
||||||
|
ModuleContext,
|
||||||
|
ModuleInterfaceProvider,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
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"
|
MODULE_ID = "projects"
|
||||||
@@ -30,8 +60,97 @@ OPTIONAL_DEPENDENCIES = (
|
|||||||
"connectors",
|
"connectors",
|
||||||
"search",
|
"search",
|
||||||
"notifications",
|
"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(
|
def _permission(
|
||||||
scope: str,
|
scope: str,
|
||||||
@@ -52,9 +171,15 @@ def _permission(
|
|||||||
|
|
||||||
|
|
||||||
PERMISSIONS = (
|
PERMISSIONS = (
|
||||||
_permission(READ_SCOPE, "View projects", "Read accessible projects and portfolios."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."),
|
READ_SCOPE, "View projects", "Read accessible projects and portfolios."
|
||||||
_permission(ADMIN_SCOPE, "Administer projects", "Configure project types and policies."),
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE, "Administer projects", "Configure project types and policies."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_TEMPLATES = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -77,17 +202,19 @@ DOCUMENTATION = (
|
|||||||
id="projects.module-boundary",
|
id="projects.module-boundary",
|
||||||
title="Projects module boundary",
|
title="Projects module boundary",
|
||||||
summary=(
|
summary=(
|
||||||
"Portfolios, projects, goals, milestones, participants, status, "
|
"Portfolios, projects, versioned goals and outcomes, milestones, "
|
||||||
"work structure, and project-level references."
|
"dependencies, capacity, benefits, participants, status, and references."
|
||||||
),
|
),
|
||||||
body=(
|
body=(
|
||||||
"Projects owns native project context. Tasks and Tickets own "
|
"Projects owns native project context. Tasks and Tickets own "
|
||||||
"actionable work, Cases owns formal procedures, and Connectors "
|
"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",
|
layer="available",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner"),
|
audience=("user", "operator", "module_admin", "product_owner"),
|
||||||
related_modules=OPTIONAL_DEPENDENCIES,
|
related_modules=OPTIONAL_DEPENDENCIES,
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(
|
DocumentationLink(
|
||||||
@@ -102,13 +229,17 @@ DOCUMENTATION = (
|
|||||||
"portfolio",
|
"portfolio",
|
||||||
"project",
|
"project",
|
||||||
"milestone",
|
"milestone",
|
||||||
|
"versioned goal and intended outcome",
|
||||||
|
"dependency and capacity assumption",
|
||||||
|
"benefit review",
|
||||||
"project participant",
|
"project participant",
|
||||||
"project resource link",
|
"project resource link",
|
||||||
"external project reference",
|
"external project reference",
|
||||||
],
|
],
|
||||||
"first_slice": (
|
"first_slice": (
|
||||||
"Implement project identity, status, milestones, participants, "
|
"Implement project and portfolio identity, status, milestones, "
|
||||||
"resource links, and OpenProject reference mapping."
|
"participants, outcome/benefit intent, dependency/resource links, "
|
||||||
|
"and OpenProject reference mapping."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -126,7 +257,123 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
role_templates=ROLE_TEMPLATES,
|
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,
|
documentation=DOCUMENTATION,
|
||||||
|
architecture=ARCHITECTURE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Projects Alembic revisions."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Projects migration versions."""
|
||||||
+259
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
+10
-2
@@ -23,8 +23,16 @@ class ProjectsManifestTests(unittest.TestCase):
|
|||||||
self.assertIn("connectors", manifest.optional_dependencies)
|
self.assertIn("connectors", manifest.optional_dependencies)
|
||||||
self.assertIn("tickets", manifest.optional_dependencies)
|
self.assertIn("tickets", manifest.optional_dependencies)
|
||||||
self.assertTrue(manifest.documentation)
|
self.assertTrue(manifest.documentation)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||||
self.assertIsNone(manifest.frontend)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record<string, unknown>>;
|
||||||
|
outcomes: Array<{ key: string; title: string; description?: string | null }>;
|
||||||
|
benefits: Array<{ key: string; title: string; target?: string | null }>;
|
||||||
|
dependencies: Array<Record<string, unknown>>;
|
||||||
|
capacity_assumptions: Array<Record<string, unknown>>;
|
||||||
|
change_impacts: Array<Record<string, unknown>>;
|
||||||
|
benefit_reviews: Array<Record<string, unknown>>;
|
||||||
|
resource_links: Array<Record<string, unknown>>;
|
||||||
|
external_references: Array<Record<string, unknown>>;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ProjectListResponse> {
|
||||||
|
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<ProjectRecord> {
|
||||||
|
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<string, unknown>,
|
||||||
|
changeReason: string
|
||||||
|
): Promise<ProjectRecord> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ProjectObjectKind, string[]> = {
|
||||||
|
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<ProjectObjectKind, string> = {
|
||||||
|
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<ProjectObjectKind | "">("");
|
||||||
|
const [objects, setObjects] = useState<ProjectRecord[]>([]);
|
||||||
|
const [parentOptions, setParentOptions] = useState<ProjectRecord[]>([]);
|
||||||
|
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<ProjectRecord | null>(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 (
|
||||||
|
<main className="projects-page">
|
||||||
|
<div className="projects-shell">
|
||||||
|
<div className="projects-toolbar">
|
||||||
|
<form className="projects-search" onSubmit={submitSearch}>
|
||||||
|
<Search size={17} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
aria-label="Search projects"
|
||||||
|
placeholder="Search portfolios, projects, and milestones"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="primary">Search</Button>
|
||||||
|
</form>
|
||||||
|
<label className="projects-kind-filter">
|
||||||
|
<span>Type</span>
|
||||||
|
<select value={kind} onChange={(event) => setKind(event.target.value as ProjectObjectKind | "")}>
|
||||||
|
<option value="">All planning objects</option>
|
||||||
|
<option value="portfolio">Portfolios</option>
|
||||||
|
<option value="project">Projects</option>
|
||||||
|
<option value="milestone">Milestones</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span className="projects-count">{total} objects</span>
|
||||||
|
{canWrite &&
|
||||||
|
<Button type="button" variant="primary" onClick={openCreate}>
|
||||||
|
<Plus size={16} aria-hidden="true" /> New
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{error &&
|
||||||
|
<DismissibleAlert tone="error" onDismiss={() => setError("")}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
<div className="projects-workspace">
|
||||||
|
<PageScrollViewport className="projects-list-viewport">
|
||||||
|
{loading && <LoadingIndicator label="Loading projects" />}
|
||||||
|
{!loading && objects.length === 0 &&
|
||||||
|
<div className="projects-empty">No matching planning objects.</div>
|
||||||
|
}
|
||||||
|
<div className="projects-list" role="list">
|
||||||
|
{objects.map((item) =>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
key={objectKey(item)}
|
||||||
|
className={`project-row${objectKey(item) === selectedKey ? " is-selected" : ""}`}
|
||||||
|
onClick={() => setSelectedKey(objectKey(item))}>
|
||||||
|
<span className="project-row-icon">{kindIcon(item.object_kind)}</span>
|
||||||
|
<span className="project-row-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<small>{item.object_key}</small>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={statusTone(item.state)} label={humanize(item.state)} />
|
||||||
|
<span className="project-row-date">{formatDate(item.due_at)}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageScrollViewport>
|
||||||
|
<PageScrollViewport className="project-detail-viewport">
|
||||||
|
{selected ?
|
||||||
|
<ProjectDetail record={selected} canWrite={canWrite} onEdit={openEdit} /> :
|
||||||
|
<div className="projects-empty">Select a portfolio, project, or milestone.</div>
|
||||||
|
}
|
||||||
|
</PageScrollViewport>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProjectEditorDialog
|
||||||
|
open={editorOpen}
|
||||||
|
record={editing}
|
||||||
|
objects={parentOptions}
|
||||||
|
saving={saving}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
onSave={save}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function ProjectDetail({ record, canWrite, onEdit }: {
|
||||||
|
record: ProjectRecord;
|
||||||
|
canWrite: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className="project-detail">
|
||||||
|
<header className="project-detail-header">
|
||||||
|
<div>
|
||||||
|
<span className="project-eyebrow">{humanize(record.object_kind)} · {record.object_key}</span>
|
||||||
|
<h1>{record.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="project-detail-actions">
|
||||||
|
<StatusBadge status={statusTone(record.state)} label={humanize(record.state)} />
|
||||||
|
{canWrite &&
|
||||||
|
<IconButton variant="ghost" label="Edit planning object" icon={<Pencil size={17} />} onClick={onEdit} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{record.description && <p className="project-description">{record.description}</p>}
|
||||||
|
<div className="project-facts">
|
||||||
|
<div><span>Starts</span><strong>{formatDate(record.starts_at)}</strong></div>
|
||||||
|
<div><span>Due</span><strong>{formatDate(record.due_at)}</strong></div>
|
||||||
|
<div><span>Visibility</span><strong>{humanize(record.visibility)}</strong></div>
|
||||||
|
<div><span>Revision</span><strong>{record.revision}</strong></div>
|
||||||
|
</div>
|
||||||
|
<section className="project-planning-section">
|
||||||
|
<h2><Target size={17} /> Outcomes and benefits</h2>
|
||||||
|
<div className="project-stat-grid">
|
||||||
|
<PlanningStat label="Outcomes" value={record.outcomes.length} />
|
||||||
|
<PlanningStat label="Benefits" value={record.benefits.length} />
|
||||||
|
<PlanningStat label="Benefit reviews" value={record.benefit_reviews.length} />
|
||||||
|
<PlanningStat label="Change impacts" value={record.change_impacts.length} />
|
||||||
|
</div>
|
||||||
|
{record.outcomes.length > 0 &&
|
||||||
|
<div className="project-detail-list">
|
||||||
|
{record.outcomes.map((item) =>
|
||||||
|
<div key={item.key}><strong>{item.title}</strong><span>{item.description || item.key}</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
<section className="project-planning-section">
|
||||||
|
<h2><FolderKanban size={17} /> Governance links</h2>
|
||||||
|
<div className="project-stat-grid">
|
||||||
|
<PlanningStat label="Members" value={record.memberships.length + (record.owner ? 1 : 0)} />
|
||||||
|
<PlanningStat label="Dependencies" value={record.dependencies.length} />
|
||||||
|
<PlanningStat label="Resources" value={record.resource_links.length} />
|
||||||
|
<PlanningStat label="External refs" value={record.external_references.length} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanningStat({ label, value }: { label: string; value: number }) {
|
||||||
|
return <div><span>{label}</span><strong>{value}</strong></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectEditorDialog({ open, record, objects, saving, onClose, onSave }: {
|
||||||
|
open: boolean;
|
||||||
|
record: ProjectRecord | null;
|
||||||
|
objects: ProjectRecord[];
|
||||||
|
saving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (values: EditorValues) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [values, setValues] = useState<EditorValues>(() => editorValues(record));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setValues(editorValues(record));
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
function set<K extends keyof EditorValues>(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 (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
title={record ? "Edit planning object" : "New planning object"}
|
||||||
|
onClose={onClose}
|
||||||
|
closeDisabled={saving}
|
||||||
|
className="project-editor-dialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
|
<Button type="submit" form="project-editor-form" variant="primary" disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<form id="project-editor-form" className="project-editor-form" onSubmit={submit}>
|
||||||
|
<label>
|
||||||
|
<span>Type</span>
|
||||||
|
<select
|
||||||
|
value={values.kind}
|
||||||
|
disabled={Boolean(record)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextKind = event.target.value as ProjectObjectKind;
|
||||||
|
setValues((current) => ({ ...current, kind: nextKind, state: INITIAL_STATE[nextKind] }));
|
||||||
|
}}>
|
||||||
|
<option value="portfolio">Portfolio</option>
|
||||||
|
<option value="project">Project</option>
|
||||||
|
<option value="milestone">Milestone</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{values.kind !== "portfolio" &&
|
||||||
|
<label className="project-editor-wide">
|
||||||
|
<span>{values.kind === "milestone" ? "Parent project" : "Portfolio"}</span>
|
||||||
|
<select
|
||||||
|
value={values.parentRef}
|
||||||
|
required={values.kind === "milestone"}
|
||||||
|
onChange={(event) => set("parentRef", event.target.value)}>
|
||||||
|
<option value="">{values.kind === "project" ? "No portfolio" : "Select a project"}</option>
|
||||||
|
{eligibleParents.map((item) =>
|
||||||
|
<option key={objectKey(item)} value={`${item.object_kind}:${item.object_id}`}>
|
||||||
|
{item.title} ({item.object_key})
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{record?.parent_kind && record.parent_id && !eligibleParents.some(
|
||||||
|
(item) => item.object_kind === record.parent_kind && item.object_id === record.parent_id
|
||||||
|
) &&
|
||||||
|
<option value={`${record.parent_kind}:${record.parent_id}`}>{record.parent_id}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
<label>
|
||||||
|
<span>Key</span>
|
||||||
|
<input value={values.key} disabled={Boolean(record)} required maxLength={120} onChange={(event) => set("key", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="project-editor-wide">
|
||||||
|
<span>Title</span>
|
||||||
|
<input value={values.title} required maxLength={500} onChange={(event) => set("title", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>State</span>
|
||||||
|
<select value={values.state} onChange={(event) => set("state", event.target.value)}>
|
||||||
|
{STATES[values.kind].map((state) => <option key={state} value={state}>{humanize(state)}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Visibility</span>
|
||||||
|
<select value={values.visibility} onChange={(event) => set("visibility", event.target.value as EditorValues["visibility"])}>
|
||||||
|
<option value="tenant">Tenant</option>
|
||||||
|
<option value="restricted">Restricted</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Starts</span>
|
||||||
|
<input type="date" value={values.startsAt} onChange={(event) => set("startsAt", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Due</span>
|
||||||
|
<input type="date" value={values.dueAt} onChange={(event) => set("dueAt", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="project-editor-wide">
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea rows={5} value={values.description} onChange={(event) => set("description", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="project-editor-wide">
|
||||||
|
<span>Change reason</span>
|
||||||
|
<input value={values.changeReason} required maxLength={1000} onChange={(event) => set("changeReason", event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorValues(record: ProjectRecord | null): EditorValues {
|
||||||
|
const kind = record?.object_kind ?? "project";
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
key: record?.object_key ?? "",
|
||||||
|
title: record?.title ?? "",
|
||||||
|
state: record?.state ?? INITIAL_STATE[kind],
|
||||||
|
description: record?.description ?? "",
|
||||||
|
visibility: record?.visibility ?? "tenant",
|
||||||
|
parentRef: record?.parent_kind && record.parent_id ? `${record.parent_kind}:${record.parent_id}` : "",
|
||||||
|
startsAt: dateInput(record?.starts_at),
|
||||||
|
dueAt: dateInput(record?.due_at),
|
||||||
|
changeReason: record ? "Updated project planning information." : "Created planning object."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParentRef(value: string): { kind: ProjectObjectKind; id: string } | null {
|
||||||
|
const separator = value.indexOf(":");
|
||||||
|
if (separator < 1) return null;
|
||||||
|
return {
|
||||||
|
kind: value.slice(0, separator) as ProjectObjectKind,
|
||||||
|
id: value.slice(separator + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectKey(record: ProjectRecord): string {
|
||||||
|
return `${record.object_kind}:${record.object_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateValue(value: string): string | null {
|
||||||
|
return value ? new Date(`${value}T00:00:00.000Z`).toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateInput(value?: string | null): string {
|
||||||
|
return value ? value.slice(0, 10) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null): string {
|
||||||
|
return value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(value)) : "Not set";
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string): string {
|
||||||
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(state: string): "active" | "inactive" | "warning" {
|
||||||
|
if (["active", "approved", "achieved", "completed"].includes(state)) return "active";
|
||||||
|
if (["cancelled", "missed"].includes(state)) return "inactive";
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindIcon(kind: ProjectObjectKind) {
|
||||||
|
if (kind === "portfolio") return <FolderKanban size={17} aria-hidden="true" />;
|
||||||
|
if (kind === "milestone") return <Milestone size={17} aria-hidden="true" />;
|
||||||
|
return <CalendarDays size={17} aria-hidden="true" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default, projectsModule } from "./module";
|
||||||
|
export * from "./api/projects";
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createElement, lazy } from "react";
|
||||||
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
import "./styles/projects.css";
|
||||||
|
|
||||||
|
|
||||||
|
const ProjectsPage = lazy(() => import("./features/projects/ProjectsPage"));
|
||||||
|
|
||||||
|
export const projectsModule: PlatformWebModule = {
|
||||||
|
id: "projects",
|
||||||
|
label: "Projects",
|
||||||
|
version: "0.1.14",
|
||||||
|
optionalDependencies: [
|
||||||
|
"tasks",
|
||||||
|
"tickets",
|
||||||
|
"cases",
|
||||||
|
"wiki",
|
||||||
|
"calendar",
|
||||||
|
"files",
|
||||||
|
"workflow_engine",
|
||||||
|
"connectors",
|
||||||
|
"search",
|
||||||
|
"notifications",
|
||||||
|
"reporting",
|
||||||
|
"risk_compliance"
|
||||||
|
],
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/projects",
|
||||||
|
anyOf: ["projects:project:read"],
|
||||||
|
order: 36,
|
||||||
|
surfaceId: "projects.workspace",
|
||||||
|
render: (context) => createElement(ProjectsPage, context)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
navItems: [
|
||||||
|
{
|
||||||
|
to: "/projects",
|
||||||
|
label: "Projects",
|
||||||
|
iconName: "folder-kanban",
|
||||||
|
anyOf: ["projects:project:read"],
|
||||||
|
order: 36,
|
||||||
|
surfaceId: "projects.navigation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
viewSurfaces: [
|
||||||
|
{ id: "projects.navigation", moduleId: "projects", kind: "navigation", label: "Projects navigation", order: 10 },
|
||||||
|
{ id: "projects.workspace", moduleId: "projects", kind: "route", label: "Projects workspace", order: 20 },
|
||||||
|
{ id: "projects.portfolios", moduleId: "projects", kind: "section", label: "Portfolio planning", parentId: "projects.workspace", order: 30 },
|
||||||
|
{ id: "projects.outcomes", moduleId: "projects", kind: "section", label: "Outcomes and benefits", parentId: "projects.workspace", order: 40 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default projectsModule;
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
.projects-page,
|
||||||
|
.projects-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(560px, 48vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-search input {
|
||||||
|
min-width: 160px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-kind-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-kind-filter > span,
|
||||||
|
.projects-count {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-count {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-workspace {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: minmax(360px, 42%) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-list-viewport,
|
||||||
|
.project-detail-viewport {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-list-viewport {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface-subtle, var(--surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-list {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto minmax(96px, auto);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row:hover,
|
||||||
|
.project-row:focus-visible,
|
||||||
|
.project-row.is-selected {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row.is-selected {
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row-icon {
|
||||||
|
display: grid;
|
||||||
|
color: var(--text-soft);
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row-main strong,
|
||||||
|
.project-row-main small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row-main small,
|
||||||
|
.project-row-date {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail {
|
||||||
|
width: min(100%, 980px);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4px 8px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-header h1 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-eyebrow {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-description {
|
||||||
|
max-width: 78ch;
|
||||||
|
margin: 18px 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-facts,
|
||||||
|
.project-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-facts > div,
|
||||||
|
.project-stat-grid > div {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 11px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-facts span,
|
||||||
|
.project-stat-grid span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-planning-section {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-planning-section h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-list > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 9px 2px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-detail-list span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-empty {
|
||||||
|
padding: 36px 10px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-dialog {
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-form label {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-form label > span {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.projects-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-count {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.projects-list-viewport {
|
||||||
|
max-height: 42vh;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-facts,
|
||||||
|
.project-stat-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.project-row {
|
||||||
|
grid-template-columns: 26px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-row-date {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-editor-wide {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user