MPS-20: auction quantity (lot size) + full owner config form

Lets a shop owner auction a portion of inventory rather than all of it,
and reach every auction field from the product edit form (today's flow
only flipped pricing_mode and left start/end/reserve/buy-now unreachable).

Schema:
- mps_auction.quantity (Integer, default 1, server_default="1") —
  how many units this auction sells. Digital products force 1.
- Idempotent migration 1a419114ddf7 (column_exists guard)

Model:
- MpsAuction.is_lot_auction property (quantity > 1)

Owner-side form (product_edit.j2 + views/product.py):
- All auction config fields editable while state == DRAFT:
  quantity (physical only), start/end (datetime-local), start_price,
  reserve_price, buy_now_price (modes 2/4 only), bid_increment,
  soft_close_seconds
- "Schedule auction" checkbox flips DRAFT → SCHEDULED (or → ACTIVE
  if start_timestamp is already past)
- Validates end > start; blocks scheduling when invalid
- Locks all fields once SCHEDULED to preserve bidder trust
  (no rules-changes mid-flight)
- views/product.py serializes auction timestamps to YYYY-MM-DDTHH:MM
  for the datetime-local input

Cart integration:
- auction_checkout view sets cart.set_product_quantity(product, auction.quantity)
  so the cart success path's update_inventory() naturally deducts
  the right number of physical units. Cart total still uses the
  winning bid amount via auction_offer_override_in_cents — quantity
  affects inventory only, not price.

Tests:
- 2 unit (test_models.py): is_lot_auction default False, True for
  quantity > 1 (multiple values)
- 7 functional (test_functional.py): set quantity on physical auction,
  digital quantity forced to 1, schedule with future start →
  SCHEDULED, schedule with passed start → ACTIVE, end-before-start
  rejected, locked fields not overwritten after scheduled,
  auction_checkout sets cart quantity to lot size

Total: 958 tests pass (was 949 + 9).
This commit is contained in:
russell@unturf.com 2026-05-10 10:52:11 -04:00
parent 4f751c6c03
commit 891f2899b2
No known key found for this signature in database
7 changed files with 542 additions and 7 deletions

View file

@ -99,6 +99,15 @@ class MpsAuction(RBase, Base):
Integer, nullable=False, default=DEFAULT_SOFT_CLOSE_SECONDS
)
# MPS-20: how many units this auction sells (lot size). Default 1.
# For physical products with N inventory, owner can auction K units
# (K <= N) while keeping the other N-K at list price (mode 2).
# Digital products force quantity=1 — auctioning multiple copies of
# a digital file makes no sense (no scarcity).
quantity = Column(
Integer, nullable=False, default=1, server_default="1"
)
winner_user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True)
winning_bid_id = Column(UUIDType, nullable=True)
payment_deadline_timestamp = Column(BigInteger, nullable=True)
@ -145,6 +154,11 @@ class MpsAuction(RBase, Base):
self.created_timestamp = now_timestamp()
self.updated_timestamp = self.created_timestamp
@property
def is_lot_auction(self):
"""True when this auction sells more than 1 unit at once."""
return self.quantity > 1
@property
def is_draft(self):
return self.state == AUCTION_STATE_DRAFT

View file

@ -0,0 +1,39 @@
"""MPS-20: auction quantity column
Revision ID: 1a419114ddf7
Revises: 34e1c65d0bea
Create Date: 2026-05-10 10:32:31.725890
Adds MpsAuction.quantity (Integer, default 1) so a single auction can
sell more than one unit at once. For physical products, owner can lot
N units of inventory; the other inventory stays at list price.
Idempotent make init-db creates tables from models.
"""
from alembic import op
import sqlalchemy as sa
revision = '1a419114ddf7'
down_revision = '34e1c65d0bea'
branch_labels = None
depends_on = None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_auction", "quantity"):
op.add_column(
"mps_auction",
sa.Column("quantity", sa.Integer(), server_default="1", nullable=False),
)
def downgrade():
if _column_exists("mps_auction", "quantity"):
op.drop_column("mps_auction", "quantity")

View file

@ -321,12 +321,105 @@ Your cover (<code>thumbnail1</code>) will show up on search pages.
{% 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>
<fieldset>
<legend>Auction settings</legend>
<p>
<a href="/a/{{ product.auction.uuid_str }}" class="mps-button">View live auction page</a>
<small>(state: {{ product.auction.state_human }})</small>
</p>
{# Owner controls — only editable while auction is in DRAFT state.
Once SCHEDULED or ACTIVE, fields lock to prevent rules-changes
mid-flight (preserves bidder trust). #}
{% set locked = product.auction.state > 0 %}
<label for="auction_quantity">Quantity (units in this auction)</label>
<input type="number" name="auction_quantity" id="auction_quantity"
min="1" step="1" required
value="{{ product.auction.quantity }}"
{% if locked or not product.is_physical %}disabled{% endif %} />
<small>
{% if not product.is_physical -%}
Digital products always sell 1 unit per auction.
{%- elif locked -%}
Locked once auction is scheduled.
{%- else -%}
How many units of inventory to lot in this auction. Other units stay at list price (mode 2).
{%- endif %}
</small>
<br /><br />
<label for="auction_start">Start time</label>
<input type="datetime-local" name="auction_start" id="auction_start"
value="{{ auction_start_iso }}"
{% if locked %}disabled{% endif %} />
<br /><br />
<label for="auction_end">End time</label>
<input type="datetime-local" name="auction_end" id="auction_end"
value="{{ auction_end_iso }}"
{% if locked %}disabled{% endif %} />
<br /><br />
<label for="auction_start_price">Start price ($)</label>
<input type="number" name="auction_start_price" id="auction_start_price"
min="0" step="0.01"
value="{{ '%.2f'|format(product.auction.start_price) }}"
{% if locked %}disabled{% endif %} />
<br /><br />
<label for="auction_reserve_price">Reserve price ($, optional)</label>
<input type="number" name="auction_reserve_price" id="auction_reserve_price"
min="0" step="0.01"
value="{% if product.auction.reserve_price_in_cents %}{{ '%.2f'|format(product.auction.reserve_price) }}{% endif %}"
placeholder="No reserve"
{% if locked %}disabled{% endif %} />
<small>Hidden from bidders. Auction with no high bid above reserve = no winner.</small>
<br /><br />
{% if product.pricing_mode == 2 or product.pricing_mode == 4 %}
<label for="auction_buy_now_price">Buy-now price ($, optional)</label>
<input type="number" name="auction_buy_now_price" id="auction_buy_now_price"
min="0" step="0.01"
value="{% if product.auction.buy_now_price_in_cents %}{{ '%.2f'|format(product.auction.buy_now_price) }}{% endif %}"
placeholder="No buy-now"
{% if locked %}disabled{% endif %} />
<small>Lets a buyer end the auction immediately at this price.</small>
<br /><br />
{% endif %}
<label for="auction_increment">Bid increment ($)</label>
<input type="number" name="auction_increment" id="auction_increment"
min="0.01" step="0.01"
value="{{ '%.2f'|format(product.auction.bid_increment) }}"
{% if locked %}disabled{% endif %} />
<br /><br />
<label for="auction_soft_close">Soft-close window (seconds)</label>
<input type="number" name="auction_soft_close" id="auction_soft_close"
min="0" step="1"
value="{{ product.auction.soft_close_seconds }}"
{% if locked %}disabled{% endif %} />
<small>A bid placed within this window of the end time extends the end by the same window.</small>
<br /><br />
{% if not locked %}
<label>
<input type="checkbox" name="auction_schedule" value="on" />
Schedule this auction (lock fields and move to SCHEDULED state)
</label>
<br /><br />
{% endif %}
</fieldset>
<br />
{% endif %}

View file

@ -6567,3 +6567,239 @@ class TestBuyNowGating(_AuthenticatedBase):
res = self.testapp.get(f"/p/{pid}/{slug}", status=200)
self.assertNotIn(b"Add To Cart", res.body)
self.assertNotIn(b"sold-out-button", res.body)
class TestAuctionConfigForm(_AuthenticatedBase):
"""MPS-20: owner sets quantity, start/end, reserve, buy-now,
increment, soft-close on a draft auction via product edit form."""
def _make_auction_product(self, is_physical=False, pricing_mode=1):
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Cfg", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = is_physical
product.is_sellable = True
product.pricing_mode = pricing_mode
self.dbsession.add(product)
self.dbsession.flush()
# Trigger draft auction creation by the form_section handler.
product_id = product.uuid_str
transaction.commit()
# Re-POST the edit form to flip pricing_mode (no-op since it's
# already set, but this is how the auction would be created in
# the real flow). Instead just create it directly to keep the
# test short.
from ..models.auction import (
MpsAuction, DEFAULT_BID_INCREMENT_IN_CENTS,
DEFAULT_SOFT_CLOSE_SECONDS,
)
product = self.dbsession.query(Product).get(product_id)
auction = MpsAuction(
product=product, shop=product.shop,
start_price_in_cents=5000,
bid_increment_in_cents=DEFAULT_BID_INCREMENT_IN_CENTS,
soft_close_seconds=DEFAULT_SOFT_CLOSE_SECONDS,
)
self.dbsession.add(auction)
self.dbsession.flush()
transaction.commit()
return product_id
def _post_edit(self, product_id, **kw):
params = {
"title": "Cfg",
"description": "...",
"price": "50.00",
"visibility": "1",
"submit": "true",
}
params.update(kw)
return self.testapp.post(f"/p/{product_id}/edit", params)
def test_set_quantity_on_physical_auction(self):
from ..models.product import get_product_by_id
pid = self._make_auction_product(is_physical=True, pricing_mode=1)
self._post_edit(
pid, pricing_mode="1",
auction_quantity="5",
auction_start_price="50.00",
auction_increment="1.00",
auction_soft_close="60",
)
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, pid)
self.assertEqual(product.auction.quantity, 5)
self.assertTrue(product.auction.is_lot_auction)
def test_digital_auction_quantity_forced_to_one(self):
from ..models.product import get_product_by_id
pid = self._make_auction_product(is_physical=False, pricing_mode=1)
# Try to set quantity=10 on a digital auction; view should clamp to 1.
self._post_edit(
pid, pricing_mode="1",
auction_quantity="10",
auction_start_price="50.00",
auction_increment="1.00",
auction_soft_close="60",
)
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, pid)
self.assertEqual(product.auction.quantity, 1)
def test_schedule_auction_locks_fields(self):
from ..models.product import get_product_by_id
from ..models.auction import (
AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
)
pid = self._make_auction_product(is_physical=True, pricing_mode=1)
# Future start.
self._post_edit(
pid, pricing_mode="1",
auction_quantity="3",
auction_start="2030-01-01T00:00",
auction_end="2030-01-02T00:00",
auction_start_price="50.00",
auction_increment="1.00",
auction_soft_close="60",
auction_schedule="on",
)
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, pid)
self.assertEqual(product.auction.state, AUCTION_STATE_SCHEDULED)
self.assertEqual(product.auction.quantity, 3)
def test_schedule_with_passed_start_jumps_to_active(self):
from ..models.product import get_product_by_id
from ..models.auction import AUCTION_STATE_ACTIVE
pid = self._make_auction_product(is_physical=True, pricing_mode=1)
self._post_edit(
pid, pricing_mode="1",
auction_quantity="2",
auction_start="2020-01-01T00:00", # past
auction_end="2030-01-01T00:00",
auction_start_price="50.00",
auction_increment="1.00",
auction_soft_close="60",
auction_schedule="on",
)
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, pid)
self.assertEqual(product.auction.state, AUCTION_STATE_ACTIVE)
def test_schedule_rejects_end_before_start(self):
from ..models.product import get_product_by_id
from ..models.auction import AUCTION_STATE_DRAFT
pid = self._make_auction_product(is_physical=True, pricing_mode=1)
self._post_edit(
pid, pricing_mode="1",
auction_quantity="1",
auction_start="2030-02-01T00:00",
auction_end="2030-01-01T00:00",
auction_start_price="50.00",
auction_increment="1.00",
auction_soft_close="60",
auction_schedule="on",
)
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, pid)
self.assertEqual(product.auction.state, AUCTION_STATE_DRAFT)
def test_locked_fields_not_overwritten_after_scheduled(self):
from ..models.product import get_product_by_id
from ..models.auction import AUCTION_STATE_SCHEDULED
pid = self._make_auction_product(is_physical=True, pricing_mode=1)
# Schedule.
self._post_edit(
pid, pricing_mode="1",
auction_quantity="3",
auction_start="2030-01-01T00:00",
auction_end="2030-01-02T00:00",
auction_start_price="50.00",
auction_increment="1.00",
auction_soft_close="60",
auction_schedule="on",
)
# Try to change quantity after scheduling — should be ignored
# because handler only updates draft auctions.
self._post_edit(
pid, pricing_mode="1",
auction_quantity="999",
auction_start_price="9999.00",
auction_increment="1.00",
auction_soft_close="60",
)
self.dbsession.expire_all()
product = get_product_by_id(self.dbsession, pid)
self.assertEqual(product.auction.state, AUCTION_STATE_SCHEDULED)
self.assertEqual(product.auction.quantity, 3)
self.assertEqual(product.auction.start_price_in_cents, 5000)
class TestAuctionCheckoutQuantity(_AuthenticatedBase):
"""MPS-20: when an auction.quantity > 1 settles, cart deducts the
right number of units from inventory."""
def test_checkout_sets_cart_quantity_to_lot_size(self):
from ..models.auction import (
MpsAuction, MpsBid, AUCTION_STATE_ENDED, now_timestamp,
)
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
product = Product(title="Lot", description="...")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = True
product.is_sellable = True
product.pricing_mode = 1
import json
product.json_file_metadata = json.dumps(
{"originals": {}, "extensions": {"thumbnail1": "jpg"}},
)
self.dbsession.add(product)
self.dbsession.flush()
from ..models.user import get_or_create_user_by_email
winner = get_or_create_user_by_email(
self.dbsession, "test2@example.com",
)
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.state = AUCTION_STATE_ENDED
auction.start_timestamp = now_timestamp() - 60_000
auction.end_timestamp = now_timestamp() - 1000
auction.winner = winner
auction.quantity = 4 # 4-unit lot
self.dbsession.add(auction)
bid = MpsBid(auction=auction, bidder=winner, amount_in_cents=2200)
bid.is_winning = True
self.dbsession.add(bid)
self.dbsession.flush()
auction_id = auction.uuid_str
product_id = product.uuid_str
transaction.commit()
# Winner checks out.
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
self.testapp.post(f"/a/{auction_id}/checkout", status=302)
# The active cart's product line item should have quantity=4
# so inventory deduction at success will subtract 4 units.
from ..models.cart import Cart
winner = get_or_create_user_by_email(
self.dbsession, "test2@example.com",
)
carts_with_auction = (
self.dbsession.query(Cart)
.filter(Cart.user_id == winner.id)
.all()
)
match = [c for c in carts_with_auction if c.cart_auctions]
self.assertEqual(len(match), 1)
self.assertEqual(match[0].cart.get(product_id, 0), 4)
# Total still uses winning bid amount, not list price × 4.
self.assertEqual(match[0].total_price_in_cents, 2200)

View file

@ -4800,6 +4800,23 @@ class TestAuctionTickPureFunctions(unittest.TestCase):
self.assertFalse(transition_active_to_ended(a4, now_ms=2000))
class TestAuctionQuantity(unittest.TestCase):
"""MPS-20: lot-size auctions (multi-unit)."""
def test_is_lot_auction_default_false(self):
from types import SimpleNamespace
from ..models.auction import MpsAuction
a = SimpleNamespace(quantity=1)
self.assertFalse(MpsAuction.is_lot_auction.fget(a))
def test_is_lot_auction_true_when_quantity_above_one(self):
from types import SimpleNamespace
from ..models.auction import MpsAuction
for n in (2, 5, 100):
a = SimpleNamespace(quantity=n)
self.assertTrue(MpsAuction.is_lot_auction.fget(a))
class TestEmailNotificationContent(unittest.TestCase):
"""MPS-20 + MPS-21: email helpers format templates with the right
fields. Mocks the underlying send_pyramid_email to verify the call."""

View file

@ -282,13 +282,19 @@ def auction_checkout(request):
request.session.flash(("Only the auction winner can check out.", "error"))
return HTTPFound(f"/a/{auction.uuid_str}")
# Build a fresh cart for this auction. Single product, override price.
# Build a fresh cart for this auction. The product is added with
# quantity=auction.quantity so update_inventory() (cart success hook)
# naturally deducts the right number of units from inventory.
# Cart total comes from auction.winning_bid (override), not the
# quantity × product.price computation.
cart = Cart(user=request.user)
cart.shop = auction.shop
cart.active = True
request.dbsession.add(cart)
request.dbsession.flush()
cart.add_product(auction.product)
if auction.quantity > 1:
cart.set_product_quantity(auction.product, auction.quantity)
request.dbsession.add(
MpsCartAuction(cart=cart, auction=auction)
)

View file

@ -357,6 +357,119 @@ def product_edit(request):
product_modified = True
product.allow_offers = new_allow
# MPS-20: auction config — only meaningful when product has an
# auction and the auction is still in DRAFT (locked once SCHEDULED).
if product.auction is not None and product.auction.is_draft:
from ..models.auction import (
AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
now_timestamp as auction_now_ms,
)
from datetime import datetime, timezone
auction = product.auction
def _input_to_ms(raw):
"""datetime-local 'YYYY-MM-DDTHH:MM' → ms since epoch (UTC)."""
if not raw:
return None
try:
dt = datetime.strptime(raw.strip(), "%Y-%m-%dT%H:%M")
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)
except ValueError:
return None
def _dollar_to_cents(raw):
if raw is None or raw == "":
return None
try:
return int(round(float(raw) * 100))
except (TypeError, ValueError):
return None
# Quantity — physical only; digital forces 1.
try:
new_qty = int(request.params.get("auction_quantity", auction.quantity))
except (TypeError, ValueError):
new_qty = auction.quantity
if not product.is_physical:
new_qty = 1
new_qty = max(1, new_qty)
if new_qty != auction.quantity:
auction.quantity = new_qty
new_start = _input_to_ms(request.params.get("auction_start", ""))
new_end = _input_to_ms(request.params.get("auction_end", ""))
if new_start is not None and new_start != auction.start_timestamp:
auction.start_timestamp = new_start
if new_end is not None and new_end != auction.end_timestamp:
auction.end_timestamp = new_end
auction.original_end_timestamp = new_end
new_start_price = _dollar_to_cents(request.params.get("auction_start_price"))
if new_start_price is not None and new_start_price != auction.start_price_in_cents:
auction.start_price_in_cents = max(0, new_start_price)
# Reserve / buy-now: blank input clears.
reserve_raw = (request.params.get("auction_reserve_price") or "").strip()
if reserve_raw == "":
auction.reserve_price_in_cents = None
else:
rp = _dollar_to_cents(reserve_raw)
if rp is not None:
auction.reserve_price_in_cents = rp
if product.pricing_mode in (2, 4):
buy_now_raw = (request.params.get("auction_buy_now_price") or "").strip()
if buy_now_raw == "":
auction.buy_now_price_in_cents = None
else:
bn = _dollar_to_cents(buy_now_raw)
if bn is not None:
auction.buy_now_price_in_cents = bn
else:
auction.buy_now_price_in_cents = None
new_inc = _dollar_to_cents(request.params.get("auction_increment"))
if new_inc is not None and new_inc > 0:
auction.bid_increment_in_cents = new_inc
try:
new_sc = int(request.params.get("auction_soft_close", auction.soft_close_seconds))
auction.soft_close_seconds = max(0, new_sc)
except (TypeError, ValueError):
pass
auction.updated_timestamp = auction_now_ms()
# Schedule: flip DRAFT → SCHEDULED. Validate timestamps first.
if request.params.get("auction_schedule") == "on":
if not auction.start_timestamp or not auction.end_timestamp:
request.session.flash((
"Set both start and end times before scheduling the auction.",
"error",
))
elif auction.end_timestamp <= auction.start_timestamp:
request.session.flash((
"Auction end must be after start.",
"error",
))
else:
# If start has already passed, jump straight to ACTIVE.
now = auction_now_ms()
if auction.start_timestamp <= now:
auction.state = AUCTION_STATE_ACTIVE
request.session.flash((
"Auction is now ACTIVE — accepting bids.",
"success",
))
else:
auction.state = AUCTION_STATE_SCHEDULED
request.session.flash((
"Auction scheduled. Fields locked.",
"success",
))
product_modified = True
request.dbsession.add(auction)
# 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:
@ -613,6 +726,15 @@ def product_edit(request):
except Exception:
pass
# MPS-20: format auction timestamps for datetime-local input.
def _ms_to_input(ts_ms):
if not ts_ms:
return ""
from datetime import datetime, timezone
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime(
"%Y-%m-%dT%H:%M",
)
return {
"product": product,
"title": title,
@ -624,6 +746,14 @@ def product_edit(request):
"locations": locations,
"inventories": inventories if product.is_physical else {},
"price_history": price_history,
"auction_start_iso": (
_ms_to_input(product.auction.start_timestamp)
if product.auction else ""
),
"auction_end_iso": (
_ms_to_input(product.auction.end_timestamp)
if product.auction else ""
),
# MPS-22: torrent kill switch hides all torrent context from edit UI
"torrent_enabled": (
product.shop.torrent_enabled if request.torrent_enabled else False