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:
parent
981b1316f2
commit
2cad2482b8
9 changed files with 539 additions and 182 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
90
make_post_sell/static/js/offer.js
Normal file
90
make_post_sell/static/js/offer.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue