diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index b38df34..a0e7846 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -489,6 +489,33 @@ class Shop(RBase, Base):
is not None
)
+ @property
+ def has_offer_products(self):
+ """MPS-21: True when this shop has at least one product that
+ accepts offers, accounting for the per-product allow_offers
+ override. A product accepts offers when:
+ - product.allow_offers IS TRUE (explicit opt-in), OR
+ - product.allow_offers IS NULL and shop.offer_enabled is True
+ (default inheritance).
+ Used to gate the buyer-side "My Offers" button — a shop with
+ offer_enabled=False can still have offer-capable products via
+ the per-product override.
+ """
+ from sqlalchemy import or_
+ from .product import Product
+ q = (
+ object_session(self)
+ .query(Product.id)
+ .filter(Product.shop_id == self.id)
+ )
+ if self.offer_enabled:
+ q = q.filter(
+ or_(Product.allow_offers == True, Product.allow_offers.is_(None))
+ )
+ else:
+ q = q.filter(Product.allow_offers == True)
+ return q.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/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2
index a314815..916098d 100644
--- a/make_post_sell/templates/user_settings.j2
+++ b/make_post_sell/templates/user_settings.j2
@@ -189,7 +189,7 @@
My Purchases
- {% if request.shop.offer_enabled %}
+ {% if request.shop.has_offer_products %}
My Offers
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py
index 6b0db16..e833c27 100644
--- a/make_post_sell/tests/test_functional.py
+++ b/make_post_sell/tests/test_functional.py
@@ -7352,6 +7352,29 @@ class TestUserOffersBidsDashboards(_AuthenticatedBase):
self.assertNotIn(b'href="/u/offers"', res.body)
self.assertNotIn(b'href="/u/bids"', res.body)
+ def test_settings_offers_button_for_product_level_opt_in(self):
+ """Regression: shop.offer_enabled=False but product.allow_offers=True
+ still shows the My Offers button. The original gate (shop-level
+ only) missed this case — operators who flipped offers per-product
+ instead of shop-wide saw no button on /u/settings.
+ """
+ from ..models.product import Product
+ shop = self._create_shop_helper(user_creds=self.user1_creds)
+ shop.offer_enabled = False # shop default OFF
+ product = Product(title="Per-product opt-in", description="...")
+ product.shop = shop
+ product.price_in_cents = 10000
+ product.is_physical = False
+ product.is_sellable = True
+ product.pricing_mode = 3 # offer mode
+ product.allow_offers = True # explicit override on the product
+ self.dbsession.add(product)
+ self.dbsession.flush()
+ transaction.commit()
+
+ res = self.testapp.get("/u/settings", status=200)
+ self.assertIn(b'href="/u/offers"', res.body)
+
class TestAuctionConfigForm(_AuthenticatedBase):
"""MPS-20: owner sets quantity, start/end, reserve, buy-now,
diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py
index bb67402..73a2724 100644
--- a/make_post_sell/tests/test_integration.py
+++ b/make_post_sell/tests/test_integration.py
@@ -4051,6 +4051,41 @@ class TestAuctionFoundation(DatabaseIntegrationTests):
self.dbsession.flush()
return shop, product
+ def test_shop_has_offer_products_property(self):
+ """MPS-21: Shop.has_offer_products gates buyer-side offer UI.
+ Mirrors has_auction_products but accounts for the per-product
+ allow_offers override — a shop with offer_enabled=False can
+ still have offer-capable products via product.allow_offers=True.
+ """
+ shop, product = self._make_shop_and_product(pricing_mode=3)
+
+ # Default: shop.offer_enabled=False, product.allow_offers=None.
+ # No offer-capable products.
+ shop.offer_enabled = False
+ product.allow_offers = None
+ self.dbsession.flush()
+ self.assertFalse(shop.has_offer_products)
+
+ # Shop default enabled, product inherits NULL → True.
+ shop.offer_enabled = True
+ product.allow_offers = None
+ self.dbsession.flush()
+ self.assertTrue(shop.has_offer_products)
+
+ # Shop default disabled, but product explicitly opts in → True.
+ # (This is the case fox hit on shop.unturf.com.)
+ shop.offer_enabled = False
+ product.allow_offers = True
+ self.dbsession.flush()
+ self.assertTrue(shop.has_offer_products)
+
+ # Shop default enabled, product explicitly opts out → False
+ # (and no other offer-capable products in this shop).
+ shop.offer_enabled = True
+ product.allow_offers = False
+ self.dbsession.flush()
+ self.assertFalse(shop.has_offer_products)
+
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
diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py
index 84e44ea..fe94c70 100644
--- a/make_post_sell/views/user.py
+++ b/make_post_sell/views/user.py
@@ -61,7 +61,7 @@ def user_offers(request):
user = request.user
shop = request.shop
- if not shop.offer_enabled:
+ if not shop.has_offer_products:
raise HTTPNotFound()
offers = (