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:
parent
98352496a9
commit
80b4fa6698
9 changed files with 1542 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
345
make_post_sell/models/auction.py
Normal file
345
make_post_sell/models/auction.py
Normal 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)
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
269
make_post_sell/models/offer.py
Normal file
269
make_post_sell/models/offer.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue