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.
4.8 KiB
Auction House Mode (MPS-20)
eBay-style bidding for any product. Shop owner flips Product.pricing_mode
to 1 (auction) or 2 (auction + buy-now) on the product edit page; the
system creates a draft MpsAuction row and the auction page goes live
once the owner schedules start_timestamp and end_timestamp.
State Machine
draft → scheduled → active → ended → settled
↘ cancelled
| state | transition |
|---|---|
| 0 draft | owner editing; not visible to buyers |
| 1 scheduled | countdown to start_timestamp; tick promotes to active |
| 2 active | accepting bids; soft-close extends end_timestamp |
| 3 ended | bidding closed; winner determined; payment_deadline_timestamp set |
| 4 settled | winner paid via cart; product transferred (mark_paid hook) |
| 5 cancelled | owner aborted (pre-active only) |
Models
MpsAuction— one per Product (unique index onproduct_id); fields for state, timestamps, prices (start/reserve/buy_now), bid_increment, soft_close_seconds, winner_user_id, payment_deadline_timestamp.MpsBid— one row per bid;is_winningflag flips when outbid. Storesmax_proxy_in_centsfor proxy bidding.MpsAuctionWatcher— user follows the auction; drives notifications.MpsCartAuction— cart-side association so checkout pays the winning bid amount instead ofProduct.price_in_cents.
Bidding Logic (lib/auction.py)
Pure helpers:
validate_bid— state must be ACTIVE; first bid >= start_price; subsequent >= current_high + increment.is_within_soft_close/extended_end_timestamp— soft-close math.resolve_proxy— eBay-style: higher proxy wins; loser auto-bids defending bidder up tomin(loser_proxy + increment, winner_proxy); ties go to the existing top.
Orchestrator:
place_bid(auction, bidder, amount, max_proxy)— validates, writes the newMpsBid, marks prior winning bidis_winning=Falsewithoutbid_timestamp, applies proxy resolution, applies soft-close, flushes. RaisesBidRejectedon rejection.
Buy-Now (mode 2)
POST /a/{id}/buy-now from a buyer places a bid at
buy_now_price_in_cents, sets state=ENDED, records winner. Buyer
proceeds to /a/{id}/checkout to pay.
Soft-Close (anti-snipe)
A bid placed within soft_close_seconds of end_timestamp extends the
end by soft_close_seconds. original_end_timestamp preserves the
scheduled close for audit.
Tick (scripts/auction_tick.py)
Cron-driven state transitions:
SCHEDULED + start_timestamp <= now → ACTIVEACTIVE + end_timestamp <= now → ENDED- records winner from
is_winningbid (if any) - sets
payment_deadline_timestamp = now + 48h
- records winner from
Recommended cron: every minute (* * * * *). 60s default soft-close
window means 1-min granularity is fine.
Routes
GET /a/{auction_id} live page (auction.j2 + auction.js)
GET /a/{auction_id}.json JSON state for poll
POST /a/{auction_id}/bid place bid (login required, no self-bid)
POST /a/{auction_id}/buy-now end auction at buy_now (mode 2)
POST /a/{auction_id}/watch toggle watcher
POST /a/{auction_id}/checkout winner pays via standard cart flow
Cart Integration
When cart.cart_auctions has one row, cart.total_price_in_cents
short-circuits to the winning bid amount + handling +
gift-card-purchases. After standard cart payment success,
_finalize_auction_offer_state flips state=SETTLED and records
winner_user_id + winning_bid_id.
Live UI (static/js/auction.js)
- Countdown clock ticks every 1s (data-end-timestamp attribute)
- Polls
/a/{id}.jsonevery 5s for state changes - AJAX bid submit; success/error flash without page reload
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
AUCTION_OUTBID— sent to previous high bidder when their bid is beaten (sent fromauction_bidview, afterplace_bidsucceeds and a different user takes the lead).
Tick-driven won emails (when an auction ends) are deferred — tick scripts run from cron without a Pyramid request context. A future commit will either wire a request-less email path or queue the events for the next view to flush.
Testing
712 baseline tests + 27 (foundation) + 24 (lib/auction) + 11 (views)
- 9 (form sections) + 13 (cart integration) + 10 (tick) + 4 (emails)
- 3 (UI) = 813 net new across MPS-20 (some shared with MPS-21).