feat: buyer dashboards — /u/offers + /u/bids — gated per shop

Two new buyer-side pages mirror /u/purchases — scoped to request.shop,
listing every offer or bid the user has placed within the current shop:

- /u/offers — open offers (pending/countered) on top, terminal below.
  404s when the shop has offer_enabled=False. Wired to user_offers.j2.
- /u/bids — latest bid per auction in this shop. 404s when the shop
  has zero products in auction pricing_mode. Wired to user_bids.j2.

Gating exposed on /u/settings:
- "My Offers" button: visible iff request.shop.offer_enabled is True.
- "My Bids" button:  visible iff request.shop.has_auction_products.

New property Shop.has_auction_products: returns True when the shop
has at least one product with pricing_mode in (1, 2). Auctions have
no shop-level toggle — they're enabled per product — so the buyer
gate is derived. Unit-test coverage in test_integration.py.

Tests:
- TestAuctionFoundation.test_shop_has_auction_products_property
  covers all three pricing-mode transitions on the same shop.
- TestUserOffersBidsDashboards (7 tests) covers 404 paths, page
  renders, and button visibility on plain / offer / auction shops.
This commit is contained in:
russell@unturf.com 2026-05-13 09:55:46 -04:00
parent 759d98a968
commit 812e116f50
No known key found for this signature in database
8 changed files with 381 additions and 1 deletions

View file

@ -23,7 +23,7 @@ from .meta import (
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.orm import relationship
from sqlalchemy.orm import relationship, object_session
from .user_shop import UserShop
@ -471,6 +471,21 @@ class Shop(RBase, Base):
def is_adyen_not_ready(self):
return not self.is_adyen_ready
@property
def has_auction_products(self):
"""MPS-20: True when this shop has at least one product in an
auction pricing_mode (1 or 2). Used to gate buyer-side bid UI —
auctions have no shop-level toggle, they're enabled per product.
"""
from .product import Product
return (
object_session(self)
.query(Product.id)
.filter(Product.shop_id == self.id, Product.pricing_mode.in_([1, 2]))
.first()
is not None
)
def is_ready_for_payment(self, request):
"""Check if shop is ready based on enabled payment methods."""
# If Stripe is enabled, shop needs Stripe API keys

View file

@ -63,6 +63,8 @@ def includeme(config):
config.add_route("user_storage_settings", "/u/settings/storage")
config.add_route("user_sandbox_upload", "/u/sandbox/upload")
config.add_route("user_purchases", "/u/purchases")
config.add_route("user_offers", "/u/offers")
config.add_route("user_bids", "/u/bids")
config.add_route("user_addresses", "/u/addresses")
config.add_route("user_address_save", "/u/addresses/save")
config.add_route("user_address_delete", "/u/addresses/{address_id}/delete")

View file

@ -0,0 +1,55 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>My Bids &mdash; {{ shop.name }}</title>
{%- endblock %}
{% block content %}
<section class="one-column shop-offers-page">
<div class="well">
<h1 class="type-title">My Bids at {{ shop.name }}</h1>
<p class="profile-meta">
{% if active_count %}{{ active_count }} active auction{% if active_count != 1 %}s{% endif %}{% if active_count != bids|length %} of {{ bids|length }} total{% endif %}.{% else %}No active bids.{% endif %}
</p>
</div>
{% if bids %}
<div class="well">
<table class="shop-offers-table">
<thead>
<tr>
<th>Product</th>
<th>Your bid</th>
<th>State</th>
<th>Ends</th>
<th>Bid placed</th>
<th></th>
</tr>
</thead>
<tbody>
{% for b in bids %}
<tr class="{% if b.is_winning %}offer-row-needs-action{% elif b.is_ended %}offer-row-terminal{% endif %}">
<td>{{ b.product_title }}</td>
<td>${{ "%.2f"|format(b.bid_amount) }}</td>
<td>
<span class="offer-state-badge offer-state-{{ b.state }}">{{ b.state_human }}</span>
{% if b.is_winning and b.is_active %}<span class="offer-row-flag">winning</span>{% elif b.is_winning %}<span class="offer-row-flag">won</span>{% endif %}
</td>
<td>{{ b.ends_human }}</td>
<td>{{ b.bid_placed_human }}</td>
<td><a href="/a/{{ b.auction_id }}" class="mps-button-small">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="well">
<p>You haven't placed any bids in this shop yet. Browse the shop for items on auction.</p>
<p><a href="/" class="mps-button">Browse {{ shop.name }}</a></p>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,55 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>My Offers &mdash; {{ shop.name }}</title>
{%- endblock %}
{% block content %}
<section class="one-column shop-offers-page">
<div class="well">
<h1 class="type-title">My Offers at {{ shop.name }}</h1>
<p class="profile-meta">
{% if open_count %}{{ open_count }} open{% if open_count != offers|length %} of {{ offers|length }} total{% endif %}.{% else %}No open offers.{% endif %}
</p>
</div>
{% if offers %}
<div class="well">
<table class="shop-offers-table">
<thead>
<tr>
<th>Product</th>
<th>Your offer</th>
<th>Round</th>
<th>State</th>
<th>Last action</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in offers %}
<tr class="{% if o.waiting_on_buyer %}offer-row-needs-action{% elif o.is_terminal %}offer-row-terminal{% endif %}">
<td>{{ o.product_title }}</td>
<td>${{ "%.2f"|format(o.current_amount) }}</td>
<td>{{ o.round_count }}</td>
<td>
<span class="offer-state-badge offer-state-{{ o.state }}">{{ o.state_human }}</span>
{% if o.waiting_on_buyer %}<span class="offer-row-flag">your turn</span>{% endif %}
</td>
<td>{{ o.last_action_human }}</td>
<td><a href="/o/{{ o.id }}" class="mps-button-small">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="well">
<p>You haven't made any offers in this shop yet. When you do, every negotiation shows up here.</p>
<p><a href="/" class="mps-button">Browse {{ shop.name }}</a></p>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -189,6 +189,16 @@
<a href="/u/purchases" class="product-download-button mps-button">My Purchases</a>
<br/>
<br/>
{% if request.shop.offer_enabled %}
<a href="/u/offers" class="product-download-button mps-button">My Offers</a>
<br/>
<br/>
{% endif %}
{% if request.shop.has_auction_products %}
<a href="/u/bids" class="product-download-button mps-button">My Bids</a>
<br/>
<br/>
{% endif %}
<a href="/u/crypto-quotes" class="product-download-button mps-button">Crypto Payment History</a>
<br/>
<br/>

View file

@ -7094,6 +7094,91 @@ class TestBuyNowGating(_AuthenticatedBase):
self.assertNotIn(b"sold-out-button", res.body)
class TestUserOffersBidsDashboards(_AuthenticatedBase):
"""MPS-20 + MPS-21: buyer-side /u/offers and /u/bids pages plus the
button gating in /u/settings. Each dashboard scopes to request.shop
(mirrors /u/purchases) and 404s when the shop doesn't expose the
relevant feature."""
def _shop_with_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
self.dbsession.add(shop)
product = Product(title="Negotiable thing", description="...")
product.shop = shop
product.price_in_cents = list_price
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3 # offer mode
self.dbsession.add(product)
self.dbsession.flush()
pid = product.uuid_str
transaction.commit()
return pid
def _shop_with_auction_product(self, list_price=10000):
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Auctioned thing", description="...")
product.shop = shop
product.price_in_cents = list_price
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 1 # auction
self.dbsession.add(product)
self.dbsession.flush()
pid = product.uuid_str
transaction.commit()
return pid
def test_user_offers_404_when_offers_disabled(self):
"""Shop has offers off → /u/offers returns 404."""
self._create_shop_helper(user_creds=self.user1_creds)
# Default shop has offer_enabled=False.
self.testapp.get("/u/offers", status=404)
def test_user_offers_page_loads_when_enabled(self):
self._shop_with_offer_product()
res = self.testapp.get("/u/offers", status=200)
self.assertIn(b"My Offers", res.body)
def test_user_bids_404_when_no_auction_products(self):
"""Shop has no auction-mode products → /u/bids returns 404."""
self._create_shop_helper(user_creds=self.user1_creds)
self.testapp.get("/u/bids", status=404)
def test_user_bids_page_loads_with_auction_product(self):
self._shop_with_auction_product()
res = self.testapp.get("/u/bids", status=200)
self.assertIn(b"My Bids", res.body)
def test_settings_buttons_appear_for_offer_shop(self):
"""My Offers button shown when shop.offer_enabled is True."""
self._shop_with_offer_product()
res = self.testapp.get("/u/settings", status=200)
self.assertIn(b'href="/u/offers"', res.body)
self.assertNotIn(b'href="/u/bids"', res.body)
def test_settings_buttons_appear_for_auction_shop(self):
"""My Bids button shown when shop has auction-mode products."""
self._shop_with_auction_product()
res = self.testapp.get("/u/settings", status=200)
self.assertIn(b'href="/u/bids"', res.body)
self.assertNotIn(b'href="/u/offers"', res.body)
def test_settings_buttons_hidden_for_plain_shop(self):
"""Neither button appears on a shop with no offer / auction config."""
self._create_shop_helper(user_creds=self.user1_creds)
res = self.testapp.get("/u/settings", status=200)
self.assertNotIn(b'href="/u/offers"', res.body)
self.assertNotIn(b'href="/u/bids"', res.body)
class TestAuctionConfigForm(_AuthenticatedBase):
"""MPS-20: owner sets quantity, start/end, reserve, buy-now,
increment, soft-close on a draft auction via product edit form."""

View file

@ -4019,6 +4019,40 @@ class TestAuctionFoundation(DatabaseIntegrationTests):
self.dbsession.flush()
return shop, product
def test_shop_has_auction_products_property(self):
"""MPS-20: Shop.has_auction_products gates buyer-side auction UI.
Auctions don't have a shop-level toggle — they're enabled by
flipping a product's pricing_mode to 1 (auction) or 2 (auction +
buy_now). The property must reflect that derivation.
"""
from ..models.product import Product
shop, product = self._make_shop_and_product(pricing_mode=1)
self.assertTrue(shop.has_auction_products)
# Flip to fixed price — no auction products left.
product.pricing_mode = 0
self.dbsession.flush()
self.assertFalse(shop.has_auction_products)
# pricing_mode 2 (auction + buy_now) also counts.
product.pricing_mode = 2
self.dbsession.flush()
self.assertTrue(shop.has_auction_products)
# Add a second fixed-price product alongside — still True because
# at least one auction-mode product exists.
product.pricing_mode = 1
p2 = Product(title="Buy now item", description="fixed")
p2.shop = shop
p2.price_in_cents = 500
p2.is_physical = False
p2.is_sellable = True
p2.pricing_mode = 0
self.dbsession.add(p2)
self.dbsession.flush()
self.assertTrue(shop.has_auction_products)
def test_create_and_persist_auction(self):
from ..models.auction import (
MpsAuction, AUCTION_STATE_DRAFT, AUCTION_STATE_ACTIVE,

View file

@ -12,6 +12,7 @@ from ..models.user import (
from ..models.invoice import Invoice
from ..models.offer import MpsOffer
from ..models.auction import MpsAuction, MpsBid
from ..models.shop import get_shop_by_id
@ -45,6 +46,129 @@ def user_purchases(request):
}
@view_config(route_name="user_offers", renderer="user_offers.j2")
@user_required()
@shop_is_ready_required()
def user_offers(request):
"""Buyer-side inbox: every offer this user has opened in this shop.
Mirrors /u/purchases — scopes to request.shop. Open offers (pending /
countered) sort to the top by most-recent activity; terminal offers
follow.
"""
from ..models.offer import OFFER_PARTY_BUYER
user = request.user
shop = request.shop
if not shop.offer_enabled:
raise HTTPNotFound()
offers = (
request.dbsession.query(MpsOffer)
.filter(MpsOffer.shop_id == shop.id, MpsOffer.buyer_user_id == user.id)
.all()
)
offers.sort(key=lambda o: (o.is_terminal, -(o.last_action_timestamp or 0)))
from datetime import datetime, timezone
def _human(ts):
if not ts:
return ""
return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC"
)
rows = []
for o in offers:
rows.append({
"id": o.uuid_str,
"product_title": o.product.title if o.product else "(removed product)",
"state": o.state,
"state_human": o.state_human,
"is_open": o.is_open,
"is_terminal": o.is_terminal,
"current_amount": o.current_amount,
"round_count": o.round_count,
"last_action_human": _human(o.last_action_timestamp),
"waiting_on_buyer": (
o.is_open and o.current_party == OFFER_PARTY_BUYER
),
})
return {
"the_title": "My Offers",
"shop": shop,
"offers": rows,
"open_count": sum(1 for r in rows if not r["is_terminal"]),
}
@view_config(route_name="user_bids", renderer="user_bids.j2")
@user_required()
@shop_is_ready_required()
def user_bids(request):
"""Buyer-side dashboard: every bid this user has placed on an auction
in this shop. Latest bid per auction wins the row — older bids fold
into history on the auction page itself.
"""
user = request.user
shop = request.shop
if not shop.has_auction_products:
raise HTTPNotFound()
bids = (
request.dbsession.query(MpsBid)
.join(MpsAuction, MpsBid.auction_id == MpsAuction.id)
.filter(
MpsBid.bidder_user_id == user.id,
MpsAuction.shop_id == shop.id,
)
.order_by(MpsBid.created_timestamp.desc())
.all()
)
seen_auctions = set()
latest_per_auction = []
for b in bids:
if b.auction_id in seen_auctions:
continue
seen_auctions.add(b.auction_id)
latest_per_auction.append(b)
from datetime import datetime, timezone
def _human(ts):
if not ts:
return ""
return datetime.fromtimestamp(ts / 1000, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC"
)
rows = []
for b in latest_per_auction:
a = b.auction
rows.append({
"auction_id": a.uuid_str,
"product_title": a.product.title if a.product else "(removed product)",
"bid_amount": b.amount,
"is_winning": b.is_winning,
"state": a.state,
"state_human": a.state_human,
"is_active": a.is_active,
"is_ended": a.is_ended,
"ends_human": _human(a.end_timestamp),
"bid_placed_human": _human(b.created_timestamp),
})
return {
"the_title": "My Bids",
"shop": shop,
"bids": rows,
"active_count": sum(1 for r in rows if r["is_active"]),
}
@view_config(route_name="user_settings", renderer="user_settings.j2")
@user_required(
flash_msg="To view your settings, please verify your email address below.",