diff --git a/make_post_sell/lib/auction_tick.py b/make_post_sell/lib/auction_tick.py index 2f7a6ec..b519502 100644 --- a/make_post_sell/lib/auction_tick.py +++ b/make_post_sell/lib/auction_tick.py @@ -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 diff --git a/make_post_sell/lib/notifications.py b/make_post_sell/lib/notifications.py index 220ac58..380996a 100644 --- a/make_post_sell/lib/notifications.py +++ b/make_post_sell/lib/notifications.py @@ -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, + ), + ) diff --git a/make_post_sell/lib/offer_tick.py b/make_post_sell/lib/offer_tick.py index a50d8a1..fda00e7 100644 --- a/make_post_sell/lib/offer_tick.py +++ b/make_post_sell/lib/offer_tick.py @@ -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} diff --git a/make_post_sell/models/notification.py b/make_post_sell/models/notification.py index 9b60832..8de0462 100644 --- a/make_post_sell/models/notification.py +++ b/make_post_sell/models/notification.py @@ -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): diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 3900cee..89bc040 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -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