From fdbf2591822e6fb896508b68b84fb462dc7dccc6 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Thu, 1 Jan 2026 13:50:28 +0000 Subject: [PATCH] feat: add render_page() helper and fix username display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/animaltrack/web/routes/actions.py | 209 +++++++++++++--------- src/animaltrack/web/routes/animals.py | 5 +- src/animaltrack/web/routes/eggs.py | 40 ++--- src/animaltrack/web/routes/events.py | 10 +- src/animaltrack/web/routes/feed.py | 35 +++- src/animaltrack/web/routes/locations.py | 5 +- src/animaltrack/web/routes/move.py | 33 ++-- src/animaltrack/web/routes/products.py | 23 +-- src/animaltrack/web/routes/registry.py | 5 +- src/animaltrack/web/templates/__init__.py | 4 +- src/animaltrack/web/templates/base.py | 25 +++ 11 files changed, 243 insertions(+), 151 deletions(-) diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index d664f60..851751b 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -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, diff --git a/src/animaltrack/web/routes/animals.py b/src/animaltrack/web/routes/animals.py index 1dfcba4..8c2c7b6 100644 --- a/src/animaltrack/web/routes/animals.py +++ b/src/animaltrack/web/routes/animals.py @@ -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, diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index 6aea78e..0238f1e 100644 --- a/src/animaltrack/web/routes/eggs.py +++ b/src/animaltrack/web/routes/eggs.py @@ -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, diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py index ad91a00..5a6746d 100644 --- a/src/animaltrack/web/routes/events.py +++ b/src/animaltrack/web/routes/events.py @@ -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, ) diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py index 73863dd..d36d1a5 100644 --- a/src/animaltrack/web/routes/feed.py +++ b/src/animaltrack/web/routes/feed.py @@ -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, diff --git a/src/animaltrack/web/routes/locations.py b/src/animaltrack/web/routes/locations.py index 0acdc31..02f3f45 100644 --- a/src/animaltrack/web/routes/locations.py +++ b/src/animaltrack/web/routes/locations.py @@ -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, diff --git a/src/animaltrack/web/routes/move.py b/src/animaltrack/web/routes/move.py index 315f6d9..364c620 100644 --- a/src/animaltrack/web/routes/move.py +++ b/src/animaltrack/web/routes/move.py @@ -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, diff --git a/src/animaltrack/web/routes/products.py b/src/animaltrack/web/routes/products.py index 487a62a..3c0618e 100644 --- a/src/animaltrack/web/routes/products.py +++ b/src/animaltrack/web/routes/products.py @@ -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, diff --git a/src/animaltrack/web/routes/registry.py b/src/animaltrack/web/routes/registry.py index e673f62..4e6d7de 100644 --- a/src/animaltrack/web/routes/registry.py +++ b/src/animaltrack/web/routes/registry.py @@ -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, diff --git a/src/animaltrack/web/templates/__init__.py b/src/animaltrack/web/templates/__init__.py index 612d2dd..7b3def1 100644 --- a/src/animaltrack/web/templates/__init__.py +++ b/src/animaltrack/web/templates/__init__.py @@ -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"] diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index 0b4e4e8..73bf864 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -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, + )