From 5be8da96f26f51e19a3d28ece9de82952df6176f Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Fri, 9 Jan 2026 14:23:50 +0000 Subject: [PATCH] Fix 405 error after event deletion via HX-Push-Url header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/animaltrack/web/routes/actions.py | 30 +++++++++++++++++------ src/animaltrack/web/routes/eggs.py | 10 +++++--- src/animaltrack/web/routes/feed.py | 10 +++++--- src/animaltrack/web/routes/move.py | 6 +++-- src/animaltrack/web/templates/__init__.py | 4 +-- src/animaltrack/web/templates/base.py | 25 ++++++++++++++++++- 6 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/animaltrack/web/routes/actions.py b/src/animaltrack/web/routes/actions.py index 153f908..9931d88 100644 --- a/src/animaltrack/web/routes/actions.py +++ b/src/animaltrack/web/routes/actions.py @@ -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, ) diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index 3e060e0..8bc1086 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 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", ) diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py index b140982..0865fe2 100644 --- a/src/animaltrack/web/routes/feed.py +++ b/src/animaltrack/web/routes/feed.py @@ -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", ) diff --git a/src/animaltrack/web/routes/move.py b/src/animaltrack/web/routes/move.py index 9bee078..2817d6d 100644 --- a/src/animaltrack/web/routes/move.py +++ b/src/animaltrack/web/routes/move.py @@ -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", ) diff --git a/src/animaltrack/web/templates/__init__.py b/src/animaltrack/web/templates/__init__.py index 7b3def1..ca6d070 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, 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"] diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py index bd35017..37de91c 100644 --- a/src/animaltrack/web/templates/base.py +++ b/src/animaltrack/web/templates/base.py @@ -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))