feat: add render_page() helper and fix username display

Create render_page() wrapper that auto-extracts auth from request
for page() calls. This eliminates manual username/user_role passing
and ensures consistent auth handling across all routes.

Updated all route files to use render_page():
- actions.py, eggs.py, feed.py, move.py, products.py
- animals.py, events.py, locations.py, registry.py

This fixes "Guest" username display on forms by ensuring auth
is always extracted from request.scope.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-01 13:50:28 +00:00
parent b74ca53f20
commit fdbf259182
11 changed files with 243 additions and 151 deletions

View File

@@ -38,7 +38,7 @@ from animaltrack.selection import compute_roster_hash, parse_filter, resolve_fil
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 import render_page
from animaltrack.web.templates.actions import (
attrs_diff_panel,
attrs_form,
@@ -110,7 +110,8 @@ def cohort_index(request: Request):
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_active()
return page(
return render_page(
request,
cohort_form(locations, species_list),
title="Create Cohort - AnimalTrack",
active_nav=None,
@@ -141,20 +142,32 @@ async def animal_cohort(request: Request):
try:
count = int(count_str)
if count < 1:
return _render_cohort_error(locations, species_list, "Count must be at least 1", form)
return _render_cohort_error(
request, 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)
return _render_cohort_error(
request, locations, species_list, "Count must be a valid number", form
)
# Validate required fields - check for empty or placeholder values
# Note: disabled placeholder options may send their text instead of empty value
if not species or species not in ("duck", "goose"):
return _render_cohort_error(locations, species_list, "Please select a species", form)
return _render_cohort_error(
request, locations, species_list, "Please select a species", form
)
if not location_id or len(location_id) != 26:
return _render_cohort_error(locations, species_list, "Please select a location", form)
return _render_cohort_error(
request, locations, species_list, "Please select a location", form
)
if not life_stage or life_stage not in ("hatchling", "juvenile", "subadult", "adult"):
return _render_cohort_error(locations, species_list, "Please select a life stage", form)
return _render_cohort_error(
request, locations, species_list, "Please select a life stage", form
)
if not origin or origin not in ("hatched", "purchased", "rescued", "unknown"):
return _render_cohort_error(locations, species_list, "Please select an origin", form)
return _render_cohort_error(
request, locations, species_list, "Please select an origin", form
)
# Create payload
try:
@@ -168,7 +181,7 @@ async def animal_cohort(request: Request):
notes=notes,
)
except Exception as e:
return _render_cohort_error(locations, species_list, str(e), form)
return _render_cohort_error(request, locations, species_list, str(e), form)
# Get actor from auth
auth = request.scope.get("auth")
@@ -183,12 +196,13 @@ async def animal_cohort(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-cohort"
)
except ValidationError as e:
return _render_cohort_error(locations, species_list, str(e), form)
return _render_cohort_error(request, locations, species_list, str(e), form)
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
cohort_form(locations, species_list),
title="Create Cohort - AnimalTrack",
active_nav=None,
@@ -211,6 +225,7 @@ async def animal_cohort(request: Request):
def _render_cohort_error(
request: Request,
locations: list,
species_list: list,
error_message: str,
@@ -219,7 +234,8 @@ def _render_cohort_error(
"""Render cohort form with error message."""
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
cohort_form(
locations,
species_list,
@@ -255,7 +271,8 @@ def hatch_index(request: Request):
locations = LocationRepository(db).list_active()
species_list = SpeciesRepository(db).list_active()
return page(
return render_page(
request,
hatch_form(locations, species_list),
title="Record Hatch - AnimalTrack",
active_nav=None,
@@ -285,18 +302,22 @@ async def hatch_recorded(request: Request):
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
request, 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
request, 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)
return _render_hatch_error(
request, 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)
return _render_hatch_error(
request, locations, species_list, "Please select a hatch location", form
)
# Create payload
try:
@@ -308,7 +329,7 @@ async def hatch_recorded(request: Request):
notes=notes,
)
except Exception as e:
return _render_hatch_error(locations, species_list, str(e), form)
return _render_hatch_error(request, locations, species_list, str(e), form)
# Get actor from auth
auth = request.scope.get("auth")
@@ -323,12 +344,13 @@ async def hatch_recorded(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/hatch-recorded"
)
except ValidationError as e:
return _render_hatch_error(locations, species_list, str(e), form)
return _render_hatch_error(request, locations, species_list, str(e), form)
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
hatch_form(locations, species_list),
title="Record Hatch - AnimalTrack",
active_nav=None,
@@ -351,6 +373,7 @@ async def hatch_recorded(request: Request):
def _render_hatch_error(
request: Request,
locations: list,
species_list: list,
error_message: str,
@@ -359,7 +382,8 @@ def _render_hatch_error(
"""Render hatch form with error message."""
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
hatch_form(
locations,
species_list,
@@ -402,7 +426,8 @@ def promote_index(request: Request, animal_id: str):
if animal.identified:
return HTMLResponse(content="Animal is already identified", status_code=400)
return page(
return render_page(
request,
promote_form(animal),
title="Promote Animal - AnimalTrack",
active_nav=None,
@@ -436,10 +461,10 @@ async def animal_promote(request: Request):
return HTMLResponse(content="Animal not found", status_code=404)
if animal.status != "alive":
return _render_promote_error(animal, "Only alive animals can be promoted", form)
return _render_promote_error(request, animal, "Only alive animals can be promoted", form)
if animal.identified:
return _render_promote_error(animal, "Animal is already identified", form)
return _render_promote_error(request, animal, "Animal is already identified", form)
# Create payload
try:
@@ -452,7 +477,7 @@ async def animal_promote(request: Request):
notes=notes,
)
except Exception as e:
return _render_promote_error(animal, str(e), form)
return _render_promote_error(request, animal, str(e), form)
# Get actor from auth
auth = request.scope.get("auth")
@@ -465,7 +490,7 @@ async def animal_promote(request: Request):
try:
service.promote_animal(payload, ts_utc, actor, nonce=nonce, route="/actions/animal-promote")
except ValidationError as e:
return _render_promote_error(animal, str(e), form)
return _render_promote_error(request, animal, str(e), form)
# Success: redirect to animal detail page
from starlette.responses import RedirectResponse
@@ -479,6 +504,7 @@ async def animal_promote(request: Request):
def _render_promote_error(
request: Request,
animal: Any,
error_message: str,
form_data: Any = None,
@@ -486,7 +512,8 @@ def _render_promote_error(
"""Render promote form with error message."""
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
promote_form(
animal,
error=error_message,
@@ -529,7 +556,8 @@ def tag_add_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return page(
return render_page(
request,
tag_add_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -563,11 +591,11 @@ async def animal_tag_add(request: Request):
# Validation: tag required
if not tag:
return _render_tag_add_error_form(db, filter_str, "Please enter a tag")
return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag")
# Validation: must have animals
if not resolved_ids:
return _render_tag_add_error_form(db, filter_str, "No animals selected")
return _render_tag_add_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
@@ -586,7 +614,8 @@ async def animal_tag_add(request: Request):
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
tag_add_diff_panel(
diff=result.diff,
filter_str=filter_str,
@@ -613,7 +642,7 @@ async def animal_tag_add(request: Request):
# Check we still have animals
if not ids_to_tag:
return _render_tag_add_error_form(db, filter_str, "No animals remaining to tag")
return _render_tag_add_error_form(request, db, filter_str, "No animals remaining to tag")
# Create payload
try:
@@ -622,7 +651,7 @@ async def animal_tag_add(request: Request):
tag=tag,
)
except Exception as e:
return _render_tag_add_error_form(db, filter_str, str(e))
return _render_tag_add_error_form(request, db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
@@ -636,12 +665,13 @@ async def animal_tag_add(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-tag-add"
)
except ValidationError as e:
return _render_tag_add_error_form(db, filter_str, str(e))
return _render_tag_add_error_form(request, db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
tag_add_form(),
title="Add Tag - AnimalTrack",
active_nav=None,
@@ -663,7 +693,7 @@ async def animal_tag_add(request: Request):
return response
def _render_tag_add_error_form(db, filter_str, error_message):
def _render_tag_add_error_form(request, db, filter_str, error_message):
"""Render tag add form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
@@ -680,7 +710,8 @@ def _render_tag_add_error_form(db, filter_str, error_message):
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
tag_add_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -753,7 +784,8 @@ def tag_end_index(request: Request):
roster_hash = compute_roster_hash(resolved_ids, None)
active_tags = _get_active_tags_for_animals(db, resolved_ids)
return page(
return render_page(
request,
tag_end_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -788,11 +820,11 @@ async def animal_tag_end(request: Request):
# Validation: tag required
if not tag:
return _render_tag_end_error_form(db, filter_str, "Please select a tag to end")
return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end")
# Validation: must have animals
if not resolved_ids:
return _render_tag_end_error_form(db, filter_str, "No animals selected")
return _render_tag_end_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
@@ -811,7 +843,8 @@ async def animal_tag_end(request: Request):
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
tag_end_diff_panel(
diff=result.diff,
filter_str=filter_str,
@@ -838,7 +871,7 @@ async def animal_tag_end(request: Request):
# Check we still have animals
if not ids_to_untag:
return _render_tag_end_error_form(db, filter_str, "No animals remaining")
return _render_tag_end_error_form(request, db, filter_str, "No animals remaining")
# Create payload
try:
@@ -847,7 +880,7 @@ async def animal_tag_end(request: Request):
tag=tag,
)
except Exception as e:
return _render_tag_end_error_form(db, filter_str, str(e))
return _render_tag_end_error_form(request, db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
@@ -861,12 +894,13 @@ async def animal_tag_end(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-tag-end"
)
except ValidationError as e:
return _render_tag_end_error_form(db, filter_str, str(e))
return _render_tag_end_error_form(request, db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
tag_end_form(),
title="End Tag - AnimalTrack",
active_nav=None,
@@ -888,7 +922,7 @@ async def animal_tag_end(request: Request):
return response
def _render_tag_end_error_form(db, filter_str, error_message):
def _render_tag_end_error_form(request, db, filter_str, error_message):
"""Render tag end form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
@@ -907,7 +941,8 @@ def _render_tag_end_error_form(db, filter_str, error_message):
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
tag_end_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -951,7 +986,8 @@ def attrs_index(request: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return page(
return render_page(
request,
attrs_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -988,12 +1024,12 @@ async def animal_attrs(request: Request):
# 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"
request, 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")
return _render_attrs_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
@@ -1012,7 +1048,8 @@ async def animal_attrs(request: Request):
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
attrs_diff_panel(
diff=result.diff,
filter_str=filter_str,
@@ -1041,7 +1078,7 @@ async def animal_attrs(request: Request):
# Check we still have animals
if not ids_to_update:
return _render_attrs_error_form(db, filter_str, "No animals remaining")
return _render_attrs_error_form(request, db, filter_str, "No animals remaining")
# Create payload
try:
@@ -1055,7 +1092,7 @@ async def animal_attrs(request: Request):
set=attr_set,
)
except Exception as e:
return _render_attrs_error_form(db, filter_str, str(e))
return _render_attrs_error_form(request, db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
@@ -1069,12 +1106,13 @@ async def animal_attrs(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-attrs"
)
except ValidationError as e:
return _render_attrs_error_form(db, filter_str, str(e))
return _render_attrs_error_form(request, db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
attrs_form(),
title="Update Attributes - AnimalTrack",
active_nav=None,
@@ -1096,7 +1134,7 @@ async def animal_attrs(request: Request):
return response
def _render_attrs_error_form(db, filter_str, error_message):
def _render_attrs_error_form(request, db, filter_str, error_message):
"""Render attributes form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
@@ -1113,7 +1151,8 @@ def _render_attrs_error_form(db, filter_str, error_message):
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
attrs_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -1160,7 +1199,8 @@ def outcome_index(request: Request):
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return page(
return render_page(
request,
outcome_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -1218,17 +1258,19 @@ async def animal_outcome(request: Request):
# Validation: outcome required
if not outcome_str:
return _render_outcome_error_form(db, filter_str, "Please select an outcome")
return _render_outcome_error_form(request, 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}")
return _render_outcome_error_form(
request, 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")
return _render_outcome_error_form(request, db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
@@ -1247,7 +1289,8 @@ async def animal_outcome(request: Request):
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
outcome_diff_panel(
diff=result.diff,
filter_str=filter_str,
@@ -1279,7 +1322,7 @@ async def animal_outcome(request: Request):
# Check we still have animals
if not ids_to_update:
return _render_outcome_error_form(db, filter_str, "No animals remaining")
return _render_outcome_error_form(request, db, filter_str, "No animals remaining")
# Build yield items if provided
yield_items: list[YieldItem] | None = None
@@ -1307,7 +1350,7 @@ async def animal_outcome(request: Request):
notes=notes,
)
except Exception as e:
return _render_outcome_error_form(db, filter_str, str(e))
return _render_outcome_error_form(request, db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
@@ -1321,7 +1364,7 @@ async def animal_outcome(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-outcome"
)
except ValidationError as e:
return _render_outcome_error_form(db, filter_str, str(e))
return _render_outcome_error_form(request, db, filter_str, str(e))
# Success: re-render fresh form
product_repo = ProductRepository(db)
@@ -1329,7 +1372,8 @@ async def animal_outcome(request: Request):
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
outcome_form(
filter_str="",
resolved_ids=[],
@@ -1358,7 +1402,7 @@ async def animal_outcome(request: Request):
return response
def _render_outcome_error_form(db, filter_str, error_message):
def _render_outcome_error_form(request, db, filter_str, error_message):
"""Render outcome form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
@@ -1379,7 +1423,8 @@ def _render_outcome_error_form(db, filter_str, error_message):
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
outcome_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -1424,7 +1469,8 @@ async def status_correct_index(req: Request):
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return page(
return render_page(
req,
status_correct_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
@@ -1461,23 +1507,23 @@ async def animal_status_correct(req: Request):
# Validation: new_status required
if not new_status_str:
return _render_status_correct_error_form(db, filter_str, "Please select a new status")
return _render_status_correct_error_form(req, 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}"
req, 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")
return _render_status_correct_error_form(req, 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")
return _render_status_correct_error_form(req, db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
@@ -1496,7 +1542,8 @@ async def animal_status_correct(req: Request):
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
render_page(
req,
status_correct_diff_panel(
diff=result.diff,
filter_str=filter_str,
@@ -1524,7 +1571,7 @@ async def animal_status_correct(req: Request):
# Check we still have animals
if not ids_to_update:
return _render_status_correct_error_form(db, filter_str, "No animals remaining")
return _render_status_correct_error_form(req, db, filter_str, "No animals remaining")
# Create payload
try:
@@ -1535,7 +1582,7 @@ async def animal_status_correct(req: Request):
notes=notes,
)
except Exception as e:
return _render_status_correct_error_form(db, filter_str, str(e))
return _render_status_correct_error_form(req, db, filter_str, str(e))
# Get actor from auth
auth = req.scope.get("auth")
@@ -1549,12 +1596,13 @@ async def animal_status_correct(req: Request):
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))
return _render_status_correct_error_form(req, db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
render_page(
req,
status_correct_form(
filter_str="",
resolved_ids=[],
@@ -1582,7 +1630,7 @@ async def animal_status_correct(req: Request):
return response
def _render_status_correct_error_form(db, filter_str, error_message):
def _render_status_correct_error_form(request, 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)
@@ -1599,7 +1647,8 @@ def _render_status_correct_error_form(db, filter_str, error_message):
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
status_correct_form(
filter_str=filter_str,
resolved_ids=resolved_ids,

View File

@@ -7,7 +7,7 @@ from starlette.responses import HTMLResponse
from animaltrack.repositories.animal_timeline import AnimalTimelineRepository
from animaltrack.web.templates.animal_detail import animal_detail_page
from animaltrack.web.templates.base import page
from animaltrack.web.templates.base import render_page
# APIRouter for multi-file route organization
ar = APIRouter()
@@ -40,7 +40,8 @@ def animal_detail(request: Request, animal_id: str):
title = f"{display_name} - AnimalTrack"
# Full page render
return page(
return render_page(
request,
animal_detail_page(animal, timeline, merge_info),
title=title,
active_nav=None,

View File

@@ -24,7 +24,7 @@ from animaltrack.repositories.products import ProductRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository
from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates import render_page
from animaltrack.web.templates.eggs import eggs_page
# APIRouter for multi-file route organization
@@ -78,10 +78,9 @@ def egg_index(request: Request):
locations = LocationRepository(db).list_active()
products = _get_sellable_products(db)
# Get auth info for user role
# Get auth info for user defaults
auth = request.scope.get("auth")
username = auth.username if auth else None
user_role = auth.role if auth else None
# Check for active tab from query params
active_tab = request.query_params.get("tab", "harvest")
@@ -97,7 +96,8 @@ def egg_index(request: Request):
if defaults:
selected_location_id = defaults.location_id
return page(
return render_page(
request,
eggs_page(
locations,
products,
@@ -108,8 +108,6 @@ def egg_index(request: Request):
),
title="Eggs - AnimalTrack",
active_nav="eggs",
user_role=user_role,
username=username,
)
@@ -122,7 +120,6 @@ async def product_collected(request: Request):
# Get auth info
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
user_role = auth.role if auth else None
# Extract form data
location_id = form.get("location_id", "")
@@ -208,7 +205,8 @@ async def product_collected(request: Request):
# Success: re-render form with location sticking, qty cleared
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
eggs_page(
locations,
products,
@@ -219,8 +217,6 @@ async def product_collected(request: Request):
),
title="Eggs - AnimalTrack",
active_nav="eggs",
user_role=user_role,
username=actor,
)
),
)
@@ -242,7 +238,6 @@ async def product_sold(request: Request):
# Get auth info
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
user_role = auth.role if auth else None
# Extract form data
product_code = form.get("product_code", "")
@@ -321,7 +316,8 @@ async def product_sold(request: Request):
# Success: re-render form with product sticking
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
eggs_page(
locations,
products,
@@ -332,8 +328,6 @@ async def product_sold(request: Request):
),
title="Eggs - AnimalTrack",
active_nav="eggs",
user_role=user_role,
username=actor,
)
),
)
@@ -359,13 +353,10 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
Returns:
HTMLResponse with 422 status.
"""
auth = request.scope.get("auth")
user_role = auth.role if auth else None
username = auth.username if auth else None
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
eggs_page(
locations,
products,
@@ -377,8 +368,6 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
),
title="Eggs - AnimalTrack",
active_nav="eggs",
user_role=user_role,
username=username,
)
),
status_code=422,
@@ -398,13 +387,10 @@ def _render_sell_error(request, locations, products, selected_product_code, erro
Returns:
HTMLResponse with 422 status.
"""
auth = request.scope.get("auth")
user_role = auth.role if auth else None
username = auth.username if auth else None
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
eggs_page(
locations,
products,
@@ -416,8 +402,6 @@ def _render_sell_error(request, locations, products, selected_product_code, erro
),
title="Eggs - AnimalTrack",
active_nav="eggs",
user_role=user_role,
username=username,
)
),
status_code=422,

View File

@@ -12,7 +12,7 @@ from starlette.responses import HTMLResponse
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.web.templates import page
from animaltrack.web.templates import render_page
from animaltrack.web.templates.events import event_log_list, event_log_panel
# APIRouter for multi-file route organization
@@ -61,10 +61,9 @@ def event_log_index(request: Request):
"""GET /event-log - Event log for a location."""
db = request.app.state.db
# Get auth info
# Get username for user defaults lookup
auth = request.scope.get("auth")
username = auth.username if auth else None
user_role = auth.role if auth else None
# Get location_id from query params
location_id = request.query_params.get("location_id")
@@ -100,10 +99,9 @@ def event_log_index(request: Request):
return HTMLResponse(content=to_xml(event_log_list(events)))
# Full page render
return page(
return render_page(
request,
event_log_panel(events, locations, location_id),
title="Event Log - AnimalTrack",
active_nav="event_log",
user_role=user_role,
username=username,
)

View File

@@ -21,7 +21,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository
from animaltrack.services.feed import FeedService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates import render_page
from animaltrack.web.templates.feed import feed_page
@@ -91,7 +91,8 @@ def feed_index(request: Request):
selected_feed_type_code = defaults.feed_type_code
default_amount_kg = defaults.amount_kg
return page(
return render_page(
request,
feed_page(
locations,
feed_types,
@@ -134,6 +135,7 @@ async def feed_given(request: Request):
# Validate location_id
if not location_id:
return _render_give_error(
request,
locations,
feed_types,
"Please select a location",
@@ -144,6 +146,7 @@ async def feed_given(request: Request):
# Validate feed_type_code
if not feed_type_code:
return _render_give_error(
request,
locations,
feed_types,
"Please select a feed type",
@@ -156,6 +159,7 @@ async def feed_given(request: Request):
amount_kg = int(amount_kg_str)
except ValueError:
return _render_give_error(
request,
locations,
feed_types,
"Amount must be a number",
@@ -165,6 +169,7 @@ async def feed_given(request: Request):
if amount_kg < 1:
return _render_give_error(
request,
locations,
feed_types,
"Amount must be at least 1 kg",
@@ -206,6 +211,7 @@ async def feed_given(request: Request):
)
except ValidationError as e:
return _render_give_error(
request,
locations,
feed_types,
str(e),
@@ -235,7 +241,8 @@ async def feed_given(request: Request):
# Success: re-render form with location/type sticking, amount reset
response = HTMLResponse(
content=str(
page(
render_page(
request,
feed_page(
locations,
feed_types,
@@ -288,6 +295,7 @@ async def feed_purchased(request: Request):
# Validate feed_type_code
if not feed_type_code:
return _render_purchase_error(
request,
locations,
feed_types,
"Please select a feed type",
@@ -298,6 +306,7 @@ async def feed_purchased(request: Request):
bag_size_kg = int(bag_size_kg_str)
except ValueError:
return _render_purchase_error(
request,
locations,
feed_types,
"Bag size must be a number",
@@ -305,6 +314,7 @@ async def feed_purchased(request: Request):
if bag_size_kg < 1:
return _render_purchase_error(
request,
locations,
feed_types,
"Bag size must be at least 1 kg",
@@ -315,6 +325,7 @@ async def feed_purchased(request: Request):
bags_count = int(bags_count_str)
except ValueError:
return _render_purchase_error(
request,
locations,
feed_types,
"Bags count must be a number",
@@ -322,6 +333,7 @@ async def feed_purchased(request: Request):
if bags_count < 1:
return _render_purchase_error(
request,
locations,
feed_types,
"Bags count must be at least 1",
@@ -332,6 +344,7 @@ async def feed_purchased(request: Request):
bag_price_cents = int(bag_price_cents_str)
except ValueError:
return _render_purchase_error(
request,
locations,
feed_types,
"Price must be a number",
@@ -339,6 +352,7 @@ async def feed_purchased(request: Request):
if bag_price_cents < 0:
return _render_purchase_error(
request,
locations,
feed_types,
"Price cannot be negative",
@@ -379,6 +393,7 @@ async def feed_purchased(request: Request):
)
except ValidationError as e:
return _render_purchase_error(
request,
locations,
feed_types,
str(e),
@@ -390,7 +405,8 @@ async def feed_purchased(request: Request):
# Success: re-render form with fields cleared
response = HTMLResponse(
content=str(
page(
render_page(
request,
feed_page(
locations,
feed_types,
@@ -418,6 +434,7 @@ async def feed_purchased(request: Request):
def _render_give_error(
request,
locations,
feed_types,
error_message,
@@ -427,6 +444,7 @@ def _render_give_error(
"""Render give form with error message.
Args:
request: The Starlette request object.
locations: List of active locations.
feed_types: List of active feed types.
error_message: Error message to display.
@@ -438,7 +456,8 @@ def _render_give_error(
"""
return HTMLResponse(
content=str(
page(
render_page(
request,
feed_page(
locations,
feed_types,
@@ -457,10 +476,11 @@ def _render_give_error(
)
def _render_purchase_error(locations, feed_types, error_message):
def _render_purchase_error(request, locations, feed_types, error_message):
"""Render purchase form with error message.
Args:
request: The Starlette request object.
locations: List of active locations.
feed_types: List of active feed types.
error_message: Error message to display.
@@ -470,7 +490,8 @@ def _render_purchase_error(locations, feed_types, error_message):
"""
return HTMLResponse(
content=str(
page(
render_page(
request,
feed_page(
locations,
feed_types,

View File

@@ -18,7 +18,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.services.location import LocationService, ValidationError
from animaltrack.web.auth import require_role
from animaltrack.web.responses import success_toast
from animaltrack.web.templates import page
from animaltrack.web.templates import render_page
from animaltrack.web.templates.locations import location_list, rename_form
# APIRouter for multi-file route organization
@@ -45,7 +45,8 @@ async def locations_index(req: Request):
db = req.app.state.db
locations = LocationRepository(db).list_all()
return page(
return render_page(
req,
location_list(locations),
title="Locations - AnimalTrack",
active_nav=None,

View File

@@ -21,7 +21,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates import render_page
from animaltrack.web.templates.move import diff_panel, move_form
@@ -111,7 +111,8 @@ def move_index(request: Request):
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
return page(
return render_page(
request,
move_form(
locations,
filter_str=filter_str,
@@ -153,16 +154,16 @@ async def animal_move(request: Request):
# Validation: destination required
if not to_location_id:
return _render_error_form(db, locations, filter_str, "Please select a destination")
return _render_error_form(request, db, locations, filter_str, "Please select a destination")
# Validation: must have animals
if not resolved_ids:
return _render_error_form(db, locations, filter_str, "No animals selected to move")
return _render_error_form(request, db, locations, filter_str, "No animals selected to move")
# Validation: destination must be different from source
if to_location_id == from_location_id:
return _render_error_form(
db, locations, filter_str, "Destination must be different from source"
request, db, locations, filter_str, "Destination must be different from source"
)
# Validate destination exists and is active
@@ -173,7 +174,9 @@ async def animal_move(request: Request):
break
if not dest_location:
return _render_error_form(db, locations, filter_str, "Invalid destination location")
return _render_error_form(
request, db, locations, filter_str, "Invalid destination location"
)
# Build selection context for validation
context = SelectionContext(
@@ -192,7 +195,8 @@ async def animal_move(request: Request):
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
diff_panel(
diff=result.diff,
filter_str=filter_str,
@@ -227,7 +231,9 @@ async def animal_move(request: Request):
# Check we still have animals to move after validation
if not ids_to_move:
return _render_error_form(db, locations, filter_str, "No animals remaining to move")
return _render_error_form(
request, db, locations, filter_str, "No animals remaining to move"
)
# Create animal service
event_store = EventStore(db)
@@ -255,12 +261,13 @@ async def animal_move(request: Request):
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
)
except ValidationError as e:
return _render_error_form(db, locations, filter_str, str(e))
return _render_error_form(request, db, locations, filter_str, str(e))
# Success: re-render fresh form (nothing sticks per spec)
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
move_form(
locations,
action=animal_move,
@@ -284,10 +291,11 @@ async def animal_move(request: Request):
return response
def _render_error_form(db, locations, filter_str, error_message):
def _render_error_form(request, db, locations, filter_str, error_message):
"""Render form with error message.
Args:
request: The Starlette request object.
db: Database connection.
locations: List of active locations.
filter_str: Current filter string.
@@ -314,7 +322,8 @@ def _render_error_form(db, locations, filter_str, error_message):
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
move_form(
locations,
filter_str=filter_str,

View File

@@ -16,7 +16,7 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.products import ProductsProjection
from animaltrack.repositories.products import ProductRepository
from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates import render_page
from animaltrack.web.templates.products import product_sold_form
# APIRouter for multi-file route organization
@@ -70,25 +70,25 @@ async def product_sold(request: Request):
# Validate product_code
if not product_code:
return _render_error_form(products, None, "Please select a product")
return _render_error_form(request, products, None, "Please select a product")
# Validate quantity
try:
quantity = int(quantity_str)
except ValueError:
return _render_error_form(products, product_code, "Quantity must be a number")
return _render_error_form(request, products, product_code, "Quantity must be a number")
if quantity < 1:
return _render_error_form(products, product_code, "Quantity must be at least 1")
return _render_error_form(request, products, product_code, "Quantity must be at least 1")
# Validate total_price_cents
try:
total_price_cents = int(total_price_str)
except ValueError:
return _render_error_form(products, product_code, "Total price must be a number")
return _render_error_form(request, products, product_code, "Total price must be a number")
if total_price_cents < 0:
return _render_error_form(products, product_code, "Total price cannot be negative")
return _render_error_form(request, products, product_code, "Total price cannot be negative")
# Get current timestamp
ts_utc = int(time.time() * 1000)
@@ -124,12 +124,13 @@ async def product_sold(request: Request):
route="/actions/product-sold",
)
except ValidationError as e:
return _render_error_form(products, product_code, str(e))
return _render_error_form(request, products, product_code, str(e))
# Success: re-render form with product sticking, other fields cleared
response = HTMLResponse(
content=to_xml(
page(
render_page(
request,
product_sold_form(
products, selected_product_code=product_code, action=product_sold
),
@@ -147,10 +148,11 @@ async def product_sold(request: Request):
return response
def _render_error_form(products, selected_product_code, error_message):
def _render_error_form(request, products, selected_product_code, error_message):
"""Render form with error message.
Args:
request: The Starlette request object.
products: List of sellable products.
selected_product_code: Currently selected product code.
error_message: Error message to display.
@@ -160,7 +162,8 @@ def _render_error_form(products, selected_product_code, error_message):
"""
return HTMLResponse(
content=to_xml(
page(
render_page(
request,
product_sold_form(
products,
selected_product_code=selected_product_code,

View File

@@ -8,7 +8,7 @@ from starlette.responses import HTMLResponse
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.web.templates.base import page
from animaltrack.web.templates.base import render_page
from animaltrack.web.templates.registry import (
animal_table_rows,
registry_page,
@@ -56,7 +56,8 @@ def registry_index(request: Request):
return HTMLResponse(content=to_xml(tuple(rows)))
# Full page render
return page(
return render_page(
request,
registry_page(
animals=result.items,
facets=facets,

View File

@@ -1,7 +1,7 @@
# ABOUTME: Templates package for AnimalTrack web UI.
# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI.
from animaltrack.web.templates.base import page
from animaltrack.web.templates.base import page, render_page
from animaltrack.web.templates.nav import BottomNav
__all__ = ["page", "BottomNav"]
__all__ = ["page", "render_page", "BottomNav"]

View File

@@ -2,6 +2,7 @@
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
from fasthtml.common import Container, Div, Script, Title
from starlette.requests import Request
from animaltrack.models.reference import UserRole
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
@@ -123,3 +124,27 @@ def page(
# Mobile bottom nav
BottomNav(active_id=active_nav),
)
def render_page(request: Request, content, **page_kwargs):
"""Wrapper that auto-extracts auth from request for page().
Extracts username and user_role from request.scope["auth"] and
passes them to page(), eliminating the need to manually pass these
values in every route handler.
Args:
request: The Starlette request object.
content: Page content (FT components).
**page_kwargs: Additional arguments passed to page() (title, active_nav).
Returns:
Tuple of FT components for the complete page.
"""
auth = request.scope.get("auth")
return page(
content,
username=auth.username if auth else None,
user_role=auth.role if auth else None,
**page_kwargs,
)