MPS-20 + edit page: defensive pricing_mode flush + 2-col grid layout

Two fixes in one commit:

1) defensive: flush pricing_mode change to DB immediately after the
   radio writes it, instead of relying on the trailing
   product_modified-block flush at end of view. Fox reported saving
   "Make an offer with buy-now" reverts to fixed price; could not
   reproduce locally. Direct flush eliminates any code path between
   the radio change and end-of-view that might raise and abort the
   txn before the deferred save.

2) layout: div.edit-page goes from auto-fit minmax(240px, 1fr)
   (fits 4+ wells on wide screens) to a strict 2-column max grid:
   1 column on mobile, exactly 2 columns above 720px. The 4 wells
   on the product edit page now stack 2x2 instead of 4-across.

Tests:
- test_flip_to_offer_with_buy_now_mode_4_persists — direct POST
  pricing_mode=4 (Make Offer with Buy Now) persists
- test_flip_from_3_to_4_persists — Offer-only → Offer+buy-now
  transition persists
- test_render_form_then_submit_mode_4_via_form — fetches the rendered
  edit form via webtest and submits it back with mode=4 selected; the
  closest reproduction of fox's browser flow

All three pass locally. The save path is correct in the test harness.
If the prod issue persists after this commit, it's almost certainly
a browser-level issue (form not sending the field), not server-side.

Total: 961 tests pass (was 958 + 3).
This commit is contained in:
russell@unturf.com 2026-05-10 14:01:47 -04:00
parent 5fbaf8fbfa
commit 0652f8cf31
No known key found for this signature in database
3 changed files with 80 additions and 1 deletions

View file

@ -1310,10 +1310,16 @@ img.serp-thumbnail {
div.edit-page {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
grid-template-columns: 1fr;
grid-gap: 20px;
}
@media (min-width: 720px) {
div.edit-page {
grid-template-columns: repeat(2, 1fr);
}
}
hr {
color: var(--border-light, #EEEEEE);
}

View file

@ -6071,6 +6071,72 @@ class TestPricingModeFormSection(_AuthenticatedBase):
# Auction inherits product price as default start.
self.assertEqual(product.auction.start_price_in_cents, 5000)
def test_flip_to_offer_with_buy_now_mode_4_persists(self):
"""Reproduce: fox reports saving as 'make offer with buy now' (mode 4)
reverts to fixed price (mode 0). Should persist as 4."""
shop, product_id = self._make_product()
self._post_edit(product_id, pricing_mode="4")
self.dbsession.expire_all()
from ..models.product import get_product_by_id
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 4)
self.assertTrue(product.is_offer_mode)
self.assertTrue(product.is_buy_now_allowed)
def test_flip_from_3_to_4_persists(self):
"""Test the path from mode 3 → mode 4 specifically — could be
what fox hit if they had previously set mode 3."""
shop, product_id = self._make_product()
self._post_edit(product_id, pricing_mode="3")
self.dbsession.expire_all()
from ..models.product import get_product_by_id
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 3)
# Now flip to mode 4.
self._post_edit(product_id, pricing_mode="4")
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 4)
def test_render_form_then_submit_mode_4_via_form(self):
"""Reproduce: fetch edit page, parse the actual form, submit it
with pricing_mode=4 selected. Closer to fox's browser flow."""
from ..models.product import get_product_by_id
import re
shop, product_id = self._make_product()
# Render the edit page.
res = self.testapp.get(f"/p/{product_id}/edit", status=200)
body = res.body.decode()
# Verify all 5 pricing_mode radios render.
self.assertEqual(body.count('name="pricing_mode"'), 5)
# Pick out the form fields in the main form (action="*/edit").
# webtest can fill out forms directly:
form = res.forms[0] # not the right one — find by action
for f in res.forms.values():
action = f.action or ""
if action.endswith("/edit"):
form = f
break
else:
self.fail("No form posting to /edit found")
# Set the radio.
form["pricing_mode"] = "4"
# Find the visibility field (also a radio); preserve current value.
# webtest preserves all radio defaults if not touched.
result = form.submit("submit")
self.assertIn(result.status_int, (200, 302))
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 4)
def test_flip_to_offer_does_not_create_auction(self):
shop, product_id = self._make_product()
self._post_edit(product_id, pricing_mode="3")

View file

@ -320,6 +320,13 @@ def product_edit(request):
if new_mode in (0, 1, 2, 3, 4) and new_mode != product.pricing_mode:
product_modified = True
product.pricing_mode = new_mode
# Flush immediately so the change persists even if a later
# block in this view raises. Defense in depth — the trailing
# dbsession.add(product) at function end also flushes, but we
# want to commit the pricing_mode change as soon as it is
# validated.
request.dbsession.add(product)
request.dbsession.flush()
mode_label = {
0: "fixed price", 1: "auction", 2: "auction + buy now",
3: "make an offer", 4: "make an offer + buy now",