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.services.animal import AnimalService, ValidationError
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 (
attrs_diff_panel,
attrs_form,
@@ -206,9 +206,11 @@ async def animal_cohort(request: Request, session):
)
# 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,
cohort_form(locations, species_list),
push_url="/actions/cohort",
title="Create Cohort - AnimalTrack",
active_nav=None,
)
@@ -349,9 +351,11 @@ async def hatch_recorded(request: Request, session):
)
# 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,
hatch_form(locations, species_list),
push_url="/actions/hatch",
title="Record Hatch - AnimalTrack",
active_nav=None,
)
@@ -690,9 +694,11 @@ async def animal_tag_add(request: Request, session):
)
# 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,
tag_add_form(),
push_url="/actions/tag-add",
title="Add Tag - AnimalTrack",
active_nav=None,
)
@@ -939,9 +945,11 @@ async def animal_tag_end(request: Request, session):
)
# 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,
tag_end_form(),
push_url="/actions/tag-end",
title="End Tag - AnimalTrack",
active_nav=None,
)
@@ -1175,9 +1183,11 @@ async def animal_attrs(request: Request, session):
)
# 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,
attrs_form(),
push_url="/actions/attrs",
title="Update Attributes - AnimalTrack",
active_nav=None,
)
@@ -1455,10 +1465,11 @@ async def animal_outcome(request: Request, session):
)
# Success: re-render fresh form
# Use render_page_post to set HX-Push-Url header for correct browser URL
product_repo = ProductRepository(db)
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
return render_page(
return render_page_post(
request,
outcome_form(
filter_str="",
@@ -1468,6 +1479,7 @@ async def animal_outcome(request: Request, session):
resolved_count=0,
products=products,
),
push_url="/actions/outcome",
title="Record Outcome - AnimalTrack",
active_nav=None,
)
@@ -1678,7 +1690,8 @@ async def animal_status_correct(req: Request, session):
)
# 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,
status_correct_form(
filter_str="",
@@ -1687,6 +1700,7 @@ async def animal_status_correct(req: Request, session):
ts_utc=int(time.time() * 1000),
resolved_count=0,
),
push_url="/actions/status-correct",
title="Correct Status - AnimalTrack",
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 render_page
from animaltrack.web.templates import render_page, render_page_post
from animaltrack.web.templates.eggs import eggs_page
# 30 days in milliseconds
@@ -483,7 +483,8 @@ async def product_collected(request: Request, session):
display_data = _get_eggs_display_data(db, locations)
# 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,
eggs_page(
locations,
@@ -494,6 +495,7 @@ async def product_collected(request: Request, session):
sell_action=product_sold,
**display_data,
),
push_url="/",
title="Eggs - AnimalTrack",
active_nav="eggs",
)
@@ -652,7 +654,8 @@ async def product_sold(request: Request, session):
display_data = _get_eggs_display_data(db, locations)
# 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,
eggs_page(
locations,
@@ -663,6 +666,7 @@ async def product_sold(request: Request, session):
sell_action=product_sold,
**display_data,
),
push_url="/",
title="Eggs - AnimalTrack",
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.users import UserRepository
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
# 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)
# 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,
feed_page(
locations,
@@ -512,6 +513,7 @@ async def feed_given(request: Request, session):
purchase_action=feed_purchased,
**display_data,
),
push_url="/feed",
title="Feed - AnimalTrack",
active_nav="feed",
)
@@ -666,7 +668,8 @@ async def feed_purchased(request: Request, session):
display_data = _get_feed_display_data(db, locations, feed_types)
# 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,
feed_page(
locations,
@@ -676,6 +679,7 @@ async def feed_purchased(request: Request, session):
purchase_action=feed_purchased,
**display_data,
),
push_url="/feed",
title="Feed - AnimalTrack",
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.validation import SelectionContext, validate_selection
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
# Milliseconds per day
@@ -396,13 +396,15 @@ async def animal_move(request: Request, session):
display_data = _get_move_display_data(db, locations)
# 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,
move_form(
locations,
action=animal_move,
**display_data,
),
push_url="/move",
title="Move - AnimalTrack",
active_nav="move",
)

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, render_page
from animaltrack.web.templates.base import page, render_page, render_page_post
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: 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 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,
**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))