feat: implement governed project portfolio
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.auth import has_scope
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
|
||||
|
||||
class ProjectScopeAclProvider:
|
||||
"""Expose coarse scope checks; the Projects registry rechecks object grants."""
|
||||
|
||||
def __init__(self, resource_type: str) -> None:
|
||||
self.resource_type = resource_type
|
||||
|
||||
def can_read(self, principal: object, resource_id: str) -> bool:
|
||||
del resource_id
|
||||
return has_scope(principal, "projects:project:read")
|
||||
|
||||
def can_write(self, principal: object, resource_id: str) -> bool:
|
||||
del resource_id
|
||||
return has_scope(principal, "projects:project:write")
|
||||
|
||||
def explain(self, principal: object, resource_id: str) -> AccessDecision:
|
||||
del resource_id
|
||||
allowed = self.can_read(principal, "")
|
||||
return AccessDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else "Missing scope: projects:project:read",
|
||||
requirements=("projects:project:read",),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ProjectScopeAclProvider"]
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Projects database models."""
|
||||
|
||||
from govoplan_projects.backend.db.models import (
|
||||
ProjectMembershipGrant,
|
||||
ProjectObjectEvent,
|
||||
ProjectObjectIdentity,
|
||||
ProjectObjectRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProjectMembershipGrant",
|
||||
"ProjectObjectEvent",
|
||||
"ProjectObjectIdentity",
|
||||
"ProjectObjectRevision",
|
||||
]
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class ProjectObjectIdentity(Base, TimestampMixin):
|
||||
__tablename__ = "project_object_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
name="uq_project_object_identity",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_key",
|
||||
name="uq_project_object_key",
|
||||
),
|
||||
Index(
|
||||
"ix_project_object_identity_catalog",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_key",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
object_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
class ProjectObjectRevision(Base, TimestampMixin):
|
||||
__tablename__ = "project_object_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"revision",
|
||||
name="uq_project_object_revision",
|
||||
),
|
||||
Index(
|
||||
"ix_project_object_current",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"superseded_at",
|
||||
),
|
||||
Index(
|
||||
"ix_project_object_parent",
|
||||
"tenant_id",
|
||||
"parent_kind",
|
||||
"parent_id",
|
||||
"object_kind",
|
||||
"state",
|
||||
),
|
||||
Index(
|
||||
"ix_project_object_catalog",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"state",
|
||||
"recorded_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
identity_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("project_object_identities.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
previous_revision_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("project_object_revisions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
parent_kind: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, index=True
|
||||
)
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, default="tenant", index=True
|
||||
)
|
||||
starts_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
due_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
search_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
changed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
|
||||
|
||||
class ProjectMembershipGrant(Base, TimestampMixin):
|
||||
__tablename__ = "project_membership_grants"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
name="uq_project_membership_subject",
|
||||
),
|
||||
Index(
|
||||
"ix_project_membership_lookup",
|
||||
"tenant_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"active",
|
||||
),
|
||||
Index(
|
||||
"ix_project_membership_object",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"active",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
role: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
permissions: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, nullable=False, index=True
|
||||
)
|
||||
source_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class ProjectObjectEvent(Base, TimestampMixin):
|
||||
__tablename__ = "project_object_events"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "event_id", name="uq_project_object_event"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_project_object_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_project_object_event_history",
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"occurred_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
object_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
object_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
object_revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProjectMembershipGrant",
|
||||
"ProjectObjectEvent",
|
||||
"ProjectObjectIdentity",
|
||||
"ProjectObjectRevision",
|
||||
]
|
||||
@@ -0,0 +1,886 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from govoplan_core.core.external_references import ExternalObjectReference
|
||||
|
||||
|
||||
ProjectObjectKind = Literal["portfolio", "project", "milestone"]
|
||||
ProjectVisibility = Literal["tenant", "restricted"]
|
||||
|
||||
OBJECT_KINDS = frozenset({"portfolio", "project", "milestone"})
|
||||
VISIBILITIES = frozenset({"tenant", "restricted"})
|
||||
SUBJECT_KINDS = frozenset(
|
||||
{
|
||||
"account",
|
||||
"identity",
|
||||
"group",
|
||||
"role",
|
||||
"function",
|
||||
"function_assignment",
|
||||
"organization_unit",
|
||||
"service_account",
|
||||
}
|
||||
)
|
||||
PROJECT_STATES: Mapping[str, frozenset[str]] = {
|
||||
"portfolio": frozenset({"draft", "active", "on_hold", "completed", "cancelled"}),
|
||||
"project": frozenset(
|
||||
{
|
||||
"draft",
|
||||
"proposed",
|
||||
"approved",
|
||||
"active",
|
||||
"on_hold",
|
||||
"completed",
|
||||
"cancelled",
|
||||
}
|
||||
),
|
||||
"milestone": frozenset({"planned", "active", "achieved", "missed", "cancelled"}),
|
||||
}
|
||||
STATE_TRANSITIONS: Mapping[str, Mapping[str, frozenset[str]]] = {
|
||||
"portfolio": {
|
||||
"draft": frozenset({"active", "cancelled"}),
|
||||
"active": frozenset({"on_hold", "completed", "cancelled"}),
|
||||
"on_hold": frozenset({"active", "cancelled"}),
|
||||
"completed": frozenset({"active"}),
|
||||
"cancelled": frozenset(),
|
||||
},
|
||||
"project": {
|
||||
"draft": frozenset({"proposed", "cancelled"}),
|
||||
"proposed": frozenset({"draft", "approved", "cancelled"}),
|
||||
"approved": frozenset({"active", "on_hold", "cancelled"}),
|
||||
"active": frozenset({"on_hold", "completed", "cancelled"}),
|
||||
"on_hold": frozenset({"approved", "active", "cancelled"}),
|
||||
"completed": frozenset({"active"}),
|
||||
"cancelled": frozenset(),
|
||||
},
|
||||
"milestone": {
|
||||
"planned": frozenset({"active", "achieved", "missed", "cancelled"}),
|
||||
"active": frozenset({"achieved", "missed", "cancelled"}),
|
||||
"achieved": frozenset(),
|
||||
"missed": frozenset({"active", "achieved", "cancelled"}),
|
||||
"cancelled": frozenset(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ProjectDomainError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectSubjectRef:
|
||||
kind: str
|
||||
id: str
|
||||
label: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind not in SUBJECT_KINDS:
|
||||
raise ProjectDomainError(
|
||||
f"Unsupported project subject kind: {self.kind!r}."
|
||||
)
|
||||
_required(self.id, "Project subject identifier", maximum=255)
|
||||
_optional(self.label, "Project subject label", maximum=500)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {"kind": self.kind, "id": self.id, "label": self.label}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectSubjectRef":
|
||||
return cls(
|
||||
kind=_required(value.get("kind"), "Project subject kind", maximum=40),
|
||||
id=_required(value.get("id"), "Project subject identifier", maximum=255),
|
||||
label=_optional(value.get("label"), "Project subject label", maximum=500),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectMembership:
|
||||
subject: ProjectSubjectRef
|
||||
role: str
|
||||
permissions: tuple[str, ...] = ("read",)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.role, "Project membership role", maximum=80)
|
||||
normalized = _bounded_strings(
|
||||
self.permissions,
|
||||
"Project membership permissions",
|
||||
maximum_items=20,
|
||||
maximum_length=80,
|
||||
)
|
||||
if not normalized:
|
||||
raise ProjectDomainError(
|
||||
"Project memberships require at least one permission."
|
||||
)
|
||||
object.__setattr__(self, "permissions", normalized)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"subject": self.subject.to_dict(),
|
||||
"role": self.role,
|
||||
"permissions": list(self.permissions),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectMembership":
|
||||
return cls(
|
||||
subject=ProjectSubjectRef.from_mapping(_mapping(value, "subject")),
|
||||
role=_required(value.get("role"), "Project membership role", maximum=80),
|
||||
permissions=_string_items(value.get("permissions")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectOutcome:
|
||||
key: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
success_indicators: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_key(self.key, "Project outcome key")
|
||||
_required(self.title, "Project outcome title", maximum=500)
|
||||
_optional(self.description, "Project outcome description", maximum=20_000)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"success_indicators",
|
||||
_bounded_strings(
|
||||
self.success_indicators,
|
||||
"Project success indicators",
|
||||
maximum_items=100,
|
||||
maximum_length=2_000,
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"success_indicators": list(self.success_indicators),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectOutcome":
|
||||
return cls(
|
||||
key=_required(value.get("key"), "Project outcome key", maximum=120),
|
||||
title=_required(value.get("title"), "Project outcome title", maximum=500),
|
||||
description=_optional(
|
||||
value.get("description"),
|
||||
"Project outcome description",
|
||||
maximum=20_000,
|
||||
),
|
||||
success_indicators=_string_items(value.get("success_indicators")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectBenefit:
|
||||
key: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
owner: ProjectSubjectRef | None = None
|
||||
target: str | None = None
|
||||
measure_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_key(self.key, "Project benefit key")
|
||||
_required(self.title, "Project benefit title", maximum=500)
|
||||
_optional(self.description, "Project benefit description", maximum=20_000)
|
||||
_optional(self.target, "Project benefit target", maximum=2_000)
|
||||
_optional(self.measure_ref, "Project benefit measure reference", maximum=500)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"owner": self.owner.to_dict() if self.owner else None,
|
||||
"target": self.target,
|
||||
"measure_ref": self.measure_ref,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectBenefit":
|
||||
owner = value.get("owner")
|
||||
return cls(
|
||||
key=_required(value.get("key"), "Project benefit key", maximum=120),
|
||||
title=_required(value.get("title"), "Project benefit title", maximum=500),
|
||||
description=_optional(
|
||||
value.get("description"),
|
||||
"Project benefit description",
|
||||
maximum=20_000,
|
||||
),
|
||||
owner=(
|
||||
ProjectSubjectRef.from_mapping(owner)
|
||||
if isinstance(owner, Mapping)
|
||||
else None
|
||||
),
|
||||
target=_optional(
|
||||
value.get("target"), "Project benefit target", maximum=2_000
|
||||
),
|
||||
measure_ref=_optional(
|
||||
value.get("measure_ref"),
|
||||
"Project benefit measure reference",
|
||||
maximum=500,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectDependency:
|
||||
key: str
|
||||
relationship: str
|
||||
target_module: str
|
||||
target_type: str
|
||||
target_id: str
|
||||
description: str | None = None
|
||||
critical: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_key(self.key, "Project dependency key")
|
||||
_required(self.relationship, "Project dependency relationship", maximum=80)
|
||||
_required(self.target_module, "Project dependency module", maximum=100)
|
||||
_required(self.target_type, "Project dependency type", maximum=100)
|
||||
_required(self.target_id, "Project dependency identifier", maximum=255)
|
||||
_optional(
|
||||
self.description,
|
||||
"Project dependency description",
|
||||
maximum=10_000,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"relationship": self.relationship,
|
||||
"target_module": self.target_module,
|
||||
"target_type": self.target_type,
|
||||
"target_id": self.target_id,
|
||||
"description": self.description,
|
||||
"critical": self.critical,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectDependency":
|
||||
return cls(
|
||||
key=_required(value.get("key"), "Project dependency key", maximum=120),
|
||||
relationship=_required(
|
||||
value.get("relationship"),
|
||||
"Project dependency relationship",
|
||||
maximum=80,
|
||||
),
|
||||
target_module=_required(
|
||||
value.get("target_module"),
|
||||
"Project dependency module",
|
||||
maximum=100,
|
||||
),
|
||||
target_type=_required(
|
||||
value.get("target_type"),
|
||||
"Project dependency type",
|
||||
maximum=100,
|
||||
),
|
||||
target_id=_required(
|
||||
value.get("target_id"),
|
||||
"Project dependency identifier",
|
||||
maximum=255,
|
||||
),
|
||||
description=_optional(
|
||||
value.get("description"),
|
||||
"Project dependency description",
|
||||
maximum=10_000,
|
||||
),
|
||||
critical=bool(value.get("critical", False)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectCapacityAssumption:
|
||||
key: str
|
||||
label: str
|
||||
amount: float | None = None
|
||||
unit: str | None = None
|
||||
period_start: datetime | None = None
|
||||
period_end: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_key(self.key, "Project capacity key")
|
||||
_required(self.label, "Project capacity label", maximum=500)
|
||||
_optional(self.unit, "Project capacity unit", maximum=80)
|
||||
_optional(self.notes, "Project capacity notes", maximum=10_000)
|
||||
_aware(self.period_start, "Project capacity period_start")
|
||||
_aware(self.period_end, "Project capacity period_end")
|
||||
if (
|
||||
self.period_start is not None
|
||||
and self.period_end is not None
|
||||
and self.period_end < self.period_start
|
||||
):
|
||||
raise ProjectDomainError(
|
||||
"Project capacity period_end cannot precede period_start."
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"amount": self.amount,
|
||||
"unit": self.unit,
|
||||
"period_start": _datetime_text(self.period_start),
|
||||
"period_end": _datetime_text(self.period_end),
|
||||
"notes": self.notes,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls,
|
||||
value: Mapping[str, object],
|
||||
) -> "ProjectCapacityAssumption":
|
||||
amount = value.get("amount")
|
||||
return cls(
|
||||
key=_required(value.get("key"), "Project capacity key", maximum=120),
|
||||
label=_required(value.get("label"), "Project capacity label", maximum=500),
|
||||
amount=float(amount) if amount is not None else None,
|
||||
unit=_optional(value.get("unit"), "Project capacity unit", maximum=80),
|
||||
period_start=_optional_datetime(value.get("period_start")),
|
||||
period_end=_optional_datetime(value.get("period_end")),
|
||||
notes=_optional(
|
||||
value.get("notes"), "Project capacity notes", maximum=10_000
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectChangeImpact:
|
||||
key: str
|
||||
audience: str
|
||||
description: str
|
||||
severity: str = "medium"
|
||||
mitigation: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_key(self.key, "Project change-impact key")
|
||||
_required(self.audience, "Project change-impact audience", maximum=500)
|
||||
_required(self.description, "Project change-impact description", maximum=20_000)
|
||||
if self.severity not in {"low", "medium", "high", "critical"}:
|
||||
raise ProjectDomainError("Unsupported project change-impact severity.")
|
||||
_optional(self.mitigation, "Project change-impact mitigation", maximum=20_000)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"audience": self.audience,
|
||||
"description": self.description,
|
||||
"severity": self.severity,
|
||||
"mitigation": self.mitigation,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectChangeImpact":
|
||||
return cls(
|
||||
key=_required(value.get("key"), "Project change-impact key", maximum=120),
|
||||
audience=_required(
|
||||
value.get("audience"),
|
||||
"Project change-impact audience",
|
||||
maximum=500,
|
||||
),
|
||||
description=_required(
|
||||
value.get("description"),
|
||||
"Project change-impact description",
|
||||
maximum=20_000,
|
||||
),
|
||||
severity=str(value.get("severity") or "medium"),
|
||||
mitigation=_optional(
|
||||
value.get("mitigation"),
|
||||
"Project change-impact mitigation",
|
||||
maximum=20_000,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectBenefitReview:
|
||||
benefit_key: str
|
||||
status: str
|
||||
observed_at: datetime
|
||||
summary: str
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_key(self.benefit_key, "Project benefit-review key")
|
||||
_required(self.status, "Project benefit-review status", maximum=80)
|
||||
_aware(self.observed_at, "Project benefit-review observed_at")
|
||||
_required(self.summary, "Project benefit-review summary", maximum=20_000)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"evidence_refs",
|
||||
_bounded_strings(
|
||||
self.evidence_refs,
|
||||
"Project benefit-review evidence",
|
||||
maximum_items=100,
|
||||
maximum_length=1_000,
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"benefit_key": self.benefit_key,
|
||||
"status": self.status,
|
||||
"observed_at": self.observed_at.isoformat(),
|
||||
"summary": self.summary,
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectBenefitReview":
|
||||
observed_at = _optional_datetime(value.get("observed_at"))
|
||||
if observed_at is None:
|
||||
raise ProjectDomainError("Project benefit-review observed_at is required.")
|
||||
return cls(
|
||||
benefit_key=_required(
|
||||
value.get("benefit_key"),
|
||||
"Project benefit-review key",
|
||||
maximum=120,
|
||||
),
|
||||
status=_required(
|
||||
value.get("status"),
|
||||
"Project benefit-review status",
|
||||
maximum=80,
|
||||
),
|
||||
observed_at=observed_at,
|
||||
summary=_required(
|
||||
value.get("summary"),
|
||||
"Project benefit-review summary",
|
||||
maximum=20_000,
|
||||
),
|
||||
evidence_refs=_string_items(value.get("evidence_refs")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectResourceLink:
|
||||
owner_module: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
relationship: str
|
||||
label: str | None = None
|
||||
href: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.owner_module, "Project resource owner", maximum=100)
|
||||
_required(self.resource_type, "Project resource type", maximum=100)
|
||||
_required(self.resource_id, "Project resource identifier", maximum=255)
|
||||
_required(self.relationship, "Project resource relationship", maximum=80)
|
||||
_optional(self.label, "Project resource label", maximum=500)
|
||||
_optional(self.href, "Project resource link", maximum=1_500)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"owner_module": self.owner_module,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_id": self.resource_id,
|
||||
"relationship": self.relationship,
|
||||
"label": self.label,
|
||||
"href": self.href,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectResourceLink":
|
||||
return cls(
|
||||
owner_module=_required(
|
||||
value.get("owner_module"),
|
||||
"Project resource owner",
|
||||
maximum=100,
|
||||
),
|
||||
resource_type=_required(
|
||||
value.get("resource_type"),
|
||||
"Project resource type",
|
||||
maximum=100,
|
||||
),
|
||||
resource_id=_required(
|
||||
value.get("resource_id"),
|
||||
"Project resource identifier",
|
||||
maximum=255,
|
||||
),
|
||||
relationship=_required(
|
||||
value.get("relationship"),
|
||||
"Project resource relationship",
|
||||
maximum=80,
|
||||
),
|
||||
label=_optional(value.get("label"), "Project resource label", maximum=500),
|
||||
href=_optional(value.get("href"), "Project resource link", maximum=1_500),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectRecord:
|
||||
tenant_id: str
|
||||
object_kind: ProjectObjectKind
|
||||
object_id: str
|
||||
object_key: str
|
||||
revision: int
|
||||
title: str
|
||||
state: str
|
||||
recorded_at: datetime
|
||||
change_reason: str
|
||||
description: str | None = None
|
||||
visibility: ProjectVisibility = "tenant"
|
||||
parent_kind: ProjectObjectKind | None = None
|
||||
parent_id: str | None = None
|
||||
starts_at: datetime | None = None
|
||||
due_at: datetime | None = None
|
||||
owner: ProjectSubjectRef | None = None
|
||||
memberships: tuple[ProjectMembership, ...] = ()
|
||||
outcomes: tuple[ProjectOutcome, ...] = ()
|
||||
benefits: tuple[ProjectBenefit, ...] = ()
|
||||
dependencies: tuple[ProjectDependency, ...] = ()
|
||||
capacity_assumptions: tuple[ProjectCapacityAssumption, ...] = ()
|
||||
change_impacts: tuple[ProjectChangeImpact, ...] = ()
|
||||
benefit_reviews: tuple[ProjectBenefitReview, ...] = ()
|
||||
resource_links: tuple[ProjectResourceLink, ...] = ()
|
||||
external_references: tuple[ExternalObjectReference, ...] = ()
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_required(self.tenant_id, "Project tenant", maximum=255)
|
||||
if self.object_kind not in OBJECT_KINDS:
|
||||
raise ProjectDomainError(
|
||||
f"Unsupported project object kind: {self.object_kind!r}."
|
||||
)
|
||||
_required(self.object_id, "Project object identifier", maximum=255)
|
||||
_key(self.object_key, "Project object key")
|
||||
if self.revision < 1:
|
||||
raise ProjectDomainError("Project revisions start at one.")
|
||||
_required(self.title, "Project title", maximum=500)
|
||||
if self.state not in PROJECT_STATES[self.object_kind]:
|
||||
raise ProjectDomainError(
|
||||
f"Unsupported {self.object_kind} state: {self.state!r}."
|
||||
)
|
||||
if self.visibility not in VISIBILITIES:
|
||||
raise ProjectDomainError(
|
||||
f"Unsupported project visibility: {self.visibility!r}."
|
||||
)
|
||||
_aware(self.recorded_at, "Project recorded_at")
|
||||
_aware(self.starts_at, "Project starts_at")
|
||||
_aware(self.due_at, "Project due_at")
|
||||
_required(self.change_reason, "Project change reason", maximum=1_000)
|
||||
_optional(self.description, "Project description", maximum=100_000)
|
||||
if self.starts_at and self.due_at and self.due_at < self.starts_at:
|
||||
raise ProjectDomainError("Project due_at cannot precede starts_at.")
|
||||
_validate_parent(self.object_kind, self.parent_kind, self.parent_id)
|
||||
_unique_keys(self.outcomes, "Project outcomes")
|
||||
_unique_keys(self.benefits, "Project benefits")
|
||||
_unique_keys(self.dependencies, "Project dependencies")
|
||||
_unique_keys(self.capacity_assumptions, "Project capacity assumptions")
|
||||
_unique_keys(self.change_impacts, "Project change impacts")
|
||||
benefit_keys = {item.key for item in self.benefits}
|
||||
missing_benefits = {
|
||||
item.benefit_key for item in self.benefit_reviews
|
||||
} - benefit_keys
|
||||
if missing_benefits:
|
||||
raise ProjectDomainError(
|
||||
"Project benefit reviews reference unknown benefits: "
|
||||
+ ", ".join(sorted(missing_benefits))
|
||||
)
|
||||
member_keys = [
|
||||
(item.subject.kind, item.subject.id) for item in self.memberships
|
||||
]
|
||||
if len(member_keys) != len(set(member_keys)):
|
||||
raise ProjectDomainError(
|
||||
"Project memberships must contain unique subjects."
|
||||
)
|
||||
if len(self.metadata) > 200:
|
||||
raise ProjectDomainError("Project metadata is limited to 200 entries.")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"tenant_id": self.tenant_id,
|
||||
"object_kind": self.object_kind,
|
||||
"object_id": self.object_id,
|
||||
"object_key": self.object_key,
|
||||
"revision": self.revision,
|
||||
"title": self.title,
|
||||
"state": self.state,
|
||||
"description": self.description,
|
||||
"visibility": self.visibility,
|
||||
"parent_kind": self.parent_kind,
|
||||
"parent_id": self.parent_id,
|
||||
"starts_at": _datetime_text(self.starts_at),
|
||||
"due_at": _datetime_text(self.due_at),
|
||||
"recorded_at": self.recorded_at.isoformat(),
|
||||
"change_reason": self.change_reason,
|
||||
"owner": self.owner.to_dict() if self.owner else None,
|
||||
"memberships": [item.to_dict() for item in self.memberships],
|
||||
"outcomes": [item.to_dict() for item in self.outcomes],
|
||||
"benefits": [item.to_dict() for item in self.benefits],
|
||||
"dependencies": [item.to_dict() for item in self.dependencies],
|
||||
"capacity_assumptions": [
|
||||
item.to_dict() for item in self.capacity_assumptions
|
||||
],
|
||||
"change_impacts": [item.to_dict() for item in self.change_impacts],
|
||||
"benefit_reviews": [item.to_dict() for item in self.benefit_reviews],
|
||||
"resource_links": [item.to_dict() for item in self.resource_links],
|
||||
"external_references": [
|
||||
item.to_dict() for item in self.external_references
|
||||
],
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, object]) -> "ProjectRecord":
|
||||
metadata = value.get("metadata") or {}
|
||||
if not isinstance(metadata, Mapping):
|
||||
raise ProjectDomainError("Project metadata must be an object.")
|
||||
owner = value.get("owner")
|
||||
recorded_at = _optional_datetime(value.get("recorded_at"))
|
||||
if recorded_at is None:
|
||||
raise ProjectDomainError("Project recorded_at is required.")
|
||||
object_kind = str(value.get("object_kind") or "")
|
||||
parent_kind = value.get("parent_kind")
|
||||
return cls(
|
||||
tenant_id=_required(value.get("tenant_id"), "Project tenant", maximum=255),
|
||||
object_kind=cast(ProjectObjectKind, object_kind),
|
||||
object_id=_required(
|
||||
value.get("object_id"),
|
||||
"Project object identifier",
|
||||
maximum=255,
|
||||
),
|
||||
object_key=_required(
|
||||
value.get("object_key"),
|
||||
"Project object key",
|
||||
maximum=120,
|
||||
),
|
||||
revision=int(value.get("revision") or 0),
|
||||
title=_required(value.get("title"), "Project title", maximum=500),
|
||||
state=_required(value.get("state"), "Project state", maximum=30),
|
||||
description=_optional(
|
||||
value.get("description"),
|
||||
"Project description",
|
||||
maximum=100_000,
|
||||
),
|
||||
visibility=cast(
|
||||
ProjectVisibility,
|
||||
str(value.get("visibility") or "tenant"),
|
||||
),
|
||||
parent_kind=(
|
||||
cast(ProjectObjectKind, str(parent_kind))
|
||||
if parent_kind is not None
|
||||
else None
|
||||
),
|
||||
parent_id=_optional(
|
||||
value.get("parent_id"),
|
||||
"Project parent identifier",
|
||||
maximum=255,
|
||||
),
|
||||
starts_at=_optional_datetime(value.get("starts_at")),
|
||||
due_at=_optional_datetime(value.get("due_at")),
|
||||
recorded_at=recorded_at,
|
||||
change_reason=_required(
|
||||
value.get("change_reason"),
|
||||
"Project change reason",
|
||||
maximum=1_000,
|
||||
),
|
||||
owner=(
|
||||
ProjectSubjectRef.from_mapping(owner)
|
||||
if isinstance(owner, Mapping)
|
||||
else None
|
||||
),
|
||||
memberships=_objects(
|
||||
value.get("memberships"), ProjectMembership.from_mapping
|
||||
),
|
||||
outcomes=_objects(value.get("outcomes"), ProjectOutcome.from_mapping),
|
||||
benefits=_objects(value.get("benefits"), ProjectBenefit.from_mapping),
|
||||
dependencies=_objects(
|
||||
value.get("dependencies"), ProjectDependency.from_mapping
|
||||
),
|
||||
capacity_assumptions=_objects(
|
||||
value.get("capacity_assumptions"),
|
||||
ProjectCapacityAssumption.from_mapping,
|
||||
),
|
||||
change_impacts=_objects(
|
||||
value.get("change_impacts"), ProjectChangeImpact.from_mapping
|
||||
),
|
||||
benefit_reviews=_objects(
|
||||
value.get("benefit_reviews"), ProjectBenefitReview.from_mapping
|
||||
),
|
||||
resource_links=_objects(
|
||||
value.get("resource_links"), ProjectResourceLink.from_mapping
|
||||
),
|
||||
external_references=tuple(
|
||||
_external_reference(item)
|
||||
for item in _mapping_items(
|
||||
value.get("external_references"),
|
||||
"Project external references",
|
||||
)
|
||||
),
|
||||
metadata=dict(metadata),
|
||||
)
|
||||
|
||||
|
||||
def validate_state_transition(
|
||||
object_kind: str,
|
||||
current_state: str,
|
||||
next_state: str,
|
||||
) -> None:
|
||||
if next_state == current_state:
|
||||
return
|
||||
allowed = STATE_TRANSITIONS.get(object_kind, {}).get(current_state, frozenset())
|
||||
if next_state not in allowed:
|
||||
raise ProjectDomainError(
|
||||
f"Cannot move {object_kind} from {current_state!r} to {next_state!r}."
|
||||
)
|
||||
|
||||
|
||||
def _validate_parent(
|
||||
object_kind: str,
|
||||
parent_kind: str | None,
|
||||
parent_id: str | None,
|
||||
) -> None:
|
||||
if object_kind == "portfolio":
|
||||
if parent_kind is not None or parent_id is not None:
|
||||
raise ProjectDomainError("Portfolios cannot have a parent object.")
|
||||
return
|
||||
expected = "portfolio" if object_kind == "project" else "project"
|
||||
if parent_kind is None and parent_id is None and object_kind == "project":
|
||||
return
|
||||
if parent_kind != expected or not str(parent_id or "").strip():
|
||||
raise ProjectDomainError(f"A {object_kind} parent must identify a {expected}.")
|
||||
|
||||
|
||||
def _external_reference(value: Mapping[str, object]) -> ExternalObjectReference:
|
||||
payload = dict(value)
|
||||
observed_at = payload.get("observed_at")
|
||||
if observed_at is not None:
|
||||
payload["observed_at"] = _optional_datetime(observed_at)
|
||||
return ExternalObjectReference(**payload) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _objects(value: object, factory):
|
||||
return tuple(factory(item) for item in _mapping_items(value, "Project items"))
|
||||
|
||||
|
||||
def _mapping_items(
|
||||
value: object,
|
||||
label: str,
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, (list, tuple)) or any(
|
||||
not isinstance(item, Mapping) for item in value
|
||||
):
|
||||
raise ProjectDomainError(f"{label} must be a list of objects.")
|
||||
return tuple(value) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _mapping(value: Mapping[str, object], key: str) -> Mapping[str, object]:
|
||||
result = value.get(key)
|
||||
if not isinstance(result, Mapping):
|
||||
raise ProjectDomainError(f"Project {key} must be an object.")
|
||||
return result
|
||||
|
||||
|
||||
def _string_items(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, (list, tuple)):
|
||||
raise ProjectDomainError("Project values must be a list of strings.")
|
||||
return tuple(str(item) for item in value)
|
||||
|
||||
|
||||
def _bounded_strings(
|
||||
values: Sequence[str],
|
||||
label: str,
|
||||
*,
|
||||
maximum_items: int,
|
||||
maximum_length: int,
|
||||
) -> tuple[str, ...]:
|
||||
normalized = tuple(dict.fromkeys(str(item).strip() for item in values))
|
||||
if any(not item for item in normalized):
|
||||
raise ProjectDomainError(f"{label} cannot contain blank values.")
|
||||
if len(normalized) > maximum_items:
|
||||
raise ProjectDomainError(f"{label} supports at most {maximum_items} values.")
|
||||
if any(len(item) > maximum_length for item in normalized):
|
||||
raise ProjectDomainError(
|
||||
f"{label} values are limited to {maximum_length} characters."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _unique_keys(items: Sequence[object], label: str) -> None:
|
||||
keys = [str(getattr(item, "key")) for item in items]
|
||||
if len(keys) != len(set(keys)):
|
||||
raise ProjectDomainError(f"{label} require unique keys.")
|
||||
|
||||
|
||||
def _key(value: object, label: str) -> str:
|
||||
key = _required(value, label, maximum=120).casefold()
|
||||
allowed = "abcdefghijklmnopqrstuvwxyz0123456789._-"
|
||||
if any(character not in allowed for character in key):
|
||||
raise ProjectDomainError(
|
||||
f"{label} may contain only letters, digits, dot, underscore, and hyphen."
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def _required(value: object, label: str, *, maximum: int) -> str:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
raise ProjectDomainError(f"{label} is required.")
|
||||
if len(result) > maximum:
|
||||
raise ProjectDomainError(f"{label} is limited to {maximum} characters.")
|
||||
return result
|
||||
|
||||
|
||||
def _optional(value: object, label: str, *, maximum: int) -> str | None:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
return None
|
||||
if len(result) > maximum:
|
||||
raise ProjectDomainError(f"{label} is limited to {maximum} characters.")
|
||||
return result
|
||||
|
||||
|
||||
def _aware(value: datetime | None, label: str) -> None:
|
||||
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
|
||||
raise ProjectDomainError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _optional_datetime(value: object) -> datetime | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
result = value
|
||||
else:
|
||||
try:
|
||||
result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ProjectDomainError("Project timestamp is invalid.") from exc
|
||||
_aware(result, "Project timestamp")
|
||||
return result
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OBJECT_KINDS",
|
||||
"PROJECT_STATES",
|
||||
"ProjectBenefit",
|
||||
"ProjectBenefitReview",
|
||||
"ProjectCapacityAssumption",
|
||||
"ProjectChangeImpact",
|
||||
"ProjectDependency",
|
||||
"ProjectDomainError",
|
||||
"ProjectMembership",
|
||||
"ProjectObjectKind",
|
||||
"ProjectOutcome",
|
||||
"ProjectRecord",
|
||||
"ProjectResourceLink",
|
||||
"ProjectSubjectRef",
|
||||
"ProjectVisibility",
|
||||
"STATE_TRANSITIONS",
|
||||
"validate_state_transition",
|
||||
]
|
||||
@@ -1,16 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_projects.backend.acl import ProjectScopeAclProvider
|
||||
from govoplan_projects.backend.db import models as project_models
|
||||
from govoplan_projects.backend.search_source import create_projects_search_source
|
||||
from govoplan_projects.backend.service import (
|
||||
CAPABILITY_PROJECTS_REGISTRY,
|
||||
SqlProjectRegistry,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "projects"
|
||||
@@ -30,8 +60,97 @@ OPTIONAL_DEPENDENCIES = (
|
||||
"connectors",
|
||||
"search",
|
||||
"notifications",
|
||||
"reporting",
|
||||
"risk_compliance",
|
||||
)
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="domain_capability",
|
||||
kind="domain",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/PROJECTS_DOMAIN_BOUNDARY.md",
|
||||
summary=(
|
||||
"Defines portfolio, project, outcome, governance, and integration "
|
||||
"ownership."
|
||||
),
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_project_service.py",
|
||||
summary=(
|
||||
"Proves immutable revisions, OCC, idempotency, tenant isolation, "
|
||||
"restricted memberships, state transitions, and search rechecks."
|
||||
),
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"OpenProject transport and synchronization remain connector-owned provider work.",
|
||||
"Task execution remains in Tasks or Tickets; Projects links work without duplicating its lifecycle.",
|
||||
"The first WebUI edits core planning fields; advanced outcome, capacity, and benefit structures remain available through the governed API.",
|
||||
),
|
||||
supported_authority_modes=(
|
||||
"native_authoritative",
|
||||
"external_authoritative",
|
||||
"external_mirror",
|
||||
"governed_sync",
|
||||
"governance_overlay",
|
||||
"linked_reference",
|
||||
),
|
||||
owned_concepts=(
|
||||
"portfolio and project identity",
|
||||
"project goals and intended outcomes",
|
||||
"project dependencies and capacity assumptions",
|
||||
"project benefit and change-impact review",
|
||||
),
|
||||
non_owned_concepts=(
|
||||
"actionable task and ticket lifecycle",
|
||||
"risk and control lifecycle",
|
||||
"report calculation and presentation",
|
||||
"external project-system transport",
|
||||
),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
operations=("docs/PROJECTS_DOMAIN_BOUNDARY.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _router(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_projects.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _registry(context: ModuleContext) -> SqlProjectRegistry:
|
||||
del context
|
||||
return SqlProjectRegistry()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
counts = {
|
||||
kind: count
|
||||
for kind, count in (
|
||||
session.query(
|
||||
project_models.ProjectObjectRevision.object_kind,
|
||||
func.count(),
|
||||
)
|
||||
.filter(
|
||||
project_models.ProjectObjectRevision.tenant_id == tenant_id,
|
||||
project_models.ProjectObjectRevision.superseded_at.is_(None),
|
||||
)
|
||||
.group_by(project_models.ProjectObjectRevision.object_kind)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
return {
|
||||
"portfolios": int(counts.get("portfolio", 0)),
|
||||
"projects": int(counts.get("project", 0)),
|
||||
"milestones": int(counts.get("milestone", 0)),
|
||||
}
|
||||
|
||||
|
||||
def _permission(
|
||||
scope: str,
|
||||
@@ -52,9 +171,15 @@ def _permission(
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(READ_SCOPE, "View projects", "Read accessible projects and portfolios."),
|
||||
_permission(WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."),
|
||||
_permission(ADMIN_SCOPE, "Administer projects", "Configure project types and policies."),
|
||||
_permission(
|
||||
READ_SCOPE, "View projects", "Read accessible projects and portfolios."
|
||||
),
|
||||
_permission(
|
||||
WRITE_SCOPE, "Manage projects", "Create and update projects and milestones."
|
||||
),
|
||||
_permission(
|
||||
ADMIN_SCOPE, "Administer projects", "Configure project types and policies."
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -77,17 +202,19 @@ DOCUMENTATION = (
|
||||
id="projects.module-boundary",
|
||||
title="Projects module boundary",
|
||||
summary=(
|
||||
"Portfolios, projects, goals, milestones, participants, status, "
|
||||
"work structure, and project-level references."
|
||||
"Portfolios, projects, versioned goals and outcomes, milestones, "
|
||||
"dependencies, capacity, benefits, participants, status, and references."
|
||||
),
|
||||
body=(
|
||||
"Projects owns native project context. Tasks and Tickets own "
|
||||
"actionable work, Cases owns formal procedures, and Connectors "
|
||||
"owns OpenProject synchronization."
|
||||
"owns OpenProject synchronization. Reporting owns measured indicators; "
|
||||
"Risk Compliance owns risks and controls; Projects links those facts to "
|
||||
"planning, change impact, and benefit review."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin",),
|
||||
audience=("operator", "module_admin", "product_owner"),
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "product_owner"),
|
||||
related_modules=OPTIONAL_DEPENDENCIES,
|
||||
links=(
|
||||
DocumentationLink(
|
||||
@@ -102,13 +229,17 @@ DOCUMENTATION = (
|
||||
"portfolio",
|
||||
"project",
|
||||
"milestone",
|
||||
"versioned goal and intended outcome",
|
||||
"dependency and capacity assumption",
|
||||
"benefit review",
|
||||
"project participant",
|
||||
"project resource link",
|
||||
"external project reference",
|
||||
],
|
||||
"first_slice": (
|
||||
"Implement project identity, status, milestones, participants, "
|
||||
"resource links, and OpenProject reference mapping."
|
||||
"Implement project and portfolio identity, status, milestones, "
|
||||
"participants, outcome/benefit intent, dependency/resource links, "
|
||||
"and OpenProject reference mapping."
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -126,7 +257,123 @@ manifest = ModuleManifest(
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_router,
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/projects",
|
||||
label="Projects",
|
||||
icon="folder-kanban",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/projects-webui",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/projects",
|
||||
component="ProjectsPage",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/projects",
|
||||
label="Projects",
|
||||
icon="folder-kanban",
|
||||
required_any=(READ_SCOPE,),
|
||||
order=36,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="projects.navigation",
|
||||
module_id=MODULE_ID,
|
||||
kind="navigation",
|
||||
label="Projects navigation",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="projects.workspace",
|
||||
module_id=MODULE_ID,
|
||||
kind="route",
|
||||
label="Projects workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="projects.portfolios",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Portfolio planning",
|
||||
parent_id="projects.workspace",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="projects.outcomes",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Outcomes and benefits",
|
||||
parent_id="projects.workspace",
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="projects.registry", version="0.1.0"),
|
||||
),
|
||||
capability_factories={CAPABILITY_PROJECTS_REGISTRY: _registry},
|
||||
capability_documentation={
|
||||
CAPABILITY_PROJECTS_REGISTRY: CapabilityDocumentation(
|
||||
label="Projects registry",
|
||||
summary=(
|
||||
"Persists portfolios, projects, milestones, planning intent, "
|
||||
"membership grants, revisions, and lifecycle events."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="projects.objects",
|
||||
factory=create_projects_search_source,
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id=MODULE_ID,
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
project_models.ProjectObjectEvent,
|
||||
project_models.ProjectMembershipGrant,
|
||||
project_models.ProjectObjectRevision,
|
||||
project_models.ProjectObjectIdentity,
|
||||
label="Projects",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement requires a database snapshot and removes "
|
||||
"Projects identities, immutable revisions, memberships, and events."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
project_models.ProjectObjectIdentity,
|
||||
project_models.ProjectObjectRevision,
|
||||
project_models.ProjectMembershipGrant,
|
||||
project_models.ProjectObjectEvent,
|
||||
label="Projects",
|
||||
),
|
||||
),
|
||||
resource_acl_providers=(
|
||||
ProjectScopeAclProvider("portfolio"),
|
||||
ProjectScopeAclProvider("project"),
|
||||
ProjectScopeAclProvider("milestone"),
|
||||
),
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
documentation=DOCUMENTATION,
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Projects Alembic revisions."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Projects migration versions."""
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
"""v0.1.14 Projects persistent portfolio baseline.
|
||||
|
||||
Revision ID: c4a1e8f2d6b9
|
||||
Revises: None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c4a1e8f2d6b9"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"project_object_identities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("object_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("object_key", sa.String(length=120), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_project_object_identities")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
name="uq_project_object_identity",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_key",
|
||||
name="uq_project_object_key",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"object_key",
|
||||
"created_by",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_project_object_identities_{column}"),
|
||||
"project_object_identities",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_object_identity_catalog",
|
||||
"project_object_identities",
|
||||
["tenant_id", "object_kind", "object_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"project_object_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("object_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("parent_kind", sa.String(length=30), nullable=True),
|
||||
sa.Column("parent_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("state", sa.String(length=30), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=False),
|
||||
sa.Column("visibility", sa.String(length=30), nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("search_text", sa.Text(), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("changed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["project_object_identities.id"],
|
||||
name=op.f(
|
||||
"fk_project_object_revisions_identity_id_project_object_identities"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["previous_revision_id"],
|
||||
["project_object_revisions.id"],
|
||||
name=op.f(
|
||||
"fk_project_object_revisions_previous_revision_id_project_object_revisions"
|
||||
),
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_project_object_revisions")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"revision",
|
||||
name="uq_project_object_revision",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"identity_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"previous_revision_id",
|
||||
"parent_kind",
|
||||
"parent_id",
|
||||
"state",
|
||||
"visibility",
|
||||
"starts_at",
|
||||
"due_at",
|
||||
"recorded_at",
|
||||
"superseded_at",
|
||||
"changed_by",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_project_object_revisions_{column}"),
|
||||
"project_object_revisions",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_object_current",
|
||||
"project_object_revisions",
|
||||
["tenant_id", "object_kind", "object_id", "superseded_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_object_parent",
|
||||
"project_object_revisions",
|
||||
["tenant_id", "parent_kind", "parent_id", "object_kind", "state"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_object_catalog",
|
||||
"project_object_revisions",
|
||||
["tenant_id", "object_kind", "state", "recorded_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"project_membership_grants",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("object_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("subject_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("subject_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=80), nullable=False),
|
||||
sa.Column("permissions", sa.JSON(), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("source_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_project_membership_grants")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
name="uq_project_membership_subject",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"active",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_project_membership_grants_{column}"),
|
||||
"project_membership_grants",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_membership_lookup",
|
||||
"project_membership_grants",
|
||||
["tenant_id", "subject_kind", "subject_id", "active"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_membership_object",
|
||||
"project_membership_grants",
|
||||
["tenant_id", "object_kind", "object_id", "active"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"project_object_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("object_kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("object_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("object_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=120), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("request_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_project_object_events")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"event_id",
|
||||
name="uq_project_object_event",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_project_object_idempotency",
|
||||
),
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"object_kind",
|
||||
"object_id",
|
||||
"event_id",
|
||||
"event_type",
|
||||
"occurred_at",
|
||||
"actor_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_project_object_events_{column}"),
|
||||
"project_object_events",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_project_object_event_history",
|
||||
"project_object_events",
|
||||
["tenant_id", "object_kind", "object_id", "occurred_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("project_object_events")
|
||||
op.drop_table("project_membership_grants")
|
||||
op.drop_table("project_object_revisions")
|
||||
op.drop_table("project_object_identities")
|
||||
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_projects.backend.domain import ProjectDomainError, ProjectRecord
|
||||
from govoplan_projects.backend.manifest import (
|
||||
READ_SCOPE,
|
||||
WRITE_SCOPE,
|
||||
)
|
||||
from govoplan_projects.backend.schemas import (
|
||||
ProjectObjectEventsResponse,
|
||||
ProjectObjectHistoryResponse,
|
||||
ProjectObjectListResponse,
|
||||
ProjectObjectUpdateRequest,
|
||||
ProjectObjectWriteRequest,
|
||||
)
|
||||
from govoplan_projects.backend.service import (
|
||||
ProjectStoreError,
|
||||
create_project_object,
|
||||
get_project_object,
|
||||
list_project_objects,
|
||||
project_object_events,
|
||||
project_object_history,
|
||||
update_project_object,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _error(exc: Exception) -> HTTPException:
|
||||
message = str(exc)
|
||||
lowered = message.casefold()
|
||||
if isinstance(exc, LookupError):
|
||||
code = 404
|
||||
elif isinstance(exc, PermissionError):
|
||||
code = 403
|
||||
elif any(word in lowered for word in ("conflict", "already", "stale")):
|
||||
code = 409
|
||||
else:
|
||||
code = 400
|
||||
return HTTPException(status_code=code, detail=message)
|
||||
|
||||
|
||||
@router.get("/objects", response_model=ProjectObjectListResponse)
|
||||
def api_list_project_objects(
|
||||
object_kind: list[str] | None = Query(default=None),
|
||||
state: list[str] | None = Query(default=None),
|
||||
parent_kind: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
query: str = "",
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ProjectObjectListResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
items, total = list_project_objects(
|
||||
session,
|
||||
principal,
|
||||
object_kinds=object_kind,
|
||||
states=state,
|
||||
parent_kind=parent_kind,
|
||||
parent_id=parent_id,
|
||||
query=query,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
except (ProjectStoreError, ProjectDomainError) as exc:
|
||||
raise _error(exc) from exc
|
||||
return ProjectObjectListResponse(
|
||||
objects=[item.to_dict() for item in items],
|
||||
total=total,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/objects",
|
||||
response_model=dict,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_create_project_object(
|
||||
payload: ProjectObjectWriteRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
try:
|
||||
item = create_project_object(
|
||||
session,
|
||||
principal,
|
||||
record=ProjectRecord.from_mapping(payload.record),
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except (ProjectStoreError, ProjectDomainError, PermissionError) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get("/objects/{object_kind}/{object_id}", response_model=dict)
|
||||
def api_get_project_object(
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
item = get_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
revision=revision,
|
||||
)
|
||||
except (ProjectStoreError, ProjectDomainError) as exc:
|
||||
raise _error(exc) from exc
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Project object not found")
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.patch("/objects/{object_kind}/{object_id}", response_model=dict)
|
||||
def api_update_project_object(
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
payload: ProjectObjectUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict:
|
||||
_require(principal, WRITE_SCOPE)
|
||||
try:
|
||||
item = update_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
changes=payload.changes,
|
||||
recorded_at=payload.recorded_at,
|
||||
change_reason=payload.change_reason,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
)
|
||||
session.commit()
|
||||
except (
|
||||
ProjectStoreError,
|
||||
ProjectDomainError,
|
||||
PermissionError,
|
||||
LookupError,
|
||||
) as exc:
|
||||
session.rollback()
|
||||
raise _error(exc) from exc
|
||||
return item.to_dict()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/objects/{object_kind}/{object_id}/history",
|
||||
response_model=ProjectObjectHistoryResponse,
|
||||
)
|
||||
def api_project_object_history(
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ProjectObjectHistoryResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
revisions = project_object_history(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
limit=limit,
|
||||
)
|
||||
if not revisions:
|
||||
raise HTTPException(status_code=404, detail="Project object not found")
|
||||
return ProjectObjectHistoryResponse(
|
||||
revisions=[item.to_dict() for item in revisions]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/objects/{object_kind}/{object_id}/events",
|
||||
response_model=ProjectObjectEventsResponse,
|
||||
)
|
||||
def api_project_object_events(
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> ProjectObjectEventsResponse:
|
||||
_require(principal, READ_SCOPE)
|
||||
events = project_object_events(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
limit=limit,
|
||||
)
|
||||
if not events:
|
||||
item = get_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Project object not found")
|
||||
return ProjectObjectEventsResponse(events=list(events))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProjectObjectWriteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
record: dict[str, Any]
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class ProjectObjectUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
recorded_at: datetime
|
||||
change_reason: str = Field(min_length=1, max_length=1_000)
|
||||
idempotency_key: str = Field(min_length=1, max_length=255)
|
||||
changes: dict[str, Any]
|
||||
|
||||
|
||||
class ProjectObjectListResponse(BaseModel):
|
||||
objects: list[dict[str, Any]]
|
||||
total: int
|
||||
offset: int
|
||||
limit: int
|
||||
|
||||
|
||||
class ProjectObjectHistoryResponse(BaseModel):
|
||||
revisions: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ProjectObjectEventsResponse(BaseModel):
|
||||
events: list[dict[str, Any]]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProjectObjectEventsResponse",
|
||||
"ProjectObjectHistoryResponse",
|
||||
"ProjectObjectListResponse",
|
||||
"ProjectObjectUpdateRequest",
|
||||
"ProjectObjectWriteRequest",
|
||||
]
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_projects.backend.db.models import (
|
||||
ProjectMembershipGrant,
|
||||
ProjectObjectIdentity,
|
||||
ProjectObjectRevision,
|
||||
)
|
||||
from govoplan_projects.backend.service import can_read_project_object
|
||||
|
||||
|
||||
PROVIDER_ID = "projects.objects"
|
||||
RESOURCE_TYPES: Mapping[str, str] = {
|
||||
"project_portfolio": "portfolio",
|
||||
"project": "project",
|
||||
"project_milestone": "milestone",
|
||||
}
|
||||
|
||||
|
||||
class ProjectsSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
labels = {
|
||||
"project_portfolio": "Project portfolios",
|
||||
"project": "Projects",
|
||||
"project_milestone": "Project milestones",
|
||||
}
|
||||
return tuple(
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="projects",
|
||||
resource_type=resource_type,
|
||||
label=labels[resource_type],
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
for resource_type in RESOURCE_TYPES
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
if request.provider_id != PROVIDER_ID:
|
||||
raise ValueError("Unsupported Projects search provider.")
|
||||
object_kind = RESOURCE_TYPES.get(request.resource_type)
|
||||
if object_kind is None:
|
||||
raise ValueError("Unsupported Projects search resource type.")
|
||||
db = _session(session)
|
||||
query = db.query(ProjectObjectRevision).filter(
|
||||
ProjectObjectRevision.tenant_id == request.tenant_id,
|
||||
ProjectObjectRevision.object_kind == object_kind,
|
||||
ProjectObjectRevision.superseded_at.is_(None),
|
||||
)
|
||||
if request.cursor:
|
||||
query = query.filter(ProjectObjectRevision.id > request.cursor)
|
||||
rows = (
|
||||
query.order_by(ProjectObjectRevision.id.asc())
|
||||
.limit(request.limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
acl_tokens = _acl_tokens(db, selected)
|
||||
high_watermark = (
|
||||
db.query(func.max(ProjectObjectRevision.updated_at))
|
||||
.filter(
|
||||
ProjectObjectRevision.tenant_id == request.tenant_id,
|
||||
ProjectObjectRevision.object_kind == object_kind,
|
||||
ProjectObjectRevision.superseded_at.is_(None),
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(
|
||||
_document(
|
||||
row,
|
||||
resource_type=request.resource_type,
|
||||
acl_tokens=acl_tokens[(row.object_kind, row.object_id)],
|
||||
)
|
||||
for row in selected
|
||||
),
|
||||
next_cursor=selected[-1].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=(
|
||||
high_watermark.isoformat() if high_watermark is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {request.reference.key: False for request in requests}
|
||||
db = _session(session)
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||
for request in requests:
|
||||
reference = request.reference
|
||||
object_kind = RESOURCE_TYPES.get(reference.resource_type)
|
||||
if (
|
||||
reference.tenant_id != tenant_id
|
||||
or reference.module_id != "projects"
|
||||
or object_kind is None
|
||||
):
|
||||
continue
|
||||
decisions[reference.key] = can_read_project_object(
|
||||
db,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=reference.resource_id,
|
||||
)
|
||||
return decisions
|
||||
|
||||
|
||||
def create_projects_search_source(
|
||||
context: ModuleContext,
|
||||
) -> ProjectsSearchSource:
|
||||
del context
|
||||
return ProjectsSearchSource()
|
||||
|
||||
|
||||
def _document(
|
||||
row: ProjectObjectRevision,
|
||||
*,
|
||||
resource_type: str,
|
||||
acl_tokens: tuple[str, ...],
|
||||
) -> SearchDocument:
|
||||
payload = dict(row.payload or {})
|
||||
description = str(payload.get("description") or "").strip() or None
|
||||
keywords = [row.object_kind, row.state]
|
||||
keywords.extend(
|
||||
str(item.get("title") or "")
|
||||
for item in payload.get("outcomes", ())
|
||||
if isinstance(item, Mapping)
|
||||
)
|
||||
updated_at = row.updated_at or row.recorded_at
|
||||
restricted_tokens = tuple(
|
||||
dict.fromkeys((*acl_tokens, "scope:projects:project:admin"))
|
||||
)
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="projects",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=resource_type,
|
||||
resource_id=row.object_id,
|
||||
title=row.title,
|
||||
url=(
|
||||
f"/projects?kind={quote(row.object_kind, safe='')}"
|
||||
f"&objectId={quote(row.object_id, safe='')}"
|
||||
),
|
||||
summary=description,
|
||||
body=row.search_text,
|
||||
keywords=tuple(item for item in keywords if item),
|
||||
visibility=row.visibility,
|
||||
acl_tokens=restricted_tokens if row.visibility == "restricted" else (),
|
||||
metadata={
|
||||
"object_kind": row.object_kind,
|
||||
"state": row.state,
|
||||
"parent_kind": row.parent_kind,
|
||||
"parent_id": row.parent_id,
|
||||
"due_at": row.due_at.isoformat() if row.due_at else None,
|
||||
},
|
||||
source_revision=str(row.revision),
|
||||
source_updated_at=updated_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _acl_tokens(
|
||||
session: Session,
|
||||
rows: Sequence[ProjectObjectRevision],
|
||||
) -> Mapping[tuple[str, str], tuple[str, ...]]:
|
||||
result: dict[tuple[str, str], list[str]] = defaultdict(list)
|
||||
if not rows:
|
||||
return result
|
||||
identity_ids = {row.identity_id for row in rows}
|
||||
identities = (
|
||||
session.query(ProjectObjectIdentity)
|
||||
.filter(ProjectObjectIdentity.id.in_(identity_ids))
|
||||
.all()
|
||||
)
|
||||
for identity in identities:
|
||||
if identity.created_by:
|
||||
result[(identity.object_kind, identity.object_id)].append(
|
||||
f"account:{identity.created_by}"
|
||||
)
|
||||
object_keys = {(row.object_kind, row.object_id) for row in rows}
|
||||
grants = (
|
||||
session.query(ProjectMembershipGrant)
|
||||
.filter(
|
||||
ProjectMembershipGrant.tenant_id == rows[0].tenant_id,
|
||||
ProjectMembershipGrant.active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
token_prefix = {
|
||||
"function_assignment": "function",
|
||||
}
|
||||
for grant in grants:
|
||||
key = (grant.object_kind, grant.object_id)
|
||||
if key not in object_keys:
|
||||
continue
|
||||
prefix = token_prefix.get(grant.subject_kind, grant.subject_kind)
|
||||
result[key].append(f"{prefix}:{grant.subject_id}")
|
||||
return {key: tuple(dict.fromkeys(tokens)) for key, tokens in result.items()}
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Projects search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPES",
|
||||
"ProjectsSearchSource",
|
||||
"create_projects_search_source",
|
||||
]
|
||||
@@ -0,0 +1,977 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import and_, exists, func, or_
|
||||
from sqlalchemy.orm import Query, Session
|
||||
|
||||
from govoplan_core.core.events import (
|
||||
EventActorRef,
|
||||
EventObjectRef,
|
||||
EventTenantRef,
|
||||
PlatformEvent,
|
||||
emit_platform_event,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_projects.backend.db.models import (
|
||||
ProjectMembershipGrant,
|
||||
ProjectObjectEvent,
|
||||
ProjectObjectIdentity,
|
||||
ProjectObjectRevision,
|
||||
)
|
||||
from govoplan_projects.backend.domain import (
|
||||
OBJECT_KINDS,
|
||||
ProjectDomainError,
|
||||
ProjectRecord,
|
||||
validate_state_transition,
|
||||
)
|
||||
|
||||
|
||||
CAPABILITY_PROJECTS_REGISTRY = "projects.registry"
|
||||
READ_SCOPE = "projects:project:read"
|
||||
WRITE_SCOPE = "projects:project:write"
|
||||
ADMIN_SCOPE = "projects:project:admin"
|
||||
|
||||
|
||||
class ProjectStoreError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def create_project_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
record: ProjectRecord,
|
||||
idempotency_key: str,
|
||||
) -> ProjectRecord:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
if record.tenant_id != tenant_id:
|
||||
raise ProjectStoreError("Project objects cannot cross tenants.")
|
||||
if record.revision != 1:
|
||||
raise ProjectStoreError("A new project object must start at revision 1.")
|
||||
request_sha256 = _request_sha256(record.to_dict())
|
||||
replay = _replay(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
_validate_parent_reference(session, principal, record)
|
||||
identity = _identity(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
)
|
||||
if identity is not None:
|
||||
raise ProjectStoreError("A project object with this identifier exists.")
|
||||
duplicate_key = (
|
||||
session.query(ProjectObjectIdentity.id)
|
||||
.filter(
|
||||
ProjectObjectIdentity.tenant_id == tenant_id,
|
||||
ProjectObjectIdentity.object_kind == record.object_kind,
|
||||
ProjectObjectIdentity.object_key == record.object_key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if duplicate_key is not None:
|
||||
raise ProjectStoreError("A project object with this key exists.")
|
||||
identity = ProjectObjectIdentity(
|
||||
tenant_id=tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
object_key=record.object_key,
|
||||
created_by=_principal_actor(principal),
|
||||
)
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
return _record_revision(
|
||||
session,
|
||||
principal,
|
||||
identity=identity,
|
||||
record=record,
|
||||
current=None,
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
operation="created",
|
||||
)
|
||||
|
||||
|
||||
def update_project_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
expected_revision: int,
|
||||
changes: Mapping[str, object],
|
||||
recorded_at: datetime,
|
||||
change_reason: str,
|
||||
idempotency_key: str,
|
||||
) -> ProjectRecord:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
_object_kind(object_kind)
|
||||
current_row = _current_row(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
lock=True,
|
||||
)
|
||||
if current_row is None:
|
||||
raise LookupError("Project object not found.")
|
||||
if not can_write_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
):
|
||||
raise PermissionError("Project object write access is denied.")
|
||||
current = _record_from_row(current_row)
|
||||
normalized = _normalized_changes(changes)
|
||||
_require_aware(recorded_at, "Project recorded_at")
|
||||
clean_reason = _required_text(
|
||||
change_reason,
|
||||
"Project change reason",
|
||||
maximum=1_000,
|
||||
)
|
||||
request_sha256 = _request_sha256(
|
||||
{
|
||||
"object_kind": object_kind,
|
||||
"object_id": object_id,
|
||||
"expected_revision": expected_revision,
|
||||
"changes": normalized,
|
||||
"recorded_at": recorded_at,
|
||||
"change_reason": clean_reason,
|
||||
}
|
||||
)
|
||||
replay = _replay(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
if current.revision != expected_revision:
|
||||
raise ProjectStoreError(
|
||||
"Project revision conflict: the expected revision is stale."
|
||||
)
|
||||
next_payload = current.to_dict()
|
||||
next_payload.update(normalized)
|
||||
next_payload.update(
|
||||
{
|
||||
"revision": current.revision + 1,
|
||||
"recorded_at": recorded_at.isoformat(),
|
||||
"change_reason": clean_reason,
|
||||
}
|
||||
)
|
||||
next_record = ProjectRecord.from_mapping(next_payload)
|
||||
validate_state_transition(
|
||||
current.object_kind,
|
||||
current.state,
|
||||
next_record.state,
|
||||
)
|
||||
_validate_parent_reference(session, principal, next_record)
|
||||
identity = _identity(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
)
|
||||
if identity is None:
|
||||
raise ProjectStoreError("Project object identity is missing.")
|
||||
return _record_revision(
|
||||
session,
|
||||
principal,
|
||||
identity=identity,
|
||||
record=next_record,
|
||||
current=current_row,
|
||||
idempotency_key=idempotency_key,
|
||||
request_sha256=request_sha256,
|
||||
operation=(
|
||||
"state_changed" if current.state != next_record.state else "updated"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_project_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
revision: int | None = None,
|
||||
) -> ProjectRecord | None:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
_object_kind(object_kind)
|
||||
if not can_read_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
):
|
||||
return None
|
||||
query = session.query(ProjectObjectRevision).filter(
|
||||
ProjectObjectRevision.tenant_id == tenant_id,
|
||||
ProjectObjectRevision.object_kind == object_kind,
|
||||
ProjectObjectRevision.object_id == object_id,
|
||||
)
|
||||
if revision is None:
|
||||
query = query.filter(ProjectObjectRevision.superseded_at.is_(None))
|
||||
else:
|
||||
query = query.filter(ProjectObjectRevision.revision == revision)
|
||||
row = query.order_by(ProjectObjectRevision.revision.desc()).first()
|
||||
return _record_from_row(row) if row is not None else None
|
||||
|
||||
|
||||
def list_project_objects(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kinds: Sequence[str] | None = None,
|
||||
states: Sequence[str] | None = None,
|
||||
parent_kind: str | None = None,
|
||||
parent_id: str | None = None,
|
||||
query: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> tuple[tuple[ProjectRecord, ...], int]:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
if offset < 0 or not 1 <= limit <= 200:
|
||||
raise ProjectStoreError(
|
||||
"Project list offset must be non-negative and limit between 1 and 200."
|
||||
)
|
||||
kinds = tuple(dict.fromkeys(object_kinds or tuple(OBJECT_KINDS)))
|
||||
if any(item not in OBJECT_KINDS for item in kinds):
|
||||
raise ProjectStoreError("Project list contains an unsupported object kind.")
|
||||
statement = session.query(ProjectObjectRevision).filter(
|
||||
ProjectObjectRevision.tenant_id == tenant_id,
|
||||
ProjectObjectRevision.superseded_at.is_(None),
|
||||
ProjectObjectRevision.object_kind.in_(kinds),
|
||||
)
|
||||
statement = _filter_accessible(statement, principal)
|
||||
if states:
|
||||
statement = statement.filter(
|
||||
ProjectObjectRevision.state.in_(tuple(dict.fromkeys(states)))
|
||||
)
|
||||
if parent_kind is not None or parent_id is not None:
|
||||
if parent_kind is None or parent_id is None:
|
||||
raise ProjectStoreError(
|
||||
"Project parent filters require both parent_kind and parent_id."
|
||||
)
|
||||
_object_kind(parent_kind)
|
||||
statement = statement.filter(
|
||||
ProjectObjectRevision.parent_kind == parent_kind,
|
||||
ProjectObjectRevision.parent_id == parent_id,
|
||||
)
|
||||
clean_query = query.strip().casefold()
|
||||
if clean_query:
|
||||
statement = statement.filter(
|
||||
ProjectObjectRevision.search_text.contains(clean_query)
|
||||
)
|
||||
total = int(statement.with_entities(func.count()).scalar() or 0)
|
||||
rows = (
|
||||
statement.order_by(
|
||||
ProjectObjectRevision.due_at.asc().nullslast(),
|
||||
ProjectObjectRevision.title.asc(),
|
||||
ProjectObjectRevision.object_id.asc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return tuple(_record_from_row(row) for row in rows), total
|
||||
|
||||
|
||||
def project_object_history(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
limit: int = 100,
|
||||
) -> tuple[ProjectRecord, ...]:
|
||||
if not 1 <= limit <= 200:
|
||||
raise ProjectStoreError("Project history limit must be between 1 and 200.")
|
||||
if not can_read_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
):
|
||||
return ()
|
||||
rows = (
|
||||
session.query(ProjectObjectRevision)
|
||||
.filter(
|
||||
ProjectObjectRevision.tenant_id == _principal_tenant(principal),
|
||||
ProjectObjectRevision.object_kind == object_kind,
|
||||
ProjectObjectRevision.object_id == object_id,
|
||||
)
|
||||
.order_by(ProjectObjectRevision.revision.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return tuple(_record_from_row(row) for row in rows)
|
||||
|
||||
|
||||
def project_object_events(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
limit: int = 200,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
if not 1 <= limit <= 500:
|
||||
raise ProjectStoreError("Project event limit must be between 1 and 500.")
|
||||
if not can_read_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
):
|
||||
return ()
|
||||
rows = (
|
||||
session.query(ProjectObjectEvent)
|
||||
.filter(
|
||||
ProjectObjectEvent.tenant_id == _principal_tenant(principal),
|
||||
ProjectObjectEvent.object_kind == object_kind,
|
||||
ProjectObjectEvent.object_id == object_id,
|
||||
)
|
||||
.order_by(
|
||||
ProjectObjectEvent.occurred_at.desc(),
|
||||
ProjectObjectEvent.id.desc(),
|
||||
)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return tuple(
|
||||
{
|
||||
"event_id": row.event_id,
|
||||
"event_type": row.event_type,
|
||||
"object_revision": row.object_revision,
|
||||
"occurred_at": _datetime_text(row.occurred_at),
|
||||
"actor_id": row.actor_id,
|
||||
"payload": dict(row.payload or {}),
|
||||
}
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def can_read_project_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
) -> bool:
|
||||
if not _has_scope(principal, READ_SCOPE):
|
||||
return False
|
||||
return _object_access(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
permission="read",
|
||||
)
|
||||
|
||||
|
||||
def can_write_project_object(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
) -> bool:
|
||||
if not _has_scope(principal, WRITE_SCOPE):
|
||||
return False
|
||||
return _object_access(
|
||||
session,
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
permission="write",
|
||||
)
|
||||
|
||||
|
||||
class SqlProjectRegistry:
|
||||
def create(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
record: ProjectRecord,
|
||||
idempotency_key: str,
|
||||
) -> ProjectRecord:
|
||||
return create_project_object(
|
||||
_session(session),
|
||||
principal,
|
||||
record=record,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
def get(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
revision: int | None = None,
|
||||
) -> ProjectRecord | None:
|
||||
return get_project_object(
|
||||
_session(session),
|
||||
principal,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
def list(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
object_kinds: Sequence[str] | None = None,
|
||||
limit: int = 100,
|
||||
) -> Sequence[ProjectRecord]:
|
||||
records, _total = list_project_objects(
|
||||
_session(session),
|
||||
principal,
|
||||
object_kinds=object_kinds,
|
||||
limit=limit,
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def _record_revision(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
identity: ProjectObjectIdentity,
|
||||
record: ProjectRecord,
|
||||
current: ProjectObjectRevision | None,
|
||||
idempotency_key: str,
|
||||
request_sha256: str,
|
||||
operation: str,
|
||||
) -> ProjectRecord:
|
||||
clean_key = _required_text(
|
||||
idempotency_key,
|
||||
"Project idempotency key",
|
||||
maximum=255,
|
||||
)
|
||||
if current is not None:
|
||||
current.superseded_at = record.recorded_at
|
||||
payload = record.to_dict()
|
||||
row = ProjectObjectRevision(
|
||||
tenant_id=identity.tenant_id,
|
||||
identity_id=identity.id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
revision=record.revision,
|
||||
previous_revision_id=current.id if current is not None else None,
|
||||
parent_kind=record.parent_kind,
|
||||
parent_id=record.parent_id,
|
||||
state=record.state,
|
||||
title=record.title,
|
||||
visibility=record.visibility,
|
||||
starts_at=record.starts_at,
|
||||
due_at=record.due_at,
|
||||
recorded_at=record.recorded_at,
|
||||
search_text=_search_text(record),
|
||||
payload=payload,
|
||||
changed_by=_principal_actor(principal),
|
||||
)
|
||||
event_id = str(uuid.uuid4())
|
||||
event_type = f"projects.{record.object_kind}.{operation}"
|
||||
event_payload = {
|
||||
"object_key": record.object_key,
|
||||
"object_kind": record.object_kind,
|
||||
"state": record.state,
|
||||
"revision": record.revision,
|
||||
"change_reason": record.change_reason,
|
||||
}
|
||||
event = ProjectObjectEvent(
|
||||
tenant_id=identity.tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
object_revision=record.revision,
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
occurred_at=record.recorded_at,
|
||||
actor_id=_principal_actor(principal),
|
||||
idempotency_key=clean_key,
|
||||
request_sha256=request_sha256,
|
||||
payload=event_payload,
|
||||
)
|
||||
session.add_all((row, event))
|
||||
session.flush()
|
||||
_sync_memberships(session, record)
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
event_id=event_id,
|
||||
type=event_type,
|
||||
module_id="projects",
|
||||
payload=event_payload,
|
||||
occurred_at=record.recorded_at,
|
||||
actor=EventActorRef(type="account", id=_principal_actor(principal)),
|
||||
tenant=EventTenantRef(id=identity.tenant_id),
|
||||
resource=EventObjectRef(
|
||||
type=record.object_kind,
|
||||
id=record.object_id,
|
||||
label=record.title,
|
||||
),
|
||||
classification=(
|
||||
"restricted" if record.visibility == "restricted" else "internal"
|
||||
),
|
||||
),
|
||||
)
|
||||
return _record_from_row(row)
|
||||
|
||||
|
||||
def _sync_memberships(session: Session, record: ProjectRecord) -> None:
|
||||
rows = (
|
||||
session.query(ProjectMembershipGrant)
|
||||
.filter(
|
||||
ProjectMembershipGrant.tenant_id == record.tenant_id,
|
||||
ProjectMembershipGrant.object_kind == record.object_kind,
|
||||
ProjectMembershipGrant.object_id == record.object_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
existing = {(row.subject_kind, row.subject_id): row for row in rows}
|
||||
desired: dict[tuple[str, str], tuple[str, list[str]]] = {}
|
||||
if record.owner is not None:
|
||||
desired[(record.owner.kind, record.owner.id)] = (
|
||||
"owner",
|
||||
["read", "write", "admin"],
|
||||
)
|
||||
for membership in record.memberships:
|
||||
key = (membership.subject.kind, membership.subject.id)
|
||||
desired[key] = (membership.role, list(membership.permissions))
|
||||
for key, row in existing.items():
|
||||
if key not in desired:
|
||||
row.active = False
|
||||
row.source_revision = record.revision
|
||||
for key, (role, permissions) in desired.items():
|
||||
row = existing.get(key)
|
||||
if row is None:
|
||||
row = ProjectMembershipGrant(
|
||||
tenant_id=record.tenant_id,
|
||||
object_kind=record.object_kind,
|
||||
object_id=record.object_id,
|
||||
subject_kind=key[0],
|
||||
subject_id=key[1],
|
||||
role=role,
|
||||
permissions=permissions,
|
||||
active=True,
|
||||
source_revision=record.revision,
|
||||
)
|
||||
session.add(row)
|
||||
else:
|
||||
row.role = role
|
||||
row.permissions = permissions
|
||||
row.active = True
|
||||
row.source_revision = record.revision
|
||||
session.flush()
|
||||
|
||||
|
||||
def _replay(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
idempotency_key: str,
|
||||
request_sha256: str,
|
||||
) -> ProjectRecord | None:
|
||||
clean_key = _required_text(
|
||||
idempotency_key,
|
||||
"Project idempotency key",
|
||||
maximum=255,
|
||||
)
|
||||
row = (
|
||||
session.query(ProjectObjectEvent)
|
||||
.filter(
|
||||
ProjectObjectEvent.tenant_id == tenant_id,
|
||||
ProjectObjectEvent.idempotency_key == clean_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
if row.request_sha256 != request_sha256:
|
||||
raise ProjectStoreError(
|
||||
"Project idempotency conflict: the key belongs to another request."
|
||||
)
|
||||
revision = (
|
||||
session.query(ProjectObjectRevision)
|
||||
.filter(
|
||||
ProjectObjectRevision.tenant_id == tenant_id,
|
||||
ProjectObjectRevision.object_kind == row.object_kind,
|
||||
ProjectObjectRevision.object_id == row.object_id,
|
||||
ProjectObjectRevision.revision == row.object_revision,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
return _record_from_row(revision)
|
||||
|
||||
|
||||
def _validate_parent_reference(
|
||||
session: Session,
|
||||
principal: object,
|
||||
record: ProjectRecord,
|
||||
) -> None:
|
||||
if record.parent_kind is None or record.parent_id is None:
|
||||
return
|
||||
parent = _current_row(
|
||||
session,
|
||||
tenant_id=record.tenant_id,
|
||||
object_kind=record.parent_kind,
|
||||
object_id=record.parent_id,
|
||||
lock=False,
|
||||
)
|
||||
if parent is None:
|
||||
raise ProjectStoreError("Project parent object does not exist.")
|
||||
if not can_read_project_object(
|
||||
session,
|
||||
principal,
|
||||
object_kind=record.parent_kind,
|
||||
object_id=record.parent_id,
|
||||
):
|
||||
raise PermissionError("Project parent object access is denied.")
|
||||
|
||||
|
||||
def _object_access(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
permission: str,
|
||||
) -> bool:
|
||||
tenant_id = _principal_tenant(principal)
|
||||
current = _current_row(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
lock=False,
|
||||
)
|
||||
if current is None:
|
||||
return False
|
||||
if _has_scope(principal, ADMIN_SCOPE):
|
||||
return True
|
||||
identity = _identity(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
object_kind=object_kind,
|
||||
object_id=object_id,
|
||||
)
|
||||
actors = _principal_actor_ids(principal)
|
||||
if identity is not None and identity.created_by in actors:
|
||||
return True
|
||||
if permission == "read" and current.visibility == "tenant":
|
||||
return True
|
||||
subjects = _principal_subjects(principal)
|
||||
if not subjects:
|
||||
return False
|
||||
clauses = [
|
||||
and_(
|
||||
ProjectMembershipGrant.subject_kind == kind,
|
||||
ProjectMembershipGrant.subject_id == subject_id,
|
||||
)
|
||||
for kind, subject_id in subjects
|
||||
]
|
||||
rows = (
|
||||
session.query(ProjectMembershipGrant)
|
||||
.filter(
|
||||
ProjectMembershipGrant.tenant_id == tenant_id,
|
||||
ProjectMembershipGrant.object_kind == object_kind,
|
||||
ProjectMembershipGrant.object_id == object_id,
|
||||
ProjectMembershipGrant.active.is_(True),
|
||||
or_(*clauses),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return any(
|
||||
permission == "read"
|
||||
or permission in set(row.permissions or ())
|
||||
or "admin" in set(row.permissions or ())
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def _filter_accessible(query: Query, principal: object) -> Query:
|
||||
if _has_scope(principal, ADMIN_SCOPE):
|
||||
return query
|
||||
actor_ids = _principal_actor_ids(principal)
|
||||
subjects = _principal_subjects(principal)
|
||||
conditions = [ProjectObjectRevision.visibility == "tenant"]
|
||||
if actor_ids:
|
||||
conditions.append(
|
||||
exists()
|
||||
.where(ProjectObjectIdentity.id == ProjectObjectRevision.identity_id)
|
||||
.where(ProjectObjectIdentity.created_by.in_(actor_ids))
|
||||
)
|
||||
if subjects:
|
||||
subject_condition = or_(
|
||||
*(
|
||||
and_(
|
||||
ProjectMembershipGrant.subject_kind == kind,
|
||||
ProjectMembershipGrant.subject_id == subject_id,
|
||||
)
|
||||
for kind, subject_id in subjects
|
||||
)
|
||||
)
|
||||
conditions.append(
|
||||
exists()
|
||||
.where(ProjectMembershipGrant.tenant_id == ProjectObjectRevision.tenant_id)
|
||||
.where(
|
||||
ProjectMembershipGrant.object_kind == ProjectObjectRevision.object_kind
|
||||
)
|
||||
.where(ProjectMembershipGrant.object_id == ProjectObjectRevision.object_id)
|
||||
.where(ProjectMembershipGrant.active.is_(True))
|
||||
.where(subject_condition)
|
||||
)
|
||||
return query.filter(or_(*conditions))
|
||||
|
||||
|
||||
def _normalized_changes(changes: Mapping[str, object]) -> dict[str, object]:
|
||||
allowed = {
|
||||
"title",
|
||||
"state",
|
||||
"description",
|
||||
"visibility",
|
||||
"parent_kind",
|
||||
"parent_id",
|
||||
"starts_at",
|
||||
"due_at",
|
||||
"owner",
|
||||
"memberships",
|
||||
"outcomes",
|
||||
"benefits",
|
||||
"dependencies",
|
||||
"capacity_assumptions",
|
||||
"change_impacts",
|
||||
"benefit_reviews",
|
||||
"resource_links",
|
||||
"external_references",
|
||||
"metadata",
|
||||
}
|
||||
unknown = set(changes) - allowed
|
||||
if unknown:
|
||||
raise ProjectStoreError(
|
||||
"Unsupported project update fields: " + ", ".join(sorted(unknown))
|
||||
)
|
||||
return _json_value(dict(changes))
|
||||
|
||||
|
||||
def _record_from_row(row: ProjectObjectRevision) -> ProjectRecord:
|
||||
payload = dict(row.payload or {})
|
||||
payload.update(
|
||||
{
|
||||
"revision": row.revision,
|
||||
"state": row.state,
|
||||
"title": row.title,
|
||||
"visibility": row.visibility,
|
||||
"parent_kind": row.parent_kind,
|
||||
"parent_id": row.parent_id,
|
||||
"starts_at": _datetime_text(row.starts_at),
|
||||
"due_at": _datetime_text(row.due_at),
|
||||
"recorded_at": _datetime_text(row.recorded_at),
|
||||
}
|
||||
)
|
||||
return ProjectRecord.from_mapping(payload)
|
||||
|
||||
|
||||
def _search_text(record: ProjectRecord) -> str:
|
||||
values = [
|
||||
record.object_key,
|
||||
record.title,
|
||||
record.description or "",
|
||||
record.state,
|
||||
*(item.title for item in record.outcomes),
|
||||
*(item.title for item in record.benefits),
|
||||
*(item.description or "" for item in record.dependencies),
|
||||
]
|
||||
return " ".join(value for value in values if value).casefold()
|
||||
|
||||
|
||||
def _current_row(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
lock: bool,
|
||||
) -> ProjectObjectRevision | None:
|
||||
query = session.query(ProjectObjectRevision).filter(
|
||||
ProjectObjectRevision.tenant_id == tenant_id,
|
||||
ProjectObjectRevision.object_kind == object_kind,
|
||||
ProjectObjectRevision.object_id == object_id,
|
||||
ProjectObjectRevision.superseded_at.is_(None),
|
||||
)
|
||||
if lock:
|
||||
query = query.with_for_update()
|
||||
return query.one_or_none()
|
||||
|
||||
|
||||
def _identity(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
object_kind: str,
|
||||
object_id: str,
|
||||
) -> ProjectObjectIdentity | None:
|
||||
return (
|
||||
session.query(ProjectObjectIdentity)
|
||||
.filter(
|
||||
ProjectObjectIdentity.tenant_id == tenant_id,
|
||||
ProjectObjectIdentity.object_kind == object_kind,
|
||||
ProjectObjectIdentity.object_id == object_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def _principal_subjects(principal: object) -> tuple[tuple[str, str], ...]:
|
||||
values: list[tuple[str, str]] = []
|
||||
singular = {
|
||||
"account": getattr(principal, "account_id", None),
|
||||
"identity": getattr(principal, "identity_id", None),
|
||||
"function_assignment": getattr(principal, "acting_assignment_id", None),
|
||||
"service_account": getattr(principal, "service_account_id", None),
|
||||
}
|
||||
for kind, value in singular.items():
|
||||
if str(value or "").strip():
|
||||
values.append((kind, str(value)))
|
||||
collections = {
|
||||
"group": getattr(principal, "group_ids", ()),
|
||||
"role": getattr(principal, "role_ids", ()),
|
||||
"function_assignment": getattr(principal, "function_assignment_ids", ()),
|
||||
}
|
||||
for kind, items in collections.items():
|
||||
values.extend(
|
||||
(kind, str(item)) for item in items or () if str(item or "").strip()
|
||||
)
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
|
||||
def _principal_actor_ids(principal: object) -> tuple[str, ...]:
|
||||
user = getattr(principal, "user", None)
|
||||
values = (
|
||||
getattr(user, "id", None),
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
)
|
||||
return tuple(
|
||||
dict.fromkeys(str(value) for value in values if str(value or "").strip())
|
||||
)
|
||||
|
||||
|
||||
def _principal_actor(principal: object) -> str | None:
|
||||
user = getattr(principal, "user", None)
|
||||
for value in (
|
||||
getattr(principal, "account_id", None),
|
||||
getattr(principal, "identity_id", None),
|
||||
getattr(principal, "membership_id", None),
|
||||
getattr(user, "id", None),
|
||||
):
|
||||
if str(value or "").strip():
|
||||
return str(value)
|
||||
return None
|
||||
|
||||
|
||||
def _principal_tenant(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise ProjectStoreError("Project operations require a tenant-bound principal.")
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
method = getattr(principal, "has", None)
|
||||
if callable(method):
|
||||
return bool(method(scope))
|
||||
scopes = frozenset(getattr(principal, "scopes", ()) or ())
|
||||
return scopes_grant_compatible(scopes, scope)
|
||||
|
||||
|
||||
def _object_kind(value: str) -> str:
|
||||
if value not in OBJECT_KINDS:
|
||||
raise ProjectStoreError(f"Unsupported project object kind: {value!r}.")
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(value: object, label: str, *, maximum: int) -> str:
|
||||
result = str(value or "").strip()
|
||||
if not result:
|
||||
raise ProjectStoreError(f"{label} is required.")
|
||||
if len(result) > maximum:
|
||||
raise ProjectStoreError(f"{label} is limited to {maximum} characters.")
|
||||
return result
|
||||
|
||||
|
||||
def _require_aware(value: datetime, label: str) -> None:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ProjectStoreError(f"{label} must include a timezone.")
|
||||
|
||||
|
||||
def _request_sha256(value: object) -> str:
|
||||
payload = json.dumps(
|
||||
_json_value(value),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _json_value(value: object) -> Any:
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if hasattr(value, "to_dict"):
|
||||
return _json_value(value.to_dict())
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _datetime_text(value: datetime | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not hasattr(value, "query"):
|
||||
raise ProjectDomainError("Project registry requires a database session.")
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_PROJECTS_REGISTRY",
|
||||
"ProjectStoreError",
|
||||
"SqlProjectRegistry",
|
||||
"can_read_project_object",
|
||||
"can_write_project_object",
|
||||
"create_project_object",
|
||||
"get_project_object",
|
||||
"list_project_objects",
|
||||
"project_object_events",
|
||||
"project_object_history",
|
||||
"update_project_object",
|
||||
]
|
||||
Reference in New Issue
Block a user