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:
parent
a7d9577631
commit
7ce8e5431c
5 changed files with 87 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue