diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index ff0ef0a..1b49bb7 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -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
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index da93ad0..0df291b 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -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")
diff --git a/make_post_sell/templates/user_bids.j2 b/make_post_sell/templates/user_bids.j2
new file mode 100644
index 0000000..c3084e7
--- /dev/null
+++ b/make_post_sell/templates/user_bids.j2
@@ -0,0 +1,55 @@
+{% extends "base.j2" -%}
+
+{%- block append_to_head_tag_section %}
+
My Bids — {{ shop.name }}
+{%- endblock %}
+
+{% block content %}
+
+
+
+
My Bids at {{ shop.name }}
+
+ {% 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 %}
+
+
+
+ {% if bids %}
+
+
+
+
+ | Product |
+ Your bid |
+ State |
+ Ends |
+ Bid placed |
+ |
+
+
+
+ {% for b in bids %}
+
+ | {{ b.product_title }} |
+ ${{ "%.2f"|format(b.bid_amount) }} |
+
+ {{ b.state_human }}
+ {% if b.is_winning and b.is_active %}winning{% elif b.is_winning %}won{% endif %}
+ |
+ {{ b.ends_human }} |
+ {{ b.bid_placed_human }} |
+ View |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
You haven't placed any bids in this shop yet. Browse the shop for items on auction.
+
Browse {{ shop.name }}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/make_post_sell/templates/user_offers.j2 b/make_post_sell/templates/user_offers.j2
new file mode 100644
index 0000000..8f91575
--- /dev/null
+++ b/make_post_sell/templates/user_offers.j2
@@ -0,0 +1,55 @@
+{% extends "base.j2" -%}
+
+{%- block append_to_head_tag_section %}
+ My Offers — {{ shop.name }}
+{%- endblock %}
+
+{% block content %}
+
+
+
+
My Offers at {{ shop.name }}
+
+ {% if open_count %}{{ open_count }} open{% if open_count != offers|length %} of {{ offers|length }} total{% endif %}.{% else %}No open offers.{% endif %}
+
+
+
+ {% if offers %}
+
+
+
+
+ | Product |
+ Your offer |
+ Round |
+ State |
+ Last action |
+ |
+
+
+
+ {% for o in offers %}
+
+ | {{ o.product_title }} |
+ ${{ "%.2f"|format(o.current_amount) }} |
+ {{ o.round_count }} |
+
+ {{ o.state_human }}
+ {% if o.waiting_on_buyer %}your turn{% endif %}
+ |
+ {{ o.last_action_human }} |
+ View |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
You haven't made any offers in this shop yet. When you do, every negotiation shows up here.
+
Browse {{ shop.name }}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/make_post_sell/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2
index 5da3003..a314815 100644
--- a/make_post_sell/templates/user_settings.j2
+++ b/make_post_sell/templates/user_settings.j2
@@ -189,6 +189,16 @@
My Purchases
+ {% if request.shop.offer_enabled %}
+ My Offers
+
+
+ {% endif %}
+ {% if request.shop.has_auction_products %}
+ My Bids
+
+
+ {% endif %}
Crypto Payment History
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 1cfed3a..171bfa2 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -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."""
diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py
index 520eecb..f6acf99 100644
--- a/make_post_sell/tests/test_integration.py
+++ b/make_post_sell/tests/test_integration.py
@@ -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,
diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py
index 30c2d52..84e44ea 100644
--- a/make_post_sell/views/user.py
+++ b/make_post_sell/views/user.py
@@ -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.",