MPS-20 + MPS-21: foundation — pricing_mode + auction/offer models

Auction (MPS-20) and make-an-offer (MPS-21) modes share a Product.pricing_mode
column so a single migration adds the foundation for both.

Schema:
- mps_product.pricing_mode (Integer, default 0): 0=fixed, 1=auction,
  2=auction+buy_now, 3=offer, 4=offer+buy_now
- mps_product.allow_offers (Boolean nullable): per-product override of
  shop default; NULL = inherit shop.offer_enabled
- mps_shop: 7 offer-* settings columns (enabled, min, auto-accept/decline
  thresholds, expiration, max rounds, min buyer age)
- 5 new tables: mps_auction, mps_bid, mps_auction_watcher,
  mps_offer, mps_offer_event

Models:
- MpsAuction: state machine helpers (is_draft/active/ended/settled/etc.),
  current_high_in_cents (queries top bid), reserve_met, time_remaining_ms,
  has_buy_now, has_reserve, min_next_bid_in_cents
- MpsBid: amount + max_proxy_in_cents (proxy bidding ceiling) + is_winning
  flag the bid resolution code will flip
- MpsAuctionWatcher: per-user notification preferences
- MpsOffer: state machine (pending/countered/accepted/declined/expired/
  withdrawn/paid), waiting_on_buyer/seller, time_remaining_ms, is_expired
- MpsOfferEvent: audit log row per action (open/counter/accept/etc.)

Product gets is_fixed_price / is_auction / is_buy_now_allowed /
is_offer_mode / offers_allowed helpers — offers_allowed resolves the
per-product override + shop default.

Migration is idempotent (table_exists / column_exists guards) since
make init-db creates tables from models.

Tests:
- 17 unit tests in test_models.py (state helpers, pricing_mode classifiers,
  inheritance rules)
- 10 integration tests in test_integration.py (DB persistence, cascade
  delete bids/watchers/events when parent deleted, unique product_id
  on auction, defaults applied)

Functional tests defer to commits 4-5 (auction views, offer views).
This commit is contained in:
russell@unturf.com 2026-05-09 18:31:29 -04:00
parent 98352496a9
commit 80b4fa6698
No known key found for this signature in database
9 changed files with 1542 additions and 0 deletions

View file

@ -38,6 +38,9 @@ from .cart_gift_card import *
from .api_key import *
from .auction import *
from .offer import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.
configure_mappers()

View file

@ -0,0 +1,345 @@
"""Auction models — MPS-20.
An auction is a Product whose price is determined by competitive bidding
within a time window. The Product carries `pricing_mode` (0=fixed, 1=auction,
2=auction+buy_now); MpsAuction carries the auction-specific fields and
state machine; MpsBid is one bid per (auction, bidder, attempt); and
MpsAuctionWatcher lets users follow auctions for notifications.
State machine (auction.state):
0 draft owner editing, not visible
1 scheduled countdown to start_timestamp
2 active bidding open
3 ended bidding closed; winner determined or reserve not met
4 settled winner paid via cart; product transferred
5 cancelled owner aborted (pre-active only without admin override)
Soft-close: a bid placed within `soft_close_seconds` of `end_timestamp`
extends `end_timestamp` by `soft_close_seconds`. `original_end_timestamp`
preserves the scheduled close for audit.
See `lib/auction.py` for pure-function bid/proxy/soft-close logic.
"""
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Integer, Unicode
from sqlalchemy.orm import relationship, backref
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
)
from ..lib.currency import cents_to_dollars
# Auction state constants — mirror VISIBILITY_INT_TO_HUMAN style.
AUCTION_STATE_DRAFT = 0
AUCTION_STATE_SCHEDULED = 1
AUCTION_STATE_ACTIVE = 2
AUCTION_STATE_ENDED = 3
AUCTION_STATE_SETTLED = 4
AUCTION_STATE_CANCELLED = 5
AUCTION_STATE_INT_TO_HUMAN = {
AUCTION_STATE_DRAFT: "Draft",
AUCTION_STATE_SCHEDULED: "Scheduled",
AUCTION_STATE_ACTIVE: "Active",
AUCTION_STATE_ENDED: "Ended",
AUCTION_STATE_SETTLED: "Settled",
AUCTION_STATE_CANCELLED: "Cancelled",
}
# Default soft-close window in seconds (mirrors eBay's last-minute extension).
DEFAULT_SOFT_CLOSE_SECONDS = 60
# Default bid increment as a percentage of current high (5%). The model
# stores this as cents — view layer computes default from listed price.
DEFAULT_BID_INCREMENT_IN_CENTS = 100
class MpsAuction(RBase, Base):
"""One auction per Product (1:1). Created when shop owner enables
pricing_mode=1 or 2 on a product."""
id = Column(UUIDType, primary_key=True, index=True)
product_id = Column(
UUIDType,
foreign_key("Product", "id"),
nullable=False,
unique=True,
index=True,
)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True)
state = Column(
Integer,
nullable=False,
default=AUCTION_STATE_DRAFT,
server_default=str(AUCTION_STATE_DRAFT),
)
start_timestamp = Column(BigInteger, nullable=True)
end_timestamp = Column(BigInteger, nullable=True)
original_end_timestamp = Column(BigInteger, nullable=True)
start_price_in_cents = Column(BigInteger, nullable=False, default=0)
reserve_price_in_cents = Column(BigInteger, nullable=True)
buy_now_price_in_cents = Column(BigInteger, nullable=True)
bid_increment_in_cents = Column(
BigInteger, nullable=False, default=DEFAULT_BID_INCREMENT_IN_CENTS
)
soft_close_seconds = Column(
Integer, nullable=False, default=DEFAULT_SOFT_CLOSE_SECONDS
)
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)
currency = Column(Unicode(3), nullable=False, default="USD")
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
product = relationship(
argument="Product",
uselist=False,
backref=backref("auction", uselist=False),
)
shop = relationship(argument="Shop", uselist=False)
winner = relationship(argument="User", uselist=False, foreign_keys=[winner_user_id])
bids = relationship(
argument="MpsBid",
lazy="dynamic",
order_by="MpsBid.created_timestamp.desc()",
back_populates="auction",
cascade="all, delete-orphan",
)
watchers = relationship(
argument="MpsAuctionWatcher",
lazy="dynamic",
back_populates="auction",
cascade="all, delete-orphan",
)
def __init__(self, product, shop, start_price_in_cents=0,
bid_increment_in_cents=DEFAULT_BID_INCREMENT_IN_CENTS,
soft_close_seconds=DEFAULT_SOFT_CLOSE_SECONDS,
currency="USD"):
self.id = uuid.uuid1()
self.product = product
self.shop = shop
self.state = AUCTION_STATE_DRAFT
self.start_price_in_cents = start_price_in_cents
self.bid_increment_in_cents = bid_increment_in_cents
self.soft_close_seconds = soft_close_seconds
self.currency = currency
self.created_timestamp = now_timestamp()
self.updated_timestamp = self.created_timestamp
@property
def is_draft(self):
return self.state == AUCTION_STATE_DRAFT
@property
def is_scheduled(self):
return self.state == AUCTION_STATE_SCHEDULED
@property
def is_active(self):
return self.state == AUCTION_STATE_ACTIVE
@property
def is_ended(self):
return self.state == AUCTION_STATE_ENDED
@property
def is_settled(self):
return self.state == AUCTION_STATE_SETTLED
@property
def is_cancelled(self):
return self.state == AUCTION_STATE_CANCELLED
@property
def is_terminal(self):
return self.state in (
AUCTION_STATE_SETTLED,
AUCTION_STATE_CANCELLED,
)
@property
def state_human(self):
return AUCTION_STATE_INT_TO_HUMAN.get(self.state, "Unknown")
@property
def time_remaining_ms(self):
"""Milliseconds until end_timestamp; 0 if past or unset."""
if not self.end_timestamp:
return 0
return max(0, self.end_timestamp - now_timestamp())
@property
def has_buy_now(self):
return self.buy_now_price_in_cents is not None
@property
def has_reserve(self):
return self.reserve_price_in_cents is not None
@property
def current_high_in_cents(self):
"""Highest bid amount; falls back to start_price when no bids."""
top = (
self.bids.order_by(None)
.order_by(MpsBid.amount_in_cents.desc())
.first()
)
if top is None:
return self.start_price_in_cents
return top.amount_in_cents
@property
def reserve_met(self):
if not self.has_reserve:
return True
return self.current_high_in_cents >= self.reserve_price_in_cents
@property
def start_price(self):
return cents_to_dollars(self.start_price_in_cents)
@property
def reserve_price(self):
if self.reserve_price_in_cents is None:
return None
return cents_to_dollars(self.reserve_price_in_cents)
@property
def buy_now_price(self):
if self.buy_now_price_in_cents is None:
return None
return cents_to_dollars(self.buy_now_price_in_cents)
@property
def bid_increment(self):
return cents_to_dollars(self.bid_increment_in_cents)
@property
def current_high(self):
return cents_to_dollars(self.current_high_in_cents)
@property
def min_next_bid_in_cents(self):
"""Smallest bid that would be accepted right now."""
if self.bids.count() == 0:
return self.start_price_in_cents
return self.current_high_in_cents + self.bid_increment_in_cents
@property
def min_next_bid(self):
return cents_to_dollars(self.min_next_bid_in_cents)
class MpsBid(RBase, Base):
"""One bid on an auction. amount_in_cents is the actual bid; max_proxy
is the bidder's secret ceiling, used by lib/auction proxy resolution."""
id = Column(UUIDType, primary_key=True, index=True)
auction_id = Column(
UUIDType,
foreign_key("MpsAuction", "id"),
nullable=False,
index=True,
)
bidder_user_id = Column(
UUIDType,
foreign_key("User", "id"),
nullable=False,
index=True,
)
amount_in_cents = Column(BigInteger, nullable=False)
max_proxy_in_cents = Column(BigInteger, nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
outbid_timestamp = Column(BigInteger, nullable=True)
is_winning = Column(
Boolean, nullable=False, default=False, server_default="0"
)
auction = relationship(argument="MpsAuction", uselist=False, back_populates="bids")
bidder = relationship(argument="User", uselist=False)
def __init__(self, auction, bidder, amount_in_cents, max_proxy_in_cents=None):
self.id = uuid.uuid1()
self.auction = auction
self.bidder = bidder
self.amount_in_cents = amount_in_cents
self.max_proxy_in_cents = max_proxy_in_cents
self.created_timestamp = now_timestamp()
self.is_winning = False
@property
def amount(self):
return cents_to_dollars(self.amount_in_cents)
@property
def max_proxy(self):
if self.max_proxy_in_cents is None:
return None
return cents_to_dollars(self.max_proxy_in_cents)
class MpsAuctionWatcher(RBase, Base):
"""User watches an auction. Drives ending-soon and outbid emails."""
id = Column(UUIDType, primary_key=True, index=True)
auction_id = Column(
UUIDType,
foreign_key("MpsAuction", "id"),
nullable=False,
index=True,
)
user_id = Column(
UUIDType,
foreign_key("User", "id"),
nullable=False,
index=True,
)
created_timestamp = Column(BigInteger, nullable=False)
notify_on_outbid = Column(
Boolean, nullable=False, default=True, server_default="1"
)
notify_on_ending_soon = Column(
Boolean, nullable=False, default=True, server_default="1"
)
auction = relationship(
argument="MpsAuction", uselist=False, back_populates="watchers"
)
user = relationship(argument="User", uselist=False)
def __init__(self, auction, user, notify_on_outbid=True,
notify_on_ending_soon=True):
self.id = uuid.uuid1()
self.auction = auction
self.user = user
self.notify_on_outbid = notify_on_outbid
self.notify_on_ending_soon = notify_on_ending_soon
self.created_timestamp = now_timestamp()
def get_auction_by_id(dbsession, auction_id):
return get_object_by_id(dbsession, auction_id, MpsAuction)
def get_bid_by_id(dbsession, bid_id):
return get_object_by_id(dbsession, bid_id, MpsBid)

View file

@ -47,6 +47,11 @@ CLASS_TO_TABLE = {
"GiftCardTransaction": "mps_gift_card_transaction",
"CartGiftCard": "mps_cart_gift_card",
"MpsApiKey": "mps_api_key",
"MpsAuction": "mps_auction",
"MpsBid": "mps_bid",
"MpsAuctionWatcher": "mps_auction_watcher",
"MpsOffer": "mps_offer",
"MpsOfferEvent": "mps_offer_event",
}

View file

@ -0,0 +1,269 @@
"""Offer models — MPS-21.
Make-an-offer mode lets a buyer propose a price for a Product. The seller
can accept, counter, or decline. State machine permits a finite number
of counter rounds before forcing terminal resolution. Auto-accept (when
buyer offer >= threshold) and auto-decline (when below threshold) skip
the seller's queue entirely.
State machine (offer.state):
0 pending offer waiting on the other party (current_party)
1 accepted terminal; cart line item created at agreed price
2 countered counter active (still pending to other party)
3 declined terminal; rejected
4 expired terminal; auto-expired past expires_timestamp
5 withdrawn terminal; buyer pulled the offer
6 paid terminal; cart payment succeeded, transferred
current_party:
0 buyer's turn (initial state, or seller just countered to buyer)
1 seller's turn (buyer just countered or opened the offer)
See `lib/offer.py` for pure-function counter/accept/decline/expire logic.
"""
import uuid
from sqlalchemy import Column, BigInteger, Integer, UnicodeText
from sqlalchemy.orm import relationship, backref
from .meta import (
Base,
RBase,
UUIDType,
foreign_key,
now_timestamp,
get_object_by_id,
)
from ..lib.currency import cents_to_dollars
# State constants.
OFFER_STATE_PENDING = 0
OFFER_STATE_ACCEPTED = 1
OFFER_STATE_COUNTERED = 2
OFFER_STATE_DECLINED = 3
OFFER_STATE_EXPIRED = 4
OFFER_STATE_WITHDRAWN = 5
OFFER_STATE_PAID = 6
OFFER_STATE_INT_TO_HUMAN = {
OFFER_STATE_PENDING: "Pending",
OFFER_STATE_ACCEPTED: "Accepted",
OFFER_STATE_COUNTERED: "Countered",
OFFER_STATE_DECLINED: "Declined",
OFFER_STATE_EXPIRED: "Expired",
OFFER_STATE_WITHDRAWN: "Withdrawn",
OFFER_STATE_PAID: "Paid",
}
OFFER_TERMINAL_STATES = (
OFFER_STATE_ACCEPTED,
OFFER_STATE_DECLINED,
OFFER_STATE_EXPIRED,
OFFER_STATE_WITHDRAWN,
OFFER_STATE_PAID,
)
OFFER_PARTY_BUYER = 0
OFFER_PARTY_SELLER = 1
OFFER_PARTY_OTHER = {
OFFER_PARTY_BUYER: OFFER_PARTY_SELLER,
OFFER_PARTY_SELLER: OFFER_PARTY_BUYER,
}
# Event log types.
OFFER_EVENT_OPEN = 0
OFFER_EVENT_COUNTER = 1
OFFER_EVENT_ACCEPT = 2
OFFER_EVENT_DECLINE = 3
OFFER_EVENT_WITHDRAW = 4
OFFER_EVENT_EXPIRE = 5
OFFER_EVENT_PAY = 6
OFFER_EVENT_INT_TO_HUMAN = {
OFFER_EVENT_OPEN: "Opened",
OFFER_EVENT_COUNTER: "Countered",
OFFER_EVENT_ACCEPT: "Accepted",
OFFER_EVENT_DECLINE: "Declined",
OFFER_EVENT_WITHDRAW: "Withdrew",
OFFER_EVENT_EXPIRE: "Expired",
OFFER_EVENT_PAY: "Paid",
}
# Defaults — shop settings override these per-shop.
DEFAULT_OFFER_EXPIRATION_HOURS = 168 # 7 days
DEFAULT_OFFER_MAX_ROUNDS = 3
DEFAULT_OFFER_AUTO_ACCEPT_PCT = 95
DEFAULT_OFFER_AUTO_DECLINE_PCT = 50
class MpsOffer(RBase, Base):
"""One open negotiation between a buyer and seller for a single Product."""
id = Column(UUIDType, primary_key=True, index=True)
product_id = Column(
UUIDType,
foreign_key("Product", "id"),
nullable=False,
index=True,
)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False, index=True)
buyer_user_id = Column(
UUIDType,
foreign_key("User", "id"),
nullable=False,
index=True,
)
state = Column(
Integer,
nullable=False,
default=OFFER_STATE_PENDING,
server_default=str(OFFER_STATE_PENDING),
)
current_amount_in_cents = Column(BigInteger, nullable=False)
current_party = Column(
Integer,
nullable=False,
default=OFFER_PARTY_SELLER, # buyer just opened → seller's turn
server_default=str(OFFER_PARTY_SELLER),
)
created_timestamp = Column(BigInteger, nullable=False)
last_action_timestamp = Column(BigInteger, nullable=False)
expires_timestamp = Column(BigInteger, nullable=False)
paid_timestamp = Column(BigInteger, nullable=True)
round_count = Column(
Integer, nullable=False, default=0, server_default="0"
)
buyer_message = Column(UnicodeText, nullable=True)
seller_message = Column(UnicodeText, nullable=True)
product = relationship(argument="Product", uselist=False)
shop = relationship(argument="Shop", uselist=False)
buyer = relationship(argument="User", uselist=False)
events = relationship(
argument="MpsOfferEvent",
lazy="dynamic",
order_by="MpsOfferEvent.created_timestamp.asc()",
back_populates="offer",
cascade="all, delete-orphan",
)
def __init__(self, product, shop, buyer, amount_in_cents,
expires_timestamp, buyer_message=None):
self.id = uuid.uuid1()
self.product = product
self.shop = shop
self.buyer = buyer
self.current_amount_in_cents = amount_in_cents
self.current_party = OFFER_PARTY_SELLER # seller responds to buyer's open
self.state = OFFER_STATE_PENDING
self.expires_timestamp = expires_timestamp
self.buyer_message = buyer_message
self.round_count = 0
self.created_timestamp = now_timestamp()
self.last_action_timestamp = self.created_timestamp
@property
def is_pending(self):
return self.state == OFFER_STATE_PENDING
@property
def is_countered(self):
return self.state == OFFER_STATE_COUNTERED
@property
def is_open(self):
"""Either party still has an action to take."""
return self.state in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED)
@property
def is_accepted(self):
return self.state == OFFER_STATE_ACCEPTED
@property
def is_paid(self):
return self.state == OFFER_STATE_PAID
@property
def is_terminal(self):
return self.state in OFFER_TERMINAL_STATES
@property
def state_human(self):
return OFFER_STATE_INT_TO_HUMAN.get(self.state, "Unknown")
@property
def time_remaining_ms(self):
return max(0, self.expires_timestamp - now_timestamp())
@property
def is_expired(self):
"""True when expires_timestamp is past, regardless of state column.
The tick job is responsible for flipping state to OFFER_STATE_EXPIRED;
this property reports clock truth even before the tick runs."""
return self.time_remaining_ms == 0 and not self.is_terminal
@property
def waiting_on_buyer(self):
return self.is_open and self.current_party == OFFER_PARTY_BUYER
@property
def waiting_on_seller(self):
return self.is_open and self.current_party == OFFER_PARTY_SELLER
@property
def current_amount(self):
return cents_to_dollars(self.current_amount_in_cents)
class MpsOfferEvent(RBase, Base):
"""Audit log for an offer — every action gets a row."""
id = Column(UUIDType, primary_key=True, index=True)
offer_id = Column(
UUIDType,
foreign_key("MpsOffer", "id"),
nullable=False,
index=True,
)
actor_user_id = Column(UUIDType, foreign_key("User", "id"), nullable=True)
event_type = Column(Integer, nullable=False)
amount_in_cents = Column(BigInteger, nullable=True)
message = Column(UnicodeText, nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
offer = relationship(argument="MpsOffer", uselist=False, back_populates="events")
actor = relationship(argument="User", uselist=False)
def __init__(self, offer, event_type, actor=None, amount_in_cents=None,
message=None):
self.id = uuid.uuid1()
self.offer = offer
self.event_type = event_type
self.actor = actor
self.amount_in_cents = amount_in_cents
self.message = message
self.created_timestamp = now_timestamp()
@property
def event_type_human(self):
return OFFER_EVENT_INT_TO_HUMAN.get(self.event_type, "Unknown")
@property
def amount(self):
if self.amount_in_cents is None:
return None
return cents_to_dollars(self.amount_in_cents)
def get_offer_by_id(dbsession, offer_id):
return get_object_by_id(dbsession, offer_id, MpsOffer)

View file

@ -131,6 +131,17 @@ class Product(RBase, Base):
price_in_cents = Column(BigInteger, nullable=False, default=0)
# MPS-20 / MPS-21: how this product's price is determined.
# 0=fixed (price_in_cents), 1=auction, 2=auction+buy_now,
# 3=offer, 4=offer+buy_now.
pricing_mode = Column(
Integer, nullable=False, default=0, server_default="0"
)
# MPS-21: per-product override of shop's offer setting.
# NULL = inherit shop, True/False = override.
allow_offers = Column(Boolean, nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
@ -201,6 +212,36 @@ class Product(RBase, Base):
self.created_timestamp = now_timestamp()
self.updated_timestamp = now_timestamp()
@property
def is_fixed_price(self):
"""pricing_mode=0 — buy now at price_in_cents (today's behavior)."""
return self.pricing_mode == 0
@property
def is_auction(self):
"""pricing_mode=1 or 2 — auction (with or without buy_now)."""
return self.pricing_mode in (1, 2)
@property
def is_buy_now_allowed(self):
"""pricing_mode in {0, 2, 4} permits direct purchase at listed price."""
return self.pricing_mode in (0, 2, 4)
@property
def is_offer_mode(self):
"""pricing_mode=3 or 4 — make-an-offer enabled (with or without buy_now)."""
return self.pricing_mode in (3, 4)
@property
def offers_allowed(self):
"""Resolves shop default + per-product override.
allow_offers=True/False overrides; NULL inherits shop.offer_enabled."""
if not self.is_offer_mode:
return False
if self.allow_offers is not None:
return self.allow_offers
return bool(getattr(self.shop, "offer_enabled", False))
@property
def human_updated_timestamp(self):
return timestamp_to_ago_string(self.updated_timestamp)

View file

@ -190,6 +190,25 @@ class Shop(RBase, Base):
# Torrent / magnet link distribution (off by default)
torrent_enabled = Column(Boolean, default=False)
# MPS-21: make-an-offer settings (all off / sane defaults)
offer_enabled = Column(Boolean, default=False)
offer_min_in_cents = Column(BigInteger, nullable=True)
offer_auto_accept_threshold_pct = Column(
BigInteger, nullable=False, default=95, server_default="95"
)
offer_auto_decline_threshold_pct = Column(
BigInteger, nullable=False, default=50, server_default="50"
)
offer_expiration_hours = Column(
BigInteger, nullable=False, default=168, server_default="168"
)
offer_max_rounds = Column(
BigInteger, nullable=False, default=3, server_default="3"
)
offer_min_buyer_account_age_hours = Column(
BigInteger, nullable=False, default=0, server_default="0"
)
# Precomputed discovery ring: circular ordering of all public products
json_discovery_ring = Column(UnicodeText, nullable=True)

View file

@ -0,0 +1,234 @@
"""MPS-20 + MPS-21: pricing_mode, auction and offer tables
Revision ID: f0213a54cf7b
Revises: 1a2b3c4d5e6f
Create Date: 2026-05-09 18:11:18.572145
Foundation for auction-house mode (MPS-20) and make-an-offer mode (MPS-21):
- Adds pricing_mode + allow_offers to mps_product
- Adds 7 offer-* settings columns to mps_shop
- Creates mps_auction, mps_bid, mps_auction_watcher tables
- Creates mps_offer, mps_offer_event tables
Idempotent make init-db creates tables from models, so this migration
must skip create_table / add_column when the target already exists.
"""
from alembic import op
import sqlalchemy as sa
import sqlalchemy_utils
revision = 'f0213a54cf7b'
down_revision = '1a2b3c4d5e6f'
branch_labels = None
depends_on = None
def _table_exists(name):
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:name"),
{"name": name},
)
return result.fetchone() is not 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())
UUID = sqlalchemy_utils.types.uuid.UUIDType(binary=False)
def upgrade():
# mps_product: pricing_mode + allow_offers
if not _column_exists("mps_product", "pricing_mode"):
op.add_column(
"mps_product",
sa.Column(
"pricing_mode",
sa.Integer(),
server_default="0",
nullable=False,
),
)
if not _column_exists("mps_product", "allow_offers"):
op.add_column(
"mps_product",
sa.Column("allow_offers", sa.Boolean(), nullable=True),
)
# mps_shop: offer-* columns (7).
shop_offer_columns = [
("offer_enabled", sa.Column("offer_enabled", sa.Boolean(), nullable=True)),
("offer_min_in_cents", sa.Column("offer_min_in_cents", sa.BigInteger(), nullable=True)),
("offer_auto_accept_threshold_pct", sa.Column("offer_auto_accept_threshold_pct", sa.BigInteger(), server_default="95", nullable=False)),
("offer_auto_decline_threshold_pct", sa.Column("offer_auto_decline_threshold_pct", sa.BigInteger(), server_default="50", nullable=False)),
("offer_expiration_hours", sa.Column("offer_expiration_hours", sa.BigInteger(), server_default="168", nullable=False)),
("offer_max_rounds", sa.Column("offer_max_rounds", sa.BigInteger(), server_default="3", nullable=False)),
("offer_min_buyer_account_age_hours", sa.Column("offer_min_buyer_account_age_hours", sa.BigInteger(), server_default="0", nullable=False)),
]
for col_name, col in shop_offer_columns:
if not _column_exists("mps_shop", col_name):
op.add_column("mps_shop", col)
# mps_auction
if not _table_exists("mps_auction"):
op.create_table(
"mps_auction",
sa.Column("id", UUID, nullable=False),
sa.Column("product_id", UUID, nullable=False),
sa.Column("shop_id", UUID, nullable=False),
sa.Column("state", sa.Integer(), server_default="0", nullable=False),
sa.Column("start_timestamp", sa.BigInteger(), nullable=True),
sa.Column("end_timestamp", sa.BigInteger(), nullable=True),
sa.Column("original_end_timestamp", sa.BigInteger(), nullable=True),
sa.Column("start_price_in_cents", sa.BigInteger(), nullable=False),
sa.Column("reserve_price_in_cents", sa.BigInteger(), nullable=True),
sa.Column("buy_now_price_in_cents", sa.BigInteger(), nullable=True),
sa.Column("bid_increment_in_cents", sa.BigInteger(), nullable=False),
sa.Column("soft_close_seconds", sa.Integer(), nullable=False),
sa.Column("winner_user_id", UUID, nullable=True),
sa.Column("winning_bid_id", UUID, nullable=True),
sa.Column("payment_deadline_timestamp", sa.BigInteger(), nullable=True),
sa.Column("currency", sa.Unicode(length=3), nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("updated_timestamp", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["product_id"], ["mps_product.id"]),
sa.ForeignKeyConstraint(["shop_id"], ["mps_shop.id"]),
sa.ForeignKeyConstraint(["winner_user_id"], ["mps_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_auction_id", "mps_auction", ["id"])
op.create_index("ix_mps_auction_product_id", "mps_auction", ["product_id"], unique=True)
op.create_index("ix_mps_auction_shop_id", "mps_auction", ["shop_id"])
# mps_bid
if not _table_exists("mps_bid"):
op.create_table(
"mps_bid",
sa.Column("id", UUID, nullable=False),
sa.Column("auction_id", UUID, nullable=False),
sa.Column("bidder_user_id", UUID, nullable=False),
sa.Column("amount_in_cents", sa.BigInteger(), nullable=False),
sa.Column("max_proxy_in_cents", sa.BigInteger(), nullable=True),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("outbid_timestamp", sa.BigInteger(), nullable=True),
sa.Column("is_winning", sa.Boolean(), server_default="0", nullable=False),
sa.ForeignKeyConstraint(["auction_id"], ["mps_auction.id"]),
sa.ForeignKeyConstraint(["bidder_user_id"], ["mps_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_bid_id", "mps_bid", ["id"])
op.create_index("ix_mps_bid_auction_id", "mps_bid", ["auction_id"])
op.create_index("ix_mps_bid_bidder_user_id", "mps_bid", ["bidder_user_id"])
# mps_auction_watcher
if not _table_exists("mps_auction_watcher"):
op.create_table(
"mps_auction_watcher",
sa.Column("id", UUID, nullable=False),
sa.Column("auction_id", UUID, nullable=False),
sa.Column("user_id", UUID, nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("notify_on_outbid", sa.Boolean(), server_default="1", nullable=False),
sa.Column("notify_on_ending_soon", sa.Boolean(), server_default="1", nullable=False),
sa.ForeignKeyConstraint(["auction_id"], ["mps_auction.id"]),
sa.ForeignKeyConstraint(["user_id"], ["mps_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_auction_watcher_id", "mps_auction_watcher", ["id"])
op.create_index("ix_mps_auction_watcher_auction_id", "mps_auction_watcher", ["auction_id"])
op.create_index("ix_mps_auction_watcher_user_id", "mps_auction_watcher", ["user_id"])
# mps_offer
if not _table_exists("mps_offer"):
op.create_table(
"mps_offer",
sa.Column("id", UUID, nullable=False),
sa.Column("product_id", UUID, nullable=False),
sa.Column("shop_id", UUID, nullable=False),
sa.Column("buyer_user_id", UUID, nullable=False),
sa.Column("state", sa.Integer(), server_default="0", nullable=False),
sa.Column("current_amount_in_cents", sa.BigInteger(), nullable=False),
sa.Column("current_party", sa.Integer(), server_default="1", nullable=False),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("last_action_timestamp", sa.BigInteger(), nullable=False),
sa.Column("expires_timestamp", sa.BigInteger(), nullable=False),
sa.Column("paid_timestamp", sa.BigInteger(), nullable=True),
sa.Column("round_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("buyer_message", sa.UnicodeText(), nullable=True),
sa.Column("seller_message", sa.UnicodeText(), nullable=True),
sa.ForeignKeyConstraint(["buyer_user_id"], ["mps_user.id"]),
sa.ForeignKeyConstraint(["product_id"], ["mps_product.id"]),
sa.ForeignKeyConstraint(["shop_id"], ["mps_shop.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_offer_id", "mps_offer", ["id"])
op.create_index("ix_mps_offer_product_id", "mps_offer", ["product_id"])
op.create_index("ix_mps_offer_shop_id", "mps_offer", ["shop_id"])
op.create_index("ix_mps_offer_buyer_user_id", "mps_offer", ["buyer_user_id"])
# mps_offer_event
if not _table_exists("mps_offer_event"):
op.create_table(
"mps_offer_event",
sa.Column("id", UUID, nullable=False),
sa.Column("offer_id", UUID, nullable=False),
sa.Column("actor_user_id", UUID, nullable=True),
sa.Column("event_type", sa.Integer(), nullable=False),
sa.Column("amount_in_cents", sa.BigInteger(), nullable=True),
sa.Column("message", sa.UnicodeText(), nullable=True),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["mps_user.id"]),
sa.ForeignKeyConstraint(["offer_id"], ["mps_offer.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mps_offer_event_id", "mps_offer_event", ["id"])
op.create_index("ix_mps_offer_event_offer_id", "mps_offer_event", ["offer_id"])
def downgrade():
if _table_exists("mps_offer_event"):
op.drop_index("ix_mps_offer_event_offer_id", table_name="mps_offer_event")
op.drop_index("ix_mps_offer_event_id", table_name="mps_offer_event")
op.drop_table("mps_offer_event")
if _table_exists("mps_offer"):
op.drop_index("ix_mps_offer_buyer_user_id", table_name="mps_offer")
op.drop_index("ix_mps_offer_shop_id", table_name="mps_offer")
op.drop_index("ix_mps_offer_product_id", table_name="mps_offer")
op.drop_index("ix_mps_offer_id", table_name="mps_offer")
op.drop_table("mps_offer")
if _table_exists("mps_auction_watcher"):
op.drop_index("ix_mps_auction_watcher_user_id", table_name="mps_auction_watcher")
op.drop_index("ix_mps_auction_watcher_auction_id", table_name="mps_auction_watcher")
op.drop_index("ix_mps_auction_watcher_id", table_name="mps_auction_watcher")
op.drop_table("mps_auction_watcher")
if _table_exists("mps_bid"):
op.drop_index("ix_mps_bid_bidder_user_id", table_name="mps_bid")
op.drop_index("ix_mps_bid_auction_id", table_name="mps_bid")
op.drop_index("ix_mps_bid_id", table_name="mps_bid")
op.drop_table("mps_bid")
if _table_exists("mps_auction"):
op.drop_index("ix_mps_auction_shop_id", table_name="mps_auction")
op.drop_index("ix_mps_auction_product_id", table_name="mps_auction")
op.drop_index("ix_mps_auction_id", table_name="mps_auction")
op.drop_table("mps_auction")
for col in (
"offer_min_buyer_account_age_hours",
"offer_max_rounds",
"offer_expiration_hours",
"offer_auto_decline_threshold_pct",
"offer_auto_accept_threshold_pct",
"offer_min_in_cents",
"offer_enabled",
):
if _column_exists("mps_shop", col):
op.drop_column("mps_shop", col)
if _column_exists("mps_product", "allow_offers"):
op.drop_column("mps_product", "allow_offers")
if _column_exists("mps_product", "pricing_mode"):
op.drop_column("mps_product", "pricing_mode")

View file

@ -3991,3 +3991,375 @@ class TestBYOBIntegration(DatabaseIntegrationTests):
self.assertEqual(fetched.primary_s3_bucket, "test-bucket")
self.assertEqual(fetched.primary_s3_cdn_endpoint, "https://cdn.example.com")
transaction.commit()
class TestAuctionFoundation(DatabaseIntegrationTests):
"""MPS-20: persist auction + bids + watchers, exercise relationships."""
def _make_shop_and_product(self, pricing_mode=1):
from ..models.product import Product
shop = Shop(
name="Auction House",
phone_number="555-555-0001",
billing_address="1 auction lane",
description="auction shop",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "auction.test"
self.dbsession.add(shop)
product = Product(title="Rare item", description="One of a kind")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = pricing_mode
self.dbsession.add(product)
self.dbsession.flush()
return shop, product
def test_create_and_persist_auction(self):
from ..models.auction import (
MpsAuction, AUCTION_STATE_DRAFT, AUCTION_STATE_ACTIVE,
now_timestamp, get_auction_by_id,
)
shop, product = self._make_shop_and_product(pricing_mode=1)
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=5000,
bid_increment_in_cents=100, soft_close_seconds=60,
)
self.dbsession.add(auction)
self.dbsession.flush()
self.assertEqual(auction.state, AUCTION_STATE_DRAFT)
self.assertTrue(auction.is_draft)
self.assertFalse(auction.is_terminal)
self.assertEqual(auction.start_price_in_cents, 5000)
self.assertEqual(auction.bid_increment_in_cents, 100)
self.assertEqual(auction.currency, "USD")
auction_id = auction.id
transaction.commit()
fetched = get_auction_by_id(self.dbsession, auction_id)
self.assertIsNotNone(fetched)
self.assertEqual(fetched.start_price_in_cents, 5000)
self.assertEqual(fetched.product.title, "Rare item")
self.assertEqual(fetched.shop.name, "Auction House")
# Verify Product.auction backref is wired.
self.assertIs(fetched.product.auction, fetched)
# Mutate and round-trip.
fetched.state = AUCTION_STATE_ACTIVE
fetched.start_timestamp = now_timestamp()
fetched.end_timestamp = now_timestamp() + 3_600_000
transaction.commit()
fetched2 = get_auction_by_id(self.dbsession, auction_id)
self.assertTrue(fetched2.is_active)
def test_one_auction_per_product_unique_constraint(self):
from ..models.auction import MpsAuction
from sqlalchemy.exc import IntegrityError
shop, product = self._make_shop_and_product(pricing_mode=1)
a1 = MpsAuction(product=product, shop=shop, start_price_in_cents=1000)
self.dbsession.add(a1)
self.dbsession.flush()
transaction.commit()
# Second auction for the same product violates unique index on product_id.
a2 = MpsAuction(product=product, shop=shop, start_price_in_cents=2000)
self.dbsession.add(a2)
with self.assertRaises(IntegrityError):
self.dbsession.flush()
transaction.abort()
def test_bids_relationship_and_current_high(self):
from ..models.auction import MpsAuction, MpsBid
shop, product = self._make_shop_and_product(pricing_mode=1)
u1 = get_or_create_user_by_email(self.dbsession, "bidder1@example.com")
u2 = get_or_create_user_by_email(self.dbsession, "bidder2@example.com")
self.dbsession.add(u1)
self.dbsession.add(u2)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
self.dbsession.add(auction)
self.dbsession.flush()
# No bids: current high = start price.
self.assertEqual(auction.current_high_in_cents, 1000)
self.assertEqual(auction.min_next_bid_in_cents, 1000)
b1 = MpsBid(auction=auction, bidder=u1, amount_in_cents=1100)
b2 = MpsBid(auction=auction, bidder=u2, amount_in_cents=1300)
b3 = MpsBid(auction=auction, bidder=u1, amount_in_cents=1500)
for b in (b1, b2, b3):
self.dbsession.add(b)
self.dbsession.flush()
self.assertEqual(auction.bids.count(), 3)
self.assertEqual(auction.current_high_in_cents, 1500)
self.assertEqual(auction.min_next_bid_in_cents, 1500 + auction.bid_increment_in_cents)
transaction.commit()
def test_reserve_met_logic(self):
from ..models.auction import MpsAuction, MpsBid
shop, product = self._make_shop_and_product(pricing_mode=1)
u1 = get_or_create_user_by_email(self.dbsession, "reserve_bidder@example.com")
self.dbsession.add(u1)
self.dbsession.flush()
auction = MpsAuction(
product=product, shop=shop, start_price_in_cents=1000,
)
auction.reserve_price_in_cents = 5000
self.dbsession.add(auction)
self.dbsession.flush()
self.assertTrue(auction.has_reserve)
# No bids → reserve not met.
self.assertFalse(auction.reserve_met)
# Bid below reserve.
self.dbsession.add(
MpsBid(auction=auction, bidder=u1, amount_in_cents=4000)
)
self.dbsession.flush()
self.assertFalse(auction.reserve_met)
# Bid at/above reserve.
self.dbsession.add(
MpsBid(auction=auction, bidder=u1, amount_in_cents=5000)
)
self.dbsession.flush()
self.assertTrue(auction.reserve_met)
transaction.commit()
def test_watcher_relationship_and_cascade_delete(self):
from ..models.auction import (
MpsAuction, MpsBid, MpsAuctionWatcher, get_auction_by_id,
)
shop, product = self._make_shop_and_product(pricing_mode=1)
watcher_user = get_or_create_user_by_email(self.dbsession, "w@example.com")
bidder_user = get_or_create_user_by_email(self.dbsession, "b@example.com")
self.dbsession.add(watcher_user)
self.dbsession.add(bidder_user)
self.dbsession.flush()
auction = MpsAuction(product=product, shop=shop, start_price_in_cents=100)
self.dbsession.add(auction)
self.dbsession.flush()
watcher = MpsAuctionWatcher(auction=auction, user=watcher_user)
bid = MpsBid(auction=auction, bidder=bidder_user, amount_in_cents=200)
self.dbsession.add(watcher)
self.dbsession.add(bid)
self.dbsession.flush()
auction_id = auction.id
transaction.commit()
# Re-query after commit (session is fresh).
fetched = get_auction_by_id(self.dbsession, auction_id)
self.assertEqual(fetched.watchers.count(), 1)
self.assertEqual(fetched.bids.count(), 1)
# Delete auction → bids and watchers cascade.
self.dbsession.delete(fetched)
self.dbsession.flush()
self.assertEqual(self.dbsession.query(MpsBid).count(), 0)
self.assertEqual(self.dbsession.query(MpsAuctionWatcher).count(), 0)
transaction.commit()
class TestOfferFoundation(DatabaseIntegrationTests):
"""MPS-21: persist offer + events, exercise state + relationships."""
def _make_shop_and_product(self, pricing_mode=3, allow_offers=None,
offer_enabled=True):
from ..models.product import Product
shop = Shop(
name="Negotiation Shop",
phone_number="555-555-0002",
billing_address="2 offer way",
description="offers shop",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "offers.test"
shop.offer_enabled = offer_enabled
self.dbsession.add(shop)
product = Product(title="Negotiable", description="Make me an offer")
product.shop = shop
product.price_in_cents = 10000
product.is_physical = False
product.is_sellable = True
product.pricing_mode = pricing_mode
product.allow_offers = allow_offers
self.dbsession.add(product)
self.dbsession.flush()
return shop, product
def test_create_and_persist_offer(self):
from ..models.offer import (
MpsOffer, MpsOfferEvent, OFFER_EVENT_OPEN,
OFFER_STATE_PENDING, OFFER_PARTY_SELLER,
now_timestamp, get_offer_by_id,
)
shop, product = self._make_shop_and_product()
buyer = get_or_create_user_by_email(self.dbsession, "buyer@example.com")
self.dbsession.add(buyer)
self.dbsession.flush()
expires = now_timestamp() + 7 * 24 * 3600 * 1000
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=8000,
expires_timestamp=expires,
buyer_message="please?",
)
self.dbsession.add(offer)
self.dbsession.flush()
# Open event fires up front (we'll validate state machine in lib tests
# — for now just confirm we can write & query an event row).
evt = MpsOfferEvent(
offer=offer, event_type=OFFER_EVENT_OPEN,
actor=buyer, amount_in_cents=8000, message="opened",
)
self.dbsession.add(evt)
self.dbsession.flush()
offer_id = offer.id
transaction.commit()
fetched = get_offer_by_id(self.dbsession, offer_id)
self.assertIsNotNone(fetched)
self.assertEqual(fetched.state, OFFER_STATE_PENDING)
self.assertEqual(fetched.current_party, OFFER_PARTY_SELLER)
self.assertEqual(fetched.current_amount_in_cents, 8000)
self.assertEqual(fetched.round_count, 0)
self.assertEqual(fetched.events.count(), 1)
self.assertEqual(fetched.product.title, "Negotiable")
self.assertTrue(fetched.is_open)
self.assertTrue(fetched.waiting_on_seller)
def test_event_cascade_delete(self):
from ..models.offer import (
MpsOffer, MpsOfferEvent, OFFER_EVENT_OPEN, OFFER_EVENT_COUNTER,
now_timestamp, get_offer_by_id,
)
shop, product = self._make_shop_and_product()
buyer = get_or_create_user_by_email(self.dbsession, "buyer2@example.com")
self.dbsession.add(buyer)
self.dbsession.flush()
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=5000, expires_timestamp=now_timestamp() + 1_000_000,
)
self.dbsession.add(offer)
self.dbsession.flush()
for et in (OFFER_EVENT_OPEN, OFFER_EVENT_COUNTER, OFFER_EVENT_COUNTER):
self.dbsession.add(
MpsOfferEvent(offer=offer, event_type=et, actor=buyer)
)
self.dbsession.flush()
offer_id = offer.id
transaction.commit()
fetched = get_offer_by_id(self.dbsession, offer_id)
self.assertEqual(fetched.events.count(), 3)
# Delete offer → events cascade.
self.dbsession.delete(fetched)
self.dbsession.flush()
self.assertEqual(self.dbsession.query(MpsOfferEvent).count(), 0)
transaction.commit()
class TestPricingModePersistence(DatabaseIntegrationTests):
"""MPS-20 + MPS-21: pricing_mode + allow_offers persist on Product."""
def test_pricing_mode_default_zero(self):
shop = Shop(
name="Default Shop", phone_number="x", billing_address="x",
description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "d.test"
self.dbsession.add(shop)
product = Product(title="Plain", description="No mode set")
product.shop = shop
product.price_in_cents = 0
product.is_physical = False
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.id
transaction.commit()
fetched = self.dbsession.query(Product).get(product_id)
self.assertEqual(fetched.pricing_mode, 0)
self.assertTrue(fetched.is_fixed_price)
self.assertIsNone(fetched.allow_offers)
self.assertFalse(fetched.is_offer_mode)
self.assertFalse(fetched.offers_allowed)
def test_offers_allowed_inherits_shop_offer_enabled(self):
# pricing_mode=3 + allow_offers=NULL + shop.offer_enabled=True → True
shop = Shop(
name="Offer Shop", phone_number="x", billing_address="x",
description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "o.test"
shop.offer_enabled = True
self.dbsession.add(shop)
product = Product(title="Offerable", description="...")
product.shop = shop
product.price_in_cents = 5000
product.is_physical = False
product.pricing_mode = 3
# allow_offers=NULL → inherit shop default.
self.dbsession.add(product)
self.dbsession.flush()
product_id = product.id
transaction.commit()
fetched = self.dbsession.query(Product).get(product_id)
self.assertTrue(fetched.is_offer_mode)
self.assertIsNone(fetched.allow_offers)
self.assertTrue(fetched.offers_allowed)
def test_shop_offer_settings_defaults_persist(self):
shop = Shop(
name="Settings Shop", phone_number="x", billing_address="x",
description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "s.test"
# Don't set offer_* — defaults should apply on flush.
self.dbsession.add(shop)
self.dbsession.flush()
shop_id = shop.id
transaction.commit()
fetched = self.dbsession.query(Shop).get(shop_id)
self.assertEqual(fetched.offer_auto_accept_threshold_pct, 95)
self.assertEqual(fetched.offer_auto_decline_threshold_pct, 50)
self.assertEqual(fetched.offer_expiration_hours, 168)
self.assertEqual(fetched.offer_max_rounds, 3)
self.assertEqual(fetched.offer_min_buyer_account_age_hours, 0)
# Booleans/nullable: offer_enabled defaults None (no server_default set)
# offer_min_in_cents nullable.
self.assertIsNone(fetched.offer_min_in_cents)

View file

@ -4165,3 +4165,257 @@ class TestFeatureKillSwitches(unittest.TestCase):
def test_torrent_bool_passthrough(self):
self.assertTrue(self._torrent_resolve({"features.torrent.enabled": True}))
self.assertFalse(self._torrent_resolve({"features.torrent.enabled": False}))
class TestProductPricingMode(unittest.TestCase):
"""MPS-20 + MPS-21: pricing_mode helper properties on Product."""
def _make_product(self, pricing_mode=0, allow_offers=None,
shop_offer_enabled=False):
"""Stub Product/Shop with only the attributes pricing_mode helpers need.
Avoids spinning up SQLAlchemy mappers (Mock(spec=Shop) would import them)."""
from types import SimpleNamespace
from ..models.product import Product
product = SimpleNamespace(
pricing_mode=pricing_mode,
allow_offers=allow_offers,
shop=SimpleNamespace(offer_enabled=shop_offer_enabled),
)
# Bind real properties for direct testing.
product.is_fixed_price = Product.is_fixed_price.fget(product)
product.is_auction = Product.is_auction.fget(product)
product.is_buy_now_allowed = Product.is_buy_now_allowed.fget(product)
product.is_offer_mode = Product.is_offer_mode.fget(product)
product.offers_allowed = Product.offers_allowed.fget(product)
return product
def test_fixed_mode_zero(self):
p = self._make_product(pricing_mode=0)
self.assertTrue(p.is_fixed_price)
self.assertFalse(p.is_auction)
self.assertTrue(p.is_buy_now_allowed)
self.assertFalse(p.is_offer_mode)
def test_auction_only_mode_one(self):
p = self._make_product(pricing_mode=1)
self.assertFalse(p.is_fixed_price)
self.assertTrue(p.is_auction)
self.assertFalse(p.is_buy_now_allowed)
self.assertFalse(p.is_offer_mode)
def test_auction_with_buy_now_mode_two(self):
p = self._make_product(pricing_mode=2)
self.assertTrue(p.is_auction)
self.assertTrue(p.is_buy_now_allowed)
self.assertFalse(p.is_offer_mode)
def test_offer_only_mode_three(self):
p = self._make_product(pricing_mode=3)
self.assertFalse(p.is_auction)
self.assertFalse(p.is_buy_now_allowed)
self.assertTrue(p.is_offer_mode)
def test_offer_with_buy_now_mode_four(self):
p = self._make_product(pricing_mode=4)
self.assertTrue(p.is_offer_mode)
self.assertTrue(p.is_buy_now_allowed)
def test_offers_allowed_inherits_shop_when_override_null(self):
p = self._make_product(
pricing_mode=3, allow_offers=None, shop_offer_enabled=True,
)
self.assertTrue(p.offers_allowed)
p2 = self._make_product(
pricing_mode=3, allow_offers=None, shop_offer_enabled=False,
)
self.assertFalse(p2.offers_allowed)
def test_offers_allowed_per_product_override_wins(self):
# Override True even when shop is False.
p = self._make_product(
pricing_mode=3, allow_offers=True, shop_offer_enabled=False,
)
self.assertTrue(p.offers_allowed)
# Override False even when shop is True.
p2 = self._make_product(
pricing_mode=3, allow_offers=False, shop_offer_enabled=True,
)
self.assertFalse(p2.offers_allowed)
def test_offers_allowed_false_outside_offer_mode(self):
# Even with override True, offers are blocked when pricing_mode != 3 or 4.
p = self._make_product(
pricing_mode=0, allow_offers=True, shop_offer_enabled=True,
)
self.assertFalse(p.offers_allowed)
class TestMpsAuctionStateHelpers(unittest.TestCase):
"""MPS-20: state helpers on MpsAuction (no DB; we exercise pure properties)."""
def _make_auction(self, state=0, end_timestamp=None,
reserve_price_in_cents=None,
buy_now_price_in_cents=None,
start_price_in_cents=0):
from types import SimpleNamespace
from ..models.auction import MpsAuction
a = SimpleNamespace(
state=state,
end_timestamp=end_timestamp,
reserve_price_in_cents=reserve_price_in_cents,
buy_now_price_in_cents=buy_now_price_in_cents,
start_price_in_cents=start_price_in_cents,
)
for name in [
"is_draft", "is_scheduled", "is_active", "is_ended",
"is_settled", "is_cancelled", "is_terminal",
"state_human", "time_remaining_ms",
"has_buy_now", "has_reserve",
]:
setattr(a, name, getattr(MpsAuction, name).fget(a))
return a
def test_state_classifiers(self):
from ..models.auction import (
AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
AUCTION_STATE_ENDED, AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED,
)
cases = [
(AUCTION_STATE_DRAFT, "is_draft", "Draft"),
(AUCTION_STATE_SCHEDULED, "is_scheduled", "Scheduled"),
(AUCTION_STATE_ACTIVE, "is_active", "Active"),
(AUCTION_STATE_ENDED, "is_ended", "Ended"),
(AUCTION_STATE_SETTLED, "is_settled", "Settled"),
(AUCTION_STATE_CANCELLED, "is_cancelled", "Cancelled"),
]
for state, prop, human in cases:
a = self._make_auction(state=state)
self.assertTrue(getattr(a, prop), f"{prop} should be True for state {state}")
self.assertEqual(a.state_human, human)
def test_terminal_states(self):
from ..models.auction import AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED
for terminal in (AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED):
self.assertTrue(self._make_auction(state=terminal).is_terminal)
for non_terminal in (0, 1, 2, 3):
self.assertFalse(self._make_auction(state=non_terminal).is_terminal)
def test_time_remaining_ms_no_end(self):
self.assertEqual(self._make_auction(end_timestamp=None).time_remaining_ms, 0)
def test_time_remaining_ms_past(self):
from ..models.auction import now_timestamp
past = now_timestamp() - 60_000
self.assertEqual(self._make_auction(end_timestamp=past).time_remaining_ms, 0)
def test_time_remaining_ms_future(self):
from ..models.auction import now_timestamp
future = now_timestamp() + 60_000
remaining = self._make_auction(end_timestamp=future).time_remaining_ms
self.assertGreater(remaining, 50_000)
self.assertLessEqual(remaining, 60_000)
def test_has_buy_now_and_reserve(self):
self.assertFalse(self._make_auction().has_buy_now)
self.assertFalse(self._make_auction().has_reserve)
self.assertTrue(self._make_auction(buy_now_price_in_cents=10000).has_buy_now)
self.assertTrue(self._make_auction(reserve_price_in_cents=5000).has_reserve)
class TestMpsOfferStateHelpers(unittest.TestCase):
"""MPS-21: state helpers on MpsOffer."""
def _make_offer(self, state=0, current_party=1,
expires_timestamp=None,
current_amount_in_cents=1000):
from types import SimpleNamespace
from ..models.offer import MpsOffer, now_timestamp
o = SimpleNamespace(
state=state,
current_party=current_party,
expires_timestamp=expires_timestamp or (now_timestamp() + 60_000),
current_amount_in_cents=current_amount_in_cents,
)
for name in [
"is_pending", "is_countered", "is_open", "is_accepted", "is_paid",
"is_terminal", "state_human",
"time_remaining_ms", "is_expired",
"waiting_on_buyer", "waiting_on_seller",
]:
setattr(o, name, getattr(MpsOffer, name).fget(o))
return o
def test_state_classifiers(self):
from ..models.offer import (
OFFER_STATE_PENDING, OFFER_STATE_COUNTERED, OFFER_STATE_ACCEPTED,
OFFER_STATE_DECLINED, OFFER_STATE_EXPIRED, OFFER_STATE_WITHDRAWN,
OFFER_STATE_PAID,
)
self.assertTrue(self._make_offer(state=OFFER_STATE_PENDING).is_pending)
self.assertTrue(self._make_offer(state=OFFER_STATE_COUNTERED).is_countered)
self.assertTrue(self._make_offer(state=OFFER_STATE_ACCEPTED).is_accepted)
self.assertTrue(self._make_offer(state=OFFER_STATE_PAID).is_paid)
# is_open: pending or countered only
self.assertTrue(self._make_offer(state=OFFER_STATE_PENDING).is_open)
self.assertTrue(self._make_offer(state=OFFER_STATE_COUNTERED).is_open)
self.assertFalse(self._make_offer(state=OFFER_STATE_ACCEPTED).is_open)
self.assertFalse(self._make_offer(state=OFFER_STATE_DECLINED).is_open)
# terminal: accepted/declined/expired/withdrawn/paid
for terminal in (
OFFER_STATE_ACCEPTED, OFFER_STATE_DECLINED, OFFER_STATE_EXPIRED,
OFFER_STATE_WITHDRAWN, OFFER_STATE_PAID,
):
self.assertTrue(self._make_offer(state=terminal).is_terminal)
for non_terminal in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED):
self.assertFalse(self._make_offer(state=non_terminal).is_terminal)
def test_waiting_on_party(self):
from ..models.offer import (
OFFER_PARTY_BUYER, OFFER_PARTY_SELLER,
OFFER_STATE_PENDING, OFFER_STATE_ACCEPTED,
)
# Open offer → exactly one party is waiting
buyer_turn = self._make_offer(
state=OFFER_STATE_PENDING, current_party=OFFER_PARTY_BUYER,
)
self.assertTrue(buyer_turn.waiting_on_buyer)
self.assertFalse(buyer_turn.waiting_on_seller)
seller_turn = self._make_offer(
state=OFFER_STATE_PENDING, current_party=OFFER_PARTY_SELLER,
)
self.assertFalse(seller_turn.waiting_on_buyer)
self.assertTrue(seller_turn.waiting_on_seller)
# Terminal offer → nobody waiting
terminal = self._make_offer(
state=OFFER_STATE_ACCEPTED, current_party=OFFER_PARTY_BUYER,
)
self.assertFalse(terminal.waiting_on_buyer)
self.assertFalse(terminal.waiting_on_seller)
def test_time_remaining_and_expired(self):
from ..models.offer import (
now_timestamp, OFFER_STATE_PENDING, OFFER_STATE_PAID,
)
future = now_timestamp() + 60_000
past = now_timestamp() - 60_000
live = self._make_offer(state=OFFER_STATE_PENDING, expires_timestamp=future)
self.assertGreater(live.time_remaining_ms, 50_000)
self.assertFalse(live.is_expired)
dead = self._make_offer(state=OFFER_STATE_PENDING, expires_timestamp=past)
self.assertEqual(dead.time_remaining_ms, 0)
self.assertTrue(dead.is_expired)
# is_expired only fires for non-terminal offers
terminal_past = self._make_offer(
state=OFFER_STATE_PAID, expires_timestamp=past,
)
self.assertFalse(terminal_past.is_expired)