Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 276 additions & 1 deletion apps/api/openapi.json

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions apps/api/src/cora/equipment/_alternate_identifier_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Shared Pydantic wire-format mirror of the `AlternateIdentifier` VO.

Hoisted at the first importer (`register_asset`); the two future
mutation slices `add_asset_alternate_identifier` and
`remove_asset_alternate_identifier` reuse this mirror once they
land, matching the precedent set by `_drawing_body` (Drawing) and
`_placement_body` (Placement).

`AlternateIdentifier` is a frozen dataclass at the domain layer
(`cora.equipment.aggregates.asset.state.AlternateIdentifier`);
this body is purely the wire shape that Pydantic parses, with a
single `to_domain()` method that constructs the domain VO and may
raise `InvalidAlternateIdentifierValueError` on domain-rule
violations (mapped to 400 by the BC's exception handler).
"""

from pydantic import BaseModel, Field

from cora.equipment.aggregates.asset import (
ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH,
AlternateIdentifier,
AlternateIdentifierKind,
)


class AlternateIdentifierBody(BaseModel):
"""Wire format for an `AlternateIdentifier` value object."""

kind: AlternateIdentifierKind = Field(
...,
description=(
"Closed PIDINST v1.0 vocabulary: SerialNumber (manufacturer "
"per-unit identifier), InventoryNumber (facility asset tag), "
"or Other (vendor-specific or unconventional scheme)."
),
)
value: str = Field(
...,
min_length=1,
max_length=ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH,
description=(
"Operator-supplied opaque string identifying the Asset "
"under the given scheme. Trimmed at the domain boundary."
),
)

def to_domain(self) -> AlternateIdentifier:
return AlternateIdentifier(kind=self.kind, value=self.value)
18 changes: 18 additions & 0 deletions apps/api/src/cora/equipment/aggregates/asset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from cora.equipment.aggregates.asset.events import (
AssetActivated,
AssetAlternateIdentifierAdded,
AssetAlternateIdentifierRemoved,
AssetDecommissioned,
AssetDegraded,
AssetEvent,
Expand All @@ -28,12 +30,18 @@
from cora.equipment.aggregates.asset.evolver import evolve, fold
from cora.equipment.aggregates.asset.read import load_asset
from cora.equipment.aggregates.asset.state import (
ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH,
ASSET_NAME_MAX_LENGTH,
PORT_NAME_MAX_LENGTH,
PORT_SIGNAL_TYPE_MAX_LENGTH,
AlternateIdentifier,
AlternateIdentifierKind,
Asset,
AssetAlreadyExistsError,
AssetAlternateIdentifierAlreadyPresentError,
AssetAlternateIdentifierNotPresentError,
AssetCannotActivateError,
AssetCannotAddAlternateIdentifierError,
AssetCannotAddFamilyError,
AssetCannotAddPortError,
AssetCannotDecommissionError,
Expand All @@ -49,6 +57,7 @@
AssetName,
AssetNotFoundError,
AssetPort,
InvalidAlternateIdentifierValueError,
InvalidAssetNameError,
InvalidAssetParentError,
InvalidAssetPortNameError,
Expand All @@ -58,13 +67,21 @@
)

__all__ = [
"ALTERNATE_IDENTIFIER_VALUE_MAX_LENGTH",
"ASSET_NAME_MAX_LENGTH",
"PORT_NAME_MAX_LENGTH",
"PORT_SIGNAL_TYPE_MAX_LENGTH",
"AlternateIdentifier",
"AlternateIdentifierKind",
"Asset",
"AssetActivated",
"AssetAlreadyExistsError",
"AssetAlternateIdentifierAdded",
"AssetAlternateIdentifierAlreadyPresentError",
"AssetAlternateIdentifierNotPresentError",
"AssetAlternateIdentifierRemoved",
"AssetCannotActivateError",
"AssetCannotAddAlternateIdentifierError",
"AssetCannotAddFamilyError",
"AssetCannotAddPortError",
"AssetCannotDecommissionError",
Expand Down Expand Up @@ -94,6 +111,7 @@
"AssetRelocated",
"AssetRestored",
"AssetSettingsUpdated",
"InvalidAlternateIdentifierValueError",
"InvalidAssetNameError",
"InvalidAssetParentError",
"InvalidAssetPortNameError",
Expand Down
154 changes: 152 additions & 2 deletions apps/api/src/cora/equipment/aggregates/asset/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,16 @@
precedent (also payload.get-based).
"""

from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, assert_never
from uuid import UUID

from cora.equipment.aggregates._drawing import Drawing, DrawingSystem
from cora.equipment.aggregates.asset.state import (
AlternateIdentifier,
AlternateIdentifierKind,
)
from cora.infrastructure.event_payload import deserialize_or_raise
from cora.infrastructure.ports.event_store import StoredEvent

Expand Down Expand Up @@ -88,6 +92,15 @@ class AssetRegistered:
`to_payload` uses the omit-when-None convention (key absent
rather than serialized as JSON null) to mirror the `drawing`
precedent.

`alternate_identifiers` is an optional frozenset of PIDINST
Property 13 alternate identifiers seeded at registration. The
field defaults to an empty frozenset so legacy AssetRegistered
streams (no `alternate_identifiers` key in the payload) fold
cleanly via the additive-payload pattern; `to_payload` uses the
omit-when-empty convention (key absent rather than serialized as
`[]`) to mirror the `drawing` / `model_id` precedents. See
[[project-asset-alternate-identifiers-design]] Locks A and D.
"""

asset_id: UUID
Expand All @@ -97,6 +110,13 @@ class AssetRegistered:
occurred_at: datetime
drawing: Drawing | None = None
model_id: UUID | None = None
# Parametrized default_factory for the empty frozenset trick used
# across Asset / Method / Mount: the empty frozenset has no
# element type for pyright to infer under strict, so the
# parametrized callable is supplied as the factory.
alternate_identifiers: frozenset[AlternateIdentifier] = field(
default_factory=frozenset[AlternateIdentifier]
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -277,6 +297,46 @@ class AssetPortRemoved:
occurred_at: datetime


@dataclass(frozen=True)
class AssetAlternateIdentifierAdded:
"""An alternate identifier (PIDINST Property 13) was added to an Asset.

Single-identifier event mirroring `AssetPortAdded` /
`AssetFamilyAdded`. Audit value: "when did this Asset gain the
`InventoryNumber=APS-2BM-CAM-001` tag?"

The full `AlternateIdentifier` VO (kind + value) travels in the
payload as two primitives — `kind` is the StrEnum value, `value`
is the trimmed string — so `from_stored` reconstructs the VO
without reading prior state. Mirrors `AssetPortAdded`'s
(port_name, direction, signal_type) primitive carry. The decider
enforces strict-not-idempotent semantics at command time per
[[project-asset-alternate-identifiers-design]] Lock E.
"""

asset_id: UUID
alternate_identifier: AlternateIdentifier
occurred_at: datetime


@dataclass(frozen=True)
class AssetAlternateIdentifierRemoved:
"""An alternate identifier (PIDINST Property 13) was removed from an Asset.

Mirror of `AssetAlternateIdentifierAdded`. The full
`AlternateIdentifier` VO (kind + value) travels in the payload so
the audit reader can see exactly which identifier was removed
without folding back through prior events; symmetric with the
Added event (the Port mirror carries only `port_name` because
`name` is the unique key on `AssetPort`, whereas here uniqueness
keys on the full `(kind, value)` tuple).
"""

asset_id: UUID
alternate_identifier: AlternateIdentifier
occurred_at: datetime


@dataclass(frozen=True)
class AssetSettingsUpdated:
"""An asset's settings dict was set / replaced via the
Expand Down Expand Up @@ -344,6 +404,8 @@ class AssetRelocated:
| AssetSettingsUpdated
| AssetPortAdded
| AssetPortRemoved
| AssetAlternateIdentifierAdded
| AssetAlternateIdentifierRemoved
)


Expand All @@ -367,6 +429,7 @@ def to_payload(event: AssetEvent) -> dict[str, Any]:
occurred_at=occurred_at,
drawing=drawing,
model_id=model_id,
alternate_identifiers=alternate_identifiers,
):
payload: dict[str, Any] = {
"asset_id": str(asset_id),
Expand All @@ -383,6 +446,22 @@ def to_payload(event: AssetEvent) -> dict[str, Any]:
}
if model_id is not None:
payload["model_id"] = str(model_id)
if alternate_identifiers:
# Omit-when-empty: legacy AssetRegistered shape had no
# `alternate_identifiers` key; preserve that wire shape
# so existing stream readers can't accidentally observe
# an empty list where the key was previously absent.
# Sorted by (kind, value) so payload bytes are stable
# under the equivalent VO set (frozenset iteration is
# nondeterministic; canonical bytes matter for any
# future signing/hashing slice).
payload["alternate_identifiers"] = [
{"kind": identifier.kind.value, "value": identifier.value}
for identifier in sorted(
alternate_identifiers,
key=lambda ident: (ident.kind.value, ident.value),
)
]
return payload
case AssetActivated(asset_id=asset_id, occurred_at=occurred_at):
return {
Expand Down Expand Up @@ -482,6 +561,32 @@ def to_payload(event: AssetEvent) -> dict[str, Any]:
"port_name": port_name,
"occurred_at": occurred_at.isoformat(),
}
case AssetAlternateIdentifierAdded(
asset_id=asset_id,
alternate_identifier=identifier,
occurred_at=occurred_at,
):
return {
"asset_id": str(asset_id),
"alternate_identifier": {
"kind": identifier.kind.value,
"value": identifier.value,
},
"occurred_at": occurred_at.isoformat(),
}
case AssetAlternateIdentifierRemoved(
asset_id=asset_id,
alternate_identifier=identifier,
occurred_at=occurred_at,
):
return {
"asset_id": str(asset_id),
"alternate_identifier": {
"kind": identifier.kind.value,
"value": identifier.value,
},
"occurred_at": occurred_at.isoformat(),
}
case _: # pragma: no cover # exhaustiveness guard
assert_never(event)

Expand Down Expand Up @@ -511,6 +616,14 @@ def _build_registered() -> AssetRegistered:
)
raw_model_id = payload.get("model_id")
model_id = UUID(raw_model_id) if raw_model_id is not None else None
raw_alt_ids = payload.get("alternate_identifiers", [])
alternate_identifiers = frozenset(
AlternateIdentifier(
kind=AlternateIdentifierKind(entry["kind"]),
value=entry["value"],
)
for entry in raw_alt_ids
)
return AssetRegistered(
asset_id=UUID(payload["asset_id"]),
name=payload["name"],
Expand All @@ -519,9 +632,14 @@ def _build_registered() -> AssetRegistered:
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
drawing=drawing,
model_id=model_id,
alternate_identifiers=alternate_identifiers,
)

return deserialize_or_raise("AssetRegistered", _build_registered)
return deserialize_or_raise(
"AssetRegistered",
_build_registered,
extra=(ValueError,),
)
case "AssetActivated":
return deserialize_or_raise(
"AssetActivated",
Expand Down Expand Up @@ -639,13 +757,45 @@ def _build_registered() -> AssetRegistered:
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
),
)
case "AssetAlternateIdentifierAdded":
return deserialize_or_raise(
"AssetAlternateIdentifierAdded",
lambda: AssetAlternateIdentifierAdded(
asset_id=UUID(payload["asset_id"]),
alternate_identifier=AlternateIdentifier(
kind=AlternateIdentifierKind(
payload["alternate_identifier"]["kind"],
),
value=payload["alternate_identifier"]["value"],
),
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
),
extra=(ValueError,),
)
case "AssetAlternateIdentifierRemoved":
return deserialize_or_raise(
"AssetAlternateIdentifierRemoved",
lambda: AssetAlternateIdentifierRemoved(
asset_id=UUID(payload["asset_id"]),
alternate_identifier=AlternateIdentifier(
kind=AlternateIdentifierKind(
payload["alternate_identifier"]["kind"],
),
value=payload["alternate_identifier"]["value"],
),
occurred_at=datetime.fromisoformat(payload["occurred_at"]),
),
extra=(ValueError,),
)
case _:
msg = f"Unknown AssetEvent event_type: {stored.event_type!r}"
raise ValueError(msg)


__all__ = [
"AssetActivated",
"AssetAlternateIdentifierAdded",
"AssetAlternateIdentifierRemoved",
"AssetDecommissioned",
"AssetDegraded",
"AssetEvent",
Expand Down
Loading
Loading