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:
parent
759d98a968
commit
812e116f50
8 changed files with 381 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
55
make_post_sell/templates/user_bids.j2
Normal file
55
make_post_sell/templates/user_bids.j2
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>My Bids — {{ 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 %}
|
||||
55
make_post_sell/templates/user_offers.j2
Normal file
55
make_post_sell/templates/user_offers.j2
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.j2" -%}
|
||||
|
||||
{%- block append_to_head_tag_section %}
|
||||
<title>My Offers — {{ 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 %}
|
||||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue