feat: implement governed project portfolio
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Projects Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Projects internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns native projects, portfolios, milestones, participation,
|
||||
|
||||
@@ -8,8 +8,18 @@
|
||||
milestones, participants, work structure, and links to project evidence. It is
|
||||
the native GovOPlaN project context and the integration target for OpenProject.
|
||||
|
||||
The runtime module ID is `projects`. This initial scaffold registers the
|
||||
boundary, permissions, roles, documentation, and module entry point.
|
||||
The runtime module ID is `projects`. The module persists tenant-scoped
|
||||
portfolios, projects, and milestones as immutable revisions with optimistic
|
||||
concurrency, replay-safe lifecycle events, membership-based restricted access,
|
||||
and permission-aware Search indexing. Planning records can retain outcomes,
|
||||
benefits, dependencies, capacity assumptions, change impacts, benefit reviews,
|
||||
module resource links, and canonical external-system references.
|
||||
|
||||
The `/projects` workspace provides catalogue, detail, create, and core-field
|
||||
editing views. The governed API exposes the complete planning record at
|
||||
`/api/v1/projects/objects`. OpenProject transport and synchronization remain in
|
||||
Connectors; Projects stores only the native planning object and canonical
|
||||
external reference.
|
||||
|
||||
See [docs/PROJECTS_DOMAIN_BOUNDARY.md](docs/PROJECTS_DOMAIN_BOUNDARY.md).
|
||||
|
||||
@@ -20,3 +30,7 @@ cd /mnt/DATA/git/govoplan-projects
|
||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The module migration is applied through the platform migration runner when the
|
||||
module is enabled. Destructive retirement requires the normal snapshot and
|
||||
uninstall-guard process because it removes planning history and events.
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
## Owns
|
||||
|
||||
- portfolio and project identity
|
||||
- goals, status, dates, milestones, and project structure
|
||||
- versioned goals, intended outcomes, benefits, status, dates, milestones, and
|
||||
project structure
|
||||
- portfolio dependencies, capacity assumptions, change impact, and benefit
|
||||
review
|
||||
- participant and responsibility references
|
||||
- links to tasks, tickets, cases, files, Wiki pages, and calendar objects
|
||||
- links to mandates, services, risks, controls, contracts, grants, resources,
|
||||
indicators, tasks, tickets, cases, files, Wiki pages, and calendar objects
|
||||
- project-level external references and synchronization provenance
|
||||
|
||||
## Does Not Own
|
||||
@@ -15,6 +19,8 @@
|
||||
- file storage or Wiki content
|
||||
- workflow runtime
|
||||
- OpenProject transport, credentials, or synchronization jobs
|
||||
- policy decisions, risk/control lifecycle, resource booking, contract/grant
|
||||
obligations, or report calculation
|
||||
|
||||
## OpenProject Boundary
|
||||
|
||||
@@ -24,8 +30,55 @@ mapping diagnostics, synchronization, and migration. A project may remain
|
||||
external-only, linked, partially synchronized, imported, or native according
|
||||
to the integration maturity level recorded on its external reference.
|
||||
|
||||
## First Slice
|
||||
## Runtime Contract
|
||||
|
||||
Implement project identity, status, milestones, participants, resource links,
|
||||
and canonical OpenProject reference mapping before implementing a full native
|
||||
work-package model.
|
||||
The implemented aggregate uses stable identities and immutable revisions for
|
||||
`portfolio`, `project`, and `milestone` objects. Writes require an expected
|
||||
revision, an idempotency key, an aware timestamp, and a change reason. The
|
||||
module records a durable module event in the same transaction and publishes a
|
||||
platform event after commit.
|
||||
|
||||
Current normalized membership grants make restricted-object filtering and
|
||||
authorization efficient while every historical revision retains the exact
|
||||
owner and membership snapshot. Tenant-visible objects require the read scope;
|
||||
restricted objects additionally require creator, owner, membership, or admin
|
||||
access. Search stores only bounded document data and always rechecks the source
|
||||
authorization before returning a result.
|
||||
|
||||
Parent rules are explicit: portfolios have no parent, projects may belong to a
|
||||
portfolio, and milestones belong to a project. State changes use a per-kind
|
||||
transition map. Physical deletion is deliberately absent from the object API;
|
||||
cancelled/completed lifecycle state and governed module retirement preserve the
|
||||
audit trail.
|
||||
|
||||
## User And Admin Operation
|
||||
|
||||
Users work in `/projects`, where they can search and filter the planning
|
||||
catalogue, inspect outcome/governance summaries, and create or edit core
|
||||
planning fields. The API record additionally accepts first-class outcomes,
|
||||
benefits, dependencies, capacity assumptions, change impacts, benefit reviews,
|
||||
resource links, memberships, and external references. Administrators grant the
|
||||
Projects role templates and use ordinary module activation/migration controls;
|
||||
there is no Projects-specific credential or transport configuration.
|
||||
|
||||
Use Reporting for measured indicators and Risk Compliance for risks and
|
||||
controls; Projects retains planning intent and links. Use Tasks or Tickets for
|
||||
actionable work. A canonical OpenProject reference may use any declared
|
||||
integration maturity, but Connectors owns discovery, credentials, mapping,
|
||||
synchronization, and migration.
|
||||
|
||||
## Recovery And Limits
|
||||
|
||||
Database backup and restore cover identities, immutable revisions, normalized
|
||||
memberships, and events as one logical state set. Replaying a successful write
|
||||
with its original idempotency key returns the recorded revision; reusing the
|
||||
key with another request is rejected. A stale expected revision is rejected
|
||||
without changing current state.
|
||||
|
||||
The first WebUI edits the common object fields. Advanced planning collections
|
||||
are fully validated and persisted through the API but still need specialized
|
||||
editors. OpenProject synchronization is future Connectors work. Projects does
|
||||
not implement a native work-package lifecycle.
|
||||
|
||||
Do not create a separate Goals module until another domain proves a reusable,
|
||||
independent goal lifecycle that Projects cannot own through references.
|
||||
|
||||
@@ -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 pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_projects.backend.acl import ProjectScopeAclProvider
|
||||
from govoplan_projects.backend.db import models as project_models
|
||||
from govoplan_projects.backend.search_source import create_projects_search_source
|
||||
from govoplan_projects.backend.service import (
|
||||
CAPABILITY_PROJECTS_REGISTRY,
|
||||
SqlProjectRegistry,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "projects"
|
||||
@@ -30,8 +60,97 @@ OPTIONAL_DEPENDENCIES = (
|
||||
"connectors",
|
||||
"search",
|
||||
"notifications",
|
||||
"reporting",
|
||||
"risk_compliance",
|
||||
)
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="domain_capability",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/PROJECTS_DOMAIN_BOUNDARY.md",
|
||||
summary=(
|
||||
"Defines portfolio, project, outcome, governance, and integration "
|
||||
"ownership."
|
||||
),
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_project_service.py",
|
||||
summary=(
|
||||
"Proves immutable revisions, OCC, idempotency, tenant isolation, "
|
||||
"restricted memberships, state transitions, and search rechecks."
|
||||
),
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"OpenProject transport and synchronization remain connector-owned provider work.",
|
||||
"Task execution remains in Tasks or Tickets; Projects links work without duplicating its lifecycle.",
|
||||
"The first WebUI edits core planning fields; advanced outcome, capacity, and benefit structures remain available through the governed API.",
|
||||
),
|
||||
supported_authority_modes=(
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
),
|
||||
owned_concepts=(
|
||||
"portfolio and project identity",
|
||||
"project goals and intended outcomes",
|
||||
"project dependencies and capacity assumptions",
|
||||
"project benefit and change-impact review",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"actionable task and ticket lifecycle",
|
||||
"risk and control lifecycle",
|
||||
"report calculation and presentation",
|
||||
"external project-system transport",
|
||||
),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
operations=("docs/PROJECTS_DOMAIN_BOUNDARY.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_projects.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _registry(context: ModuleContext) -> SqlProjectRegistry:
|
||||
del context
|
||||
return SqlProjectRegistry()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
counts = {
|
||||
kind: count
|
||||
for kind, count in (
|
||||
session.query(
|
||||
project_models.ProjectObjectRevision.object_kind,
|
||||
func.count(),
|
||||
)
|
||||
.filter(
|
||||
project_models.ProjectObjectRevision.tenant_id == tenant_id,
|
||||
project_models.ProjectObjectRevision.superseded_at.is_(None),
|
||||
)
|
||||
.group_by(project_models.ProjectObjectRevision.object_kind)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
return {
|
||||
"portfolios": int(counts.get("portfolio", 0)),
|
||||
"projects": int(counts.get("project", 0)),
|
||||
"milestones": int(counts.get("milestone", 0)),
|
||||
}
|
||||
|
||||
|
||||
def _permission(
|
||||
scope: str,
|
||||
@@ -52,9 +171,15 @@ def _permission(
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View projects", "Read accessible projects and portfolios."),
|
||||
_permission(WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."),
|
||||
_permission(ADMIN_SCOPE, "Administer projects", "Configure project types and policies."),
|
||||
_permission(
|
||||
READ_SCOPE, "View projects", "Read accessible projects and portfolios."
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE, "Administer projects", "Configure project types and policies."
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -77,17 +202,19 @@ DOCUMENTATION = (
|
||||
id="projects.module-boundary",
|
||||
title="Projects module boundary",
|
||||
summary=(
|
||||
"Portfolios, projects, goals, milestones, participants, status, "
|
||||
"work structure, and project-level references."
|
||||
"Portfolios, projects, versioned goals and outcomes, milestones, "
|
||||
"dependencies, capacity, benefits, participants, status, and references."
|
||||
),
|
||||
body=(
|
||||
"Projects owns native project context. Tasks and Tickets own "
|
||||
"actionable work, Cases owns formal procedures, and Connectors "
|
||||
"owns OpenProject synchronization."
|
||||
"owns OpenProject synchronization. Reporting owns measured indicators; "
|
||||
"Risk Compliance owns risks and controls; Projects links those facts to "
|
||||
"planning, change impact, and benefit review."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
@@ -102,13 +229,17 @@ DOCUMENTATION = (
|
||||
"portfolio",
|
||||
"project",
|
||||
"milestone",
|
||||
"versioned goal and intended outcome",
|
||||
"dependency and capacity assumption",
|
||||
"benefit review",
|
||||
"project participant",
|
||||
"project resource link",
|
||||
"external project reference",
|
||||
],
|
||||
"first_slice": (
|
||||
"Implement project identity, status, milestones, participants, "
|
||||
"resource links, and OpenProject reference mapping."
|
||||
"Implement project and portfolio identity, status, milestones, "
|
||||
"participants, outcome/benefit intent, dependency/resource links, "
|
||||
"and OpenProject reference mapping."
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -126,7 +257,123 @@ manifest = ModuleManifest(
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/projects",
|
||||
label="Projects",
|
||||
icon="folder-kanban",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/projects-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/projects",
|
||||
component="ProjectsPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/projects",
|
||||
label="Projects",
|
||||
icon="folder-kanban",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="projects.navigation",
|
||||
module_id=MODULE_ID,
|
||||
kind="navigation",
|
||||
label="Projects navigation",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="projects.workspace",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Projects workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="projects.portfolios",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Portfolio planning",
|
||||
parent_id="projects.workspace",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="projects.outcomes",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Outcomes and benefits",
|
||||
parent_id="projects.workspace",
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="projects.registry", version="0.1.0"),
|
||||
),
|
||||
capability_factories={CAPABILITY_PROJECTS_REGISTRY: _registry},
|
||||
capability_documentation={
|
||||
CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation(
|
||||
label="Projects registry",
|
||||
summary=(
|
||||
"Persists portfolios, projects, milestones, planning intent, "
|
||||
"membership grants, revisions, and lifecycle events."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="projects.objects",
|
||||
factory=create_projects_search_source,
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
project_models.ProjectObjectEvent,
|
||||
project_models.ProjectMembershipGrant,
|
||||
project_models.ProjectObjectRevision,
|
||||
project_models.ProjectObjectIdentity,
|
||||
label="Projects",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement requires a database snapshot and removes "
|
||||
"Projects identities, immutable revisions, memberships, and events."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
project_models.ProjectObjectIdentity,
|
||||
project_models.ProjectObjectRevision,
|
||||
project_models.ProjectMembershipGrant,
|
||||
project_models.ProjectObjectEvent,
|
||||
label="Projects",
|
||||
),
|
||||
),
|
||||
resource_acl_providers=(
|
||||
ProjectScopeAclProvider("portfolio"),
|
||||
ProjectScopeAclProvider("project"),
|
||||
ProjectScopeAclProvider("milestone"),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=DOCUMENTATION,
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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("tickets", manifest.optional_dependencies)
|
||||
self.assertTrue(manifest.documentation)
|
||||
self.assertIsNone(manifest.route_factory)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertEqual("vertical_slice", manifest.architecture.maturity)
|
||||
self.assertIn(
|
||||
"project benefit and change-impact review",
|
||||
manifest.architecture.owned_concepts,
|
||||
)
|
||||
self.assertIsNotNone(manifest.route_factory)
|
||||
self.assertEqual("@govoplan/projects-webui", manifest.frontend.package_name)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertEqual("projects.registry", manifest.provides_interfaces[0].name)
|
||||
self.assertEqual(1, len(manifest.search_sources))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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