feat: implement governed project portfolio

This commit is contained in:
2026-08-01 17:48:38 +02:00
parent df8c3907c5
commit 8bfcf2d6b4
23 changed files with 4577 additions and 20 deletions
+6
View File
@@ -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,
+16 -2
View File
@@ -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.
+59 -6
View File
@@ -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.
+31
View File
@@ -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",
]
+219
View File
@@ -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",
]
+886
View File
@@ -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",
]
+257 -10
View File
@@ -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."""
@@ -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")
+227
View File
@@ -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"]
+47
View File
@@ -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",
]
+977
View File
@@ -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
View File
@@ -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__":
+314
View File
@@ -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()
+28
View File
@@ -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
}
}
}
+107
View File
@@ -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" />;
}
+2
View File
@@ -0,0 +1,2 @@
export { default, projectsModule } from "./module";
export * from "./api/projects";
+53
View File
@@ -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;
+309
View File
@@ -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;
}
}