feat: in-app notifications with badge, list page, and breadcrumb backlinks

Every transactional email on the offer state machine now also drops a
row in a new mps_notification table — the user has a permanent in-app
inbox even if they never opened the email.

Schema (mps_notification, migration 5d01b163b805):
- user_id           recipient
- shop_id           which shop this is about (nullable)
- kind              discriminator (offer_received, offer_accepted,
                    offer_countered, offer_declined, offer_withdrawn,
                    offer_buyer_cancelled, purchase, sale,
                    auction_outbid)
- subject, body     denormalized text so deleting the source entity
                    doesn't blank the row
- link_url          primary click-through (/o/<id>, /a/<id>, /i/<id>)
- offer_id, auction_id, invoice_id
                    optional FKs for breadcrumb rendering
- created/updated_timestamp
- read              bool, drives the unread badge
- read_timestamp
Composite index on (user_id, read, created_timestamp) for cheap
unread-count queries.

Surfaces:
- request.unread_notification_count (reified) drives a navbar badge
  next to the profile name and a duplicate badge on the /u/settings
  "Notifications" button.
- /u/notifications lists rows newest-first with the kind label, time
  delta (ago.human), subject/body, and a breadcrumb chain back to
  source entities. MpsNotification.breadcrumbs walks shop → product
  → offer/auction/invoice for any combination of attached FKs.
- /u/notifications/{id}/read marks a single row read; bulk
  "Mark all read" on the list page hits /u/notifications/read-all.

Wired into every offer state transition (open, counter, accept,
decline, withdraw, buyer-cancel) alongside the existing email sends.
_safe_email and notification persist are now decoupled — SMTP outages
no longer block notification creation. (This was the regression the
new TestNotifications suite caught: pre-fix, a refused SMTP swallowed
the notification persist call too.)

Tokenized CSS for the badge (.notification-badge pill, danger color)
and the list rows (.notification-row, .notification-row-unread with
left accent border, .notification-row-breadcrumbs trail). Grid only,
no flex, no inline styles.

Tests TestNotifications.test_offer_open_drops_received_notification_for_seller
and test_badge_count_and_mark_read drive the full flow: buyer opens
offer → seller's row exists with breadcrumbs (Shop → Product → Offer)
→ /u/settings badge renders → /u/notifications/read-all clears.
This commit is contained in:
russell@unturf.com 2026-05-14 09:54:24 -04:00
parent 9dc6b7be81
commit 236763b329
No known key found for this signature in database
13 changed files with 766 additions and 24 deletions

View file

@ -42,6 +42,7 @@ from .auction import *
from .offer import *
from .cart_auction import *
from .cart_offer import *
from .notification import *
# run configure_mappers after defining all of the models
# to ensure all relationships can be setup.

View file

@ -54,6 +54,7 @@ CLASS_TO_TABLE = {
"MpsOfferEvent": "mps_offer_event",
"MpsCartAuction": "mps_cart_auction",
"MpsCartOffer": "mps_cart_offer",
"MpsNotification": "mps_notification",
}

View file

@ -0,0 +1,200 @@
"""MpsNotification — in-app notifications surfaced as a badge near the
user's profile name and a list on /u/notifications.
One row per (recipient user × event). Created alongside the existing
transactional email sends — every email_offer_* / send_purchase /
send_sale call site also drops a notification so the user can find
it later in-app even if they never opened the email.
Schema is lighter than Remarkbox's (no separate Watcher table) because
recipients are implicit per kind: offer-received → shop owners,
offer-accepted → buyer, etc. The kind column carries the discriminator.
"""
import uuid
from sqlalchemy import Column, BigInteger, Boolean, Unicode, UnicodeText, Index
from sqlalchemy.orm import relationship
from .meta import (
Base,
RBase,
UUIDType,
now_timestamp,
foreign_key,
get_object_by_id,
)
# Notification kinds — keep these strings stable; they appear in
# rendered email subjects and the per-row icon picker in the UI.
NOTIFICATION_KIND_OFFER_RECEIVED = "offer_received"
NOTIFICATION_KIND_OFFER_ACCEPTED = "offer_accepted"
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_PURCHASE = "purchase"
NOTIFICATION_KIND_SALE = "sale"
NOTIFICATION_KIND_AUCTION_OUTBID = "auction_outbid"
class MpsNotification(RBase, Base):
id = Column(UUIDType, primary_key=True, index=True)
# Recipient.
user_id = Column(
UUIDType, foreign_key("User", "id"), nullable=False, index=True,
)
# The shop this notification is about (nullable for cross-shop
# cases). Useful for filtering "notifications for shop X" or
# rendering the shop name in the row.
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=True)
# Discriminator — one of the NOTIFICATION_KIND_* constants.
kind = Column(Unicode(64), nullable=False)
# Short headline and one-line body shown in the list. Kept in the
# row (not regenerated from FKs) so deleting the underlying offer
# / auction / invoice doesn't blank the notification history.
subject = Column(Unicode(256), nullable=False)
body = Column(UnicodeText, nullable=True)
# Click-through target. Relative path (e.g. "/o/<id>").
link_url = Column(Unicode(512), nullable=True)
# Optional FK back-references for breadcrumb rendering. All nullable —
# not every notification kind has all four. Set whichever applies
# at create time. Templates walk these to surface "shop → product →
# offer/auction/invoice" trail back to the source.
offer_id = Column(UUIDType, foreign_key("MpsOffer", "id"), nullable=True)
auction_id = Column(
UUIDType, foreign_key("MpsAuction", "id"), nullable=True,
)
invoice_id = Column(UUIDType, foreign_key("Invoice", "id"), nullable=True)
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)
# Read state. read=False rows count toward the unread badge.
read = Column(Boolean, nullable=False, default=False, server_default="0")
read_timestamp = Column(BigInteger, nullable=True)
user = relationship(argument="User", uselist=False, lazy="joined")
shop = relationship(argument="Shop", uselist=False, lazy="joined")
offer = relationship(argument="MpsOffer", uselist=False, lazy="joined")
auction = relationship(argument="MpsAuction", uselist=False, lazy="joined")
invoice = relationship(argument="Invoice", uselist=False, lazy="joined")
__table_args__ = (
Index(
"ix_mps_notification_user_read_created",
"user_id", "read", "created_timestamp",
),
)
def __init__(
self,
user,
kind,
subject,
body=None,
link_url=None,
shop=None,
offer=None,
auction=None,
invoice=None,
):
self.id = uuid.uuid1()
self.user = user
self.kind = kind
self.subject = subject
self.body = body
self.link_url = link_url
self.shop = shop
self.offer = offer
self.auction = auction
self.invoice = invoice
ts = now_timestamp()
self.created_timestamp = ts
self.updated_timestamp = ts
self.read = False
@property
def breadcrumbs(self):
"""Render an ordered list of (label, url) tuples that walk back
from this notification to its source entities. Templates iterate
this for the "Shop → Product → Offer" trail under each row.
Each step is optional — only the entities that are still alive
and have a usable URL show up.
"""
crumbs = []
if self.shop is not None:
crumbs.append((self.shop.name, f"/s/{self.shop.uuid_str}"))
# Product comes from offer/auction directly, or from the first
# invoice line item for purchase/sale notifications.
product = None
if self.offer is not None and self.offer.product is not None:
product = self.offer.product
elif self.auction is not None and self.auction.product is not None:
product = self.auction.product
elif self.invoice is not None:
try:
first_item = self.invoice.line_items.first()
if first_item is not None and first_item.product is not None:
product = first_item.product
except Exception:
pass
if product is not None:
crumbs.append((product.title, f"/p/{product.uuid_str}"))
if self.offer is not None:
crumbs.append(("Offer", f"/o/{self.offer.uuid_str}"))
if self.auction is not None:
crumbs.append(("Auction", f"/a/{self.auction.uuid_str}"))
if self.invoice is not None:
crumbs.append(("Invoice", f"/i/{self.invoice.uuid_str}"))
return crumbs
def mark_read(self, now_ms=None):
"""Idempotent — already-read rows are left alone."""
if self.read:
return
ts = now_ms or now_timestamp()
self.read = True
self.read_timestamp = ts
self.updated_timestamp = ts
def get_notification_by_id(dbsession, notification_id):
return get_object_by_id(dbsession, notification_id, MpsNotification)
def count_unread_notifications(dbsession, user):
"""Cheap unread-count for the profile badge — no rows fetched."""
if user is None:
return 0
return (
dbsession.query(MpsNotification)
.filter(
MpsNotification.user_id == user.id,
MpsNotification.read == False, # noqa: E712 — SQLAlchemy idiom
)
.count()
)
def get_notifications_for_user(
dbsession, user, limit=50, offset=0, unread_only=False,
):
"""Return the user's notifications, newest first."""
q = (
dbsession.query(MpsNotification)
.filter(MpsNotification.user_id == user.id)
.order_by(MpsNotification.created_timestamp.desc())
)
if unread_only:
q = q.filter(MpsNotification.read == False) # noqa: E712
return q.limit(limit).offset(offset).all()

View file

@ -523,3 +523,18 @@ def includeme(config):
request.session.flash((message, level), queue=queue)
config.add_request_method(flash_once, "flash_once")
def add_unread_notification_count(request):
"""In-app unread-notification count for the navbar badge.
Returns 0 for anonymous users."""
from .models.notification import count_unread_notifications
if request.user is None:
return 0
return count_unread_notifications(request.dbsession, request.user)
config.add_request_method(
add_unread_notification_count,
"unread_notification_count",
reify=True,
)

View file

@ -65,6 +65,9 @@ def includeme(config):
config.add_route("user_purchases", "/u/purchases")
config.add_route("user_offers", "/u/offers")
config.add_route("user_bids", "/u/bids")
config.add_route("user_notifications", "/u/notifications")
config.add_route("user_notification_read", "/u/notifications/{notification_id}/read")
config.add_route("user_notifications_read_all", "/u/notifications/read-all")
config.add_route("user_addresses", "/u/addresses")
config.add_route("user_address_save", "/u/addresses/save")
config.add_route("user_address_delete", "/u/addresses/{address_id}/delete")

View file

@ -0,0 +1,85 @@
"""mps_notification table
Revision ID: 5d01b163b805
Revises: 27bc1bfc33dc
Create Date: 2026-05-14 09:44:13.684534
Adds the mps_notification table — one row per in-app notification.
Created alongside each transactional email send (offer events, auction
outbid, purchase, sale) so the user has a permanent in-app inbox
that doesn't depend on whether they opened the email.
"""
from alembic import op
import sqlalchemy as sa
revision = "5d01b163b805"
down_revision = "27bc1bfc33dc"
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
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 upgrade():
if _table_exists("mps_notification"):
return
op.create_table(
"mps_notification",
sa.Column("id", UUIDType, primary_key=True, index=True),
sa.Column("user_id", UUIDType, sa.ForeignKey("mps_user.id"), nullable=False),
sa.Column("shop_id", UUIDType, sa.ForeignKey("mps_shop.id"), nullable=True),
sa.Column("kind", sa.Unicode(64), nullable=False),
sa.Column("subject", sa.Unicode(256), nullable=False),
sa.Column("body", sa.UnicodeText(), nullable=True),
sa.Column("link_url", sa.Unicode(512), nullable=True),
sa.Column(
"offer_id", UUIDType, sa.ForeignKey("mps_offer.id"), nullable=True,
),
sa.Column(
"auction_id", UUIDType, sa.ForeignKey("mps_auction.id"), nullable=True,
),
sa.Column(
"invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=True,
),
sa.Column("created_timestamp", sa.BigInteger(), nullable=False),
sa.Column("updated_timestamp", sa.BigInteger(), nullable=False),
sa.Column(
"read",
sa.Boolean(),
nullable=False,
server_default="0",
),
sa.Column("read_timestamp", sa.BigInteger(), nullable=True),
)
op.create_index(
"ix_mps_notification_user_id",
"mps_notification",
["user_id"],
)
op.create_index(
"ix_mps_notification_user_read_created",
"mps_notification",
["user_id", "read", "created_timestamp"],
)
def downgrade():
if not _table_exists("mps_notification"):
return
op.drop_index(
"ix_mps_notification_user_read_created",
table_name="mps_notification",
)
op.drop_index("ix_mps_notification_user_id", table_name="mps_notification")
op.drop_table("mps_notification")

View file

@ -3253,6 +3253,86 @@ img.crypto-button-icon {
}
}
/* Notification badge near the profile name + the /u/notifications list. */
.notification-badge {
display: inline-grid;
place-items: center;
min-width: 1.5em;
padding: 0 var(--space-2, 8px);
margin-left: var(--space-1, 4px);
background: var(--color-danger, #CC6958);
color: var(--text-inverse, #fff);
border-radius: var(--radius-full, 9999px);
font-size: var(--text-xs, 0.75rem);
font-weight: var(--weight-bold, 700);
line-height: 1.6;
}
.notifications-page .notifications-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-4, 16px);
}
.notification-row {
display: grid;
gap: var(--space-1, 4px);
padding: var(--space-3, 12px);
border-radius: var(--radius-md, 8px);
background: var(--surface-dim, #f9f9fa);
}
.notification-row-unread {
background: var(--alert-info-bg, #dce8ff);
border-left: 4px solid var(--color-navy, #5871ad);
}
.notification-row-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--space-2, 8px);
}
.notification-row-kind {
font-size: var(--text-xs, 0.75rem);
font-weight: var(--weight-bold, 700);
text-transform: uppercase;
letter-spacing: var(--tracking-wider, 0.05em);
color: var(--text-secondary, #666);
}
.notification-row-when {
font-size: var(--text-xs, 0.75rem);
color: var(--text-faint, #999);
font-variant-numeric: tabular-nums;
}
.notification-row-subject {
font-weight: var(--weight-bold, 700);
text-decoration: none;
}
.notification-row-body {
margin: 0;
color: var(--text-body, #515151);
font-size: var(--text-sm, 0.875rem);
}
.notification-row-breadcrumbs {
margin-top: var(--space-1, 4px);
font-size: var(--text-xs, 0.75rem);
color: var(--text-secondary, #666);
}
.notification-row-dismiss-form {
margin-top: var(--space-1, 4px);
justify-self: end;
}
.product-add-disabled-note {
margin: var(--space-2, 8px) 0 0 0;
font-size: var(--text-sm, 0.875rem);

View file

@ -142,9 +142,9 @@
{% if request.user and request.user.authenticated %}
{% if request.user.full_name %}
<a href="/u/settings" class="user-display-name">{{ request.user.name }}</a>
<a href="/u/settings" class="user-display-name">{{ request.user.name }}{% if request.unread_notification_count %} <span class="notification-badge" title="{{ request.unread_notification_count }} unread notification{% if request.unread_notification_count != 1 %}s{% endif %}">{{ request.unread_notification_count }}</span>{% endif %}</a>
{% else %}
<a href="/u/settings" class="user-display-name">My Account</a>
<a href="/u/settings" class="user-display-name">My Account{% if request.unread_notification_count %} <span class="notification-badge" title="{{ request.unread_notification_count }} unread notification{% if request.unread_notification_count != 1 %}s{% endif %}">{{ request.unread_notification_count }}</span>{% endif %}</a>
{% endif %}

View file

@ -0,0 +1,63 @@
{% extends "base.j2" -%}
{%- block append_to_head_tag_section %}
<title>Notifications</title>
{%- endblock %}
{% block content %}
<section class="one-column shop-offers-page notifications-page">
<div class="well">
<h1 class="type-title">Notifications</h1>
{% if notifications %}
{% set unread_count = notifications|selectattr('read', 'equalto', false)|list|length %}
<p class="profile-meta">
{% if unread_count %}{{ unread_count }} unread of {{ notifications|length }} total.{% else %}All caught up.{% endif %}
</p>
{% if unread_count %}
<form method="post" action="/u/notifications/read-all" class="cart-inline-form">
{% include "snippets/csrf.j2" %}
<button type="submit" class="mps-button mps-button-small">Mark all read</button>
</form>
{% endif %}
{% else %}
<p class="profile-meta">No notifications yet.</p>
{% endif %}
</div>
{% if notifications %}
<div class="well">
<ul class="notifications-list">
{%- for n in notifications %}
<li class="notification-row {% if not n.read %}notification-row-unread{% endif %}">
<div class="notification-row-header">
<span class="notification-row-kind notification-row-kind-{{ n.kind }}">{{ n.kind|replace('_', ' ') }}</span>
<span class="notification-row-when">{{ human(n.created_timestamp) }}</span>
</div>
<a href="{{ n.link_url or '#' }}" class="notification-row-subject shop-theme-link-color">{{ n.subject }}</a>
{% if n.body %}
<p class="notification-row-body">{{ n.body }}</p>
{% endif %}
{% set crumbs = n.breadcrumbs %}
{% if crumbs %}
<nav class="notification-row-breadcrumbs" aria-label="Source links">
{% for label, url in crumbs %}
<a href="{{ url }}" class="shop-theme-link-color">{{ label }}</a>{% if not loop.last %} &rsaquo; {% endif %}
{% endfor %}
</nav>
{% endif %}
{% if not n.read %}
<form method="post" action="/u/notifications/{{ n.uuid_str }}/read" class="notification-row-dismiss-form cart-inline-form">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="next" value="/u/notifications" />
<button type="submit" class="cart-remove-link">Mark read</button>
</form>
{% endif %}
</li>
{%- endfor %}
</ul>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -185,6 +185,10 @@
<br/>
{% endif %}
<a href="/u/notifications" class="product-download-button mps-button">Notifications{% if request.unread_notification_count %} <span class="notification-badge">{{ request.unread_notification_count }}</span>{% endif %}</a>
<br/>
<br/>
{% if request.shop and request.shop.is_ready_for_payment(request) %}
<a href="/u/purchases" class="product-download-button mps-button">My Purchases</a>
<br/>

View file

@ -7459,6 +7459,119 @@ class TestBuyNowGating(_AuthenticatedBase):
self.assertNotIn(b"sold-out-button", res.body)
class TestNotifications(_AuthenticatedBase):
"""MPS notifications — in-app inbox alongside transactional email.
Every offer state transition drops a notification row for the
appropriate recipient; the navbar badge surfaces the unread count;
/u/notifications lists them with breadcrumb backlinks.
"""
def _offer_product(self, list_price=10000):
from ..models.product import Product
shop = self._create_shop_helper(user_creds=self.user1_creds)
shop.offer_enabled = True
product = Product(title="Notify item", description="...")
product.shop = shop
product.price_in_cents = list_price
product.is_physical = False
product.is_sellable = True
product.pricing_mode = 3
self.dbsession.add(product)
self.dbsession.flush()
pid = product.uuid_str
transaction.commit()
return pid
def test_offer_open_drops_received_notification_for_seller(self):
"""Buyer opens an offer → seller's notification inbox gets a
'offer_received' row with breadcrumb back to shop/product/offer."""
from ..models.notification import (
MpsNotification, NOTIFICATION_KIND_OFFER_RECEIVED,
)
from ..models.user import get_user_by_email
product_id = self._offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
# 60% offer → PENDING (below auto-accept 95%, above auto-decline 50%).
csrf_token = self.get_csrf_token(
self.dbsession.query(__import__("make_post_sell.models.shop", fromlist=["Shop"]).Shop).first().uuid_str
)
self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "60.00", "csrf_token": csrf_token},
)
# Seller sees one offer_received notification.
seller = get_user_by_email(self.dbsession, self.user1_creds[0])
rows = (
self.dbsession.query(MpsNotification)
.filter(MpsNotification.user_id == seller.id)
.all()
)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].kind, NOTIFICATION_KIND_OFFER_RECEIVED)
self.assertFalse(rows[0].read)
# Breadcrumb chain back to the source.
crumb_labels = [label for label, _url in rows[0].breadcrumbs]
self.assertIn("Offer", crumb_labels)
self.assertIn("Notify item", crumb_labels)
def test_badge_count_and_mark_read(self):
"""request.unread_notification_count fires the navbar badge,
and /u/notifications/<id>/read clears the row."""
from ..models.notification import MpsNotification
from ..models.user import get_user_by_email
product_id = self._offer_product(list_price=10000)
self.testapp.get("/log-out")
self.log_in_user(self.user2_creds)
csrf_token = self.get_csrf_token(
self.dbsession.query(__import__("make_post_sell.models.shop", fromlist=["Shop"]).Shop).first().uuid_str
)
self.testapp.post(
f"/p/{product_id}/offer",
{"amount": "60.00", "csrf_token": csrf_token},
)
# Seller (user1) sees the badge.
self.testapp.get("/log-out")
self.log_in_user(self.user1_creds)
settings_body = self.testapp.get("/u/settings", status=200).body.decode()
self.assertIn("notification-badge", settings_body)
seller = get_user_by_email(self.dbsession, self.user1_creds[0])
unread_before = (
self.dbsession.query(MpsNotification)
.filter(MpsNotification.user_id == seller.id, MpsNotification.read == False)
.count()
)
self.assertEqual(unread_before, 1)
# Mark all read via /u/notifications/read-all.
nots_body = self.testapp.get("/u/notifications", status=200).body.decode()
self.assertIn("Notifications", nots_body)
self.assertIn("offer received", nots_body)
# Breadcrumb anchor surfaced.
self.assertIn("Notify item", nots_body)
csrf_token = self.get_csrf_token(
self.dbsession.query(__import__("make_post_sell.models.shop", fromlist=["Shop"]).Shop).first().uuid_str
)
self.testapp.post(
"/u/notifications/read-all",
{"csrf_token": csrf_token},
status=302,
)
unread_after = (
self.dbsession.query(MpsNotification)
.filter(MpsNotification.user_id == seller.id, MpsNotification.read == False)
.count()
)
self.assertEqual(unread_after, 0)
class TestUserOffersBidsDashboards(_AuthenticatedBase):
"""MPS-20 + MPS-21: buyer-side /u/offers and /u/bids pages plus the
button gating in /u/settings. Each dashboard scopes to request.shop

View file

@ -184,18 +184,44 @@ def offer_open(request):
request.session.flash((str(e), "error"))
return HTTPFound(product_url)
# 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.
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)"
# Send email + drop in-app notification on the resolved state.
# Email and notification are independent — SMTP failures must not
# block notification persistence (or vice-versa).
from ..models.notification import (
NOTIFICATION_KIND_OFFER_RECEIVED,
NOTIFICATION_KIND_OFFER_ACCEPTED,
)
if offer.is_pending:
owners = list(product.shop.owners)
for owner in owners:
_safe_email(
"offer received",
lambda em=owner.email: send_offer_received_email(
request, em, offer
),
)
_notify_offer_event(
request,
NOTIFICATION_KIND_OFFER_RECEIVED,
offer,
subject=f'New offer for "{offer.product.title}"',
body=f'${offer.current_amount:.2f} from {offer.buyer.display_name}',
recipients=owners,
)
elif offer.is_accepted:
_safe_email(
"offer accepted",
lambda: send_offer_accepted_email(
request, offer.buyer.email, offer
),
)
_notify_offer_event(
request,
NOTIFICATION_KIND_OFFER_ACCEPTED,
offer,
subject=f'Your offer for "{offer.product.title}" was accepted',
body=f'Pay ${offer.current_amount:.2f} to complete the purchase.',
recipients=[offer.buyer],
)
# Flash message tuned to the offer's resolved state.
@ -401,6 +427,42 @@ def _shop_owner_emails(offer):
return {o.email for o in offer.shop.owners if o.email}
def _drop_offer_notification(request, recipient, kind, offer, subject, body):
"""Persist an in-app notification row alongside an email send.
Each transactional offer email also drops a notification so the
recipient has an in-app inbox even if they never opened the email.
Non-fatal — DB problems are logged but don't 500 the request.
"""
from ..models.notification import MpsNotification
try:
request.dbsession.add(
MpsNotification(
user=recipient,
kind=kind,
subject=subject,
body=body,
link_url=f"/o/{offer.uuid_str}",
shop=offer.shop,
offer=offer,
)
)
except Exception:
logging.getLogger(__name__).exception(
"notification persist failed (non-fatal)"
)
def _notify_offer_event(request, kind, offer, subject, body, recipients):
"""Helper: drop one notification per recipient User."""
for recipient in recipients:
if recipient is None:
continue
_drop_offer_notification(
request, recipient, kind, offer, subject, body,
)
@view_config(route_name="offer_counter", request_method="POST", renderer="json")
@user_required(flash_msg="Please log in to act on this offer.")
def offer_counter(request):
@ -421,18 +483,25 @@ def offer_counter(request):
and offer.round_count > rounds_before
and actor_party_before is not None
):
# Whoever wasn't the actor needs the email. counter_offer
# flipped current_party to the OTHER side, but the simpler
# path is: actor != buyer → notify buyer; actor == buyer →
# notify shop owners.
from ..models.notification import NOTIFICATION_KIND_OFFER_COUNTERED
subject = f'Counter offer on "{offer.product.title}"'
body = f'Now at ${offer.current_amount:.2f}.'
# Whoever wasn't the actor needs the email + notification.
if actor_party_before == OFFER_PARTY_BUYER:
for owner_email in _shop_owner_emails(offer):
owners = list(offer.shop.owners)
for owner in owners:
_safe_email(
"offer counter",
lambda em=owner_email: send_offer_countered_email(
lambda em=owner.email: send_offer_countered_email(
request, em, offer
),
)
_notify_offer_event(
request, NOTIFICATION_KIND_OFFER_COUNTERED, offer,
subject=subject, body=body, recipients=owners,
)
else:
_safe_email(
"offer counter",
@ -440,6 +509,10 @@ def offer_counter(request):
request, offer.buyer.email, offer
),
)
_notify_offer_event(
request, NOTIFICATION_KIND_OFFER_COUNTERED, offer,
subject=subject, body=body, recipients=[offer.buyer],
)
return result
@ -457,12 +530,20 @@ def offer_accept(request):
# repeated request against an already-accepted offer).
offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"])
if offer is not None and offer.is_accepted and not was_accepted:
from ..models.notification import NOTIFICATION_KIND_OFFER_ACCEPTED
_safe_email(
"offer accepted",
lambda: send_offer_accepted_email(
request, offer.buyer.email, offer
),
)
_notify_offer_event(
request, NOTIFICATION_KIND_OFFER_ACCEPTED, offer,
subject=f'Your offer for "{offer.product.title}" was accepted',
body=f'Pay ${offer.current_amount:.2f} to complete the purchase.',
recipients=[offer.buyer],
)
return result
@ -489,12 +570,20 @@ def offer_decline(request):
and not was_declined
and actor_party_before == OFFER_PARTY_SELLER
):
from ..models.notification import NOTIFICATION_KIND_OFFER_DECLINED
_safe_email(
"offer declined",
lambda: send_offer_declined_email(
request, offer.buyer.email, offer
),
)
_notify_offer_event(
request, NOTIFICATION_KIND_OFFER_DECLINED, offer,
subject=f'Your offer for "{offer.product.title}" was declined',
body=f'${offer.current_amount:.2f} declined by seller.',
recipients=[offer.buyer],
)
return result
@ -517,13 +606,22 @@ def offer_withdraw(request):
and offer.state == OFFER_STATE_WITHDRAWN
and not was_withdrawn
):
for owner_email in _shop_owner_emails(offer):
from ..models.notification import NOTIFICATION_KIND_OFFER_WITHDRAWN
owners = list(offer.shop.owners)
for owner in owners:
_safe_email(
"offer withdrawn",
lambda em=owner_email: send_offer_withdrawn_email(
lambda em=owner.email: send_offer_withdrawn_email(
request, em, offer
),
)
_notify_offer_event(
request, NOTIFICATION_KIND_OFFER_WITHDRAWN, offer,
subject=f'Offer withdrawn on "{offer.product.title}"',
body=f'${offer.current_amount:.2f} withdrawn by buyer.',
recipients=owners,
)
return result
@ -556,13 +654,24 @@ def offer_cancel_after_accept_view(request):
and offer.state == OFFER_STATE_BUYER_CANCELLED
and not was_cancelled
):
for owner_email in _shop_owner_emails(offer):
from ..models.notification import (
NOTIFICATION_KIND_OFFER_BUYER_CANCELLED,
)
owners = list(offer.shop.owners)
for owner in owners:
_safe_email(
"offer buyer-cancelled",
lambda em=owner_email: send_offer_buyer_cancelled_email(
lambda em=owner.email: send_offer_buyer_cancelled_email(
request, em, offer
),
)
_notify_offer_event(
request, NOTIFICATION_KIND_OFFER_BUYER_CANCELLED, offer,
subject=f'Buyer cancelled accepted offer on "{offer.product.title}"',
body=f'${offer.current_amount:.2f} cancelled — payment not coming.',
recipients=owners,
)
return result

View file

@ -177,6 +177,74 @@ def user_bids(request):
}
@view_config(route_name="user_notifications", renderer="user_notifications.j2")
@user_required()
def user_notifications(request):
"""List all of the current user's notifications, newest first.
Landing on the page does NOT auto-mark-all-read — fox can read at
a glance then explicitly dismiss. The unread badge stays accurate.
"""
from ..models.notification import get_notifications_for_user
rows = get_notifications_for_user(
request.dbsession, request.user, limit=200,
)
import ago
from datetime import datetime, timezone
def _human(ts):
if not ts:
return ""
return ago.human(
datetime.fromtimestamp(ts / 1000, tz=timezone.utc),
precision=1,
past_tense="{} ago",
future_tense="in {}",
)
return {
"the_title": "Notifications",
"notifications": rows,
"human": _human,
}
@view_config(route_name="user_notification_read", request_method="POST")
@user_required()
def user_notification_read(request):
"""Mark a single notification as read. Caller-supplied notification
must belong to the current user."""
from ..models.notification import get_notification_by_id
from pyramid.httpexceptions import HTTPNotFound
n = get_notification_by_id(
request.dbsession, request.matchdict["notification_id"]
)
if n is None or n.user_id != request.user.id:
raise HTTPNotFound()
n.mark_read()
request.dbsession.add(n)
return HTTPFound(
request.params.get("next") or "/u/notifications"
)
@view_config(route_name="user_notifications_read_all", request_method="POST")
@user_required()
def user_notifications_read_all(request):
"""Bulk-dismiss every unread notification for the current user."""
from ..models.notification import get_notifications_for_user
rows = get_notifications_for_user(
request.dbsession, request.user, limit=1000, unread_only=True,
)
for n in rows:
n.mark_read()
request.dbsession.add(n)
return HTTPFound("/u/notifications")
@view_config(route_name="user_settings", renderer="user_settings.j2")
@user_required(
flash_msg="To view your settings, please verify your email address below.",