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:
2025-12-29 07:02:19 +00:00
parent 144d23235e
commit b89ea41d63
7 changed files with 1007 additions and 8 deletions

View File

@@ -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),
)

View File

@@ -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."""

View File

@@ -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),
)

View File

@@ -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