MPS-20: auction views — page, JSON, bid, buy-now, watch

Routes (registered before shop_slug-style catch-alls so .json suffix is
not shadowed):
- GET  /a/{auction_id}.json   live state for poll
- POST /a/{auction_id}/bid    place a bid (login required)
- POST /a/{auction_id}/buy-now  end auction at buy_now price (mode 2)
- POST /a/{auction_id}/watch  toggle watcher
- GET  /a/{auction_id}        live page (templates/auction.j2)

views/auction.py:
- _serialize_auction shapes the same dict for both template ctx and JSON
- _user_is_seller checks request.user against auction.shop.owners — pure
  validate_bid in lib/auction does not have visibility into ownership,
  so the view enforces "no self-bidding" with HTTP 403
- auction_bid parses dollar input, converts to cents, calls place_bid,
  returns either {ok, bid_amount, is_winning, auction_state} or {error}
- auction_buy_now places a bid at buy_now price, sets state to ENDED,
  records winner_user_id and winning_bid_id
- auction_watch toggles MpsAuctionWatcher row

templates/auction.j2:
- Live page extending base.j2 with countdown placeholder, current high,
  bid form (amount + optional max_proxy), buy-now form when configured,
  watch toggle
- Hides bid form when seller views own auction or anon visitor (with
  log-in CTA)

Functional tests (11): page renders for anon, 404 unknown id, JSON
state, seller cannot bid (403), anon cannot bid (redirect), buyer
places first bid, bid below increment rejected (400), invalid amount
rejected (400), watch toggle round-trip, buy-now 404 when not offered,
buy-now ends auction and records winner.

Cart integration deferred to commit 5 (bundling with offer cart
integration since both add a non-list-price line item pattern).

Total: 892 tests pass (was 881 + 11).
This commit is contained in:
russell@unturf.com 2026-05-09 19:25:28 -04:00
parent f2f273ebaa
commit 41884fbf8d
No known key found for this signature in database
4 changed files with 510 additions and 0 deletions

View file

@ -235,3 +235,12 @@ def includeme(config):
# Checksum backfill
config.add_route("shop_checksum_backfill", "/s/{shop_id}/checksum-backfill")
config.add_route("shop_checksum_backfill_status", "/s/{shop_id}/checksum-backfill-status")
# Auctions (MPS-20). Register .json route BEFORE the bare-id route so
# /a/{id}.json matches the JSON view; otherwise {auction_id} would
# greedily match "abc.json" and shadow the JSON endpoint.
config.add_route("auction_json", "/a/{auction_id}.json")
config.add_route("auction_bid", "/a/{auction_id}/bid")
config.add_route("auction_buy_now", "/a/{auction_id}/buy-now")
config.add_route("auction_watch", "/a/{auction_id}/watch")
config.add_route("auction_page", "/a/{auction_id}")

View file

@ -0,0 +1,92 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>Auction — {{ product_title }}</title>
<meta name="description" content="Live auction for {{ product_title }}." />
{%- endblock %}
{% block content %}
<section class="one-column auction-page" data-auction-id="{{ id }}">
<div class="auction-header well">
<h1 class="type-title">{{ product_title }}</h1>
<p>
<a href="{{ product_url }}">View product page</a>
&middot;
<a href="{{ shop_url }}">View shop</a>
</p>
<div class="auction-state-badge auction-state-{{ state }}">
{{ state_human }}{% if has_reserve and not reserve_met %} &middot; reserve not met{% endif %}
</div>
</div>
<div class="auction-stats well">
<div class="auction-stat">
<span class="auction-stat-label">Current high</span>
<span class="auction-stat-value" id="auction-current-high">${{ "%.2f"|format(current_high) }}</span>
</div>
<div class="auction-stat">
<span class="auction-stat-label">Time remaining</span>
<span class="auction-stat-value" id="auction-countdown" data-end-timestamp="{{ end_timestamp or '' }}">
{%- if is_active -%}…{%- elif is_ended or is_terminal -%}ended{%- else -%}—{%- endif -%}
</span>
</div>
{% if has_buy_now %}
<div class="auction-stat">
<span class="auction-stat-label">Buy now</span>
<span class="auction-stat-value">${{ "%.2f"|format(buy_now_price) }}</span>
</div>
{% endif %}
</div>
{% if is_active %}
{% if user_is_seller %}
<div class="well">
<p>You own this auction. You cannot place bids on your own listing.</p>
</div>
{% elif request.user is none %}
<div class="well">
<p>
<a href="/join-or-log-in" class="mps-button mps-button-blue">Log in to bid</a>
</p>
</div>
{% else %}
<form method="post" action="/a/{{ id }}/bid" id="auction-bid-form" class="auction-bid-form well">
<label for="auction-bid-amount">Your bid (USD)</label>
<input
type="number" step="0.01" min="{{ "%.2f"|format(min_next_bid) }}"
name="amount" id="auction-bid-amount"
value="{{ "%.2f"|format(min_next_bid) }}"
required
/>
<small>Minimum next bid: ${{ "%.2f"|format(min_next_bid) }}</small>
<label for="auction-max-proxy">Max proxy ceiling (optional)</label>
<input
type="number" step="0.01" min="{{ "%.2f"|format(min_next_bid) }}"
name="max_proxy" id="auction-max-proxy"
placeholder="Auto-bid up to this amount"
/>
<small>The system will outbid others up to your ceiling.</small>
<input type="submit" class="mps-submit" value="Place Bid" />
</form>
{% if has_buy_now %}
<form method="post" action="/a/{{ id }}/buy-now" id="auction-buy-now-form" class="well">
<p>Skip the bidding — buy now for ${{ "%.2f"|format(buy_now_price) }}.</p>
<input type="submit" class="mps-submit" value="Buy Now" />
</form>
{% endif %}
<form method="post" action="/a/{{ id }}/watch" id="auction-watch-form">
<input type="submit" class="mps-button" value="Watch this auction" />
</form>
{% endif %}
{% endif %}
<div id="auction-flash" class="auction-flash" role="status" aria-live="polite"></div>
</section>
{% endblock %}

View file

@ -5523,3 +5523,183 @@ class TestKillSwitches(_AuthenticatedBase):
res = self.testapp.get("/", status=200)
body = res.body.decode()
self.assertNotIn("karaoke vocal isolation", body)
class TestAuctionRoutes(_AuthenticatedBase):
"""MPS-20: HTTP-level coverage for auction views."""
def _make_active_auction(
self, owner_creds=None, start_price=1000, increment=100,
end_in_ms=3_600_000, soft_close=60, has_buy_now=False,
):
"""Create a shop+product+active auction. Returns the auction id."""
from ..models.auction import (
MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
)
from ..models.product import Product
if owner_creds is None:
owner_creds = self.user1_creds
shop = self._create_shop_helper(user_creds=owner_creds)
# Create product directly via ORM (faster than UI flow).
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=soft_close,
)
auction.state = AUCTION_STATE_ACTIVE
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() + end_in_ms
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_auction_page_renders_for_anon(self):
auction_id = self._make_active_auction()
# log out user1 so we're anon.
self.testapp.get("/log-out")
res = self.testapp.get(f"/a/{auction_id}", status=200)
body = res.body.decode()
self.assertIn("Log in to bid", body)
self.assertIn("Auctionable", body)
def test_auction_page_404_unknown_id(self):
self.testapp.get("/a/00000000000000000000000000000000", status=404)
def test_auction_json_returns_state(self):
auction_id = self._make_active_auction()
self.testapp.get("/log-out")
res = self.testapp.get(f"/a/{auction_id}.json", status=200)
data = res.json
self.assertEqual(data["state"], 2) # ACTIVE
self.assertEqual(data["state_human"], "Active")
self.assertTrue(data["is_active"])
self.assertEqual(data["current_high_in_cents"], 1000)
self.assertEqual(data["min_next_bid_in_cents"], 1000) # no bids yet
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,
)
self.assertEqual(res.status_int, 403)
self.assertIn("own auction", res.json["error"])
def test_anon_cannot_bid(self):
auction_id = self._make_active_auction()
self.testapp.get("/log-out")
# Without login user_required redirects.
res = self.testapp.post(
f"/a/{auction_id}/bid",
{"amount": "12.00"},
expect_errors=True,
)
self.assertIn(res.status_int, (302, 303, 401, 403))
def test_buyer_places_first_bid(self):
# user1 owns the shop; user2 will be the bidder.
auction_id = self._make_active_auction(owner_creds=self.user1_creds)
# Log out user1, log in as user2.
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,
)
self.assertTrue(res.json["ok"])
self.assertEqual(res.json["bid_amount_in_cents"], 1000)
self.assertTrue(res.json["is_winning"])
self.assertEqual(res.json["auction_state"]["current_high_in_cents"], 1000)
def test_bid_below_increment_rejected(self):
auction_id = self._make_active_auction(start_price=1000, increment=100)
self.testapp.get("/log-out")
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)
# 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(
f"/a/{auction_id}/bid",
{"amount": "10.50"}, # below 1000 + 100 = 1100 floor
expect_errors=True,
)
self.assertEqual(res.status_int, 400)
def test_invalid_amount_rejected(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}/bid",
{"amount": "not-a-number"},
expect_errors=True,
)
self.assertEqual(res.status_int, 400)
def test_watch_toggle(self):
auction_id = self._make_active_auction()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# First click: watching.
res1 = self.testapp.post(f"/a/{auction_id}/watch", status=200)
self.assertTrue(res1.json["watching"])
# Second click: unwatch.
res2 = self.testapp.post(f"/a/{auction_id}/watch", status=200)
self.assertFalse(res2.json["watching"])
def test_buy_now_404_when_not_offered(self):
auction_id = self._make_active_auction(has_buy_now=False)
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,
)
self.assertEqual(res.status_int, 404)
def test_buy_now_ends_auction(self):
from ..models.auction import (
get_auction_by_id, AUCTION_STATE_ENDED,
)
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=200)
self.assertTrue(res.json["ok"])
self.assertEqual(res.json["auction_state"]["state"], AUCTION_STATE_ENDED)
# Re-query the auction to verify state and winner.
from ..models.user import get_or_create_user_by_email
auction = get_auction_by_id(self.dbsession, auction_id)
u2 = get_or_create_user_by_email(self.dbsession, "test2@example.com")
self.assertEqual(auction.state, AUCTION_STATE_ENDED)
self.assertEqual(auction.winner, u2)

View file

@ -0,0 +1,229 @@
"""Auction views — MPS-20.
Routes:
GET /a/{auction_id} live auction page
GET /a/{auction_id}.json JSON state for poll
POST /a/{auction_id}/bid place a bid (requires login)
POST /a/{auction_id}/buy-now buy at buy_now price (mode 2 only)
POST /a/{auction_id}/watch toggle watcher
Self-bid blocking, shop owner check, and identity resolution happen here.
The pure logic in lib/auction.py does not have visibility into who owns
which shop.
"""
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
from pyramid.view import view_config
from ..lib.auction import (
BidRejected,
place_bid,
is_within_soft_close,
extended_end_timestamp,
)
from ..lib.currency import cents_to_dollars
from ..models.auction import (
AUCTION_STATE_ACTIVE,
AUCTION_STATE_ENDED,
MpsAuction,
MpsAuctionWatcher,
get_auction_by_id,
now_timestamp,
)
from ..views import user_required
def _user_is_seller(request, auction):
"""True iff request.user is one of the auction's shop owners."""
if request.user is None:
return False
return request.user in auction.shop.owners
def _serialize_auction(auction, request):
"""Shape used by both auction.j2 (template ctx) and the JSON poll."""
current_winning = (
auction.bids
.filter_by(is_winning=True)
.one_or_none()
)
return {
"id": auction.uuid_str,
"product_title": auction.product.title,
"product_url": f"/p/{auction.product.uuid_str}/{auction.product.slug}",
"shop_url": f"/s/{auction.shop.uuid_str}",
"state": auction.state,
"state_human": auction.state_human,
"is_active": auction.is_active,
"is_ended": auction.is_ended,
"is_terminal": auction.is_terminal,
"start_timestamp": auction.start_timestamp,
"end_timestamp": auction.end_timestamp,
"time_remaining_ms": auction.time_remaining_ms,
"start_price_in_cents": auction.start_price_in_cents,
"start_price": auction.start_price,
"current_high_in_cents": auction.current_high_in_cents,
"current_high": auction.current_high,
"min_next_bid_in_cents": auction.min_next_bid_in_cents,
"min_next_bid": auction.min_next_bid,
"bid_increment_in_cents": auction.bid_increment_in_cents,
"bid_increment": auction.bid_increment,
"has_buy_now": auction.has_buy_now,
"buy_now_price_in_cents": auction.buy_now_price_in_cents,
"buy_now_price": auction.buy_now_price,
"has_reserve": auction.has_reserve,
"reserve_met": auction.reserve_met,
"winner_user_id": (
auction.winner.uuid_str if auction.winner else None
),
"current_winning_user_id": (
current_winning.bidder.uuid_str if current_winning else None
),
"currency": auction.currency,
"user_is_seller": _user_is_seller(request, auction),
"user_is_winning": (
request.user is not None
and current_winning is not None
and current_winning.bidder == request.user
),
}
@view_config(route_name="auction_page", renderer="auction.j2")
def auction_page(request):
auction = get_auction_by_id(
request.dbsession, request.matchdict["auction_id"]
)
if auction is None:
raise HTTPNotFound()
return _serialize_auction(auction, request)
@view_config(route_name="auction_json", renderer="json")
def auction_json(request):
auction = get_auction_by_id(
request.dbsession, request.matchdict["auction_id"]
)
if auction is None:
raise HTTPNotFound()
return _serialize_auction(auction, request)
@view_config(route_name="auction_bid", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to bid.")
def auction_bid(request):
auction = get_auction_by_id(
request.dbsession, request.matchdict["auction_id"]
)
if auction is None:
raise HTTPNotFound()
if _user_is_seller(request, auction):
request.response.status_int = 403
return {"error": "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"}
max_proxy_dollars = (request.params.get("max_proxy") or "").strip()
max_proxy_in_cents = None
if max_proxy_dollars:
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"}
try:
bid = place_bid(
auction=auction,
bidder=request.user,
amount_in_cents=amount_in_cents,
max_proxy_in_cents=max_proxy_in_cents,
)
except BidRejected as e:
request.response.status_int = 400
return {"error": str(e)}
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),
}
@view_config(route_name="auction_buy_now", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to buy.")
def auction_buy_now(request):
"""Buy-now ends the auction immediately. Mode 2 (auction+buy_now) only."""
auction = get_auction_by_id(
request.dbsession, request.matchdict["auction_id"]
)
if auction is None:
raise HTTPNotFound()
if not auction.has_buy_now:
request.response.status_int = 404
return {"error": "buy now not available on this auction"}
if not auction.is_active:
request.response.status_int = 400
return {"error": "auction not active"}
if _user_is_seller(request, auction):
request.response.status_int = 403
return {"error": "you cannot buy your own auction"}
# Place a bid at the buy_now price (system-driven), end the auction.
try:
bid = place_bid(
auction=auction,
bidder=request.user,
amount_in_cents=auction.buy_now_price_in_cents,
max_proxy_in_cents=auction.buy_now_price_in_cents,
)
except BidRejected as e:
request.response.status_int = 400
return {"error": str(e)}
# End the auction immediately.
auction.state = AUCTION_STATE_ENDED
auction.end_timestamp = now_timestamp()
auction.winner = request.user
auction.winning_bid_id = bid.id
request.dbsession.flush()
return {
"ok": True,
"winning_bid_id": str(bid.id),
"auction_state": _serialize_auction(auction, request),
}
@view_config(route_name="auction_watch", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to watch.")
def auction_watch(request):
"""Toggle: if watcher row exists, delete it; else create it."""
auction = get_auction_by_id(
request.dbsession, request.matchdict["auction_id"]
)
if auction is None:
raise HTTPNotFound()
existing = (
request.dbsession.query(MpsAuctionWatcher)
.filter_by(auction_id=auction.id, user_id=request.user.id)
.one_or_none()
)
if existing is not None:
request.dbsession.delete(existing)
request.dbsession.flush()
return {"watching": False}
watcher = MpsAuctionWatcher(auction=auction, user=request.user)
request.dbsession.add(watcher)
request.dbsession.flush()
return {"watching": True}