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:
parent
f2f273ebaa
commit
41884fbf8d
4 changed files with 510 additions and 0 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
92
make_post_sell/templates/auction.j2
Normal file
92
make_post_sell/templates/auction.j2
Normal 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>
|
||||
·
|
||||
<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 %} · 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 %}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
229
make_post_sell/views/auction.py
Normal file
229
make_post_sell/views/auction.py
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue