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
|
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,
|
request_id_before,
|
||||||
)
|
)
|
||||||
from animaltrack.web.routes import (
|
from animaltrack.web.routes import (
|
||||||
|
register_action_routes,
|
||||||
register_animals_routes,
|
register_animals_routes,
|
||||||
register_egg_routes,
|
register_egg_routes,
|
||||||
register_events_routes,
|
register_events_routes,
|
||||||
@@ -133,6 +134,7 @@ def create_app(
|
|||||||
|
|
||||||
# Register routes
|
# Register routes
|
||||||
register_health_routes(rt, app)
|
register_health_routes(rt, app)
|
||||||
|
register_action_routes(rt, app)
|
||||||
register_animals_routes(rt, app)
|
register_animals_routes(rt, app)
|
||||||
register_egg_routes(rt, app)
|
register_egg_routes(rt, app)
|
||||||
register_events_routes(rt, app)
|
register_events_routes(rt, app)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# ABOUTME: Routes package for AnimalTrack web application.
|
# ABOUTME: Routes package for AnimalTrack web application.
|
||||||
# ABOUTME: Contains modular route handlers for different features.
|
# 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.animals import register_animals_routes
|
||||||
from animaltrack.web.routes.eggs import register_egg_routes
|
from animaltrack.web.routes.eggs import register_egg_routes
|
||||||
from animaltrack.web.routes.events import register_events_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
|
from animaltrack.web.routes.registry import register_registry_routes
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"register_action_routes",
|
||||||
"register_animals_routes",
|
"register_animals_routes",
|
||||||
"register_egg_routes",
|
"register_egg_routes",
|
||||||
"register_events_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
|
# Icon mapping by nav item ID
|
||||||
NAV_ICONS = {
|
NAV_ICONS = {
|
||||||
"egg": EggIcon,
|
"egg": EggIcon,
|
||||||
"feed": FeedIcon,
|
"feed": FeedIcon,
|
||||||
"move": MoveIcon,
|
"move": MoveIcon,
|
||||||
"registry": RegistryIcon,
|
"registry": RegistryIcon,
|
||||||
|
"cohort": CohortIcon,
|
||||||
|
"hatch": HatchIcon,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ NAV_ITEMS = [
|
|||||||
{"id": "egg", "label": "Egg", "href": "/"},
|
{"id": "egg", "label": "Egg", "href": "/"},
|
||||||
{"id": "feed", "label": "Feed", "href": "/feed"},
|
{"id": "feed", "label": "Feed", "href": "/feed"},
|
||||||
{"id": "move", "label": "Move", "href": "/move"},
|
{"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"},
|
{"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