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 = (