feat: complete Step 9.1 with outcome, status-correct, and quick actions

- Add animal-outcome route with yield items section for harvest products
- Add animal-status-correct route with @require_role(ADMIN) decorator
- Add exception handlers for AuthenticationError (401) and AuthorizationError (403)
- Enable quick action buttons in animal detail page (Add Tag, Promote, Record Outcome)
- Add comprehensive tests for outcome and status-correct routes (81 total action tests)

🤖 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 13:45:06 +00:00
parent 3acb731a6c
commit 29ea3e27cb
5 changed files with 2036 additions and 4 deletions

View File

@@ -9,9 +9,11 @@ from fasthtml.common import Beforeware, fast_app
from monsterui.all import Theme
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import PlainTextResponse
from animaltrack.config import Settings
from animaltrack.db import get_db
from animaltrack.web.exceptions import AuthenticationError, AuthorizationError
from animaltrack.web.middleware import (
auth_before,
csrf_before,
@@ -132,6 +134,16 @@ def create_app(
app.state.settings = settings
app.state.db = db
# Register exception handlers for auth errors
async def authentication_error_handler(request, exc):
return PlainTextResponse(str(exc) or "Authentication required", status_code=401)
async def authorization_error_handler(request, exc):
return PlainTextResponse(str(exc) or "Forbidden", status_code=403)
app.add_exception_handler(AuthenticationError, authentication_error_handler)
app.add_exception_handler(AuthorizationError, authorization_error_handler)
# Register routes
register_health_routes(rt, app)
register_action_routes(rt, app)

View File

@@ -11,12 +11,18 @@ from fasthtml.common import to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.events.enums import AnimalStatus, Outcome
from animaltrack.events.payloads import (
AnimalAttributesUpdatedPayload,
AnimalCohortCreatedPayload,
AnimalOutcomePayload,
AnimalPromotedPayload,
AnimalStatusCorrectedPayload,
AnimalTagEndedPayload,
AnimalTaggedPayload,
AttributeSet,
HatchRecordedPayload,
YieldItem,
)
from animaltrack.events.store import EventStore
from animaltrack.projections import EventLogProjection, ProjectionRegistry
@@ -26,15 +32,23 @@ from animaltrack.projections.intervals import IntervalProjection
from animaltrack.projections.tags import TagProjection
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.products import ProductRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.auth import UserRole, require_role
from animaltrack.web.templates import page
from animaltrack.web.templates.actions import (
attrs_diff_panel,
attrs_form,
cohort_form,
hatch_form,
outcome_diff_panel,
outcome_form,
promote_form,
status_correct_diff_panel,
status_correct_form,
tag_add_diff_panel,
tag_add_form,
tag_end_diff_panel,
@@ -885,6 +899,709 @@ def _render_tag_end_error_form(db, filter_str, error_message):
)
# =============================================================================
# Update Attributes
# =============================================================================
def attrs_index(request: Request):
"""GET /actions/attrs - Update Attributes form."""
db = request.app.state.db
# Get filter from query params
filter_str = request.query_params.get("filter", "")
# Resolve selection if filter provided
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return page(
attrs_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
),
title="Update Attributes - AnimalTrack",
active_nav=None,
)
async def animal_attrs(request: Request):
"""POST /actions/animal-attrs - Update attributes on animals."""
db = request.app.state.db
form = await request.form()
# Extract form data
filter_str = form.get("filter", "")
sex = form.get("sex", "").strip() or None
life_stage = form.get("life_stage", "").strip() or None
repro_status = form.get("repro_status", "").strip() or None
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
# Get timestamp - use provided or current
ts_utc_str = form.get("ts_utc", "0")
try:
ts_utc = int(ts_utc_str)
if ts_utc == 0:
ts_utc = int(time.time() * 1000)
except ValueError:
ts_utc = int(time.time() * 1000)
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Validation: at least one attribute required
if not sex and not life_stage and not repro_status:
return _render_attrs_error_form(
db, filter_str, "Please select at least one attribute to update"
)
# Validation: must have animals
if not resolved_ids:
return _render_attrs_error_form(db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
filter=filter_str,
resolved_ids=list(resolved_ids),
roster_hash=roster_hash,
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
)
# Validate selection (check for concurrent changes)
result = validate_selection(db, context)
if not result.valid:
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
attrs_diff_panel(
diff=result.diff,
filter_str=filter_str,
resolved_ids=result.resolved_ids,
roster_hash=result.roster_hash,
sex=sex,
life_stage=life_stage,
repro_status=repro_status,
ts_utc=ts_utc,
),
title="Update Attributes - AnimalTrack",
active_nav=None,
)
),
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids
else:
ids_to_update = resolved_ids
# Check we still have animals
if not ids_to_update:
return _render_attrs_error_form(db, filter_str, "No animals remaining")
# Create payload
try:
attr_set = AttributeSet(
sex=sex,
life_stage=life_stage,
repro_status=repro_status,
)
payload = AnimalAttributesUpdatedPayload(
resolved_ids=list(ids_to_update),
set=attr_set,
)
except Exception as e:
return _render_attrs_error_form(db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Update attributes
service = _create_animal_service(db)
try:
event = service.update_attributes(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-attrs"
)
except ValidationError as e:
return _render_attrs_error_form(db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
attrs_form(),
title="Update Attributes - AnimalTrack",
active_nav=None,
)
),
)
# Add toast trigger header
updated_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Updated attributes on {updated_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_attrs_error_form(db, filter_str, error_message):
"""Render attributes form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return HTMLResponse(
content=to_xml(
page(
attrs_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
error=error_message,
),
title="Update Attributes - AnimalTrack",
active_nav=None,
)
),
status_code=422,
)
# =============================================================================
# Record Outcome
# =============================================================================
def outcome_index(request: Request):
"""GET /actions/outcome - Record Outcome form."""
db = request.app.state.db
# Get filter from query params
filter_str = request.query_params.get("filter", "")
# Resolve selection if filter provided
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Get active products for yield items dropdown
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return page(
outcome_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
products=products,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
)
async def animal_outcome(request: Request):
"""POST /actions/animal-outcome - Record outcome for animals."""
db = request.app.state.db
form = await request.form()
# Extract form data
filter_str = form.get("filter", "")
outcome_str = form.get("outcome", "").strip()
reason = form.get("reason", "").strip() or None
notes = form.get("notes", "").strip() or None
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
# Yield item fields
yield_product_code = form.get("yield_product_code", "").strip() or None
yield_unit = form.get("yield_unit", "").strip() or None
yield_quantity_str = form.get("yield_quantity", "").strip()
yield_weight_str = form.get("yield_weight_kg", "").strip()
yield_quantity: int | None = None
yield_weight_kg: float | None = None
if yield_quantity_str:
try:
yield_quantity = int(yield_quantity_str)
except ValueError:
pass
if yield_weight_str:
try:
yield_weight_kg = float(yield_weight_str)
except ValueError:
pass
# Get timestamp - use provided or current
ts_utc_str = form.get("ts_utc", "0")
try:
ts_utc = int(ts_utc_str)
if ts_utc == 0:
ts_utc = int(time.time() * 1000)
except ValueError:
ts_utc = int(time.time() * 1000)
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Validation: outcome required
if not outcome_str:
return _render_outcome_error_form(db, filter_str, "Please select an outcome")
# Validate outcome is valid enum value
try:
outcome_enum = Outcome(outcome_str)
except ValueError:
return _render_outcome_error_form(db, filter_str, f"Invalid outcome: {outcome_str}")
# Validation: must have animals
if not resolved_ids:
return _render_outcome_error_form(db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
filter=filter_str,
resolved_ids=list(resolved_ids),
roster_hash=roster_hash,
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
)
# Validate selection (check for concurrent changes)
result = validate_selection(db, context)
if not result.valid:
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
outcome_diff_panel(
diff=result.diff,
filter_str=filter_str,
resolved_ids=result.resolved_ids,
roster_hash=result.roster_hash,
outcome=outcome_str,
reason=reason,
yield_product_code=yield_product_code,
yield_unit=yield_unit,
yield_quantity=yield_quantity,
yield_weight_kg=yield_weight_kg,
ts_utc=ts_utc,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
)
),
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids
else:
ids_to_update = resolved_ids
# Check we still have animals
if not ids_to_update:
return _render_outcome_error_form(db, filter_str, "No animals remaining")
# Build yield items if provided
yield_items: list[YieldItem] | None = None
if yield_product_code and yield_quantity and yield_quantity > 0:
try:
yield_items = [
YieldItem(
product_code=yield_product_code,
unit=yield_unit or "piece",
quantity=yield_quantity,
weight_kg=yield_weight_kg,
)
]
except Exception:
# Invalid yield item - ignore
yield_items = None
# Create payload
try:
payload = AnimalOutcomePayload(
resolved_ids=list(ids_to_update),
outcome=outcome_enum,
reason=reason,
yield_items=yield_items,
notes=notes,
)
except Exception as e:
return _render_outcome_error_form(db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Record outcome
service = _create_animal_service(db)
try:
event = service.record_outcome(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-outcome"
)
except ValidationError as e:
return _render_outcome_error_form(db, filter_str, str(e))
# Success: re-render fresh form
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
response = HTMLResponse(
content=to_xml(
page(
outcome_form(
filter_str="",
resolved_ids=[],
roster_hash="",
ts_utc=int(time.time() * 1000),
resolved_count=0,
products=products,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
)
),
)
# Add toast trigger header
outcome_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Recorded {outcome_str} for {outcome_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_outcome_error_form(db, filter_str, error_message):
"""Render outcome form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
# Get active products for yield items dropdown
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return HTMLResponse(
content=to_xml(
page(
outcome_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
products=products,
error=error_message,
),
title="Record Outcome - AnimalTrack",
active_nav=None,
)
),
status_code=422,
)
# =============================================================================
# Correct Status (Admin-Only)
# =============================================================================
@require_role(UserRole.ADMIN)
async def status_correct_index(req: Request):
"""GET /actions/status-correct - Correct Status form (admin-only)."""
db = req.app.state.db
# Get filter from query params
filter_str = req.query_params.get("filter", "")
# Resolve selection if filter provided
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return page(
status_correct_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
),
title="Correct Status - AnimalTrack",
active_nav=None,
)
@require_role(UserRole.ADMIN)
async def animal_status_correct(req: Request):
"""POST /actions/animal-status-correct - Correct status of animals (admin-only)."""
db = req.app.state.db
form = await req.form()
# Extract form data
filter_str = form.get("filter", "")
new_status_str = form.get("new_status", "").strip()
reason = form.get("reason", "").strip()
notes = form.get("notes", "").strip() or None
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
# Get timestamp - use provided or current
ts_utc_str = form.get("ts_utc", "0")
try:
ts_utc = int(ts_utc_str)
if ts_utc == 0:
ts_utc = int(time.time() * 1000)
except ValueError:
ts_utc = int(time.time() * 1000)
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Validation: new_status required
if not new_status_str:
return _render_status_correct_error_form(db, filter_str, "Please select a new status")
# Validate status is valid enum value
try:
new_status_enum = AnimalStatus(new_status_str)
except ValueError:
return _render_status_correct_error_form(
db, filter_str, f"Invalid status: {new_status_str}"
)
# Validation: reason required for admin actions
if not reason:
return _render_status_correct_error_form(db, filter_str, "Reason is required")
# Validation: must have animals
if not resolved_ids:
return _render_status_correct_error_form(db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
filter=filter_str,
resolved_ids=list(resolved_ids),
roster_hash=roster_hash,
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
)
# Validate selection (check for concurrent changes)
result = validate_selection(db, context)
if not result.valid:
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
status_correct_diff_panel(
diff=result.diff,
filter_str=filter_str,
resolved_ids=result.resolved_ids,
roster_hash=result.roster_hash,
new_status=new_status_str,
reason=reason,
ts_utc=ts_utc,
),
title="Correct Status - AnimalTrack",
active_nav=None,
)
),
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_update = current_resolution.animal_ids
else:
ids_to_update = resolved_ids
# Check we still have animals
if not ids_to_update:
return _render_status_correct_error_form(db, filter_str, "No animals remaining")
# Create payload
try:
payload = AnimalStatusCorrectedPayload(
resolved_ids=list(ids_to_update),
new_status=new_status_enum,
reason=reason,
notes=notes,
)
except Exception as e:
return _render_status_correct_error_form(db, filter_str, str(e))
# Get actor from auth
auth = req.scope.get("auth")
actor = auth.username if auth else "unknown"
# Correct status
service = _create_animal_service(db)
try:
event = service.correct_status(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-status-correct"
)
except ValidationError as e:
return _render_status_correct_error_form(db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
status_correct_form(
filter_str="",
resolved_ids=[],
roster_hash="",
ts_utc=int(time.time() * 1000),
resolved_count=0,
),
title="Correct Status - AnimalTrack",
active_nav=None,
)
),
)
# Add toast trigger header
corrected_count = len(event.entity_refs.get("animal_ids", []))
response.headers["HX-Trigger"] = json.dumps(
{
"showToast": {
"message": f"Corrected status to {new_status_str} for {corrected_count} animal(s)",
"type": "success",
}
}
)
return response
def _render_status_correct_error_form(db, filter_str, error_message):
"""Render status correct form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return HTMLResponse(
content=to_xml(
page(
status_correct_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
error=error_message,
),
title="Correct Status - AnimalTrack",
active_nav=None,
)
),
status_code=422,
)
# =============================================================================
# Route Registration
# =============================================================================
@@ -912,3 +1629,11 @@ def register_action_routes(rt, app):
rt("/actions/animal-tag-add", methods=["POST"])(animal_tag_add)
rt("/actions/tag-end")(tag_end_index)
rt("/actions/animal-tag-end", methods=["POST"])(animal_tag_end)
rt("/actions/attrs")(attrs_index)
rt("/actions/animal-attrs", methods=["POST"])(animal_attrs)
rt("/actions/outcome")(outcome_index)
rt("/actions/animal-outcome", methods=["POST"])(animal_outcome)
# Admin-only actions
rt("/actions/status-correct")(status_correct_index)
rt("/actions/animal-status-correct", methods=["POST"])(animal_status_correct)

View File

@@ -4,7 +4,7 @@
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
from fasthtml.common import H2, H3, Div, Form, Hidden, Option, P, Span
from monsterui.all import (
Alert,
AlertT,
@@ -758,3 +758,705 @@ def tag_end_diff_panel(
confirm_form,
cls="space-y-4",
)
# =============================================================================
# Update Attributes Form
# =============================================================================
def attrs_form(
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
ts_utc: int | None = None,
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-attrs",
) -> Form:
"""Create the Update Attributes form.
Args:
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
Returns:
Form component for updating animal attributes.
"""
if resolved_ids is None:
resolved_ids = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
# Build sex options
sex_options = [
Option("No change", value="", selected=True),
Option("Female", value="female"),
Option("Male", value="male"),
Option("Unknown", value="unknown"),
]
# Build life stage options
life_stage_options = [
Option("No change", value="", selected=True),
Option("Hatchling", value="hatchling"),
Option("Juvenile", value="juvenile"),
Option("Subadult", value="subadult"),
Option("Adult", value="adult"),
]
# Build repro status options (intact, wether, spayed, unknown)
repro_status_options = [
Option("No change", value="", selected=True),
Option("Intact", value="intact"),
Option("Wether (castrated male)", value="wether"),
Option("Spayed (female)", value="spayed"),
Option("Unknown", value="unknown"),
]
# Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
H2("Update Attributes", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
placeholder="e.g., species:duck life_stage:juvenile",
),
# Selection preview
selection_preview,
# Attribute dropdowns
LabelSelect(
*sex_options,
label="Sex",
id="sex",
name="sex",
),
LabelSelect(
*life_stage_options,
label="Life Stage",
id="life_stage",
name="life_stage",
),
LabelSelect(
*repro_status_options,
label="Reproductive Status",
id="repro_status",
name="repro_status",
),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Update Attributes", type="submit", cls=ButtonT.primary),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
def attrs_diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
sex: str | None,
life_stage: str | None,
repro_status: str | None,
ts_utc: int,
action: Callable[..., Any] | str = "/actions/animal-attrs",
) -> Div:
"""Create the mismatch confirmation panel for attributes update.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
sex: Sex to set (or None).
life_stage: Life stage to set (or None).
repro_status: Repro status to set (or None).
ts_utc: Timestamp for resolution.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="sex", value=sex or ""),
Hidden(name="life_stage", value=life_stage or ""),
Hidden(name="repro_status", value=repro_status or ""),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/attrs'",
),
Button(
f"Confirm Update ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with updating {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)
# =============================================================================
# Record Outcome Form
# =============================================================================
def outcome_form(
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
ts_utc: int | None = None,
resolved_count: int = 0,
products: list[tuple[str, str]] | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-outcome",
) -> Form:
"""Create the Record Outcome form.
Args:
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
products: List of (code, name) tuples for product dropdown.
error: Optional error message to display.
action: Route function or URL string for form submission.
Returns:
Form component for recording animal outcomes.
"""
if resolved_ids is None:
resolved_ids = []
if products is None:
products = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
# Build outcome options
outcome_options = [
Option("Select outcome...", value="", selected=True, disabled=True),
Option("Death (natural)", value="death"),
Option("Harvest", value="harvest"),
Option("Sold", value="sold"),
Option("Predator Loss", value="predator_loss"),
Option("Unknown", value="unknown"),
]
# Build product options for yield items
product_options = [Option("Select product...", value="", selected=True)]
for code, name in products:
product_options.append(Option(f"{name} ({code})", value=code))
# Unit options for yield items
unit_options = [
Option("Piece", value="piece", selected=True),
Option("Kg", value="kg"),
]
# Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
# Yield items section (for harvest - visible via CSS/JS, but we include it always)
yield_section = Div(
H3("Yield Items", cls="text-lg font-semibold mt-4 mb-2"),
P("Optional: record products collected from harvest", cls="text-sm text-stone-500 mb-3"),
Div(
LabelSelect(
*product_options,
label="Product",
id="yield_product_code",
name="yield_product_code",
cls="flex-1",
),
LabelSelect(
*unit_options,
label="Unit",
id="yield_unit",
name="yield_unit",
cls="w-32",
),
cls="flex gap-3",
),
Div(
LabelInput(
label="Quantity",
id="yield_quantity",
name="yield_quantity",
type="number",
min="1",
placeholder="1",
cls="flex-1",
),
LabelInput(
label="Weight (kg)",
id="yield_weight_kg",
name="yield_weight_kg",
type="number",
step="0.001",
placeholder="Optional",
cls="flex-1",
),
cls="flex gap-3",
),
id="yield-section",
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md space-y-3",
)
return Form(
H2("Record Outcome", cls="text-xl font-bold mb-4"),
error_component,
selection_preview,
# Filter field
LabelInput(
label="Filter (DSL)",
id="filter",
name="filter",
value=filter_str,
placeholder="e.g., species:duck location:Coop1",
),
# Outcome selection
LabelSelect(
*outcome_options,
label="Outcome",
id="outcome",
name="outcome",
required=True,
),
# Reason field
LabelInput(
label="Reason (optional)",
id="reason",
name="reason",
placeholder="e.g., old age, processing day",
),
# Yield items section
yield_section,
# Notes field
LabelTextArea(
label="Notes (optional)",
id="notes",
name="notes",
rows=2,
placeholder="Any additional notes...",
),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
def outcome_diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
outcome: str,
reason: str | None,
yield_product_code: str | None,
yield_unit: str | None,
yield_quantity: int | None,
yield_weight_kg: float | None,
ts_utc: int,
action: Callable[..., Any] | str = "/actions/animal-outcome",
) -> Div:
"""Create the mismatch confirmation panel for outcome recording.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
outcome: Outcome to record.
reason: Reason for outcome (or None).
yield_product_code: Product code for yield item (or None).
yield_unit: Unit for yield item (or None).
yield_quantity: Quantity for yield item (or None).
yield_weight_kg: Weight in kg for yield item (or None).
ts_utc: Timestamp for resolution.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="outcome", value=outcome),
Hidden(name="reason", value=reason or ""),
Hidden(name="yield_product_code", value=yield_product_code or ""),
Hidden(name="yield_unit", value=yield_unit or ""),
Hidden(name="yield_quantity", value=str(yield_quantity) if yield_quantity else ""),
Hidden(name="yield_weight_kg", value=str(yield_weight_kg) if yield_weight_kg else ""),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/outcome'",
),
Button(
f"Confirm Outcome ({diff.server_count} animals)",
type="submit",
cls=ButtonT.destructive,
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with recording {outcome} for {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)
# =============================================================================
# Correct Status Form (Admin-Only)
# =============================================================================
def status_correct_form(
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
ts_utc: int | None = None,
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-status-correct",
) -> Form:
"""Create the Correct Status form (admin-only).
Args:
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
Returns:
Form component for correcting animal status.
"""
if resolved_ids is None:
resolved_ids = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Admin warning
admin_warning = Alert(
"This is an administrative action. Changes will be logged with your reason.",
cls=AlertT.warning,
)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
# Build status options
status_options = [
Option("Select new status...", value="", selected=True, disabled=True),
Option("Alive", value="alive"),
Option("Dead", value="dead"),
Option("Harvested", value="harvested"),
Option("Sold", value="sold"),
]
# Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
H2("Correct Animal Status", cls="text-xl font-bold mb-4"),
admin_warning,
error_component,
selection_preview,
# Filter field
LabelInput(
label="Filter (DSL)",
id="filter",
name="filter",
value=filter_str,
placeholder="e.g., species:duck location:Coop1",
),
# New status selection
LabelSelect(
*status_options,
label="New Status",
id="new_status",
name="new_status",
required=True,
),
# Reason field (required for admin actions)
LabelInput(
label="Reason (required)",
id="reason",
name="reason",
placeholder="e.g., Data entry error, mis-identified animal",
required=True,
),
# Notes field
LabelTextArea(
label="Notes (optional)",
id="notes",
name="notes",
rows=2,
placeholder="Any additional notes...",
),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Correct Status", type="submit", cls=ButtonT.destructive),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
def status_correct_diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
new_status: str,
reason: str,
ts_utc: int,
action: Callable[..., Any] | str = "/actions/animal-status-correct",
) -> Div:
"""Create the mismatch confirmation panel for status correction.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
new_status: New status to set.
reason: Reason for correction.
ts_utc: Timestamp for resolution.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="new_status", value=new_status),
Hidden(name="reason", value=reason),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/status-correct'",
),
Button(
f"Confirm Correction ({diff.server_count} animals)",
type="submit",
cls=ButtonT.destructive,
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with correcting status to {new_status} for {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)

View File

@@ -155,14 +155,23 @@ def quick_actions_card(animal: AnimalDetail) -> Card:
)
)
actions.append(
Button("Add Tag", cls=ButtonT.default + " w-full", disabled=True),
A(
Button("Add Tag", cls=ButtonT.default + " w-full"),
href=f"/actions/tag-add?filter=animal_id:{animal.animal_id}",
)
)
if not animal.identified:
actions.append(
Button("Promote", cls=ButtonT.default + " w-full", disabled=True),
A(
Button("Promote", cls=ButtonT.default + " w-full"),
href=f"/actions/promote/{animal.animal_id}",
)
)
actions.append(
Button("Record Outcome", cls=ButtonT.destructive + " w-full", disabled=True),
A(
Button("Record Outcome", cls=ButtonT.destructive + " w-full"),
href=f"/actions/outcome?filter=animal_id:{animal.animal_id}",
)
)
return Card(