MPS-20 + MPS-21: form sections + buyer entry points

Shop owner-facing:
- shop_settings.j2: new "Make an Offer" form section (form_section=offer-settings)
  with offer_enabled, auto_accept_threshold_pct, auto_decline_threshold_pct,
  offer_min, offer_expiration_hours, offer_max_rounds,
  offer_min_buyer_account_age_hours
- views/shop.py: form_section=offer-settings handler with input clamping
  (decline forced strictly below accept; numeric inputs bounded)
- product_edit.j2: pricing_mode radio (5 options: fixed, auction, auction+
  buy_now, offer, offer+buy_now), allow_offers radio (inherit/yes/no)
  appearing only when product is in offer mode, link to live auction
  page when one exists
- views/product.py: handles pricing_mode change; flipping into auction
  mode (1 or 2) creates a draft MpsAuction with start_price seeded
  from product.price_in_cents and default bid_increment / soft_close
  from lib/auction defaults

Buyer-facing:
- product.j2: "View live auction" link when pricing_mode is auction;
  "Make an offer" details/form when offers_allowed AND user authenticated
  AND user not in shop owners; "Log in to make an offer" CTA for anon;
  Add To Cart only renders when is_buy_now_allowed (modes 0, 2, 4)

Tests (9 new, all passing):
- TestOfferSettingsForm: enable+set thresholds (round-trip), decline
  clamped below accept, blank offer_min clears the floor
- TestPricingModeFormSection: flip to auction creates draft auction,
  flip to offer leaves auction None, allow_offers override (yes/no/
  inherit), invalid pricing_mode value ignored, Make Offer button
  renders when eligible, Make Offer hidden for shop owner

Total: 913 tests pass (was 904 + 9).
This commit is contained in:
russell@unturf.com 2026-05-09 20:08:33 -04:00
parent 2de9229264
commit 5f92e65482
No known key found for this signature in database
6 changed files with 487 additions and 0 deletions

View file

@ -217,11 +217,42 @@
{% endif %}
{% endif %}
{% else %}
{% if product.is_buy_now_allowed %}
<form method="POST" action="{{ request.route_url('cart_add_product') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit" class="product-download-button mps-button">Add To Cart</button>
</form>
{% endif %}
{% endif %}
{# MPS-20: View Auction link when product is in auction mode. #}
{% if product.is_auction and product.auction %}
<p style="margin-top: var(--space-3)">
<a href="/a/{{ product.auction.uuid_str }}" class="mps-button mps-button-blue">
View live auction &rarr;
</a>
</p>
{% endif %}
{# MPS-21: Make Offer button when offers are allowed for this product. #}
{% if product.offers_allowed and request.user and request.user.authenticated and request.user not in product.shop.owners %}
<details style="margin-top: var(--space-3)">
<summary class="mps-button">Make an offer</summary>
<form method="POST" action="/p/{{ product.id }}/offer" style="margin-top: var(--space-2)">
{% include "snippets/csrf.j2" %}
<label for="offer_amount">Your offer (USD)</label>
<input type="number" step="0.01" min="0.01" name="amount" id="offer_amount" required />
<input type="text" name="message" placeholder="Optional message" />
<input type="submit" class="mps-submit" value="Submit Offer" />
</form>
</details>
{% elif product.offers_allowed and (not request.user or not request.user.authenticated) %}
<p style="margin-top: var(--space-3)">
<a href="/join-or-log-in?next={{ request.path | urlencode }}" class="mps-button">
Log in to make an offer
</a>
</p>
{% endif %}
<br/>

View file

@ -262,6 +262,74 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
<br />
<br />
{# MPS-20 + MPS-21: pricing mode #}
<fieldset>
<legend>Pricing mode</legend>
<label style="display:block">
<input type="radio" name="pricing_mode" value="0"
{% if product.pricing_mode == 0 %}checked{% endif %} />
Fixed price (today's behavior)
</label>
<label style="display:block">
<input type="radio" name="pricing_mode" value="1"
{% if product.pricing_mode == 1 %}checked{% endif %} />
Auction
</label>
<label style="display:block">
<input type="radio" name="pricing_mode" value="2"
{% if product.pricing_mode == 2 %}checked{% endif %} />
Auction with buy-now
</label>
<label style="display:block">
<input type="radio" name="pricing_mode" value="3"
{% if product.pricing_mode == 3 %}checked{% endif %} />
Make an offer (negotiation)
</label>
<label style="display:block">
<input type="radio" name="pricing_mode" value="4"
{% if product.pricing_mode == 4 %}checked{% endif %} />
Make an offer with buy-now
</label>
</fieldset>
<br />
{# MPS-21: per-product offer override #}
{% if product.is_offer_mode %}
<fieldset>
<legend>Allow offers (override shop default)</legend>
<label style="display:block">
<input type="radio" name="allow_offers" value="inherit"
{% if product.allow_offers is none %}checked{% endif %} />
Use shop default
({% if request.shop.offer_enabled %}enabled{% else %}disabled{% endif %})
</label>
<label style="display:block">
<input type="radio" name="allow_offers" value="yes"
{% if product.allow_offers == True %}checked{% endif %} />
Yes — accept offers on this product
</label>
<label style="display:block">
<input type="radio" name="allow_offers" value="no"
{% if product.allow_offers == False %}checked{% endif %} />
No — block offers on this product
</label>
</fieldset>
<br />
{% endif %}
{% if product.auction %}
<p>
<a href="/a/{{ product.auction.uuid_str }}" class="mps-button">
View live auction page
</a>
<small>(state: {{ product.auction.state_human }})</small>
</p>
<br />
{% endif %}
{% endif %}
<style>

View file

@ -1170,6 +1170,77 @@ Existing sales honored for download buy purchasers.
<br />
<br />
{# MPS-21: Make-an-Offer settings #}
<section class="one-column">
<section class="shop-settings well">
<h3>Make an Offer</h3>
<p>Allow buyers to negotiate a price for products in offer mode. Offers ≥ auto-accept threshold are accepted instantly. Offers below auto-decline threshold are silently rejected.</p>
<form method="post" action="/s/{{ request.shop.uuid_str }}/settings" onsubmit="submit.disabled = true; return true;">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="form_section" value="offer-settings" />
<label>
<input type="checkbox" name="offer_enabled_checkbox"
{% if request.shop.offer_enabled %}checked{% endif %} />
Enable make-an-offer (shop-wide default)
</label>
<br /><br />
<label for="offer_auto_accept">Auto-accept threshold (% of list price)</label>
<input type="number" name="offer_auto_accept_threshold_pct" id="offer_auto_accept"
value="{{ request.shop.offer_auto_accept_threshold_pct }}"
min="0" max="100" step="1" class="mps-text-input" />
<br /><br />
<label for="offer_auto_decline">Auto-decline threshold (% of list price)</label>
<input type="number" name="offer_auto_decline_threshold_pct" id="offer_auto_decline"
value="{{ request.shop.offer_auto_decline_threshold_pct }}"
min="0" max="100" step="1" class="mps-text-input" />
<small>Must be strictly below auto-accept; otherwise auto-clamped.</small>
<br /><br />
<label for="offer_min">Minimum offer amount ($)</label>
<input type="number" name="offer_min" id="offer_min"
value="{% if request.shop.offer_min_in_cents %}{{ '%.2f'|format(request.shop.offer_min_in_cents / 100.0) }}{% endif %}"
min="0" step="0.01" class="mps-text-input"
placeholder="No floor" />
<br /><br />
<label for="offer_expiration">Offer expiration (hours)</label>
<input type="number" name="offer_expiration_hours" id="offer_expiration"
value="{{ request.shop.offer_expiration_hours }}"
min="1" max="8760" step="1" class="mps-text-input" />
<br /><br />
<label for="offer_max_rounds">Max counter rounds</label>
<input type="number" name="offer_max_rounds" id="offer_max_rounds"
value="{{ request.shop.offer_max_rounds }}"
min="1" max="100" step="1" class="mps-text-input" />
<br /><br />
<label for="offer_min_age">Minimum buyer account age (hours)</label>
<input type="number" name="offer_min_buyer_account_age_hours" id="offer_min_age"
value="{{ request.shop.offer_min_buyer_account_age_hours }}"
min="0" max="8760" step="1" class="mps-text-input"
placeholder="0 = open" />
<br /><br />
<input type="submit" name="submit" class="mps-submit" value="Save Settings" />
</form>
</section>
</section>
<br />
<br />
<section class="one-column">
<section class="shop-settings well">

View file

@ -5942,3 +5942,215 @@ class TestOfferRoutes(_AuthenticatedBase):
expect_errors=True,
)
self.assertEqual(res.status_int, 403)
class TestOfferSettingsForm(_AuthenticatedBase):
"""MPS-21: shop-settings offer-settings form section."""
def test_enable_and_set_thresholds(self):
shop = self._create_shop_helper()
# Defaults pre-form:
self.assertFalse(bool(shop.offer_enabled))
self.assertEqual(shop.offer_auto_accept_threshold_pct, 95)
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "offer-settings",
"offer_enabled_checkbox": "on",
"offer_auto_accept_threshold_pct": "90",
"offer_auto_decline_threshold_pct": "40",
"offer_min": "5.00",
"offer_expiration_hours": "72",
"offer_max_rounds": "5",
"offer_min_buyer_account_age_hours": "24",
"submit": "Save Settings",
},
status=302,
)
res.follow()
self.dbsession.refresh(shop)
self.assertTrue(shop.offer_enabled)
self.assertEqual(shop.offer_auto_accept_threshold_pct, 90)
self.assertEqual(shop.offer_auto_decline_threshold_pct, 40)
self.assertEqual(shop.offer_min_in_cents, 500)
self.assertEqual(shop.offer_expiration_hours, 72)
self.assertEqual(shop.offer_max_rounds, 5)
self.assertEqual(shop.offer_min_buyer_account_age_hours, 24)
def test_decline_clamped_below_accept(self):
# Setting decline >= accept should clamp decline to accept-1.
shop = self._create_shop_helper()
res = self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "offer-settings",
"offer_auto_accept_threshold_pct": "60",
"offer_auto_decline_threshold_pct": "60", # tied — should clamp
"submit": "Save Settings",
},
status=302,
)
res.follow()
self.dbsession.refresh(shop)
self.assertEqual(shop.offer_auto_accept_threshold_pct, 60)
self.assertEqual(shop.offer_auto_decline_threshold_pct, 59)
def test_offer_min_blank_clears_floor(self):
shop = self._create_shop_helper()
# First set a floor.
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "offer-settings",
"offer_min": "10.00",
"submit": "Save Settings",
},
status=302,
)
self.dbsession.refresh(shop)
self.assertEqual(shop.offer_min_in_cents, 1000)
# Then clear it (blank input).
self.testapp.post(
f"/s/{shop.id}/settings",
{
"form_section": "offer-settings",
"offer_min": "",
"submit": "Save Settings",
},
status=302,
)
self.dbsession.refresh(shop)
self.assertIsNone(shop.offer_min_in_cents)
class TestPricingModeFormSection(_AuthenticatedBase):
"""MPS-20 + MPS-21: pricing_mode + allow_offers on product edit."""
def _make_product(self, owner_creds=None):
if owner_creds is None:
owner_creds = self.user1_creds
shop = self._create_shop_helper(user_creds=owner_creds)
from ..models.product import Product
product = Product(title="P1", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = False
product.is_sellable = True
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
transaction.commit()
return shop, product_id
def _post_edit(self, product_id, **kw):
# The product edit form_url is /p/{id}/edit; minimal fields required.
params = {
"title": "P1",
"description": "...",
"price": "50.00",
"visibility": "1",
"submit": "true",
}
params.update(kw)
return self.testapp.post(f"/p/{product_id}/edit", params)
def test_flip_to_auction_creates_draft_auction(self):
from ..models.auction import (
MpsAuction, AUCTION_STATE_DRAFT,
)
shop, product_id = self._make_product()
self._post_edit(product_id, pricing_mode="1")
from ..models.product import get_product_by_id
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 1)
self.assertIsNotNone(product.auction)
self.assertEqual(product.auction.state, AUCTION_STATE_DRAFT)
# Auction inherits product price as default start.
self.assertEqual(product.auction.start_price_in_cents, 5000)
def test_flip_to_offer_does_not_create_auction(self):
shop, product_id = self._make_product()
self._post_edit(product_id, pricing_mode="3")
from ..models.product import get_product_by_id
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 3)
self.assertIsNone(product.auction)
self.assertTrue(product.is_offer_mode)
def test_allow_offers_override(self):
shop, product_id = self._make_product()
# First flip to offer mode.
self._post_edit(product_id, pricing_mode="3", allow_offers="yes")
from ..models.product import get_product_by_id
product = get_product_by_id(self.dbsession, product_id)
self.dbsession.refresh(product)
self.assertTrue(product.allow_offers)
# Flip override to "no" (block on this product).
self._post_edit(product_id, pricing_mode="3", allow_offers="no")
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.allow_offers, False)
# Flip back to "inherit" (None).
self._post_edit(product_id, pricing_mode="3", allow_offers="inherit")
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, product_id)
self.assertIsNone(product.allow_offers)
def test_invalid_pricing_mode_ignored(self):
shop, product_id = self._make_product()
self._post_edit(product_id, pricing_mode="99")
from ..models.product import get_product_by_id
product = get_product_by_id(self.dbsession, product_id)
self.assertEqual(product.pricing_mode, 0) # unchanged
def _make_ready_offer_product(self):
"""Create a product that's is_ready=True (has product file) and
opted into offer mode. Returns (shop, product_id, product_slug)."""
from ..models.product import Product, get_product_by_id
shop = self._create_shop_helper()
shop.offer_enabled = True
product = Product(title="Ready P1", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3 # offer mode
# Fake a product file so is_ready=True (file_metadata.extensions.product).
import json
product.json_file_metadata = json.dumps({
"originals": {},
"extensions": {"product": "pdf"},
})
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.uuid_str
product_slug = product.slug
transaction.commit()
return shop, product_id, product_slug
def test_make_offer_button_renders_when_eligible(self):
shop, product_id, product_slug = self._make_ready_offer_product()
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
res = self.testapp.get(
f"/p/{product_id}/{product_slug}", status=200,
)
self.assertIn(b"Make an offer", res.body)
def test_make_offer_hidden_for_seller(self):
shop, product_id, product_slug = self._make_ready_offer_product()
# user1 (shop owner) is logged in.
res = self.testapp.get(
f"/p/{product_id}/{product_slug}", status=200,
)
self.assertNotIn(b"Make an offer", res.body)

View file

@ -311,6 +311,52 @@ def product_edit(request):
request.dbsession.add(product.set_price(price))
request.session.flash(("You updated the product's price.", "success"))
# MPS-20 + MPS-21: pricing_mode + allow_offers (per-product overrides).
if "submit" in request.params:
try:
new_mode = int(request.params.get("pricing_mode", product.pricing_mode))
except (TypeError, ValueError):
new_mode = product.pricing_mode
if new_mode in (0, 1, 2, 3, 4) and new_mode != product.pricing_mode:
product_modified = True
product.pricing_mode = new_mode
mode_label = {
0: "fixed price", 1: "auction", 2: "auction + buy now",
3: "make an offer", 4: "make an offer + buy now",
}[new_mode]
request.session.flash(
(f"Pricing mode set to {mode_label}.", "success")
)
# If switching INTO auction mode and no auction exists, create one
# in DRAFT state with sane defaults from the product price.
if new_mode in (1, 2) and product.auction is None:
from ..models.auction import (
MpsAuction, DEFAULT_BID_INCREMENT_IN_CENTS,
DEFAULT_SOFT_CLOSE_SECONDS,
)
auction = MpsAuction(
product=product,
shop=product.shop,
start_price_in_cents=product.price_in_cents or 100,
bid_increment_in_cents=DEFAULT_BID_INCREMENT_IN_CENTS,
soft_close_seconds=DEFAULT_SOFT_CLOSE_SECONDS,
)
request.dbsession.add(auction)
request.session.flash((
"Auction created in draft state. Configure start/end "
"times and reserve price below before scheduling.",
"success",
))
# allow_offers — three-state radio: inherit / yes / no.
allow_raw = request.params.get("allow_offers", "inherit")
new_allow = {
"inherit": None, "yes": True, "no": False,
}.get(allow_raw, None)
if new_allow != product.allow_offers:
product_modified = True
product.allow_offers = new_allow
# torrent_opt_in — explicit per-product seeding consent.
# MPS-22: gated by global torrent kill switch.
if request.torrent_enabled and product.shop.torrent_enabled and "submit" in request.params:

View file

@ -1359,6 +1359,65 @@ def shop_settings(request):
# have a product file but no magnet link yet.
_torrent_backfill_async(request, shop)
# MPS-21: make-an-offer settings
if form_section == "offer-settings":
offer_enabled = checkbox_to_bool(
request.params.get("offer_enabled_checkbox", "off")
)
if shop.offer_enabled != offer_enabled:
shop.offer_enabled = offer_enabled
status = "enabled" if offer_enabled else "disabled"
request.session.flash((f"Make-an-offer {status}.", "success"))
# Numeric thresholds — silently clamp invalid input rather than
# rejecting the whole form.
def _int_param(name, default, lo=0, hi=10**9):
raw = (request.params.get(name) or "").strip()
if not raw:
return default
try:
val = int(float(raw))
except (TypeError, ValueError):
return default
return max(lo, min(hi, val))
new_accept = _int_param(
"offer_auto_accept_threshold_pct",
shop.offer_auto_accept_threshold_pct, 0, 100,
)
new_decline = _int_param(
"offer_auto_decline_threshold_pct",
shop.offer_auto_decline_threshold_pct, 0, 100,
)
# Auto-decline must be strictly below auto-accept, else thresholds
# collide and the queue range disappears. Clamp.
if new_decline >= new_accept:
new_decline = max(0, new_accept - 1)
shop.offer_auto_accept_threshold_pct = new_accept
shop.offer_auto_decline_threshold_pct = new_decline
shop.offer_expiration_hours = _int_param(
"offer_expiration_hours", shop.offer_expiration_hours,
1, 24 * 365,
)
shop.offer_max_rounds = _int_param(
"offer_max_rounds", shop.offer_max_rounds, 1, 100,
)
shop.offer_min_buyer_account_age_hours = _int_param(
"offer_min_buyer_account_age_hours",
shop.offer_min_buyer_account_age_hours, 0, 24 * 365,
)
# offer_min_in_cents — dollar input, optional.
offer_min_dollars = (request.params.get("offer_min") or "").strip()
if offer_min_dollars:
try:
shop.offer_min_in_cents = int(round(float(offer_min_dollars) * 100))
except (TypeError, ValueError):
request.session.flash(("Invalid offer minimum.", "error"))
else:
shop.offer_min_in_cents = None
# If we processed any form submission, respond accordingly
if form_section:
if request.headers.get("X-Requested-With") == "XMLHttpRequest":