fix: My Offers button respects per-product allow_offers override

Shop.offer_enabled is the shop-level *default* — products can override
via allow_offers=True even when the shop default is off. The original
button gate on /u/settings only checked the shop-level flag, so a
buyer in a shop with offer_enabled=False but at least one product
opting in via allow_offers=True saw no button.

Add Shop.has_offer_products mirroring has_auction_products, but
accounting for the override:

  product accepts offers iff
      product.allow_offers IS TRUE
      OR (product.allow_offers IS NULL AND shop.offer_enabled IS TRUE)

Wire user_settings.j2 to gate on has_offer_products, and update the
/u/offers view's 404 guard to the same property — otherwise the
button appeared but the page 404'd.

Tests:
- test_shop_has_offer_products_property covers the four-cell matrix
  (shop default × product override).
- test_settings_offers_button_for_product_level_opt_in is the
  regression for the case fox hit on shop.unturf.com.
This commit is contained in:
russell@unturf.com 2026-05-13 13:31:08 -04:00
parent a7d9577631
commit 7ce8e5431c
No known key found for this signature in database
5 changed files with 87 additions and 2 deletions

View file

@ -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

View file

@ -189,7 +189,7 @@
<a href="/u/purchases" class="product-download-button mps-button">My Purchases</a>
<br/>
<br/>
{% if request.shop.offer_enabled %}
{% if request.shop.has_offer_products %}
<a href="/u/offers" class="product-download-button mps-button">My Offers</a>
<br/>
<br/>

View file

@ -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,

View file

@ -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

View file

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