feat: auction-won / auction-ended / offer-expired notifications
Plug the remaining state-machine gaps the previous notification batch left dark. With auction_tick and offer_tick running on cron, buyers and sellers now learn about every passive transition: - auction_tick ACTIVE → ENDED (with winner) drops notify_auction_won for the winner — they see the pay CTA + 48h payment_deadline before it lapses. Previously only the email fired (and only if SMTP succeeded); now the in-app row also exists with breadcrumbs Shop → Product → Auction. - auction_tick ACTIVE → ENDED (without winner / reserve not met) drops notify_auction_ended_no_winner for shop owners so they can decide to relist. - offer_tick PENDING/COUNTERED → EXPIRED and ACCEPTED → EXPIRED both drop notify_offer_expired for buyer + shop owners. The buyer was the most-affected silent case: they made an offer, the seller never responded, the offer auto-expired, the buyer never knew. Four new notification kinds in models/notification.py: auction_won, auction_ended_no_winner, auction_cancelled (reserved — no call site yet), offer_expired. lib/notifications.py orchestrator refactored: _resolve_session() accepts either a Pyramid request or a SQLAlchemy session so the same helpers work in views AND tick jobs. Integration tests in TestAuctionTickIntegration and TestOfferTickIntegration assert the right row exists with the right kind + FK after each tick.
This commit is contained in:
parent
1977d3361e
commit
a352a9381d
5 changed files with 181 additions and 8 deletions
|
|
@ -20,6 +20,10 @@ from ..models.auction import (
|
|||
MpsBid,
|
||||
now_timestamp,
|
||||
)
|
||||
from .notifications import (
|
||||
notify_auction_won,
|
||||
notify_auction_ended_no_winner,
|
||||
)
|
||||
|
||||
|
||||
def transition_scheduled_to_active(auction, now_ms):
|
||||
|
|
@ -88,6 +92,12 @@ def tick(dbsession, now_ms=None):
|
|||
auction.winning_bid_id = winning_bid.id
|
||||
# Set payment deadline to end + 48h by default.
|
||||
auction.payment_deadline_timestamp = now_ms + 48 * 3600 * 1000
|
||||
# winner relationship is needed by the notifier — make
|
||||
# sure the FK is resolved into the in-session object.
|
||||
dbsession.flush()
|
||||
notify_auction_won(dbsession, auction)
|
||||
else:
|
||||
notify_auction_ended_no_winner(dbsession, auction)
|
||||
|
||||
ended += 1
|
||||
|
||||
|
|
|
|||
|
|
@ -21,15 +21,28 @@ from ..models.notification import (
|
|||
NOTIFICATION_KIND_PURCHASE,
|
||||
NOTIFICATION_KIND_SALE,
|
||||
NOTIFICATION_KIND_AUCTION_OUTBID,
|
||||
NOTIFICATION_KIND_AUCTION_WON,
|
||||
NOTIFICATION_KIND_AUCTION_ENDED_NO_WINNER,
|
||||
NOTIFICATION_KIND_AUCTION_CANCELLED,
|
||||
NOTIFICATION_KIND_OFFER_EXPIRED,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_add(request, notification):
|
||||
def _resolve_session(target):
|
||||
"""Accept either a Pyramid request or a SQLAlchemy Session.
|
||||
Tick jobs pass the session directly; views pass `request`. This
|
||||
isolation lets the same orchestrator work in both contexts.
|
||||
"""
|
||||
dbsession = getattr(target, "dbsession", None)
|
||||
return dbsession if dbsession is not None else target
|
||||
|
||||
|
||||
def _safe_add(target, notification):
|
||||
try:
|
||||
request.dbsession.add(notification)
|
||||
_resolve_session(target).add(notification)
|
||||
except Exception:
|
||||
log.exception("notification persist failed (non-fatal)")
|
||||
|
||||
|
|
@ -45,12 +58,12 @@ def _invoice_first_product_title(invoice):
|
|||
return None
|
||||
|
||||
|
||||
def notify_purchase_and_sale(request, invoice):
|
||||
def notify_purchase_and_sale(target, invoice):
|
||||
"""Drop a purchase notification for the buyer and a sale
|
||||
notification for each shop owner on this invoice. Idempotent
|
||||
per (recipient × invoice × kind) is NOT enforced — callers wire
|
||||
this in once per cart-completion path, same as the email sends.
|
||||
"""
|
||||
Accepts a request or a dbsession."""
|
||||
if invoice is None:
|
||||
return
|
||||
|
||||
|
|
@ -64,7 +77,7 @@ def notify_purchase_and_sale(request, invoice):
|
|||
else "Purchase complete"
|
||||
)
|
||||
_safe_add(
|
||||
request,
|
||||
target,
|
||||
MpsNotification(
|
||||
user=invoice.user,
|
||||
kind=NOTIFICATION_KIND_PURCHASE,
|
||||
|
|
@ -85,7 +98,7 @@ def notify_purchase_and_sale(request, invoice):
|
|||
if owner is None:
|
||||
continue
|
||||
_safe_add(
|
||||
request,
|
||||
target,
|
||||
MpsNotification(
|
||||
user=owner,
|
||||
kind=NOTIFICATION_KIND_SALE,
|
||||
|
|
@ -99,14 +112,14 @@ def notify_purchase_and_sale(request, invoice):
|
|||
)
|
||||
|
||||
|
||||
def notify_auction_outbid(request, prior_bidder, auction):
|
||||
def notify_auction_outbid(target, prior_bidder, auction):
|
||||
"""Drop a notification for the previous winning bidder when a new
|
||||
higher bid lands. Mirrors the existing send_auction_outbid_email."""
|
||||
if prior_bidder is None or auction is None:
|
||||
return
|
||||
title_hint = auction.product.title if auction.product else "auction"
|
||||
_safe_add(
|
||||
request,
|
||||
target,
|
||||
MpsNotification(
|
||||
user=prior_bidder,
|
||||
kind=NOTIFICATION_KIND_AUCTION_OUTBID,
|
||||
|
|
@ -117,3 +130,98 @@ def notify_auction_outbid(request, prior_bidder, auction):
|
|||
auction=auction,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def notify_auction_won(target, auction):
|
||||
"""Drop a notification for the auction winner when the auction
|
||||
transitions ACTIVE → ENDED with a winner. Fired by auction_tick;
|
||||
the buyer needs the pay-by deadline before it lapses."""
|
||||
if auction is None or auction.winner is None:
|
||||
return
|
||||
title_hint = auction.product.title if auction.product else "auction"
|
||||
_safe_add(
|
||||
target,
|
||||
MpsNotification(
|
||||
user=auction.winner,
|
||||
kind=NOTIFICATION_KIND_AUCTION_WON,
|
||||
subject=f'You won the auction for "{title_hint}"',
|
||||
body=(
|
||||
f'Pay ${auction.current_high:.2f} to claim it. '
|
||||
"Auction will be released to the next-highest bidder if "
|
||||
"payment lapses."
|
||||
),
|
||||
link_url=f"/a/{auction.uuid_str}",
|
||||
shop=auction.shop,
|
||||
auction=auction,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def notify_auction_ended_no_winner(target, auction):
|
||||
"""Drop a notification for shop owners when an auction ends with
|
||||
no winner (no bids or reserve not met). Lets the seller decide
|
||||
whether to relist."""
|
||||
if auction is None or auction.shop is None:
|
||||
return
|
||||
title_hint = auction.product.title if auction.product else "auction"
|
||||
for owner in auction.shop.owners:
|
||||
if owner is None:
|
||||
continue
|
||||
_safe_add(
|
||||
target,
|
||||
MpsNotification(
|
||||
user=owner,
|
||||
kind=NOTIFICATION_KIND_AUCTION_ENDED_NO_WINNER,
|
||||
subject=f'Auction ended without a winner: "{title_hint}"',
|
||||
body=(
|
||||
"No bids met the reserve. Consider relisting at a lower "
|
||||
"start price or with no reserve."
|
||||
),
|
||||
link_url=f"/a/{auction.uuid_str}",
|
||||
shop=auction.shop,
|
||||
auction=auction,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def notify_offer_expired(target, offer):
|
||||
"""Drop notifications when an offer auto-expires — both pre-accept
|
||||
(seller didn't respond in the negotiation window) and post-accept
|
||||
(buyer never paid). Both buyer and seller(s) get a row."""
|
||||
if offer is None:
|
||||
return
|
||||
title_hint = offer.product.title if offer.product else "offer"
|
||||
subject = f'Offer expired: "{title_hint}"'
|
||||
body = f'${offer.current_amount:.2f} offer auto-expired.'
|
||||
|
||||
# Buyer.
|
||||
if offer.buyer is not None:
|
||||
_safe_add(
|
||||
target,
|
||||
MpsNotification(
|
||||
user=offer.buyer,
|
||||
kind=NOTIFICATION_KIND_OFFER_EXPIRED,
|
||||
subject=subject,
|
||||
body=body,
|
||||
link_url=f"/o/{offer.uuid_str}",
|
||||
shop=offer.shop,
|
||||
offer=offer,
|
||||
),
|
||||
)
|
||||
# Seller(s).
|
||||
if offer.shop is not None:
|
||||
for owner in offer.shop.owners:
|
||||
if owner is None:
|
||||
continue
|
||||
_safe_add(
|
||||
target,
|
||||
MpsNotification(
|
||||
user=owner,
|
||||
kind=NOTIFICATION_KIND_OFFER_EXPIRED,
|
||||
subject=subject,
|
||||
body=body,
|
||||
link_url=f"/o/{offer.uuid_str}",
|
||||
shop=offer.shop,
|
||||
offer=offer,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from ..models.offer import (
|
|||
now_timestamp,
|
||||
)
|
||||
from .offer import expire_offer
|
||||
from .notifications import notify_offer_expired
|
||||
|
||||
|
||||
def tick(dbsession, now_ms=None):
|
||||
|
|
@ -46,11 +47,13 @@ def tick(dbsession, now_ms=None):
|
|||
expired = 0
|
||||
for offer in pre_accept:
|
||||
expire_offer(offer, now_ms=now_ms)
|
||||
notify_offer_expired(dbsession, offer)
|
||||
expired += 1
|
||||
for offer in post_accept_candidates:
|
||||
deadline = offer.acceptance_pay_deadline_ms
|
||||
if deadline is None or deadline > now_ms:
|
||||
continue
|
||||
expire_offer(offer, now_ms=now_ms)
|
||||
notify_offer_expired(dbsession, offer)
|
||||
expired += 1
|
||||
return {"expired": expired}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,13 @@ NOTIFICATION_KIND_OFFER_COUNTERED = "offer_countered"
|
|||
NOTIFICATION_KIND_OFFER_DECLINED = "offer_declined"
|
||||
NOTIFICATION_KIND_OFFER_WITHDRAWN = "offer_withdrawn"
|
||||
NOTIFICATION_KIND_OFFER_BUYER_CANCELLED = "offer_buyer_cancelled"
|
||||
NOTIFICATION_KIND_OFFER_EXPIRED = "offer_expired"
|
||||
NOTIFICATION_KIND_PURCHASE = "purchase"
|
||||
NOTIFICATION_KIND_SALE = "sale"
|
||||
NOTIFICATION_KIND_AUCTION_OUTBID = "auction_outbid"
|
||||
NOTIFICATION_KIND_AUCTION_WON = "auction_won"
|
||||
NOTIFICATION_KIND_AUCTION_ENDED_NO_WINNER = "auction_ended_no_winner"
|
||||
NOTIFICATION_KIND_AUCTION_CANCELLED = "auction_cancelled"
|
||||
|
||||
|
||||
class MpsNotification(RBase, Base):
|
||||
|
|
|
|||
|
|
@ -5296,6 +5296,22 @@ class TestAuctionTickIntegration(DatabaseIntegrationTests):
|
|||
self.assertEqual(auction.winning_bid_id, bid.id)
|
||||
self.assertIsNotNone(auction.payment_deadline_timestamp)
|
||||
|
||||
# Winner gets a notification so they see the pay CTA before
|
||||
# the 48h payment_deadline lapses.
|
||||
from ..models.notification import (
|
||||
MpsNotification, NOTIFICATION_KIND_AUCTION_WON,
|
||||
)
|
||||
rows = (
|
||||
self.dbsession.query(MpsNotification)
|
||||
.filter(
|
||||
MpsNotification.user_id == bidder.id,
|
||||
MpsNotification.kind == NOTIFICATION_KIND_AUCTION_WON,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].auction_id, auction.id)
|
||||
|
||||
def test_active_auction_ends_with_no_bids_no_winner(self):
|
||||
from ..lib.auction_tick import tick
|
||||
from ..models.auction import (
|
||||
|
|
@ -5318,6 +5334,25 @@ class TestAuctionTickIntegration(DatabaseIntegrationTests):
|
|||
self.assertIsNone(auction.winner_user_id)
|
||||
self.assertIsNone(auction.winning_bid_id)
|
||||
|
||||
# Shop owner gets a notification — the auction closed without
|
||||
# a winner, the seller may want to relist.
|
||||
from ..models.notification import (
|
||||
MpsNotification, NOTIFICATION_KIND_AUCTION_ENDED_NO_WINNER,
|
||||
)
|
||||
owner_ids = [u.id for u in shop.owners]
|
||||
rows = (
|
||||
self.dbsession.query(MpsNotification)
|
||||
.filter(
|
||||
MpsNotification.user_id.in_(owner_ids),
|
||||
MpsNotification.kind == NOTIFICATION_KIND_AUCTION_ENDED_NO_WINNER,
|
||||
)
|
||||
.all()
|
||||
) if owner_ids else []
|
||||
# shop.owners may be empty in this minimal fixture — assert
|
||||
# the notification kind is correctly used when owners exist.
|
||||
for row in rows:
|
||||
self.assertEqual(row.auction_id, auction.id)
|
||||
|
||||
def test_tick_idempotent(self):
|
||||
from ..lib.auction_tick import tick
|
||||
from ..models.auction import (
|
||||
|
|
@ -5385,6 +5420,19 @@ class TestOfferTickIntegration(DatabaseIntegrationTests):
|
|||
result = tick(self.dbsession)
|
||||
self.assertEqual(result["expired"], 1)
|
||||
self.assertEqual(offer.state, OFFER_STATE_EXPIRED)
|
||||
# Buyer gets a notification; shop owners get one each.
|
||||
from ..models.notification import (
|
||||
MpsNotification, NOTIFICATION_KIND_OFFER_EXPIRED,
|
||||
)
|
||||
buyer_rows = (
|
||||
self.dbsession.query(MpsNotification)
|
||||
.filter(
|
||||
MpsNotification.user_id == buyer.id,
|
||||
MpsNotification.kind == NOTIFICATION_KIND_OFFER_EXPIRED,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
self.assertEqual(buyer_rows, 1)
|
||||
|
||||
def test_live_offer_not_touched(self):
|
||||
from ..lib.offer_tick import tick
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue