feat: add Promote Animal action route (Step 9.1 continued)

- Add /actions/promote/{animal_id} GET and /actions/animal-promote POST routes
- Add promote_form() template with nickname, sex, repro_status fields
- Add AnimalRepository.get() method for single-animal lookup
- 10 new tests for promote form rendering and submission

🤖 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-31 09:41:17 +00:00
parent f9e89fe5d6
commit 99f2fbb964
4 changed files with 460 additions and 3 deletions

View File

@@ -1,13 +1,18 @@
# ABOUTME: Repository for animal registry queries.
# ABOUTME: Provides list_animals with filtering/pagination and facet counts.
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from typing import Any
from typing import TYPE_CHECKING, Any
from animaltrack.selection.parser import parse_filter
if TYPE_CHECKING:
from animaltrack.models.animals import Animal
@dataclass
class AnimalListItem:
@@ -59,6 +64,49 @@ class AnimalRepository:
"""
self.db = db
def get(self, animal_id: str) -> Animal | None:
"""Get an animal by ID.
Args:
animal_id: The animal ID to look up.
Returns:
Animal model if found, None otherwise.
"""
from animaltrack.models.animals import Animal
row = self.db.execute(
"""
SELECT
animal_id, species_code, sex, life_stage, status,
location_id, repro_status, identified, nickname,
origin, born_or_hatched_at, acquired_at, first_seen_utc, last_event_utc
FROM animal_registry
WHERE animal_id = ?
""",
(animal_id,),
).fetchone()
if not row:
return None
return Animal(
animal_id=row[0],
species_code=row[1],
sex=row[2],
life_stage=row[3],
status=row[4],
location_id=row[5],
repro_status=row[6],
identified=bool(row[7]),
nickname=row[8],
origin=row[9],
born_or_hatched_at=row[10],
acquired_at=row[11],
first_seen_utc=row[12],
last_event_utc=row[13],
)
def list_animals(
self,
filter_str: str = "",

View File

@@ -13,6 +13,7 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import (
AnimalCohortCreatedPayload,
AnimalPromotedPayload,
HatchRecordedPayload,
)
from animaltrack.events.store import EventStore
@@ -20,11 +21,12 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates.actions import cohort_form, hatch_form
from animaltrack.web.templates.actions import cohort_form, hatch_form, promote_form
def _create_animal_service(db: Any) -> AnimalService:
@@ -317,6 +319,128 @@ def _render_hatch_error(
)
# =============================================================================
# Animal Promotion
# =============================================================================
def promote_index(request: Request, animal_id: str):
"""GET /actions/promote/{animal_id} - Promote Animal form."""
db = request.app.state.db
# Look up the animal
animal_repo = AnimalRepository(db)
animal = animal_repo.get(animal_id)
if not animal:
return HTMLResponse(content="Animal not found", status_code=404)
if animal.status != "alive":
return HTMLResponse(content="Only alive animals can be promoted", status_code=400)
if animal.identified:
return HTMLResponse(content="Animal is already identified", status_code=400)
return page(
promote_form(animal),
title="Promote Animal - AnimalTrack",
active_nav=None,
)
async def animal_promote(request: Request):
"""POST /actions/animal-promote - Promote an animal to identified."""
db = request.app.state.db
form = await request.form()
# Extract form data
animal_id = form.get("animal_id", "")
nickname = form.get("nickname", "") or None
sex = form.get("sex", "") or None
repro_status = form.get("repro_status", "") or None
distinguishing_traits = form.get("distinguishing_traits", "") or None
notes = form.get("notes", "") or None
nonce = form.get("nonce")
# Validate animal_id
if not animal_id:
return HTMLResponse(content="Animal ID is required", status_code=422)
# Look up the animal
animal_repo = AnimalRepository(db)
animal = animal_repo.get(animal_id)
if not animal:
return HTMLResponse(content="Animal not found", status_code=404)
if animal.status != "alive":
return _render_promote_error(animal, "Only alive animals can be promoted", form)
if animal.identified:
return _render_promote_error(animal, "Animal is already identified", form)
# Create payload
try:
payload = AnimalPromotedPayload(
animal_id=animal_id,
nickname=nickname,
sex=sex,
repro_status=repro_status,
distinguishing_traits=distinguishing_traits,
notes=notes,
)
except Exception as e:
return _render_promote_error(animal, str(e), form)
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Promote animal
ts_utc = int(time.time() * 1000)
service = _create_animal_service(db)
try:
service.promote_animal(payload, ts_utc, actor, nonce=nonce, route="/actions/animal-promote")
except ValidationError as e:
return _render_promote_error(animal, str(e), form)
# Success: redirect to animal detail page
from starlette.responses import RedirectResponse
response = RedirectResponse(
url=f"/animals/{animal_id}",
status_code=303,
)
return response
def _render_promote_error(
animal: Any,
error_message: str,
form_data: Any = None,
) -> HTMLResponse:
"""Render promote form with error message."""
return HTMLResponse(
content=to_xml(
page(
promote_form(
animal,
error=error_message,
nickname_value=form_data.get("nickname", "") if form_data else "",
selected_sex=form_data.get("sex", "") if form_data else "",
selected_repro_status=form_data.get("repro_status", "") if form_data else "",
traits_value=form_data.get("distinguishing_traits", "") if form_data else "",
),
title="Promote Animal - AnimalTrack",
active_nav=None,
)
),
status_code=422,
)
# =============================================================================
# Route Registration
# =============================================================================
@@ -334,3 +458,7 @@ def register_action_routes(rt, app):
rt("/actions/animal-cohort", methods=["POST"])(animal_cohort)
rt("/actions/hatch")(hatch_index)
rt("/actions/hatch-recorded", methods=["POST"])(hatch_recorded)
# Single animal actions
rt("/actions/promote/{animal_id}")(promote_index)
rt("/actions/animal-promote", methods=["POST"])(animal_promote)

View File

@@ -5,9 +5,18 @@ from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, Div, Form, Hidden, Option, P
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect
from monsterui.all import (
Alert,
AlertT,
Button,
ButtonT,
LabelInput,
LabelSelect,
LabelTextArea,
)
from ulid import ULID
from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species
# =============================================================================
@@ -275,3 +284,114 @@ def hatch_form(
method="post",
cls="space-y-4",
)
# =============================================================================
# Animal Promotion Form
# =============================================================================
def promote_form(
animal: Animal,
error: str | None = None,
nickname_value: str = "",
selected_sex: str = "",
selected_repro_status: str = "",
traits_value: str = "",
action: Callable[..., Any] | str = "/actions/animal-promote",
) -> Form:
"""Create the Animal Promotion form.
Args:
animal: The animal to promote.
error: Optional error message to display.
nickname_value: Pre-filled nickname value.
selected_sex: Pre-selected sex (to refine).
selected_repro_status: Pre-selected repro status.
traits_value: Pre-filled distinguishing traits.
action: Route function or URL string for form submission.
Returns:
Form component for promoting an animal.
"""
display_id = f"{animal.animal_id[:8]}..."
# Build sex options (optional - can refine current value)
sexes = [
("", "Keep current"),
("female", "Female"),
("male", "Male"),
]
sex_options = []
for code, label in sexes:
sex_options.append(Option(label, value=code, selected=code == selected_sex))
# Build repro status options (optional)
repro_statuses = [
("", "Unknown"),
("breeding", "Breeding"),
("non_breeding", "Non-Breeding"),
("broody", "Broody"),
("molting", "Molting"),
]
repro_status_options = []
for code, label in repro_statuses:
repro_status_options.append(
Option(label, value=code, selected=code == selected_repro_status)
)
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
return Form(
H2("Promote Animal", cls="text-xl font-bold mb-4"),
# Animal info
Div(
P(f"Promoting: {display_id}", cls="text-sm text-stone-400"),
P(f"Species: {animal.species_code}, Sex: {animal.sex}", cls="text-sm text-stone-400"),
cls="p-3 bg-slate-800 rounded-md mb-4",
),
# Error message if present
error_component,
# Nickname input (optional)
LabelInput(
"Nickname (optional)",
id="nickname",
name="nickname",
value=nickname_value,
placeholder="Give this animal a name",
),
# Sex refinement dropdown (optional)
LabelSelect(
*sex_options,
label="Refine Sex (optional)",
id="sex",
name="sex",
),
# Repro status dropdown (optional)
LabelSelect(
*repro_status_options,
label="Reproductive Status",
id="repro_status",
name="repro_status",
),
# Distinguishing traits (optional)
LabelTextArea(
"Distinguishing Traits (optional)",
id="distinguishing_traits",
name="distinguishing_traits",
value=traits_value,
placeholder="Color markings, size, personality...",
),
# Hidden fields
Hidden(name="animal_id", value=animal.animal_id),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Promote to Identified", type="submit", cls=ButtonT.primary),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)