feat: implement Move Animals UI with optimistic locking (Step 7.5)

Add Move Animals form with selection context validation and concurrent
change handling via optimistic locking. When selection changes between
client resolution and submit, the user is shown a diff panel and can
confirm to proceed with the current server resolution.

Key changes:
- Add move template with form and diff panel components
- Add move routes (GET /move, POST /actions/animal-move)
- Register move routes in app
- Fix to_xml() usage for HTMLResponse (was using str())
- Use max(current_time, form_ts) for confirmed re-resolution

Tests:
- 15 route tests covering form rendering, success, validation, mismatch
- 7 E2E tests for optimistic lock flow (spec §21.8)

🤖 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-30 14:31:03 +00:00
parent b1bfdfb05c
commit ff4fa86beb
7 changed files with 1530 additions and 4 deletions

View File

@@ -17,7 +17,12 @@ from animaltrack.web.middleware import (
csrf_before,
request_id_before,
)
from animaltrack.web.routes import register_egg_routes, register_feed_routes, register_health_routes
from animaltrack.web.routes import (
register_egg_routes,
register_feed_routes,
register_health_routes,
register_move_routes,
)
# Default static directory relative to this module
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
@@ -127,5 +132,6 @@ def create_app(
register_health_routes(rt, app)
register_egg_routes(rt, app)
register_feed_routes(rt, app)
register_move_routes(rt, app)
return app, rt

View File

@@ -4,5 +4,11 @@
from animaltrack.web.routes.eggs import register_egg_routes
from animaltrack.web.routes.feed import register_feed_routes
from animaltrack.web.routes.health import register_health_routes
from animaltrack.web.routes.move import register_move_routes
__all__ = ["register_egg_routes", "register_feed_routes", "register_health_routes"]
__all__ = [
"register_egg_routes",
"register_feed_routes",
"register_health_routes",
"register_move_routes",
]

View File

@@ -7,6 +7,7 @@ import json
import time
from typing import Any
from fasthtml.common import to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
@@ -137,7 +138,7 @@ async def product_collected(request: Request):
# Success: re-render form with location sticking, qty cleared
response = HTMLResponse(
content=str(
content=to_xml(
page(
egg_form(locations, selected_location_id=location_id, action=product_collected),
title="Egg - AnimalTrack",
@@ -177,7 +178,7 @@ def _render_error_form(locations, selected_location_id, error_message):
HTMLResponse with 422 status.
"""
return HTMLResponse(
content=str(
content=to_xml(
page(
egg_form(
locations,

View File

@@ -0,0 +1,326 @@
# ABOUTME: Routes for Move Animals functionality.
# ABOUTME: Handles GET /move form and POST /actions/animal-move with optimistic locking.
from __future__ import annotations
import json
import time
from typing import Any
from fasthtml.common import to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.events.payloads import AnimalMovedPayload
from animaltrack.events.store import EventStore
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.repositories.locations import LocationRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates.move import diff_panel, move_form
def _get_from_location(
db: Any, animal_ids: list[str], ts_utc: int
) -> tuple[str | None, str | None]:
"""Get the common from_location_id for all animals at given timestamp.
Args:
db: Database connection.
animal_ids: List of animal IDs to check.
ts_utc: Timestamp for location lookup.
Returns:
Tuple of (from_location_id, from_location_name) or (None, None) if
animals are from multiple locations.
"""
if not animal_ids:
return None, None
# Get current location for each animal using location intervals
query = """
SELECT DISTINCT ali.location_id, l.name
FROM animal_location_intervals ali
JOIN locations l ON ali.location_id = l.id
WHERE ali.animal_id IN ({})
AND ali.start_utc <= ?
AND (ali.end_utc IS NULL OR ali.end_utc > ?)
""".format(",".join("?" * len(animal_ids)))
params = list(animal_ids) + [ts_utc, ts_utc]
rows = db.execute(query, params).fetchall()
if len(rows) != 1:
# Animals are from multiple locations or no location found
return None, None
return rows[0][0], rows[0][1]
def move_index(request: Request):
"""GET /move - Move Animals form."""
db = request.app.state.db
locations = LocationRepository(db).list_active()
# Get filter from query params
filter_str = request.query_params.get("filter", "")
# Resolve selection if filter provided
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
from_location_id = None
from_location_name = None
if filter_str or not request.query_params:
# If no filter, default to empty (show all alive animals)
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
return page(
move_form(
locations,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
from_location_id=from_location_id,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
from_location_name=from_location_name,
action=animal_move,
),
title="Move - AnimalTrack",
active_nav="move",
)
async def animal_move(request: Request):
"""POST /actions/animal-move - Move animals to new location."""
db = request.app.state.db
form = await request.form()
# Extract form data
filter_str = form.get("filter", "")
to_location_id = form.get("to_location_id", "")
from_location_id = form.get("from_location_id", "") or None
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
# Get timestamp - use provided or current
ts_utc_str = form.get("ts_utc", "0")
try:
ts_utc = int(ts_utc_str)
if ts_utc == 0:
ts_utc = int(time.time() * 1000)
except ValueError:
ts_utc = int(time.time() * 1000)
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Get locations for potential re-render
locations = LocationRepository(db).list_active()
# Validation: destination required
if not to_location_id:
return _render_error_form(db, locations, filter_str, "Please select a destination")
# Validation: must have animals
if not resolved_ids:
return _render_error_form(db, locations, filter_str, "No animals selected to move")
# Validation: destination must be different from source
if to_location_id == from_location_id:
return _render_error_form(
db, locations, filter_str, "Destination must be different from source"
)
# Validate destination exists and is active
dest_location = None
for loc in locations:
if loc.id == to_location_id:
dest_location = loc
break
if not dest_location:
return _render_error_form(db, locations, filter_str, "Invalid destination location")
# Build selection context for validation
context = SelectionContext(
filter=filter_str,
resolved_ids=list(resolved_ids),
roster_hash=roster_hash,
ts_utc=ts_utc,
from_location_id=from_location_id,
confirmed=confirmed,
)
# Validate selection (check for concurrent changes)
result = validate_selection(db, context)
if not result.valid:
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
diff_panel(
diff=result.diff,
filter_str=filter_str,
resolved_ids=result.resolved_ids,
roster_hash=result.roster_hash,
from_location_id=from_location_id,
to_location_id=to_location_id,
ts_utc=ts_utc,
locations=locations,
action=animal_move,
),
title="Move - AnimalTrack",
active_nav="move",
)
),
status_code=409,
)
# When confirmed, re-resolve to get current server IDs (per spec: "server re-resolves")
if confirmed:
# Re-resolve the filter at current timestamp to get animals still matching
# Use max of current time and form's ts_utc to ensure we resolve at least
# as late as the submission - important when moves happened after client's resolution
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_move = current_resolution.animal_ids
# Update from_location_id based on current resolution
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
else:
ids_to_move = resolved_ids
# Check we still have animals to move after validation
if not ids_to_move:
return _render_error_form(db, locations, filter_str, "No animals remaining to move")
# Create animal service
event_store = EventStore(db)
registry = ProjectionRegistry()
registry.register(AnimalRegistryProjection(db))
registry.register(EventAnimalsProjection(db))
registry.register(IntervalProjection(db))
animal_service = AnimalService(db, event_store, registry)
# Create payload
payload = AnimalMovedPayload(
resolved_ids=list(ids_to_move),
to_location_id=to_location_id,
)
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Move animals
try:
animal_service.move_animals(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
)
except ValidationError as e:
return _render_error_form(db, locations, filter_str, str(e))
# Success: re-render fresh form (nothing sticks per spec)
response = HTMLResponse(
content=to_xml(
page(
move_form(
locations,
action=animal_move,
),
title="Move - AnimalTrack",
active_nav="move",
)
),
)
# Add toast trigger header
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Moved {len(ids_to_move)} animals to {dest_location.name}",
"type": "success",
}
}
)
return response
def register_move_routes(rt, app):
"""Register move routes.
Args:
rt: FastHTML route decorator.
app: FastHTML application instance.
"""
rt("/move")(move_index)
rt("/actions/animal-move", methods=["POST"])(animal_move)
def _render_error_form(db, locations, filter_str, error_message):
"""Render form with error message.
Args:
db: Database connection.
locations: List of active locations.
filter_str: Current filter string.
error_message: Error message to display.
Returns:
HTMLResponse with 422 status.
"""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
from_location_id = None
from_location_name = None
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
return HTMLResponse(
content=to_xml(
page(
move_form(
locations,
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
from_location_id=from_location_id,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
from_location_name=from_location_name,
error=error_message,
action=animal_move,
),
title="Move - AnimalTrack",
active_nav="move",
)
),
status_code=422,
)

View File

@@ -0,0 +1,214 @@
# ABOUTME: Templates for Move Animals form.
# ABOUTME: Provides form components for moving animals with selection context and mismatch handling.
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect
from ulid import ULID
from animaltrack.models.reference import Location
from animaltrack.selection.validation import SelectionDiff
def move_form(
locations: list[Location],
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
from_location_id: str | None = None,
ts_utc: int | None = None,
resolved_count: int = 0,
from_location_name: str | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-move",
) -> Form:
"""Create the Move Animals form.
Args:
locations: List of active locations for the dropdown.
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
from_location_id: Common source location ID (if all animals from same location).
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
from_location_name: Name of source location for display.
error: Optional error message to display.
action: Route function or URL string for form submission.
Returns:
Form component for moving animals.
"""
if resolved_ids is None:
resolved_ids = []
# Build destination location options (exclude from_location if set)
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
for loc in locations:
if loc.id != from_location_id:
location_options.append(Option(loc.name, value=loc.id))
# Error display component
error_component = None
if error:
error_component = Alert(
error,
cls=AlertT.warning,
)
# Selection preview component
selection_preview = None
if resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else ""
selection_preview = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
f" animals selected{location_info}",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
# Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
H2("Move Animals", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck',
),
# Selection preview
selection_preview,
# Destination dropdown
LabelSelect(
*location_options,
label="Destination",
id="to_location_id",
name="to_location_id",
),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="from_location_id", value=from_location_id or ""),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
def diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
from_location_id: str,
to_location_id: str,
ts_utc: int,
locations: list[Location],
action: Callable[..., Any] | str = "/actions/animal-move",
) -> Div:
"""Create the mismatch confirmation panel.
Shows diff information and allows user to confirm or cancel.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
from_location_id: Source location ID.
to_location_id: Destination location ID.
ts_utc: Timestamp for resolution.
locations: List of locations for display.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Find destination location name
to_location_name = "Unknown"
for loc in locations:
if loc.id == to_location_id:
to_location_name = loc.name
break
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were moved since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="from_location_id", value=from_location_id),
Hidden(name="to_location_id", value=to_location_id),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/move'",
),
Button(
f"Confirm Move ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with the remaining {diff.server_count} animals to {to_location_name}?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)