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
+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",
]