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:
parent
9dc6b7be81
commit
236763b329
13 changed files with 766 additions and 24 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ CLASS_TO_TABLE = {
|
|||
"MpsOfferEvent": "mps_offer_event",
|
||||
"MpsCartAuction": "mps_cart_auction",
|
||||
"MpsCartOffer": "mps_cart_offer",
|
||||
"MpsNotification": "mps_notification",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
200
make_post_sell/models/notification.py
Normal file
200
make_post_sell/models/notification.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
|
|
|||
63
make_post_sell/templates/user_notifications.j2
Normal file
63
make_post_sell/templates/user_notifications.j2
Normal 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 %} › {% 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 %}
|
||||
|
|
@ -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/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue