MPS-20 + MPS-21: email notifications
Adds three email types and wires them into the bid + offer flows:
- AUCTION_OUTBID — sent to the previous high bidder when their bid
is beaten (auction_bid view, after place_bid succeeds and is_winning
flips to a new bidder)
- OFFER_RECEIVED — sent to all shop owners when a new pending offer
arrives (offer_open view, only when offer queues; auto-accept and
auto-decline use different paths)
- OFFER_ACCEPTED — sent to the buyer when the seller (or buyer
themselves) accepts the current amount (open_offer auto-accept lane;
offer_accept view explicit accept)
Email failures are caught and logged; the bid/offer state is already
persisted by the time we attempt to send, so a misconfigured SMTP
relay does not break the user flow.
Tick-driven emails (auction won when tick.tick ends an auction; offer
expired when tick.tick expires an offer) are deferred — they require
a request context for URL building, and tick scripts run from cron
without one. A future commit can either wire a request-less email
sender or queue the events for the next view that runs.
Templates added to lib/mail_messages.py:
- AUCTION_OUTBID_TEXT / AUCTION_OUTBID_HTML
- OFFER_RECEIVED_TEXT / OFFER_RECEIVED_HTML
- OFFER_ACCEPTED_TEXT / OFFER_ACCEPTED_HTML
Send helpers in lib/mail.py:
- send_auction_outbid_email(request, to, auction)
- send_offer_received_email(request, to, offer)
- send_offer_accepted_email(request, to, offer)
Tests:
- 3 unit (test_models.py) — verify each helper builds the right
subject/body/url with mocked send_pyramid_email
- 1 functional — POST /o/{id}/accept calls send_offer_accepted_email
with the buyer's email
Total: 940 tests pass (was 936 + 4).
This commit is contained in:
parent
a15d760ff4
commit
59a4e8d722
6 changed files with 300 additions and 1 deletions
|
|
@ -16,6 +16,12 @@ from make_post_sell.lib.mail_messages import (
|
|||
SALE_1_HTML,
|
||||
INVITE_1_TEXT,
|
||||
INVITE_1_HTML,
|
||||
AUCTION_OUTBID_TEXT,
|
||||
AUCTION_OUTBID_HTML,
|
||||
OFFER_RECEIVED_TEXT,
|
||||
OFFER_RECEIVED_HTML,
|
||||
OFFER_ACCEPTED_TEXT,
|
||||
OFFER_ACCEPTED_HTML,
|
||||
)
|
||||
|
||||
import dkim
|
||||
|
|
@ -578,3 +584,56 @@ def send_gift_card_email(request, gift_card):
|
|||
message_html = "\n".join(html_parts)
|
||||
|
||||
send_pyramid_email(request, to_email, subject, message_text, message_html)
|
||||
|
||||
|
||||
# ── Auction & offer notifications (MPS-20 + MPS-21) ──────────────────────────
|
||||
|
||||
def send_auction_outbid_email(request, to_email, auction):
|
||||
"""Notify the previous high bidder that they have been outbid."""
|
||||
auction_url = f"{request.host_url}/a/{auction.uuid_str}"
|
||||
subject = f'You have been outbid on "{auction.product.title}"'
|
||||
text = AUCTION_OUTBID_TEXT.format(
|
||||
title=auction.product.title,
|
||||
high=f"{auction.current_high:.2f}",
|
||||
auction_url=auction_url,
|
||||
)
|
||||
html = AUCTION_OUTBID_HTML.format(
|
||||
title=auction.product.title,
|
||||
high=f"{auction.current_high:.2f}",
|
||||
auction_url=auction_url,
|
||||
)
|
||||
send_pyramid_email(request, to_email, subject, text, html)
|
||||
|
||||
|
||||
def send_offer_received_email(request, to_email, offer):
|
||||
"""Notify a shop owner that a new offer arrived for review."""
|
||||
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
|
||||
subject = f'New offer for "{offer.product.title}"'
|
||||
text = OFFER_RECEIVED_TEXT.format(
|
||||
amount=f"{offer.current_amount:.2f}",
|
||||
title=offer.product.title,
|
||||
offer_url=offer_url,
|
||||
)
|
||||
html = OFFER_RECEIVED_HTML.format(
|
||||
amount=f"{offer.current_amount:.2f}",
|
||||
title=offer.product.title,
|
||||
offer_url=offer_url,
|
||||
)
|
||||
send_pyramid_email(request, to_email, subject, text, html)
|
||||
|
||||
|
||||
def send_offer_accepted_email(request, to_email, offer):
|
||||
"""Notify the buyer that their offer (or counter) was accepted."""
|
||||
offer_url = f"{request.host_url}/o/{offer.uuid_str}"
|
||||
subject = f'Your offer for "{offer.product.title}" was accepted'
|
||||
text = OFFER_ACCEPTED_TEXT.format(
|
||||
amount=f"{offer.current_amount:.2f}",
|
||||
title=offer.product.title,
|
||||
offer_url=offer_url,
|
||||
)
|
||||
html = OFFER_ACCEPTED_HTML.format(
|
||||
amount=f"{offer.current_amount:.2f}",
|
||||
title=offer.product.title,
|
||||
offer_url=offer_url,
|
||||
)
|
||||
send_pyramid_email(request, to_email, subject, text, html)
|
||||
|
|
|
|||
|
|
@ -341,3 +341,67 @@ MENTION_HTML = """
|
|||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ── Auction & offer notifications (MPS-20 + MPS-21) ──────────────────────────
|
||||
|
||||
AUCTION_OUTBID_TEXT = """
|
||||
You have been outbid on "{title}".
|
||||
|
||||
Current high: ${high}.
|
||||
Place a new bid to stay in the game:
|
||||
|
||||
{auction_url}
|
||||
"""
|
||||
|
||||
AUCTION_OUTBID_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>You have been outbid</h2>
|
||||
<p>Someone outbid you on <strong>{title}</strong>.</p>
|
||||
<p>Current high: <strong>${high}</strong>.</p>
|
||||
<p><a href="{auction_url}" style="font-weight: bold;">Place a new bid</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
OFFER_RECEIVED_TEXT = """
|
||||
You received a new offer of ${amount} for "{title}".
|
||||
|
||||
Review and respond:
|
||||
|
||||
{offer_url}
|
||||
"""
|
||||
|
||||
OFFER_RECEIVED_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>New offer received</h2>
|
||||
<p>You received a new offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
|
||||
<p><a href="{offer_url}" style="font-weight: bold;">View offer</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
OFFER_ACCEPTED_TEXT = """
|
||||
Your offer of ${amount} for "{title}" was accepted.
|
||||
|
||||
Pay now to complete the purchase:
|
||||
|
||||
{offer_url}
|
||||
"""
|
||||
|
||||
OFFER_ACCEPTED_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Offer accepted</h2>
|
||||
<p>Your offer of <strong>${amount}</strong> for <strong>{title}</strong> was accepted.</p>
|
||||
<p><a href="{offer_url}" style="font-weight: bold;">Pay now</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -6342,6 +6342,47 @@ class TestOfferCheckout(_AuthenticatedBase):
|
|||
self.assertIn("/o/", res.location)
|
||||
self.assertNotIn("/cart", res.location)
|
||||
|
||||
def test_buyer_offer_accepted_email_wired(self):
|
||||
"""Patch send_offer_accepted_email and verify offer accept wires it.
|
||||
Defensive — if email send fails, the offer state still flips."""
|
||||
from ..models.offer import (
|
||||
MpsOffer, OFFER_PARTY_BUYER, OFFER_PARTY_SELLER,
|
||||
OFFER_STATE_ACCEPTED, now_timestamp,
|
||||
)
|
||||
from ..models.product import Product
|
||||
# Build a queued offer so seller can accept.
|
||||
shop = self._create_shop_helper(user_creds=self.user1_creds)
|
||||
shop.offer_enabled = True
|
||||
product = Product(title="Wired", description="...")
|
||||
product.shop = shop
|
||||
product.price_in_cents = 10000
|
||||
product.is_physical = False
|
||||
product.is_sellable = True
|
||||
product.pricing_mode = 3
|
||||
self.dbsession.add(product)
|
||||
self.dbsession.flush()
|
||||
from ..models.user import get_or_create_user_by_email
|
||||
buyer = get_or_create_user_by_email(self.dbsession, "test2@example.com")
|
||||
offer = MpsOffer(
|
||||
product=product, shop=shop, buyer=buyer,
|
||||
amount_in_cents=7000,
|
||||
expires_timestamp=now_timestamp() + 86_400_000,
|
||||
)
|
||||
self.dbsession.add(offer)
|
||||
self.dbsession.flush()
|
||||
offer_id = offer.uuid_str
|
||||
transaction.commit()
|
||||
|
||||
# Logged in as user1 (seller). Patch the email send.
|
||||
with mock.patch(
|
||||
"make_post_sell.views.offer.send_offer_accepted_email"
|
||||
) as mock_send:
|
||||
self.testapp.post(f"/o/{offer_id}/accept", status=200)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
# First positional arg is request, second is email.
|
||||
args, _ = mock_send.call_args
|
||||
self.assertEqual(args[1], "test2@example.com")
|
||||
|
||||
def test_pending_offer_checkout_blocked(self):
|
||||
from ..models.offer import (
|
||||
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
|
||||
|
|
|
|||
|
|
@ -4798,3 +4798,77 @@ class TestAuctionTickPureFunctions(unittest.TestCase):
|
|||
self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
|
||||
a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
|
||||
self.assertFalse(transition_active_to_ended(a4, now_ms=2000))
|
||||
|
||||
|
||||
class TestEmailNotificationContent(unittest.TestCase):
|
||||
"""MPS-20 + MPS-21: email helpers format templates with the right
|
||||
fields. Mocks the underlying send_pyramid_email to verify the call."""
|
||||
|
||||
@mock.patch("make_post_sell.lib.mail.send_pyramid_email")
|
||||
def test_send_auction_outbid_email(self, mock_send):
|
||||
from ..lib.mail import send_auction_outbid_email
|
||||
from types import SimpleNamespace
|
||||
|
||||
product = SimpleNamespace(title="Treasure")
|
||||
auction = SimpleNamespace(
|
||||
uuid_str="abc-123",
|
||||
product=product,
|
||||
current_high=42.50,
|
||||
)
|
||||
request = mock.Mock()
|
||||
request.host_url = "https://shop.example"
|
||||
|
||||
send_auction_outbid_email(request, "loser@example.com", auction)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
args, kwargs = mock_send.call_args
|
||||
# Args: request, to_email, subject, text, html.
|
||||
self.assertEqual(args[1], "loser@example.com")
|
||||
self.assertIn("outbid", args[2].lower())
|
||||
self.assertIn("Treasure", args[2])
|
||||
self.assertIn("42.50", args[3])
|
||||
self.assertIn("/a/abc-123", args[4])
|
||||
|
||||
@mock.patch("make_post_sell.lib.mail.send_pyramid_email")
|
||||
def test_send_offer_received_email(self, mock_send):
|
||||
from ..lib.mail import send_offer_received_email
|
||||
from types import SimpleNamespace
|
||||
|
||||
product = SimpleNamespace(title="Item X")
|
||||
offer = SimpleNamespace(
|
||||
uuid_str="off-456",
|
||||
product=product,
|
||||
current_amount=85.00,
|
||||
)
|
||||
request = mock.Mock()
|
||||
request.host_url = "https://shop.example"
|
||||
|
||||
send_offer_received_email(request, "seller@example.com", offer)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
args, _ = mock_send.call_args
|
||||
self.assertEqual(args[1], "seller@example.com")
|
||||
self.assertIn("New offer", args[2])
|
||||
self.assertIn("Item X", args[2])
|
||||
self.assertIn("85.00", args[3])
|
||||
self.assertIn("/o/off-456", args[4])
|
||||
|
||||
@mock.patch("make_post_sell.lib.mail.send_pyramid_email")
|
||||
def test_send_offer_accepted_email(self, mock_send):
|
||||
from ..lib.mail import send_offer_accepted_email
|
||||
from types import SimpleNamespace
|
||||
|
||||
product = SimpleNamespace(title="Negotiated")
|
||||
offer = SimpleNamespace(
|
||||
uuid_str="off-789",
|
||||
product=product,
|
||||
current_amount=120.00,
|
||||
)
|
||||
request = mock.Mock()
|
||||
request.host_url = "https://shop.example"
|
||||
|
||||
send_offer_accepted_email(request, "buyer@example.com", offer)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
args, _ = mock_send.call_args
|
||||
self.assertEqual(args[1], "buyer@example.com")
|
||||
self.assertIn("accepted", args[2].lower())
|
||||
self.assertIn("120.00", args[3])
|
||||
self.assertIn("/o/off-789", args[4])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ The pure logic in lib/auction.py does not have visibility into who owns
|
|||
which shop.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||
from pyramid.view import view_config
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ from ..lib.auction import (
|
|||
is_within_soft_close,
|
||||
extended_end_timestamp,
|
||||
)
|
||||
from ..lib.mail import send_auction_outbid_email
|
||||
from ..lib.currency import cents_to_dollars
|
||||
from ..models.auction import (
|
||||
AUCTION_STATE_ACTIVE,
|
||||
|
|
@ -138,6 +141,13 @@ def auction_bid(request):
|
|||
request.response.status_int = 400
|
||||
return {"error": "invalid max_proxy"}
|
||||
|
||||
# Capture the prior winning bidder before place_bid mutates state,
|
||||
# so we know who to notify if they got outbid.
|
||||
prior_winning = (
|
||||
auction.bids.filter_by(is_winning=True).one_or_none()
|
||||
)
|
||||
prior_bidder = prior_winning.bidder if prior_winning else None
|
||||
|
||||
try:
|
||||
bid = place_bid(
|
||||
auction=auction,
|
||||
|
|
@ -149,6 +159,20 @@ def auction_bid(request):
|
|||
request.response.status_int = 400
|
||||
return {"error": str(e)}
|
||||
|
||||
# If the new bid took the lead and there was a different prior bidder,
|
||||
# send them an outbid email. Email failure must not break the bid
|
||||
# response — the bid is already persisted.
|
||||
if (
|
||||
bid.is_winning
|
||||
and prior_bidder is not None
|
||||
and prior_bidder != request.user
|
||||
):
|
||||
try:
|
||||
send_auction_outbid_email(request, prior_bidder.email, auction)
|
||||
except Exception:
|
||||
log = logging.getLogger(__name__)
|
||||
log.exception("auction outbid email failed (non-fatal)")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"bid_amount_in_cents": bid.amount_in_cents,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ or third-party access.
|
|||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||
from pyramid.view import view_config
|
||||
|
||||
import logging
|
||||
|
||||
from ..lib.offer import (
|
||||
OfferRejected,
|
||||
open_offer,
|
||||
|
|
@ -25,6 +27,10 @@ from ..lib.offer import (
|
|||
decline_offer,
|
||||
withdraw_offer,
|
||||
)
|
||||
from ..lib.mail import (
|
||||
send_offer_received_email,
|
||||
send_offer_accepted_email,
|
||||
)
|
||||
from ..lib.currency import cents_to_dollars
|
||||
from ..models.offer import (
|
||||
OFFER_PARTY_BUYER,
|
||||
|
|
@ -133,6 +139,20 @@ def offer_open(request):
|
|||
request.response.status_int = 400
|
||||
return {"error": str(e)}
|
||||
|
||||
# Send email notification — pending offers go to seller; auto-accept
|
||||
# offers go to buyer (so they know to pay). Auto-decline offers are
|
||||
# silent — seller never sees the lowball, buyer learns through the JSON.
|
||||
try:
|
||||
if offer.is_pending:
|
||||
for owner in product.shop.owners:
|
||||
send_offer_received_email(request, owner.email, offer)
|
||||
elif offer.is_accepted:
|
||||
send_offer_accepted_email(request, offer.buyer.email, offer)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception(
|
||||
"offer open email failed (non-fatal)"
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"offer_id": offer.uuid_str,
|
||||
|
|
@ -215,7 +235,24 @@ def offer_counter(request):
|
|||
@view_config(route_name="offer_accept", request_method="POST", renderer="json")
|
||||
@user_required(flash_msg="Please log in to act on this offer.")
|
||||
def offer_accept(request):
|
||||
return _offer_action(request, accept_offer)
|
||||
result = _offer_action(request, accept_offer)
|
||||
# On success, email the buyer that their offer was accepted (so they
|
||||
# know to pay). If the buyer themselves accepted a counter from the
|
||||
# seller, the seller may eventually want a "buyer accepted" email —
|
||||
# that's a follow-up; for now buyer-side notification covers the
|
||||
# primary value (close the loop to payment).
|
||||
if isinstance(result, dict) and result.get("ok"):
|
||||
offer = get_offer_by_id(
|
||||
request.dbsession, request.matchdict["offer_id"]
|
||||
)
|
||||
if offer is not None and offer.is_accepted:
|
||||
try:
|
||||
send_offer_accepted_email(request, offer.buyer.email, offer)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception(
|
||||
"offer accepted email failed (non-fatal)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@view_config(route_name="offer_decline", request_method="POST", renderer="json")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue