feat: implement Cohort and Hatch action routes (Step 9.1 partial)
- Add /actions/cohort GET and /actions/animal-cohort POST routes - Add /actions/hatch GET and /actions/hatch-recorded POST routes - Add cohort_form() and hatch_form() templates - Add Cohort and Hatch icons and navigation items - Add list_active() method to SpeciesRepository - Register action routes in app.py - 26 new tests for cohort and hatch actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,3 +79,23 @@ class SpeciesRepository:
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def list_active(self) -> list[Species]:
|
||||
"""Get all active species.
|
||||
|
||||
Returns:
|
||||
List of active species.
|
||||
"""
|
||||
rows = self.db.execute(
|
||||
"SELECT code, name, active, created_at_utc, updated_at_utc FROM species WHERE active = 1"
|
||||
).fetchall()
|
||||
return [
|
||||
Species(
|
||||
code=row[0],
|
||||
name=row[1],
|
||||
active=bool(row[2]),
|
||||
created_at_utc=row[3],
|
||||
updated_at_utc=row[4],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ from animaltrack.web.middleware import (
|
||||
request_id_before,
|
||||
)
|
||||
from animaltrack.web.routes import (
|
||||
register_action_routes,
|
||||
register_animals_routes,
|
||||
register_egg_routes,
|
||||
register_events_routes,
|
||||
@@ -133,6 +134,7 @@ def create_app(
|
||||
|
||||
# Register routes
|
||||
register_health_routes(rt, app)
|
||||
register_action_routes(rt, app)
|
||||
register_animals_routes(rt, app)
|
||||
register_egg_routes(rt, app)
|
||||
register_events_routes(rt, app)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# ABOUTME: Routes package for AnimalTrack web application.
|
||||
# ABOUTME: Contains modular route handlers for different features.
|
||||
|
||||
from animaltrack.web.routes.actions import register_action_routes
|
||||
from animaltrack.web.routes.animals import register_animals_routes
|
||||
from animaltrack.web.routes.eggs import register_egg_routes
|
||||
from animaltrack.web.routes.events import register_events_routes
|
||||
@@ -10,6 +11,7 @@ from animaltrack.web.routes.move import register_move_routes
|
||||
from animaltrack.web.routes.registry import register_registry_routes
|
||||
|
||||
__all__ = [
|
||||
"register_action_routes",
|
||||
"register_animals_routes",
|
||||
"register_egg_routes",
|
||||
"register_events_routes",
|
||||
|
||||
336
src/animaltrack/web/routes/actions.py
Normal file
336
src/animaltrack/web/routes/actions.py
Normal file
@@ -0,0 +1,336 @@
|
||||
# ABOUTME: Routes for Animal Actions (create, update, tag, outcome, etc).
|
||||
# ABOUTME: Handles all POST /actions/animal-* endpoints and their GET forms.
|
||||
|
||||
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 (
|
||||
AnimalCohortCreatedPayload,
|
||||
HatchRecordedPayload,
|
||||
)
|
||||
from animaltrack.events.store import EventStore
|
||||
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.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
|
||||
|
||||
|
||||
def _create_animal_service(db: Any) -> AnimalService:
|
||||
"""Create an AnimalService with standard projections.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
|
||||
Returns:
|
||||
Configured AnimalService instance.
|
||||
"""
|
||||
event_store = EventStore(db)
|
||||
registry = ProjectionRegistry()
|
||||
registry.register(AnimalRegistryProjection(db))
|
||||
registry.register(EventAnimalsProjection(db))
|
||||
registry.register(IntervalProjection(db))
|
||||
registry.register(EventLogProjection(db))
|
||||
return AnimalService(db, event_store, registry)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cohort Creation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cohort_index(request: Request):
|
||||
"""GET /actions/cohort - Create Cohort form."""
|
||||
db = request.app.state.db
|
||||
locations = LocationRepository(db).list_active()
|
||||
species_list = SpeciesRepository(db).list_active()
|
||||
|
||||
return page(
|
||||
cohort_form(locations, species_list),
|
||||
title="Create Cohort - AnimalTrack",
|
||||
active_nav="cohort",
|
||||
)
|
||||
|
||||
|
||||
async def animal_cohort(request: Request):
|
||||
"""POST /actions/animal-cohort - Create a new animal cohort."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
|
||||
# Extract form data
|
||||
species = form.get("species", "")
|
||||
location_id = form.get("location_id", "")
|
||||
count_str = form.get("count", "0")
|
||||
life_stage = form.get("life_stage", "")
|
||||
sex = form.get("sex", "unknown")
|
||||
origin = form.get("origin", "")
|
||||
notes = form.get("notes", "") or None
|
||||
nonce = form.get("nonce")
|
||||
|
||||
# Get reference data for potential re-render
|
||||
locations = LocationRepository(db).list_active()
|
||||
species_list = SpeciesRepository(db).list_active()
|
||||
|
||||
# Validate count
|
||||
try:
|
||||
count = int(count_str)
|
||||
if count < 1:
|
||||
return _render_cohort_error(locations, species_list, "Count must be at least 1", form)
|
||||
except ValueError:
|
||||
return _render_cohort_error(locations, species_list, "Count must be a valid number", form)
|
||||
|
||||
# Validate required fields
|
||||
if not species:
|
||||
return _render_cohort_error(locations, species_list, "Please select a species", form)
|
||||
if not location_id:
|
||||
return _render_cohort_error(locations, species_list, "Please select a location", form)
|
||||
if not life_stage:
|
||||
return _render_cohort_error(locations, species_list, "Please select a life stage", form)
|
||||
if not origin:
|
||||
return _render_cohort_error(locations, species_list, "Please select an origin", form)
|
||||
|
||||
# Create payload
|
||||
try:
|
||||
payload = AnimalCohortCreatedPayload(
|
||||
species=species,
|
||||
count=count,
|
||||
life_stage=life_stage,
|
||||
sex=sex,
|
||||
location_id=location_id,
|
||||
origin=origin,
|
||||
notes=notes,
|
||||
)
|
||||
except Exception as e:
|
||||
return _render_cohort_error(locations, species_list, str(e), form)
|
||||
|
||||
# Get actor from auth
|
||||
auth = request.scope.get("auth")
|
||||
actor = auth.username if auth else "unknown"
|
||||
|
||||
# Create cohort
|
||||
ts_utc = int(time.time() * 1000)
|
||||
service = _create_animal_service(db)
|
||||
|
||||
try:
|
||||
event = service.create_cohort(
|
||||
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-cohort"
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_cohort_error(locations, species_list, str(e), form)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
cohort_form(locations, species_list),
|
||||
title="Create Cohort - AnimalTrack",
|
||||
active_nav="cohort",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
animal_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Created {animal_count} {species}(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_cohort_error(
|
||||
locations: list,
|
||||
species_list: list,
|
||||
error_message: str,
|
||||
form_data: Any = None,
|
||||
) -> HTMLResponse:
|
||||
"""Render cohort form with error message."""
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
cohort_form(
|
||||
locations,
|
||||
species_list,
|
||||
error=error_message,
|
||||
selected_species=form_data.get("species", "") if form_data else "",
|
||||
selected_location=form_data.get("location_id", "") if form_data else "",
|
||||
selected_life_stage=form_data.get("life_stage", "") if form_data else "",
|
||||
selected_sex=form_data.get("sex", "unknown") if form_data else "",
|
||||
selected_origin=form_data.get("origin", "") if form_data else "",
|
||||
count_value=form_data.get("count", "") if form_data else "",
|
||||
),
|
||||
title="Create Cohort - AnimalTrack",
|
||||
active_nav="cohort",
|
||||
)
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hatch Recording
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def hatch_index(request: Request):
|
||||
"""GET /actions/hatch - Record Hatch form."""
|
||||
db = request.app.state.db
|
||||
locations = LocationRepository(db).list_active()
|
||||
species_list = SpeciesRepository(db).list_active()
|
||||
|
||||
return page(
|
||||
hatch_form(locations, species_list),
|
||||
title="Record Hatch - AnimalTrack",
|
||||
active_nav="hatch",
|
||||
)
|
||||
|
||||
|
||||
async def hatch_recorded(request: Request):
|
||||
"""POST /actions/hatch-recorded - Record a hatch event."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
|
||||
# Extract form data
|
||||
species = form.get("species", "")
|
||||
location_id = form.get("location_id", "")
|
||||
assigned_brood_location_id = form.get("assigned_brood_location_id", "") or None
|
||||
hatched_live_str = form.get("hatched_live", "0")
|
||||
notes = form.get("notes", "") or None
|
||||
nonce = form.get("nonce")
|
||||
|
||||
# Get reference data for potential re-render
|
||||
locations = LocationRepository(db).list_active()
|
||||
species_list = SpeciesRepository(db).list_active()
|
||||
|
||||
# Validate count
|
||||
try:
|
||||
hatched_live = int(hatched_live_str)
|
||||
if hatched_live < 1:
|
||||
return _render_hatch_error(
|
||||
locations, species_list, "Hatched count must be at least 1", form
|
||||
)
|
||||
except ValueError:
|
||||
return _render_hatch_error(
|
||||
locations, species_list, "Hatched count must be a valid number", form
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
if not species:
|
||||
return _render_hatch_error(locations, species_list, "Please select a species", form)
|
||||
if not location_id:
|
||||
return _render_hatch_error(locations, species_list, "Please select a hatch location", form)
|
||||
|
||||
# Create payload
|
||||
try:
|
||||
payload = HatchRecordedPayload(
|
||||
species=species,
|
||||
location_id=location_id,
|
||||
assigned_brood_location_id=assigned_brood_location_id,
|
||||
hatched_live=hatched_live,
|
||||
notes=notes,
|
||||
)
|
||||
except Exception as e:
|
||||
return _render_hatch_error(locations, species_list, str(e), form)
|
||||
|
||||
# Get actor from auth
|
||||
auth = request.scope.get("auth")
|
||||
actor = auth.username if auth else "unknown"
|
||||
|
||||
# Record hatch
|
||||
ts_utc = int(time.time() * 1000)
|
||||
service = _create_animal_service(db)
|
||||
|
||||
try:
|
||||
event = service.record_hatch(
|
||||
payload, ts_utc, actor, nonce=nonce, route="/actions/hatch-recorded"
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_hatch_error(locations, species_list, str(e), form)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
hatch_form(locations, species_list),
|
||||
title="Record Hatch - AnimalTrack",
|
||||
active_nav="hatch",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
animal_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Recorded {animal_count} hatchling(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_hatch_error(
|
||||
locations: list,
|
||||
species_list: list,
|
||||
error_message: str,
|
||||
form_data: Any = None,
|
||||
) -> HTMLResponse:
|
||||
"""Render hatch form with error message."""
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
page(
|
||||
hatch_form(
|
||||
locations,
|
||||
species_list,
|
||||
error=error_message,
|
||||
selected_species=form_data.get("species", "") if form_data else "",
|
||||
selected_location=form_data.get("location_id", "") if form_data else "",
|
||||
selected_brood_location=form_data.get("assigned_brood_location_id", "")
|
||||
if form_data
|
||||
else "",
|
||||
hatched_live_value=form_data.get("hatched_live", "") if form_data else "",
|
||||
),
|
||||
title="Record Hatch - AnimalTrack",
|
||||
active_nav="hatch",
|
||||
)
|
||||
),
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Route Registration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def register_action_routes(rt, app):
|
||||
"""Register animal action routes.
|
||||
|
||||
Args:
|
||||
rt: FastHTML route decorator.
|
||||
app: FastHTML application instance.
|
||||
"""
|
||||
# Creation actions
|
||||
rt("/actions/cohort")(cohort_index)
|
||||
rt("/actions/animal-cohort", methods=["POST"])(animal_cohort)
|
||||
rt("/actions/hatch")(hatch_index)
|
||||
rt("/actions/hatch-recorded", methods=["POST"])(hatch_recorded)
|
||||
277
src/animaltrack/web/templates/actions.py
Normal file
277
src/animaltrack/web/templates/actions.py
Normal file
@@ -0,0 +1,277 @@
|
||||
# ABOUTME: Form templates for animal action pages.
|
||||
# ABOUTME: Provides reusable form components for create, update, tag, outcome actions.
|
||||
|
||||
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 ulid import ULID
|
||||
|
||||
from animaltrack.models.reference import Location, Species
|
||||
|
||||
# =============================================================================
|
||||
# Cohort Creation Form
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def cohort_form(
|
||||
locations: list[Location],
|
||||
species_list: list[Species],
|
||||
error: str | None = None,
|
||||
selected_species: str = "",
|
||||
selected_location: str = "",
|
||||
selected_life_stage: str = "",
|
||||
selected_sex: str = "unknown",
|
||||
selected_origin: str = "",
|
||||
count_value: str = "",
|
||||
action: Callable[..., Any] | str = "/actions/animal-cohort",
|
||||
) -> Form:
|
||||
"""Create the Cohort Creation form.
|
||||
|
||||
Args:
|
||||
locations: List of active locations for the dropdown.
|
||||
species_list: List of active species for the dropdown.
|
||||
error: Optional error message to display.
|
||||
selected_species: Pre-selected species code.
|
||||
selected_location: Pre-selected location ID.
|
||||
selected_life_stage: Pre-selected life stage.
|
||||
selected_sex: Pre-selected sex.
|
||||
selected_origin: Pre-selected origin.
|
||||
count_value: Pre-filled count value.
|
||||
action: Route function or URL string for form submission.
|
||||
|
||||
Returns:
|
||||
Form component for creating animal cohort.
|
||||
"""
|
||||
# Build species options
|
||||
species_options = [
|
||||
Option("Select species...", value="", disabled=True, selected=not selected_species)
|
||||
]
|
||||
for sp in species_list:
|
||||
species_options.append(Option(sp.name, value=sp.code, selected=sp.code == selected_species))
|
||||
|
||||
# Build location options
|
||||
location_options = [
|
||||
Option("Select location...", value="", disabled=True, selected=not selected_location)
|
||||
]
|
||||
for loc in locations:
|
||||
location_options.append(
|
||||
Option(loc.name, value=loc.id, selected=loc.id == selected_location)
|
||||
)
|
||||
|
||||
# Build life stage options
|
||||
life_stages = [
|
||||
("hatchling", "Hatchling"),
|
||||
("juvenile", "Juvenile"),
|
||||
("subadult", "Subadult"),
|
||||
("adult", "Adult"),
|
||||
]
|
||||
life_stage_options = [
|
||||
Option("Select life stage...", value="", disabled=True, selected=not selected_life_stage)
|
||||
]
|
||||
for code, label in life_stages:
|
||||
life_stage_options.append(Option(label, value=code, selected=code == selected_life_stage))
|
||||
|
||||
# Build sex options
|
||||
sexes = [
|
||||
("unknown", "Unknown"),
|
||||
("female", "Female"),
|
||||
("male", "Male"),
|
||||
]
|
||||
sex_options = []
|
||||
for code, label in sexes:
|
||||
sex_options.append(Option(label, value=code, selected=code == selected_sex))
|
||||
|
||||
# Build origin options
|
||||
origins = [
|
||||
("hatched", "Hatched"),
|
||||
("purchased", "Purchased"),
|
||||
("rescued", "Rescued"),
|
||||
("unknown", "Unknown"),
|
||||
]
|
||||
origin_options = [
|
||||
Option("Select origin...", value="", disabled=True, selected=not selected_origin)
|
||||
]
|
||||
for code, label in origins:
|
||||
origin_options.append(Option(label, value=code, selected=code == selected_origin))
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
return Form(
|
||||
H2("Create Animal Cohort", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Species dropdown
|
||||
LabelSelect(
|
||||
*species_options,
|
||||
label="Species",
|
||||
id="species",
|
||||
name="species",
|
||||
),
|
||||
# Location dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Location",
|
||||
id="location_id",
|
||||
name="location_id",
|
||||
),
|
||||
# Count input
|
||||
LabelInput(
|
||||
"Count",
|
||||
id="count",
|
||||
name="count",
|
||||
type="number",
|
||||
min="1",
|
||||
value=count_value,
|
||||
placeholder="Number of animals",
|
||||
),
|
||||
# Life stage dropdown
|
||||
LabelSelect(
|
||||
*life_stage_options,
|
||||
label="Life Stage",
|
||||
id="life_stage",
|
||||
name="life_stage",
|
||||
),
|
||||
# Sex dropdown
|
||||
LabelSelect(
|
||||
*sex_options,
|
||||
label="Sex",
|
||||
id="sex",
|
||||
name="sex",
|
||||
),
|
||||
# Origin dropdown
|
||||
LabelSelect(
|
||||
*origin_options,
|
||||
label="Origin",
|
||||
id="origin",
|
||||
name="origin",
|
||||
),
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Create Cohort", type="submit", cls=ButtonT.primary),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hatch Recording Form
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def hatch_form(
|
||||
locations: list[Location],
|
||||
species_list: list[Species],
|
||||
error: str | None = None,
|
||||
selected_species: str = "",
|
||||
selected_location: str = "",
|
||||
selected_brood_location: str = "",
|
||||
hatched_live_value: str = "",
|
||||
action: Callable[..., Any] | str = "/actions/hatch-recorded",
|
||||
) -> Form:
|
||||
"""Create the Hatch Recording form.
|
||||
|
||||
Args:
|
||||
locations: List of active locations for the dropdown.
|
||||
species_list: List of active species for the dropdown.
|
||||
error: Optional error message to display.
|
||||
selected_species: Pre-selected species code.
|
||||
selected_location: Pre-selected hatch location ID.
|
||||
selected_brood_location: Pre-selected brood location ID.
|
||||
hatched_live_value: Pre-filled hatched count value.
|
||||
action: Route function or URL string for form submission.
|
||||
|
||||
Returns:
|
||||
Form component for recording hatch events.
|
||||
"""
|
||||
# Build species options
|
||||
species_options = [
|
||||
Option("Select species...", value="", disabled=True, selected=not selected_species)
|
||||
]
|
||||
for sp in species_list:
|
||||
species_options.append(Option(sp.name, value=sp.code, selected=sp.code == selected_species))
|
||||
|
||||
# Build location options (hatch location)
|
||||
location_options = [
|
||||
Option("Select hatch location...", value="", disabled=True, selected=not selected_location)
|
||||
]
|
||||
for loc in locations:
|
||||
location_options.append(
|
||||
Option(loc.name, value=loc.id, selected=loc.id == selected_location)
|
||||
)
|
||||
|
||||
# Build brood location options (optional)
|
||||
brood_location_options = [
|
||||
Option(
|
||||
"Same as hatch location",
|
||||
value="",
|
||||
selected=not selected_brood_location,
|
||||
)
|
||||
]
|
||||
for loc in locations:
|
||||
brood_location_options.append(
|
||||
Option(loc.name, value=loc.id, selected=loc.id == selected_brood_location)
|
||||
)
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
return Form(
|
||||
H2("Record Hatch", cls="text-xl font-bold mb-4"),
|
||||
# Error message if present
|
||||
error_component,
|
||||
# Species dropdown
|
||||
LabelSelect(
|
||||
*species_options,
|
||||
label="Species",
|
||||
id="species",
|
||||
name="species",
|
||||
),
|
||||
# Hatch location dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
label="Hatch Location",
|
||||
id="location_id",
|
||||
name="location_id",
|
||||
),
|
||||
# Hatched count input
|
||||
LabelInput(
|
||||
"Hatched Live",
|
||||
id="hatched_live",
|
||||
name="hatched_live",
|
||||
type="number",
|
||||
min="1",
|
||||
value=hatched_live_value,
|
||||
placeholder="Number hatched",
|
||||
),
|
||||
# Brood location dropdown (optional)
|
||||
Div(
|
||||
LabelSelect(
|
||||
*brood_location_options,
|
||||
label="Brood Location (optional)",
|
||||
id="assigned_brood_location_id",
|
||||
name="assigned_brood_location_id",
|
||||
),
|
||||
P(
|
||||
"If different from hatch location, hatchlings will be placed here",
|
||||
cls="text-xs text-stone-400 mt-1",
|
||||
),
|
||||
),
|
||||
# Hidden nonce for idempotency
|
||||
Hidden(name="nonce", value=str(ULID())),
|
||||
# Submit button
|
||||
Button("Record Hatch", type="submit", cls=ButtonT.primary),
|
||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||
action=action,
|
||||
method="post",
|
||||
cls="space-y-4",
|
||||
)
|
||||
@@ -84,10 +84,70 @@ def RegistryIcon(active: bool = False): # noqa: N802
|
||||
)
|
||||
|
||||
|
||||
def CohortIcon(active: bool = False): # noqa: N802
|
||||
"""Cohort/group creation icon - multiple animals/figures."""
|
||||
fill = "#b8860b" if active else "#6b6b63"
|
||||
return Svg(
|
||||
# Left duck silhouette
|
||||
Path(
|
||||
d="M6 10C6 8 7 6 9 6C11 6 12 8 12 10",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
fill="none",
|
||||
),
|
||||
# Right duck silhouette
|
||||
Path(
|
||||
d="M12 10C12 8 13 6 15 6C17 6 18 8 18 10",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
fill="none",
|
||||
),
|
||||
# Plus sign for creation
|
||||
Path(
|
||||
d="M12 14V20M9 17H15",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
stroke_linecap="round",
|
||||
),
|
||||
viewBox="0 0 24 24",
|
||||
width="28",
|
||||
height="28",
|
||||
)
|
||||
|
||||
|
||||
def HatchIcon(active: bool = False): # noqa: N802
|
||||
"""Hatch/nest icon - cracked egg with hatchling."""
|
||||
fill = "#b8860b" if active else "#6b6b63"
|
||||
return Svg(
|
||||
# Egg shell (cracked at top)
|
||||
Path(
|
||||
d="M7 12C7 7 9 4 12 4C15 4 17 7 17 12C17 17 15 20 12 20C9 20 7 17 7 12Z",
|
||||
fill="none",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
),
|
||||
# Crack lines
|
||||
Path(
|
||||
d="M10 8L12 10L14 8",
|
||||
stroke=fill,
|
||||
stroke_width="1.5",
|
||||
stroke_linecap="round",
|
||||
stroke_linejoin="round",
|
||||
),
|
||||
# Hatchling peek
|
||||
Circle(cx="12", cy="14", r="2", fill=fill),
|
||||
viewBox="0 0 24 24",
|
||||
width="28",
|
||||
height="28",
|
||||
)
|
||||
|
||||
|
||||
# Icon mapping by nav item ID
|
||||
NAV_ICONS = {
|
||||
"egg": EggIcon,
|
||||
"feed": FeedIcon,
|
||||
"move": MoveIcon,
|
||||
"registry": RegistryIcon,
|
||||
"cohort": CohortIcon,
|
||||
"hatch": HatchIcon,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ NAV_ITEMS = [
|
||||
{"id": "egg", "label": "Egg", "href": "/"},
|
||||
{"id": "feed", "label": "Feed", "href": "/feed"},
|
||||
{"id": "move", "label": "Move", "href": "/move"},
|
||||
{"id": "cohort", "label": "Cohort", "href": "/actions/cohort"},
|
||||
{"id": "hatch", "label": "Hatch", "href": "/actions/hatch"},
|
||||
{"id": "registry", "label": "Registry", "href": "/registry"},
|
||||
]
|
||||
|
||||
|
||||
425
tests/test_web_actions.py
Normal file
425
tests/test_web_actions.py
Normal file
@@ -0,0 +1,425 @@
|
||||
# ABOUTME: Tests for Animal Action web routes.
|
||||
# ABOUTME: Covers cohort creation, hatch recording, and other animal actions.
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def make_test_settings(
|
||||
csrf_secret: str = "test-secret",
|
||||
trusted_proxy_ips: str = "127.0.0.1",
|
||||
dev_mode: bool = True,
|
||||
):
|
||||
"""Create Settings for testing by setting env vars temporarily."""
|
||||
from animaltrack.config import Settings
|
||||
|
||||
old_env = os.environ.copy()
|
||||
try:
|
||||
os.environ["CSRF_SECRET"] = csrf_secret
|
||||
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
|
||||
os.environ["DEV_MODE"] = str(dev_mode).lower()
|
||||
return Settings()
|
||||
finally:
|
||||
os.environ.clear()
|
||||
os.environ.update(old_env)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(seeded_db):
|
||||
"""Create a test client for the app."""
|
||||
from animaltrack.web.app import create_app
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="testclient")
|
||||
app, rt = create_app(settings=settings, db=seeded_db)
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def location_strip1_id(seeded_db):
|
||||
"""Get Strip 1 location ID from seeded data."""
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def location_nursery1_id(seeded_db):
|
||||
"""Get Nursery 1 location ID from seeded data."""
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Nursery 1'").fetchone()
|
||||
return row[0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cohort Creation Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCohortFormRendering:
|
||||
"""Tests for GET /actions/cohort form rendering."""
|
||||
|
||||
def test_cohort_form_renders(self, client):
|
||||
"""GET /actions/cohort returns 200 with form elements."""
|
||||
resp = client.get("/actions/cohort")
|
||||
assert resp.status_code == 200
|
||||
assert "Create Animal Cohort" in resp.text or "Cohort" in resp.text
|
||||
|
||||
def test_cohort_form_has_species_dropdown(self, client):
|
||||
"""Form has species dropdown."""
|
||||
resp = client.get("/actions/cohort")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="species"' in resp.text
|
||||
|
||||
def test_cohort_form_has_location_dropdown(self, client):
|
||||
"""Form has location dropdown with seeded locations."""
|
||||
resp = client.get("/actions/cohort")
|
||||
assert resp.status_code == 200
|
||||
assert "Strip 1" in resp.text
|
||||
assert 'name="location_id"' in resp.text
|
||||
|
||||
def test_cohort_form_has_count_field(self, client):
|
||||
"""Form has count input field."""
|
||||
resp = client.get("/actions/cohort")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="count"' in resp.text
|
||||
|
||||
def test_cohort_form_has_life_stage_dropdown(self, client):
|
||||
"""Form has life stage dropdown."""
|
||||
resp = client.get("/actions/cohort")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="life_stage"' in resp.text
|
||||
|
||||
def test_cohort_form_has_origin_dropdown(self, client):
|
||||
"""Form has origin dropdown."""
|
||||
resp = client.get("/actions/cohort")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="origin"' in resp.text
|
||||
|
||||
|
||||
class TestCohortCreationSuccess:
|
||||
"""Tests for successful POST /actions/animal-cohort."""
|
||||
|
||||
def test_cohort_creates_event(self, client, seeded_db, location_strip1_id):
|
||||
"""POST creates AnimalCohortCreated event when valid."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "5",
|
||||
"life_stage": "adult",
|
||||
"sex": "female",
|
||||
"origin": "purchased",
|
||||
"nonce": "test-cohort-nonce-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify event was created
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'AnimalCohortCreated' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
assert event_row[0] == "AnimalCohortCreated"
|
||||
|
||||
def test_cohort_creates_animals(self, client, seeded_db, location_strip1_id):
|
||||
"""POST creates the correct number of animals in registry."""
|
||||
# Count animals before
|
||||
count_before = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "3",
|
||||
"life_stage": "juvenile",
|
||||
"sex": "unknown",
|
||||
"origin": "hatched",
|
||||
"nonce": "test-cohort-nonce-2",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Count animals after
|
||||
count_after = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||
|
||||
assert count_after == count_before + 3
|
||||
|
||||
def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id):
|
||||
"""Successful cohort creation returns HX-Trigger with toast."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "2",
|
||||
"life_stage": "adult",
|
||||
"sex": "male",
|
||||
"origin": "purchased",
|
||||
"nonce": "test-cohort-nonce-3",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
|
||||
|
||||
class TestCohortCreationValidation:
|
||||
"""Tests for validation errors in POST /actions/animal-cohort."""
|
||||
|
||||
def test_cohort_missing_species_returns_422(self, client, location_strip1_id):
|
||||
"""Missing species returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
# Missing species
|
||||
"location_id": location_strip1_id,
|
||||
"count": "5",
|
||||
"life_stage": "adult",
|
||||
"origin": "purchased",
|
||||
"nonce": "test-cohort-nonce-4",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_cohort_missing_location_returns_422(self, client):
|
||||
"""Missing location returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
# Missing location_id
|
||||
"count": "5",
|
||||
"life_stage": "adult",
|
||||
"origin": "purchased",
|
||||
"nonce": "test-cohort-nonce-5",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_cohort_invalid_count_returns_422(self, client, location_strip1_id):
|
||||
"""Invalid count (0 or negative) returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "0", # Invalid
|
||||
"life_stage": "adult",
|
||||
"origin": "purchased",
|
||||
"nonce": "test-cohort-nonce-6",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_cohort_missing_life_stage_returns_422(self, client, location_strip1_id):
|
||||
"""Missing life stage returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "5",
|
||||
# Missing life_stage
|
||||
"origin": "purchased",
|
||||
"nonce": "test-cohort-nonce-7",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_cohort_missing_origin_returns_422(self, client, location_strip1_id):
|
||||
"""Missing origin returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "5",
|
||||
"life_stage": "adult",
|
||||
# Missing origin
|
||||
"nonce": "test-cohort-nonce-8",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hatch Recording Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestHatchFormRendering:
|
||||
"""Tests for GET /actions/hatch form rendering."""
|
||||
|
||||
def test_hatch_form_renders(self, client):
|
||||
"""GET /actions/hatch returns 200 with form elements."""
|
||||
resp = client.get("/actions/hatch")
|
||||
assert resp.status_code == 200
|
||||
assert "Record Hatch" in resp.text or "Hatch" in resp.text
|
||||
|
||||
def test_hatch_form_has_species_dropdown(self, client):
|
||||
"""Form has species dropdown."""
|
||||
resp = client.get("/actions/hatch")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="species"' in resp.text
|
||||
|
||||
def test_hatch_form_has_location_dropdown(self, client):
|
||||
"""Form has location dropdown."""
|
||||
resp = client.get("/actions/hatch")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="location_id"' in resp.text
|
||||
|
||||
def test_hatch_form_has_hatched_live_field(self, client):
|
||||
"""Form has hatched live count field."""
|
||||
resp = client.get("/actions/hatch")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="hatched_live"' in resp.text
|
||||
|
||||
def test_hatch_form_has_brood_location_dropdown(self, client):
|
||||
"""Form has optional brood location dropdown."""
|
||||
resp = client.get("/actions/hatch")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="assigned_brood_location_id"' in resp.text
|
||||
|
||||
|
||||
class TestHatchRecordingSuccess:
|
||||
"""Tests for successful POST /actions/hatch-recorded."""
|
||||
|
||||
def test_hatch_creates_event(self, client, seeded_db, location_strip1_id):
|
||||
"""POST creates HatchRecorded event when valid."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"hatched_live": "4",
|
||||
"nonce": "test-hatch-nonce-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify event was created
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'HatchRecorded' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
assert event_row[0] == "HatchRecorded"
|
||||
|
||||
def test_hatch_creates_hatchlings(self, client, seeded_db, location_strip1_id):
|
||||
"""POST creates the correct number of hatchling animals."""
|
||||
# Count animals before
|
||||
count_before = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"hatched_live": "6",
|
||||
"nonce": "test-hatch-nonce-2",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Count animals after
|
||||
count_after = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||
|
||||
assert count_after == count_before + 6
|
||||
|
||||
def test_hatch_with_brood_location(
|
||||
self, client, seeded_db, location_strip1_id, location_nursery1_id
|
||||
):
|
||||
"""POST with brood location places hatchlings at brood location."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"assigned_brood_location_id": location_nursery1_id,
|
||||
"hatched_live": "3",
|
||||
"nonce": "test-hatch-nonce-3",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify hatchlings are at brood location
|
||||
count_at_nursery = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_registry WHERE location_id = ? AND life_stage = 'hatchling'",
|
||||
(location_nursery1_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
assert count_at_nursery >= 3
|
||||
|
||||
def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id):
|
||||
"""Successful hatch recording returns HX-Trigger with toast."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"hatched_live": "2",
|
||||
"nonce": "test-hatch-nonce-4",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
|
||||
|
||||
class TestHatchRecordingValidation:
|
||||
"""Tests for validation errors in POST /actions/hatch-recorded."""
|
||||
|
||||
def test_hatch_missing_species_returns_422(self, client, location_strip1_id):
|
||||
"""Missing species returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
# Missing species
|
||||
"location_id": location_strip1_id,
|
||||
"hatched_live": "4",
|
||||
"nonce": "test-hatch-nonce-5",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_hatch_missing_location_returns_422(self, client):
|
||||
"""Missing location returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
"species": "duck",
|
||||
# Missing location_id
|
||||
"hatched_live": "4",
|
||||
"nonce": "test-hatch-nonce-6",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_hatch_invalid_count_returns_422(self, client, location_strip1_id):
|
||||
"""Invalid count (0 or negative) returns 422."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"hatched_live": "0", # Invalid
|
||||
"nonce": "test-hatch-nonce-7",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
Reference in New Issue
Block a user