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,
|
SALE_1_HTML,
|
||||||
INVITE_1_TEXT,
|
INVITE_1_TEXT,
|
||||||
INVITE_1_HTML,
|
INVITE_1_HTML,
|
||||||
|
AUCTION_OUTBID_TEXT,
|
||||||
|
AUCTION_OUTBID_HTML,
|
||||||
|
OFFER_RECEIVED_TEXT,
|
||||||
|
OFFER_RECEIVED_HTML,
|
||||||
|
OFFER_ACCEPTED_TEXT,
|
||||||
|
OFFER_ACCEPTED_HTML,
|
||||||
)
|
)
|
||||||
|
|
||||||
import dkim
|
import dkim
|
||||||
|
|
@ -578,3 +584,56 @@ def send_gift_card_email(request, gift_card):
|
||||||
message_html = "\n".join(html_parts)
|
message_html = "\n".join(html_parts)
|
||||||
|
|
||||||
send_pyramid_email(request, to_email, subject, message_text, message_html)
|
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>
|
</body>
|
||||||
</html>
|
</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.assertIn("/o/", res.location)
|
||||||
self.assertNotIn("/cart", 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):
|
def test_pending_offer_checkout_blocked(self):
|
||||||
from ..models.offer import (
|
from ..models.offer import (
|
||||||
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
|
MpsOffer, OFFER_STATE_PENDING, now_timestamp,
|
||||||
|
|
|
||||||
|
|
@ -4798,3 +4798,77 @@ class TestAuctionTickPureFunctions(unittest.TestCase):
|
||||||
self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
|
self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
|
||||||
a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
|
a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
|
||||||
self.assertFalse(transition_active_to_ended(a4, now_ms=2000))
|
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.
|
which shop.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||||
from pyramid.view import view_config
|
from pyramid.view import view_config
|
||||||
|
|
||||||
|
|
@ -21,6 +23,7 @@ from ..lib.auction import (
|
||||||
is_within_soft_close,
|
is_within_soft_close,
|
||||||
extended_end_timestamp,
|
extended_end_timestamp,
|
||||||
)
|
)
|
||||||
|
from ..lib.mail import send_auction_outbid_email
|
||||||
from ..lib.currency import cents_to_dollars
|
from ..lib.currency import cents_to_dollars
|
||||||
from ..models.auction import (
|
from ..models.auction import (
|
||||||
AUCTION_STATE_ACTIVE,
|
AUCTION_STATE_ACTIVE,
|
||||||
|
|
@ -138,6 +141,13 @@ def auction_bid(request):
|
||||||
request.response.status_int = 400
|
request.response.status_int = 400
|
||||||
return {"error": "invalid max_proxy"}
|
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:
|
try:
|
||||||
bid = place_bid(
|
bid = place_bid(
|
||||||
auction=auction,
|
auction=auction,
|
||||||
|
|
@ -149,6 +159,20 @@ def auction_bid(request):
|
||||||
request.response.status_int = 400
|
request.response.status_int = 400
|
||||||
return {"error": str(e)}
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"bid_amount_in_cents": bid.amount_in_cents,
|
"bid_amount_in_cents": bid.amount_in_cents,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ or third-party access.
|
||||||
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
from pyramid.httpexceptions import HTTPFound, HTTPNotFound
|
||||||
from pyramid.view import view_config
|
from pyramid.view import view_config
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from ..lib.offer import (
|
from ..lib.offer import (
|
||||||
OfferRejected,
|
OfferRejected,
|
||||||
open_offer,
|
open_offer,
|
||||||
|
|
@ -25,6 +27,10 @@ from ..lib.offer import (
|
||||||
decline_offer,
|
decline_offer,
|
||||||
withdraw_offer,
|
withdraw_offer,
|
||||||
)
|
)
|
||||||
|
from ..lib.mail import (
|
||||||
|
send_offer_received_email,
|
||||||
|
send_offer_accepted_email,
|
||||||
|
)
|
||||||
from ..lib.currency import cents_to_dollars
|
from ..lib.currency import cents_to_dollars
|
||||||
from ..models.offer import (
|
from ..models.offer import (
|
||||||
OFFER_PARTY_BUYER,
|
OFFER_PARTY_BUYER,
|
||||||
|
|
@ -133,6 +139,20 @@ def offer_open(request):
|
||||||
request.response.status_int = 400
|
request.response.status_int = 400
|
||||||
return {"error": str(e)}
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"offer_id": offer.uuid_str,
|
"offer_id": offer.uuid_str,
|
||||||
|
|
@ -215,7 +235,24 @@ def offer_counter(request):
|
||||||
@view_config(route_name="offer_accept", request_method="POST", renderer="json")
|
@view_config(route_name="offer_accept", request_method="POST", renderer="json")
|
||||||
@user_required(flash_msg="Please log in to act on this offer.")
|
@user_required(flash_msg="Please log in to act on this offer.")
|
||||||
def offer_accept(request):
|
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")
|
@view_config(route_name="offer_decline", request_method="POST", renderer="json")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue