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:
parent
4f751c6c03
commit
891f2899b2
7 changed files with 542 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue