MPS-20/MPS-21: capability-driven presentation for auction & offer actions

Every bid/buy-now/watch and offer open/counter/accept/decline/withdraw
POST now works as a plain browser submit: flash + 302 redirect to the
auction/offer page. JSON is returned only when the request carries
X-Requested-With: XMLHttpRequest. Adds offer.js progressive-enhancement
layer (mirrors auction.js); pay-now CTA on accepted offers; .offer-js-flash
styling; grid layout for offer/action forms. offer_accept emails the
buyer only on the transition into ACCEPTED.

Tests: TestOfferRoutes/TestAuctionRoutes now drive the JSON path via an
AJAX helper; new TestOfferNoJsFallback/TestAuctionNoJsFallback cover the
plain-POST redirect path. 973 passed.
This commit is contained in:
russell@unturf.com 2026-05-12 12:04:19 -04:00
parent 981b1316f2
commit 2cad2482b8
No known key found for this signature in database
9 changed files with 539 additions and 182 deletions

View file

@ -99,8 +99,13 @@ gift-card-purchases. After standard cart payment success,
- Polls `/a/{id}.json` every 5s for state changes
- AJAX bid submit; success/error flash without page reload
The page works fully without JS (capability-driven presentation).
JS enhances when available.
The page works fully without JS (capability-driven presentation): `bid`,
`buy-now`, and `watch` POSTs flash a status message and `302`-redirect
back to `/a/{auction_id}` for a plain browser submit; they return JSON
only when the request carries `X-Requested-With: XMLHttpRequest`. The
no-JS path is the source of truth; JSON is an enhancement.
Functional coverage: `TestAuctionRoutes` drives the JSON path,
`TestAuctionNoJsFallback` the plain-POST path.
## Email Notifications

View file

@ -116,6 +116,18 @@ POST /o/{offer_id}/checkout buyer pays accepted offer
`offer_open` is registered before the `product_slug` catch-all so
`/p/{id}/offer` is not shadowed.
### Capability-driven presentation
Every POST route works as a plain browser form submit: the server flashes
a status message and `302`-redirects to `/o/{offer_id}` (or back to the
product page on error). When JS is available, `static/js/offer.js`
intercepts the submit, posts with `X-Requested-With: XMLHttpRequest`, and
the same handlers return JSON instead of redirecting — the JS then
navigates to `/o/{offer_id}` without a full reload of the originating
page. The no-JS path is the source of truth; JSON is an enhancement.
Functional coverage: `TestOfferRoutes` drives the JSON path,
`TestOfferNoJsFallback` the plain-POST path.
## Cart Integration
When `cart.cart_offers` has one row, `cart.total_price_in_cents`

View file

@ -1588,23 +1588,44 @@ div.edit-page > section.edit-card-full {
Grid layout gives consistent vertical rhythm the inputs and label
would otherwise sit flush against each other and against the
summary button above. */
.product-offer-form {
.product-offer-form,
.offer-action-form {
display: grid;
gap: var(--space-3, 12px);
margin-top: var(--space-3, 12px);
}
.product-offer-form label {
.product-offer-form label,
.offer-action-form label {
margin: 0;
}
.product-offer-form input[type="number"],
.product-offer-form input[type="text"] {
.product-offer-form input[type="text"],
.offer-action-form input[type="number"],
.offer-action-form input[type="text"] {
width: 100%;
box-sizing: border-box;
padding: var(--space-2, 8px) var(--space-3, 12px);
}
/* Inline flash for the offer.js progressive-enhancement layer. */
.offer-js-flash {
margin: var(--space-2, 8px) 0 0;
padding: var(--space-2, 8px) var(--space-3, 12px);
border-radius: var(--radius-sm, 4px);
font-size: 0.9em;
}
.offer-js-flash:empty { display: none; }
.offer-js-flash-error {
background: var(--color-danger, #CC6958);
color: #fff;
}
.offer-js-flash-info {
background: var(--surface-dim, #f9f9fa);
color: var(--color-text-muted, #666);
}
/* Render order on the edit page (CSS order property reorders without
changing HTML source order):
1. Edit Title, Description, or Visibility (full width, top)

View file

@ -0,0 +1,90 @@
/* MPS-21 offer progressive enhancement.
*
* Capability-driven presentation: every offer form (the "Make an offer"
* form on product pages, and the counter / accept / decline / withdraw
* forms on the offer detail page) works as a plain POST + redirect with
* no JS. When JS is available, this intercepts the submit, posts via
* fetch() with X-Requested-With: XMLHttpRequest, and the server returns
* JSON instead of a redirect. On AJAX success we navigate to the URL
* the JSON points at (the offer detail page) same destination the
* no-JS redirect would reach but without a full reload of the
* originating page first. On AJAX error we surface the message inline.
*
* Targets any <form> with class "product-offer-form" (the make-offer
* form) or "offer-action-form" (the offer-detail action forms).
*/
(function () {
"use strict";
function flash(form, text, kind) {
var el = form.querySelector(".offer-js-flash");
if (!el) {
el = document.createElement("p");
el.className = "offer-js-flash";
form.appendChild(el);
}
el.textContent = text;
el.className = "offer-js-flash offer-js-flash-" + (kind || "info");
}
function handleSubmit(e) {
var form = e.currentTarget;
e.preventDefault();
var submitBtn = form.querySelector('[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
fetch(form.action, {
method: "POST",
body: new FormData(form),
credentials: "same-origin",
headers: { "X-Requested-With": "XMLHttpRequest" },
})
.then(function (r) {
return r.json().then(function (j) {
return { status: r.status, body: j };
});
})
.then(function (res) {
if (res.status >= 200 && res.status < 300 && res.body && res.body.ok) {
// Navigate to the offer detail page — the canonical place to
// view/act on the offer next. (offer_open returns offer_id;
// the action endpoints return the updated offer object.)
var offerId =
res.body.offer_id ||
(res.body.offer && res.body.offer.id);
if (offerId) {
window.location.href = "/o/" + offerId;
return;
}
// No id to redirect to — just reload so server-rendered state
// (flash, updated offer page) shows.
window.location.reload();
return;
}
var msg = (res.body && res.body.error) || "Something went wrong.";
flash(form, msg, "error");
if (submitBtn) submitBtn.disabled = false;
})
.catch(function () {
// Network error — fall back to a normal submit so the user still
// gets the server's redirect/flash flow.
if (submitBtn) submitBtn.disabled = false;
form.submit();
});
}
function wire() {
var forms = document.querySelectorAll(
"form.product-offer-form, form.offer-action-form"
);
Array.prototype.forEach.call(forms, function (form) {
form.addEventListener("submit", handleSubmit);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", wire);
} else {
wire();
}
})();

View file

@ -29,25 +29,25 @@
<section class="offer-actions well">
<h3 class="type-title">Your turn</h3>
<form method="post" action="/o/{{ id }}/accept">
<form method="post" action="/o/{{ id }}/accept" class="offer-action-form">
<input type="text" name="message" placeholder="Optional message" />
<input type="submit" class="mps-submit" value="Accept ${{ "%.2f"|format(current_amount) }}" />
</form>
<form method="post" action="/o/{{ id }}/counter" style="margin-top: var(--space-3);">
<form method="post" action="/o/{{ id }}/counter" class="offer-action-form" style="margin-top: var(--space-3);">
<label>Counter offer (USD)</label>
<input type="number" step="0.01" min="0.01" name="amount" required />
<input type="text" name="message" placeholder="Optional message" />
<input type="submit" class="mps-button" value="Counter" />
</form>
<form method="post" action="/o/{{ id }}/decline" style="margin-top: var(--space-3);">
<form method="post" action="/o/{{ id }}/decline" class="offer-action-form" style="margin-top: var(--space-3);">
<input type="text" name="message" placeholder="Reason (optional)" />
<input type="submit" class="mps-button" value="Decline" />
</form>
{% if actor_party == 0 %}
<form method="post" action="/o/{{ id }}/withdraw" style="margin-top: var(--space-3);">
<form method="post" action="/o/{{ id }}/withdraw" class="offer-action-form" style="margin-top: var(--space-3);">
<input type="submit" class="mps-button" value="Withdraw offer" />
</form>
{% endif %}
@ -58,6 +58,15 @@
</section>
{% endif %}
{# Pay-now CTA when the offer is accepted and the viewer is the buyer. #}
{% if is_accepted and request.user and request.user.uuid_str == buyer_id %}
<section class="well">
<form method="post" action="/o/{{ id }}/checkout">
<input type="submit" class="mps-submit" value="Pay ${{ "%.2f"|format(current_amount) }} now" />
</form>
</section>
{% endif %}
<section class="offer-timeline well">
<h3 class="type-title">History</h3>
<ol class="offer-events">
@ -73,4 +82,5 @@
</section>
</section>
<script src="/static/js/offer.js?v={{ request.git_hash }}"></script>
{% endblock %}

View file

@ -265,6 +265,9 @@
You can't offer on your own product.
</p>
{% endif %}
{% if product.offers_allowed %}
<script src="/static/js/offer.js?v={{ request.git_hash }}"></script>
{% endif %}
<br/>
{% if (not request.user or not request.user.authenticated) and signed_get_object_url is none %}

View file

@ -5526,7 +5526,20 @@ class TestKillSwitches(_AuthenticatedBase):
class TestAuctionRoutes(_AuthenticatedBase):
"""MPS-20: HTTP-level coverage for auction views."""
"""MPS-20: HTTP-level coverage for auction views.
POST routes (bid / buy-now / watch) follow capability-driven
presentation: a plain browser POST gets a 302 redirect + flash; an
AJAX POST (X-Requested-With) gets JSON. These tests drive the AJAX
path; the no-JS path is covered in TestAuctionNoJsFallback below."""
AJAX = {"X-Requested-With": "XMLHttpRequest"}
def _ajax_post(self, url, params=None, status=200, expect_errors=False):
return self.testapp.post(
url, params or {}, headers=self.AJAX,
status=status, expect_errors=expect_errors,
)
def _make_active_auction(
self, owner_creds=None, start_price=1000, increment=100,
@ -5596,10 +5609,9 @@ class TestAuctionRoutes(_AuthenticatedBase):
def test_seller_cannot_bid_on_own_auction(self):
auction_id = self._make_active_auction()
# user1 (the shop owner) is logged in from _create_shop_helper.
res = self.testapp.post(
f"/a/{auction_id}/bid",
{"amount": "12.00"},
expect_errors=True,
res = self._ajax_post(
f"/a/{auction_id}/bid", {"amount": "12.00"},
status=403, expect_errors=True,
)
self.assertEqual(res.status_int, 403)
self.assertIn("own auction", res.json["error"])
@ -5622,11 +5634,7 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/a/{auction_id}/bid",
{"amount": "10.00"},
status=200,
)
res = self._ajax_post(f"/a/{auction_id}/bid", {"amount": "10.00"})
self.assertTrue(res.json["ok"])
self.assertEqual(res.json["bid_amount_in_cents"], 1000)
self.assertTrue(res.json["is_winning"])
@ -5638,15 +5646,15 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.log_in_user(self.user2_creds)
# First bid succeeds at 1000.
self.testapp.post(f"/a/{auction_id}/bid", {"amount": "10.00"}, status=200)
self._ajax_post(f"/a/{auction_id}/bid", {"amount": "10.00"})
# Switch to user1 — but user1 owns the shop, so use new_user pattern.
# We just re-bid as user2 who is now winning — that's fine for this
# check: a second user2 bid below floor should also reject.
res = self.testapp.post(
res = self._ajax_post(
f"/a/{auction_id}/bid",
{"amount": "10.50"}, # below 1000 + 100 = 1100 floor
expect_errors=True,
status=400, expect_errors=True,
)
self.assertEqual(res.status_int, 400)
@ -5655,10 +5663,9 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/a/{auction_id}/bid",
{"amount": "not-a-number"},
expect_errors=True,
res = self._ajax_post(
f"/a/{auction_id}/bid", {"amount": "not-a-number"},
status=400, expect_errors=True,
)
self.assertEqual(res.status_int, 400)
@ -5668,10 +5675,10 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.log_in_user(self.user2_creds)
# First click: watching.
res1 = self.testapp.post(f"/a/{auction_id}/watch", status=200)
res1 = self._ajax_post(f"/a/{auction_id}/watch")
self.assertTrue(res1.json["watching"])
# Second click: unwatch.
res2 = self.testapp.post(f"/a/{auction_id}/watch", status=200)
res2 = self._ajax_post(f"/a/{auction_id}/watch")
self.assertFalse(res2.json["watching"])
def test_buy_now_404_when_not_offered(self):
@ -5679,9 +5686,8 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/a/{auction_id}/buy-now",
expect_errors=True,
res = self._ajax_post(
f"/a/{auction_id}/buy-now", status=404, expect_errors=True,
)
self.assertEqual(res.status_int, 404)
@ -5693,7 +5699,7 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(f"/a/{auction_id}/buy-now", status=200)
res = self._ajax_post(f"/a/{auction_id}/buy-now")
self.assertTrue(res.json["ok"])
self.assertEqual(res.json["auction_state"]["state"], AUCTION_STATE_ENDED)
@ -5705,8 +5711,104 @@ class TestAuctionRoutes(_AuthenticatedBase):
self.assertEqual(auction.winner, u2)
class TestAuctionNoJsFallback(_AuthenticatedBase):
"""MPS-20 capability-driven presentation: every auction action works
as a plain POST 302 redirect with no JS / no X-Requested-With."""
def _make_active_auction(self, start_price=1000, increment=100,
has_buy_now=False):
from ..models.auction import (
MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
)
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Auctionable", description="...")
product.shop = shop
product.price_in_cents = start_price
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 1
self.dbsession.add(product)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop,
start_price_in_cents=start_price,
bid_increment_in_cents=increment,
soft_close_seconds=60,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() + 3_600_000
auction.original_end_timestamp = auction.end_timestamp
if has_buy_now:
auction.buy_now_price_in_cents = start_price * 5
self.dbsession.add(auction)
self.dbsession.flush()
auction_id = auction.uuid_str
transaction.commit()
return auction_id
def test_bid_plain_post_redirects(self):
auction_id = self._make_active_auction(start_price=1000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# Plain POST, no X-Requested-With → 302 to the auction page.
res = self.testapp.post(
f"/a/{auction_id}/bid", {"amount": "10.00"}, status=302,
)
self.assertIn(f"/a/{auction_id}", res.location)
# Bid persisted.
from ..models.auction import get_auction_by_id
auction = get_auction_by_id(self.dbsession, auction_id)
self.assertEqual(auction.current_high_in_cents, 1000)
def test_bid_rejected_plain_post_redirects(self):
auction_id = self._make_active_auction(start_price=1000, increment=100)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
self.testapp.post(f"/a/{auction_id}/bid", {"amount": "10.00"}, status=302)
# Below floor → still a 302 (flash carries the error), never raw JSON.
res = self.testapp.post(
f"/a/{auction_id}/bid", {"amount": "10.50"}, status=302,
)
self.assertIn(f"/a/{auction_id}", res.location)
def test_watch_plain_post_redirects(self):
auction_id = self._make_active_auction()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(f"/a/{auction_id}/watch", status=302)
self.assertIn(f"/a/{auction_id}", res.location)
from ..models.auction import MpsAuctionWatcher
self.assertEqual(
self.dbsession.query(MpsAuctionWatcher).count(), 1
)
def test_buy_now_plain_post_redirects(self):
auction_id = self._make_active_auction(has_buy_now=True)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(f"/a/{auction_id}/buy-now", status=302)
self.assertIn(f"/a/{auction_id}", res.location)
from ..models.auction import get_auction_by_id, AUCTION_STATE_ENDED
auction = get_auction_by_id(self.dbsession, auction_id)
self.assertEqual(auction.state, AUCTION_STATE_ENDED)
class TestOfferRoutes(_AuthenticatedBase):
"""MPS-21: HTTP-level coverage for offer views."""
"""MPS-21: HTTP-level coverage for offer views.
These tests exercise the AJAX path (X-Requested-With header JSON
response). The no-JS path (plain POST 302 redirect + flash) is
covered separately in TestOfferNoJsFallback below."""
AJAX = {"X-Requested-With": "XMLHttpRequest"}
def _ajax_post(self, url, params=None, status=200, expect_errors=False):
return self.testapp.post(
url, params or {}, headers=self.AJAX,
status=status, expect_errors=expect_errors,
)
def _make_offer_product(self, list_price=10000, offer_enabled=True,
auto_accept_pct=95, auto_decline_pct=50,
@ -5740,20 +5842,18 @@ class TestOfferRoutes(_AuthenticatedBase):
product_id = self._make_offer_product()
self.testapp.get("/log-out")
# user_required redirects with flash; should not reach 200.
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
expect_errors=True,
res = self._ajax_post(
f"/p/{product_id}/offer", {"amount": "70.00"},
status=None, expect_errors=True,
)
self.assertIn(res.status_int, (302, 303, 401, 403))
def test_seller_cannot_offer_on_own_product(self):
product_id = self._make_offer_product()
# user1 (shop owner) is logged in.
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
expect_errors=True,
res = self._ajax_post(
f"/p/{product_id}/offer", {"amount": "70.00"},
status=403, expect_errors=True,
)
self.assertEqual(res.status_int, 403)
@ -5763,10 +5863,9 @@ class TestOfferRoutes(_AuthenticatedBase):
self.log_in_user(self.user2_creds)
# 70% of list = $70 — between auto-decline (50%) and auto-accept (95%).
res = self.testapp.post(
res = self._ajax_post(
f"/p/{product_id}/offer",
{"amount": "70.00", "message": "any room?"},
status=200,
)
self.assertTrue(res.json["ok"])
offer = res.json["offer"]
@ -5779,45 +5878,26 @@ class TestOfferRoutes(_AuthenticatedBase):
product_id = self._make_offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# 96% of list → above 95% auto-accept threshold.
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "96.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "96.00"})
self.assertEqual(res.json["offer"]["state"], 1) # ACCEPTED
def test_auto_decline_low_offer(self):
product_id = self._make_offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# 40% of list → below 50% auto-decline threshold.
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "40.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "40.00"})
self.assertEqual(res.json["offer"]["state"], 3) # DECLINED
def test_offer_page_404_unknown(self):
self.testapp.get(
"/o/00000000000000000000000000000000",
status=404,
)
self.testapp.get("/o/00000000000000000000000000000000", status=404)
def test_offer_page_third_party_blocked(self):
product_id = self._make_offer_product()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# Open as user2 (buyer).
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "70.00"})
offer_id = res.json["offer_id"]
# Create a third user not involved.
from ..models.user import get_or_create_user_by_email
third = get_or_create_user_by_email(self.dbsession, "third@example.com")
third_password = third.new_password()
@ -5832,54 +5912,38 @@ class TestOfferRoutes(_AuthenticatedBase):
def test_full_negotiation(self):
product_id = self._make_offer_product(list_price=10000)
# Buyer opens offer.
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "70.00"})
offer_id = res.json["offer_id"]
# Seller (user1) counters.
self.testapp.get("/log-out")
self.log_in_user(self.user1_creds)
res2 = self.testapp.post(
res2 = self._ajax_post(
f"/o/{offer_id}/counter",
{"amount": "85.00", "message": "how about this?"},
status=200,
)
self.assertTrue(res2.json["ok"])
self.assertEqual(res2.json["offer"]["current_amount_in_cents"], 8500)
self.assertEqual(res2.json["offer"]["round_count"], 1)
self.assertEqual(res2.json["offer"]["state"], 2) # COUNTERED
# Buyer accepts.
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res3 = self.testapp.post(
f"/o/{offer_id}/accept",
status=200,
)
res3 = self._ajax_post(f"/o/{offer_id}/accept")
self.assertEqual(res3.json["offer"]["state"], 1) # ACCEPTED
def test_wrong_party_counter_rejected(self):
product_id = self._make_offer_product()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "70.00"})
offer_id = res.json["offer_id"]
# Buyer tries to counter their own offer when it's seller's turn.
res2 = self.testapp.post(
f"/o/{offer_id}/counter",
{"amount": "75.00"},
expect_errors=True,
res2 = self._ajax_post(
f"/o/{offer_id}/counter", {"amount": "75.00"},
status=400, expect_errors=True,
)
self.assertEqual(res2.status_int, 400)
@ -5887,40 +5951,27 @@ class TestOfferRoutes(_AuthenticatedBase):
product_id = self._make_offer_product()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "70.00"})
offer_id = res.json["offer_id"]
res2 = self.testapp.post(
f"/o/{offer_id}/withdraw",
status=200,
)
res2 = self._ajax_post(f"/o/{offer_id}/withdraw")
self.assertEqual(res2.json["offer"]["state"], 5) # WITHDRAWN
def test_seller_cannot_withdraw(self):
product_id = self._make_offer_product()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "70.00"},
status=200,
)
res = self._ajax_post(f"/p/{product_id}/offer", {"amount": "70.00"})
offer_id = res.json["offer_id"]
self.testapp.get("/log-out")
self.log_in_user(self.user1_creds)
res2 = self.testapp.post(
f"/o/{offer_id}/withdraw",
expect_errors=True,
res2 = self._ajax_post(
f"/o/{offer_id}/withdraw", status=403, expect_errors=True,
)
self.assertEqual(res2.status_int, 403)
def test_offers_disabled_on_fixed_price_product(self):
# Create product with pricing_mode=0 (fixed). Offer endpoint should 403.
from ..models.product import Product
shop = self._create_shop_helper()
product = Product(title="Fixed price", description="...")
@ -5936,14 +5987,82 @@ class TestOfferRoutes(_AuthenticatedBase):
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "30.00"},
expect_errors=True,
res = self._ajax_post(
f"/p/{product_id}/offer", {"amount": "30.00"},
status=403, expect_errors=True,
)
self.assertEqual(res.status_int, 403)
class TestOfferNoJsFallback(_AuthenticatedBase):
"""MPS-21 capability-driven presentation: every offer action works
as a plain POST 302 redirect with no JS / no X-Requested-With."""
def _make_offer_product(self, list_price=10000):
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="NoJS thing", description="...")
product.shop = shop
product.price_in_cents = list_price
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
transaction.commit()
return product_id
def test_open_offer_plain_post_redirects(self):
product_id = self._make_offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# Plain POST, no X-Requested-With → 302 redirect, no raw JSON.
res = self.testapp.post(
f"/p/{product_id}/offer", {"amount": "70.00"}, status=302,
)
# Lands on the offer detail page.
self.assertIn("/o/", res.location)
# And the offer exists in the DB.
from ..models.offer import MpsOffer
self.assertEqual(self.dbsession.query(MpsOffer).count(), 1)
def test_auto_decline_plain_post_redirects_with_flash(self):
product_id = self._make_offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# $10 of $100 = 10%, below 50% auto-decline → still redirects
# (does NOT show raw JSON).
res = self.testapp.post(
f"/p/{product_id}/offer", {"amount": "10.00"}, status=302,
)
self.assertIn("/o/", res.location)
from ..models.offer import MpsOffer, OFFER_STATE_DECLINED
offer = self.dbsession.query(MpsOffer).one()
self.assertEqual(offer.state, OFFER_STATE_DECLINED)
def test_counter_plain_post_redirects(self):
product_id = self._make_offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.post(
f"/p/{product_id}/offer", {"amount": "70.00"}, status=302,
)
offer_id = res.location.rstrip("/").split("/")[-1]
# Seller counters via plain POST.
self.testapp.get("/log-out")
self.log_in_user(self.user1_creds)
res2 = self.testapp.post(
f"/o/{offer_id}/counter", {"amount": "85.00"}, status=302,
)
self.assertIn(f"/o/{offer_id}", res2.location)
from ..models.offer import get_offer_by_id
offer = get_offer_by_id(self.dbsession, offer_id)
self.assertEqual(offer.current_amount_in_cents, 8500)
class TestOfferSettingsForm(_AuthenticatedBase):
"""MPS-21: shop-settings offer-settings form section."""
@ -6493,7 +6612,8 @@ class TestOfferCheckout(_AuthenticatedBase):
with mock.patch(
"make_post_sell.views.offer.send_offer_accepted_email"
) as mock_send:
self.testapp.post(f"/o/{offer_id}/accept", status=200)
# Plain (no-JS) POST → 302 redirect to the offer page.
self.testapp.post(f"/o/{offer_id}/accept", status=302)
self.assertEqual(mock_send.call_count, 1)
# First positional arg is request, second is email.
args, _ = mock_send.call_args

View file

@ -36,6 +36,10 @@ from ..models.auction import (
from ..views import user_required
def _is_ajax(request):
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
def _user_is_seller(request, auction):
"""True iff request.user is one of the auction's shop owners."""
if request.user is None:
@ -120,17 +124,23 @@ def auction_bid(request):
)
if auction is None:
raise HTTPNotFound()
auction_url = f"/a/{auction.uuid_str}"
def _err(status, msg):
if _is_ajax(request):
request.response.status_int = status
return {"error": msg}
request.session.flash((msg, "error"))
return HTTPFound(auction_url)
if _user_is_seller(request, auction):
request.response.status_int = 403
return {"error": "you cannot bid on your own auction"}
return _err(403, "you cannot bid on your own auction")
try:
amount_dollars = (request.params.get("amount") or "").strip()
amount_in_cents = int(round(float(amount_dollars) * 100))
except (TypeError, ValueError):
request.response.status_int = 400
return {"error": "invalid amount"}
return _err(400, "Enter a valid bid amount.")
max_proxy_dollars = (request.params.get("max_proxy") or "").strip()
max_proxy_in_cents = None
@ -138,8 +148,7 @@ def auction_bid(request):
try:
max_proxy_in_cents = int(round(float(max_proxy_dollars) * 100))
except (TypeError, ValueError):
request.response.status_int = 400
return {"error": "invalid max_proxy"}
return _err(400, "Enter a valid max proxy amount.")
# Capture the prior winning bidder before place_bid mutates state,
# so we know who to notify if they got outbid.
@ -156,8 +165,7 @@ def auction_bid(request):
max_proxy_in_cents=max_proxy_in_cents,
)
except BidRejected as e:
request.response.status_int = 400
return {"error": str(e)}
return _err(400, str(e))
# If the new bid took the lead and there was a different prior bidder,
# send them an outbid email. Email failure must not break the bid
@ -173,13 +181,23 @@ def auction_bid(request):
log = logging.getLogger(__name__)
log.exception("auction outbid email failed (non-fatal)")
return {
"ok": True,
"bid_amount_in_cents": bid.amount_in_cents,
"bid_amount": cents_to_dollars(bid.amount_in_cents),
"is_winning": bid.is_winning,
"auction_state": _serialize_auction(auction, request),
}
if _is_ajax(request):
return {
"ok": True,
"bid_amount_in_cents": bid.amount_in_cents,
"bid_amount": cents_to_dollars(bid.amount_in_cents),
"is_winning": bid.is_winning,
"auction_state": _serialize_auction(auction, request),
}
if bid.is_winning:
request.session.flash(
(f"Bid placed at ${bid.amount_in_cents / 100:.2f} — you're winning!", "success")
)
else:
request.session.flash(
("Bid placed, but a proxy bid from another bidder still leads.", "error")
)
return HTTPFound(auction_url)
@view_config(route_name="auction_buy_now", request_method="POST", renderer="json")
@ -191,15 +209,25 @@ def auction_buy_now(request):
)
if auction is None:
raise HTTPNotFound()
auction_url = f"/a/{auction.uuid_str}"
def _err(status, msg):
if _is_ajax(request):
request.response.status_int = status
return {"error": msg}
request.session.flash((msg, "error"))
return HTTPFound(auction_url)
if not auction.has_buy_now:
request.response.status_int = 404
return {"error": "buy now not available on this auction"}
if _is_ajax(request):
request.response.status_int = 404
return {"error": "buy now not available on this auction"}
request.session.flash(("Buy-now is not available on this auction.", "error"))
return HTTPFound(auction_url)
if not auction.is_active:
request.response.status_int = 400
return {"error": "auction not active"}
return _err(400, "auction not active")
if _user_is_seller(request, auction):
request.response.status_int = 403
return {"error": "you cannot buy your own auction"}
return _err(403, "you cannot buy your own auction")
# Place a bid at the buy_now price (system-driven), end the auction.
try:
@ -210,8 +238,7 @@ def auction_buy_now(request):
max_proxy_in_cents=auction.buy_now_price_in_cents,
)
except BidRejected as e:
request.response.status_int = 400
return {"error": str(e)}
return _err(400, str(e))
# End the auction immediately.
auction.state = AUCTION_STATE_ENDED
@ -220,11 +247,14 @@ def auction_buy_now(request):
auction.winning_bid_id = bid.id
request.dbsession.flush()
return {
"ok": True,
"winning_bid_id": str(bid.id),
"auction_state": _serialize_auction(auction, request),
}
if _is_ajax(request):
return {
"ok": True,
"winning_bid_id": str(bid.id),
"auction_state": _serialize_auction(auction, request),
}
request.session.flash(("Auction won — proceed to checkout.", "success"))
return HTTPFound(auction_url)
@view_config(route_name="auction_watch", request_method="POST", renderer="json")
@ -236,6 +266,7 @@ def auction_watch(request):
)
if auction is None:
raise HTTPNotFound()
auction_url = f"/a/{auction.uuid_str}"
existing = (
request.dbsession.query(MpsAuctionWatcher)
@ -245,12 +276,18 @@ def auction_watch(request):
if existing is not None:
request.dbsession.delete(existing)
request.dbsession.flush()
return {"watching": False}
if _is_ajax(request):
return {"watching": False}
request.session.flash(("No longer watching this auction.", "success"))
return HTTPFound(auction_url)
watcher = MpsAuctionWatcher(auction=auction, user=request.user)
request.dbsession.add(watcher)
request.dbsession.flush()
return {"watching": True}
if _is_ajax(request):
return {"watching": True}
request.session.flash(("Watching this auction — we'll email you on outbid and ending-soon.", "success"))
return HTTPFound(auction_url)
@view_config(route_name="auction_checkout", request_method="POST")

View file

@ -42,6 +42,20 @@ from ..models.product import get_product_by_id
from ..views import user_required
def _is_ajax(request):
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
def _respond(request, payload, redirect_url, flash_msg=None, flash_level="success"):
"""Return JSON for AJAX callers; otherwise flash + redirect so a plain
browser POST lands on a real page instead of seeing raw JSON."""
if _is_ajax(request):
return payload
if flash_msg:
request.session.flash((flash_msg, flash_level))
return HTTPFound(redirect_url)
def _user_party(request, offer):
"""Return (party_int, can_view).
@ -110,19 +124,30 @@ def offer_open(request):
)
if product is None:
raise HTTPNotFound()
product_url = product.absolute_url(request)
if not product.offers_allowed:
request.response.status_int = 403
return {"error": "this product is not accepting offers"}
if _is_ajax(request):
request.response.status_int = 403
return {"error": "this product is not accepting offers"}
request.session.flash(("This product is not accepting offers.", "error"))
return HTTPFound(product_url)
# Block self-offer.
if request.user in product.shop.owners:
request.response.status_int = 403
return {"error": "you cannot offer on your own product"}
if _is_ajax(request):
request.response.status_int = 403
return {"error": "you cannot offer on your own product"}
request.session.flash(("You cannot offer on your own product.", "error"))
return HTTPFound(product_url)
try:
amount_in_cents = int(round(float(request.params.get("amount", "")) * 100))
except (TypeError, ValueError):
request.response.status_int = 400
return {"error": "invalid amount"}
if _is_ajax(request):
request.response.status_int = 400
return {"error": "invalid amount"}
request.session.flash(("Enter a valid offer amount.", "error"))
return HTTPFound(product_url)
buyer_message = (request.params.get("message") or "").strip() or None
@ -136,12 +161,15 @@ def offer_open(request):
buyer_message=buyer_message,
)
except OfferRejected as e:
request.response.status_int = 400
return {"error": str(e)}
if _is_ajax(request):
request.response.status_int = 400
return {"error": str(e)}
request.session.flash((str(e), "error"))
return HTTPFound(product_url)
# Send email notification — pending offers go to seller; auto-accept
# offers go to buyer (so they know to pay). Auto-decline offers are
# silent — seller never sees the lowball, buyer learns through the JSON.
# silent — seller never sees the lowball.
try:
if offer.is_pending:
for owner in product.shop.owners:
@ -153,11 +181,27 @@ def offer_open(request):
"offer open email failed (non-fatal)"
)
return {
# Flash message tuned to the offer's resolved state.
if offer.is_accepted:
flash = ("Offer accepted! Pay now to complete the purchase.", "success")
elif offer.state == 3: # OFFER_STATE_DECLINED (auto-decline threshold)
flash = (
"Your offer was below the seller's minimum and was automatically "
"declined. Try a higher amount.",
"error",
)
else: # pending — seller will review
flash = ("Offer submitted. The seller will review it.", "success")
payload = {
"ok": True,
"offer_id": offer.uuid_str,
"offer": _serialize_offer(offer),
}
return _respond(
request, payload, f"/o/{offer.uuid_str}",
flash_msg=flash[0], flash_level=flash[1],
)
@view_config(route_name="offer_page", renderer="offer.j2")
@ -185,20 +229,27 @@ def offer_page(request):
def _offer_action(request, action_fn, message_required=False):
"""Common handling for counter / accept / decline / withdraw."""
"""Common handling for counter / accept / decline / withdraw.
AJAX callers get JSON; plain browser POSTs get flash + redirect to
the offer detail page."""
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if offer is None:
raise HTTPNotFound()
offer_url = f"/o/{offer.uuid_str}"
def _err(status, msg):
if _is_ajax(request):
request.response.status_int = status
return {"error": msg}
request.session.flash((msg, "error"))
return HTTPFound(offer_url)
actor_party, can_view = _user_party(request, offer)
if not can_view or actor_party is None:
request.response.status_int = 403
return {"error": "not authorized"}
return _err(403, "not authorized")
message = (request.params.get("message") or "").strip() or None
if message_required and not message:
# Optional — no validation here; counter has its own amount check.
pass
try:
if action_fn is counter_offer:
@ -206,24 +257,32 @@ def _offer_action(request, action_fn, message_required=False):
new_amount = int(round(float(
request.params.get("amount", "")) * 100))
except (TypeError, ValueError):
request.response.status_int = 400
return {"error": "invalid amount"}
return _err(400, "Enter a valid counter amount.")
action_fn(
offer, request.user, actor_party, new_amount, message=message,
)
done_msg = f"Countered at ${new_amount / 100:.2f}."
elif action_fn is withdraw_offer:
# Buyer-only.
if actor_party != OFFER_PARTY_BUYER:
request.response.status_int = 403
return {"error": "only the buyer can withdraw"}
return _err(403, "only the buyer can withdraw")
action_fn(offer, request.user, message=message)
done_msg = "Offer withdrawn."
elif action_fn is accept_offer:
action_fn(offer, request.user, actor_party, message=message)
done_msg = "Offer accepted."
elif action_fn is decline_offer:
action_fn(offer, request.user, actor_party, message=message)
done_msg = "Offer declined."
else:
action_fn(offer, request.user, actor_party, message=message)
done_msg = "Done."
except OfferRejected as e:
request.response.status_int = 400
return {"error": str(e)}
return _err(400, str(e))
return {"ok": True, "offer": _serialize_offer(offer)}
if _is_ajax(request):
return {"ok": True, "offer": _serialize_offer(offer)}
request.session.flash((done_msg, "success"))
return HTTPFound(offer_url)
@view_config(route_name="offer_counter", request_method="POST", renderer="json")
@ -235,23 +294,23 @@ def offer_counter(request):
@view_config(route_name="offer_accept", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_accept(request):
offer_before = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
was_accepted = offer_before is not None and offer_before.is_accepted
result = _offer_action(request, accept_offer)
# On success, email the buyer that their offer was accepted (so they
# know to pay). If the buyer themselves accepted a counter from the
# seller, the seller may eventually want a "buyer accepted" email —
# that's a follow-up; for now buyer-side notification covers the
# primary value (close the loop to payment).
if isinstance(result, dict) and result.get("ok"):
offer = get_offer_by_id(
request.dbsession, request.matchdict["offer_id"]
)
if offer is not None and offer.is_accepted:
try:
send_offer_accepted_email(request, offer.buyer.email, offer)
except Exception:
logging.getLogger(__name__).exception(
"offer accepted email failed (non-fatal)"
)
# Email the buyer only on the transition into ACCEPTED (not on a
# repeated request against an already-accepted offer).
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if offer is not None and offer.is_accepted and not was_accepted:
try:
send_offer_accepted_email(request, offer.buyer.email, offer)
except Exception:
logging.getLogger(__name__).exception(
"offer accepted email failed (non-fatal)"
)
return result