Fix 405 error after event deletion via HX-Push-Url header
All checks were successful
Deploy / deploy (push) Successful in 2m39s

When HTMX boosted forms submit via POST, the browser URL wasn't being
updated correctly. This caused window.location.reload() after event
deletion to reload the action URL (e.g., /actions/feed-given) instead
of the display URL (e.g., /feed), resulting in a 405 Method Not Allowed.

The fix adds a render_page_post() helper that returns FT components
with an HttpHeader("HX-Push-Url", push_url). This tells HTMX to update
the browser history to the correct URL after successful form submission.

Updated routes:
- /actions/feed-given -> push /feed
- /actions/feed-purchased -> push /feed
- /actions/product-collected -> push /
- /actions/product-sold -> push /
- /actions/animal-move -> push /move
- /actions/animal-cohort -> push /actions/cohort
- /actions/hatch-recorded -> push /actions/hatch
- /actions/animal-tag-add -> push /actions/tag-add
- /actions/animal-tag-end -> push /actions/tag-end
- /actions/animal-attrs -> push /actions/attrs
- /actions/animal-outcome -> push /actions/outcome
- /actions/animal-status-correct -> push /actions/status-correct

🤖 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-09 14:23:50 +00:00
parent 803169816b
commit 5be8da96f2
6 changed files with 66 additions and 19 deletions

View File

@@ -37,7 +37,7 @@ from animaltrack.selection import compute_roster_hash, parse_filter, resolve_fil
from animaltrack.selection.validation import SelectionContext, validate_selection from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.auth import UserRole, require_role from animaltrack.web.auth import UserRole, require_role
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.actions import ( from animaltrack.web.templates.actions import (
attrs_diff_panel, attrs_diff_panel,
attrs_form, attrs_form,
@@ -206,9 +206,11 @@ async def animal_cohort(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
cohort_form(locations, species_list), cohort_form(locations, species_list),
push_url="/actions/cohort",
title="Create Cohort - AnimalTrack", title="Create Cohort - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -349,9 +351,11 @@ async def hatch_recorded(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
hatch_form(locations, species_list), hatch_form(locations, species_list),
push_url="/actions/hatch",
title="Record Hatch - AnimalTrack", title="Record Hatch - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -690,9 +694,11 @@ async def animal_tag_add(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
tag_add_form(), tag_add_form(),
push_url="/actions/tag-add",
title="Add Tag - AnimalTrack", title="Add Tag - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -939,9 +945,11 @@ async def animal_tag_end(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
tag_end_form(), tag_end_form(),
push_url="/actions/tag-end",
title="End Tag - AnimalTrack", title="End Tag - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -1175,9 +1183,11 @@ async def animal_attrs(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
attrs_form(), attrs_form(),
push_url="/actions/attrs",
title="Update Attributes - AnimalTrack", title="Update Attributes - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -1455,10 +1465,11 @@ async def animal_outcome(request: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
# Use render_page_post to set HX-Push-Url header for correct browser URL
product_repo = ProductRepository(db) product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active] products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return render_page( return render_page_post(
request, request,
outcome_form( outcome_form(
filter_str="", filter_str="",
@@ -1468,6 +1479,7 @@ async def animal_outcome(request: Request, session):
resolved_count=0, resolved_count=0,
products=products, products=products,
), ),
push_url="/actions/outcome",
title="Record Outcome - AnimalTrack", title="Record Outcome - AnimalTrack",
active_nav=None, active_nav=None,
) )
@@ -1678,7 +1690,8 @@ async def animal_status_correct(req: Request, session):
) )
# Success: re-render fresh form # Success: re-render fresh form
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
req, req,
status_correct_form( status_correct_form(
filter_str="", filter_str="",
@@ -1687,6 +1700,7 @@ async def animal_status_correct(req: Request, session):
ts_utc=int(time.time() * 1000), ts_utc=int(time.time() * 1000),
resolved_count=0, resolved_count=0,
), ),
push_url="/actions/status-correct",
title="Correct Status - AnimalTrack", title="Correct Status - AnimalTrack",
active_nav=None, 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.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository from animaltrack.repositories.users import UserRepository
from animaltrack.services.products import ProductService, ValidationError from animaltrack.services.products import ProductService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.eggs import eggs_page from animaltrack.web.templates.eggs import eggs_page
# 30 days in milliseconds # 30 days in milliseconds
@@ -483,7 +483,8 @@ async def product_collected(request: Request, session):
display_data = _get_eggs_display_data(db, locations) display_data = _get_eggs_display_data(db, locations)
# Success: re-render form with location sticking, qty cleared # Success: re-render form with location sticking, qty cleared
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
eggs_page( eggs_page(
locations, locations,
@@ -494,6 +495,7 @@ async def product_collected(request: Request, session):
sell_action=product_sold, sell_action=product_sold,
**display_data, **display_data,
), ),
push_url="/",
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
) )
@@ -652,7 +654,8 @@ async def product_sold(request: Request, session):
display_data = _get_eggs_display_data(db, locations) display_data = _get_eggs_display_data(db, locations)
# Success: re-render form with product sticking # Success: re-render form with product sticking
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
eggs_page( eggs_page(
locations, locations,
@@ -663,6 +666,7 @@ async def product_sold(request: Request, session):
sell_action=product_sold, sell_action=product_sold,
**display_data, **display_data,
), ),
push_url="/",
title="Eggs - AnimalTrack", title="Eggs - AnimalTrack",
active_nav="eggs", active_nav="eggs",
) )

View File

@@ -22,7 +22,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.repositories.users import UserRepository from animaltrack.repositories.users import UserRepository
from animaltrack.services.feed import FeedService, ValidationError from animaltrack.services.feed import FeedService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.feed import feed_page from animaltrack.web.templates.feed import feed_page
# 30 days in milliseconds # 30 days in milliseconds
@@ -498,7 +498,8 @@ async def feed_given(request: Request, session):
display_data = _get_feed_display_data(db, locations, feed_types) display_data = _get_feed_display_data(db, locations, feed_types)
# Success: re-render form with location/type sticking, amount reset # Success: re-render form with location/type sticking, amount reset
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
feed_page( feed_page(
locations, locations,
@@ -512,6 +513,7 @@ async def feed_given(request: Request, session):
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data, **display_data,
), ),
push_url="/feed",
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
) )
@@ -666,7 +668,8 @@ async def feed_purchased(request: Request, session):
display_data = _get_feed_display_data(db, locations, feed_types) display_data = _get_feed_display_data(db, locations, feed_types)
# Success: re-render form with fields cleared # Success: re-render form with fields cleared
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
feed_page( feed_page(
locations, locations,
@@ -676,6 +679,7 @@ async def feed_purchased(request: Request, session):
purchase_action=feed_purchased, purchase_action=feed_purchased,
**display_data, **display_data,
), ),
push_url="/feed",
title="Feed - AnimalTrack", title="Feed - AnimalTrack",
active_nav="feed", active_nav="feed",
) )

View File

@@ -23,7 +23,7 @@ from animaltrack.repositories.locations import LocationRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import render_page from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.move import diff_panel, move_form from animaltrack.web.templates.move import diff_panel, move_form
# Milliseconds per day # Milliseconds per day
@@ -396,13 +396,15 @@ async def animal_move(request: Request, session):
display_data = _get_move_display_data(db, locations) display_data = _get_move_display_data(db, locations)
# Success: re-render fresh form (nothing sticks per spec) # Success: re-render fresh form (nothing sticks per spec)
return render_page( # Use render_page_post to set HX-Push-Url header for correct browser URL
return render_page_post(
request, request,
move_form( move_form(
locations, locations,
action=animal_move, action=animal_move,
**display_data, **display_data,
), ),
push_url="/move",
title="Move - AnimalTrack", title="Move - AnimalTrack",
active_nav="move", active_nav="move",
) )

View File

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

View File

@@ -1,7 +1,7 @@
# ABOUTME: Base HTML template for AnimalTrack pages. # ABOUTME: Base HTML template for AnimalTrack pages.
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav. # ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
from fasthtml.common import Container, Div, Script, Style, Title from fasthtml.common import Container, Div, HttpHeader, Script, Style, Title
from starlette.requests import Request from starlette.requests import Request
from animaltrack.models.reference import UserRole from animaltrack.models.reference import UserRole
@@ -230,3 +230,26 @@ def render_page(request: Request, content, **page_kwargs):
user_role=auth.role if auth else None, user_role=auth.role if auth else None,
**page_kwargs, **page_kwargs,
) )
def render_page_post(request: Request, content, push_url: str, **page_kwargs):
"""Wrapper for POST responses that sets HX-Push-Url header.
When HTMX boosted forms submit via POST, the browser URL may not be updated
correctly. This wrapper returns the rendered page with an HX-Push-Url header
to ensure the browser history shows the correct URL.
This fixes the issue where window.location.reload() after form submission
would reload the wrong URL (the action URL instead of the display URL).
Args:
request: The Starlette request object.
content: Page content (FT components).
push_url: The URL to push to browser history (e.g., '/feed', '/move').
**page_kwargs: Additional arguments passed to page() (title, active_nav).
Returns:
Tuple of (FT components, HttpHeader) that FastHTML processes together.
"""
page_content = render_page(request, content, **page_kwargs)
return (*page_content, HttpHeader("HX-Push-Url", push_url))