feat: add animal movement projection and service
Implement AnimalMoved event handling: - Update AnimalRegistryProjection for move events - Update IntervalProjection to close/open location intervals - Update EventAnimalsProjection to link move events to animals - Add move_animals() to AnimalService with validations Validations include: - Destination location must exist and be active - All animals must be from a single location - Cannot move to the same location as current 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.projections.base import Projection
|
||||
|
||||
@@ -26,17 +26,21 @@ class AnimalRegistryProjection(Projection):
|
||||
|
||||
def get_event_types(self) -> list[str]:
|
||||
"""Return the event types this projection handles."""
|
||||
return [ANIMAL_COHORT_CREATED]
|
||||
return [ANIMAL_COHORT_CREATED, ANIMAL_MOVED]
|
||||
|
||||
def apply(self, event: Event) -> None:
|
||||
"""Apply an event to update registry tables."""
|
||||
if event.type == ANIMAL_COHORT_CREATED:
|
||||
self._apply_cohort_created(event)
|
||||
elif event.type == ANIMAL_MOVED:
|
||||
self._apply_animal_moved(event)
|
||||
|
||||
def revert(self, event: Event) -> None:
|
||||
"""Revert an event from registry tables."""
|
||||
if event.type == ANIMAL_COHORT_CREATED:
|
||||
self._revert_cohort_created(event)
|
||||
elif event.type == ANIMAL_MOVED:
|
||||
self._revert_animal_moved(event)
|
||||
|
||||
def _apply_cohort_created(self, event: Event) -> None:
|
||||
"""Create animals in registry from cohort event.
|
||||
@@ -121,3 +125,65 @@ class AnimalRegistryProjection(Projection):
|
||||
"DELETE FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
)
|
||||
|
||||
def _apply_animal_moved(self, event: Event) -> None:
|
||||
"""Update animal locations from move event.
|
||||
|
||||
Updates both animal_registry and live_animals_by_location
|
||||
with the new location_id.
|
||||
"""
|
||||
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||
to_location_id = event.entity_refs.get("to_location_id")
|
||||
ts_utc = event.ts_utc
|
||||
|
||||
for animal_id in animal_ids:
|
||||
# Update animal_registry
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE animal_registry
|
||||
SET location_id = ?, last_event_utc = ?
|
||||
WHERE animal_id = ?
|
||||
""",
|
||||
(to_location_id, ts_utc, animal_id),
|
||||
)
|
||||
|
||||
# Update live_animals_by_location
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE live_animals_by_location
|
||||
SET location_id = ?, last_move_utc = ?
|
||||
WHERE animal_id = ?
|
||||
""",
|
||||
(to_location_id, ts_utc, animal_id),
|
||||
)
|
||||
|
||||
def _revert_animal_moved(self, event: Event) -> None:
|
||||
"""Revert animal move, restoring original location.
|
||||
|
||||
Uses from_location_id from entity_refs to restore
|
||||
the previous location state.
|
||||
"""
|
||||
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||
from_location_id = event.entity_refs.get("from_location_id")
|
||||
|
||||
for animal_id in animal_ids:
|
||||
# Restore animal_registry location
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE animal_registry
|
||||
SET location_id = ?
|
||||
WHERE animal_id = ?
|
||||
""",
|
||||
(from_location_id, animal_id),
|
||||
)
|
||||
|
||||
# Restore live_animals_by_location
|
||||
# Set last_move_utc to NULL since we're reverting to before the move
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE live_animals_by_location
|
||||
SET location_id = ?, last_move_utc = NULL
|
||||
WHERE animal_id = ?
|
||||
""",
|
||||
(from_location_id, animal_id),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.projections.base import Projection
|
||||
|
||||
@@ -26,7 +26,7 @@ class EventAnimalsProjection(Projection):
|
||||
|
||||
def get_event_types(self) -> list[str]:
|
||||
"""Return the event types this projection handles."""
|
||||
return [ANIMAL_COHORT_CREATED]
|
||||
return [ANIMAL_COHORT_CREATED, ANIMAL_MOVED]
|
||||
|
||||
def apply(self, event: Event) -> None:
|
||||
"""Link event to affected animals."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.projections.base import Projection
|
||||
|
||||
@@ -29,17 +29,21 @@ class IntervalProjection(Projection):
|
||||
|
||||
def get_event_types(self) -> list[str]:
|
||||
"""Return the event types this projection handles."""
|
||||
return [ANIMAL_COHORT_CREATED]
|
||||
return [ANIMAL_COHORT_CREATED, ANIMAL_MOVED]
|
||||
|
||||
def apply(self, event: Event) -> None:
|
||||
"""Create intervals for event."""
|
||||
if event.type == ANIMAL_COHORT_CREATED:
|
||||
self._apply_cohort_created(event)
|
||||
elif event.type == ANIMAL_MOVED:
|
||||
self._apply_animal_moved(event)
|
||||
|
||||
def revert(self, event: Event) -> None:
|
||||
"""Remove intervals created by event."""
|
||||
if event.type == ANIMAL_COHORT_CREATED:
|
||||
self._revert_cohort_created(event)
|
||||
elif event.type == ANIMAL_MOVED:
|
||||
self._revert_animal_moved(event)
|
||||
|
||||
def _apply_cohort_created(self, event: Event) -> None:
|
||||
"""Create initial intervals for new animals.
|
||||
@@ -101,3 +105,68 @@ class IntervalProjection(Projection):
|
||||
"DELETE FROM animal_attr_intervals WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
)
|
||||
|
||||
def _apply_animal_moved(self, event: Event) -> None:
|
||||
"""Close old location interval and create new one for move.
|
||||
|
||||
For each animal:
|
||||
- Close the current open location interval with end_utc=ts_utc
|
||||
- Create a new open interval at the destination location
|
||||
"""
|
||||
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||
from_location_id = event.entity_refs.get("from_location_id")
|
||||
to_location_id = event.entity_refs.get("to_location_id")
|
||||
ts_utc = event.ts_utc
|
||||
|
||||
for animal_id in animal_ids:
|
||||
# Close the old location interval
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE animal_location_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND location_id = ? AND end_utc IS NULL
|
||||
""",
|
||||
(ts_utc, animal_id, from_location_id),
|
||||
)
|
||||
|
||||
# Create new location interval at destination
|
||||
self.db.execute(
|
||||
"""
|
||||
INSERT INTO animal_location_intervals
|
||||
(animal_id, location_id, start_utc, end_utc)
|
||||
VALUES (?, ?, ?, NULL)
|
||||
""",
|
||||
(animal_id, to_location_id, ts_utc),
|
||||
)
|
||||
|
||||
def _revert_animal_moved(self, event: Event) -> None:
|
||||
"""Revert move by removing new interval and reopening old one.
|
||||
|
||||
For each animal:
|
||||
- Delete the new location interval at destination
|
||||
- Reopen the old interval by setting end_utc=NULL
|
||||
"""
|
||||
animal_ids = event.entity_refs.get("animal_ids", [])
|
||||
from_location_id = event.entity_refs.get("from_location_id")
|
||||
to_location_id = event.entity_refs.get("to_location_id")
|
||||
ts_utc = event.ts_utc
|
||||
|
||||
for animal_id in animal_ids:
|
||||
# Delete the new location interval
|
||||
self.db.execute(
|
||||
"""
|
||||
DELETE FROM animal_location_intervals
|
||||
WHERE animal_id = ? AND location_id = ? AND start_utc = ?
|
||||
""",
|
||||
(animal_id, to_location_id, ts_utc),
|
||||
)
|
||||
|
||||
# Reopen the old location interval
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE animal_location_intervals
|
||||
SET end_utc = NULL
|
||||
WHERE animal_id = ? AND location_id = ? AND end_utc = ?
|
||||
""",
|
||||
(animal_id, from_location_id, ts_utc),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
from typing import Any
|
||||
|
||||
from animaltrack.db import transaction
|
||||
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||
from animaltrack.events.payloads import AnimalCohortCreatedPayload, AnimalMovedPayload
|
||||
from animaltrack.events.processor import process_event
|
||||
from animaltrack.events.store import EventStore
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED
|
||||
from animaltrack.events.types import ANIMAL_COHORT_CREATED, ANIMAL_MOVED
|
||||
from animaltrack.id_gen import generate_id
|
||||
from animaltrack.models.events import Event
|
||||
from animaltrack.projections import ProjectionRegistry
|
||||
@@ -145,3 +145,104 @@ class AnimalService:
|
||||
if not species.active:
|
||||
msg = f"Species {species_code} is not active"
|
||||
raise ValidationError(msg)
|
||||
|
||||
def move_animals(
|
||||
self,
|
||||
payload: AnimalMovedPayload,
|
||||
ts_utc: int,
|
||||
actor: str,
|
||||
nonce: str | None = None,
|
||||
route: str | None = None,
|
||||
) -> Event:
|
||||
"""Move animals to a new location.
|
||||
|
||||
Creates an AnimalMoved event and processes it through
|
||||
all registered projections. All operations happen atomically
|
||||
within a transaction.
|
||||
|
||||
Args:
|
||||
payload: Validated move payload with to_location_id and resolved_ids.
|
||||
ts_utc: Timestamp in milliseconds since epoch.
|
||||
actor: The user performing the move.
|
||||
nonce: Optional idempotency nonce.
|
||||
route: Required if nonce provided.
|
||||
|
||||
Returns:
|
||||
The created event.
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails.
|
||||
"""
|
||||
# Validate destination location exists and is active
|
||||
self._validate_location(payload.to_location_id)
|
||||
|
||||
# Validate all animals exist and get their current location
|
||||
from_location_id = self._validate_animals_for_move(
|
||||
payload.resolved_ids, payload.to_location_id
|
||||
)
|
||||
|
||||
# Build entity_refs with from/to locations and animal IDs
|
||||
entity_refs = {
|
||||
"from_location_id": from_location_id,
|
||||
"to_location_id": payload.to_location_id,
|
||||
"animal_ids": payload.resolved_ids,
|
||||
}
|
||||
|
||||
with transaction(self.db):
|
||||
# Append event to store
|
||||
event = self.event_store.append_event(
|
||||
event_type=ANIMAL_MOVED,
|
||||
ts_utc=ts_utc,
|
||||
actor=actor,
|
||||
entity_refs=entity_refs,
|
||||
payload=payload.model_dump(),
|
||||
nonce=nonce,
|
||||
route=route,
|
||||
)
|
||||
|
||||
# Process event through projections
|
||||
process_event(event, self.registry)
|
||||
|
||||
return event
|
||||
|
||||
def _validate_animals_for_move(self, animal_ids: list[str], to_location_id: str) -> str:
|
||||
"""Validate animals exist and are from a single location.
|
||||
|
||||
Args:
|
||||
animal_ids: List of animal IDs to validate.
|
||||
to_location_id: The destination location ID.
|
||||
|
||||
Returns:
|
||||
The from_location_id (current location of all animals).
|
||||
|
||||
Raises:
|
||||
ValidationError: If animals don't exist, are from multiple
|
||||
locations, or are already at the destination.
|
||||
"""
|
||||
locations: set[str] = set()
|
||||
|
||||
for animal_id in animal_ids:
|
||||
row = self.db.execute(
|
||||
"SELECT location_id FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
msg = f"Animal {animal_id} not found"
|
||||
raise ValidationError(msg)
|
||||
|
||||
locations.add(row[0])
|
||||
|
||||
# Check all animals are from the same location
|
||||
if len(locations) > 1:
|
||||
msg = "All animals must be from a single location"
|
||||
raise ValidationError(msg)
|
||||
|
||||
from_location_id = locations.pop()
|
||||
|
||||
# Check not moving to the same location
|
||||
if from_location_id == to_location_id:
|
||||
msg = "Cannot move animals to the same location"
|
||||
raise ValidationError(msg)
|
||||
|
||||
return from_location_id
|
||||
|
||||
Reference in New Issue
Block a user