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.",