From 236763b329f1307bf6959a7974edce7067cb7258 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 14 May 2026 09:54:24 -0400 Subject: [PATCH] feat: in-app notifications with badge, list page, and breadcrumb backlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, /a/, /i/) - 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. --- make_post_sell/models/__init__.py | 1 + make_post_sell/models/meta.py | 1 + make_post_sell/models/notification.py | 200 ++++++++++++++++++ make_post_sell/request_methods.py | 15 ++ make_post_sell/routes.py | 3 + .../5d01b163b805_mps_notification_table.py | 85 ++++++++ make_post_sell/static/css/common.css | 80 +++++++ make_post_sell/templates/base.j2 | 4 +- .../templates/user_notifications.j2 | 63 ++++++ make_post_sell/templates/user_settings.j2 | 4 + make_post_sell/tests/test_functional.py | 113 ++++++++++ make_post_sell/views/offer.py | 153 ++++++++++++-- make_post_sell/views/user.py | 68 ++++++ 13 files changed, 766 insertions(+), 24 deletions(-) create mode 100644 make_post_sell/models/notification.py create mode 100644 make_post_sell/scripts/alembic/versions/5d01b163b805_mps_notification_table.py create mode 100644 make_post_sell/templates/user_notifications.j2 diff --git a/make_post_sell/models/__init__.py b/make_post_sell/models/__init__.py index a2e9ecc..ab4fc21 100644 --- a/make_post_sell/models/__init__.py +++ b/make_post_sell/models/__init__.py @@ -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. diff --git a/make_post_sell/models/meta.py b/make_post_sell/models/meta.py index 357f2b2..e09514a 100644 --- a/make_post_sell/models/meta.py +++ b/make_post_sell/models/meta.py @@ -54,6 +54,7 @@ CLASS_TO_TABLE = { "MpsOfferEvent": "mps_offer_event", "MpsCartAuction": "mps_cart_auction", "MpsCartOffer": "mps_cart_offer", + "MpsNotification": "mps_notification", } diff --git a/make_post_sell/models/notification.py b/make_post_sell/models/notification.py new file mode 100644 index 0000000..9b60832 --- /dev/null +++ b/make_post_sell/models/notification.py @@ -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/"). + 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() diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index 557187c..b1c2b19 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -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, + ) diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 3fb4f10..df39362 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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") diff --git a/make_post_sell/scripts/alembic/versions/5d01b163b805_mps_notification_table.py b/make_post_sell/scripts/alembic/versions/5d01b163b805_mps_notification_table.py new file mode 100644 index 0000000..86c1c53 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/5d01b163b805_mps_notification_table.py @@ -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") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 0f9f81b..7acc138 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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); diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index 9620a2f..5af3723 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -142,9 +142,9 @@ {% if request.user and request.user.authenticated %} {% if request.user.full_name %} - {{ request.user.name }} + {{ request.user.name }}{% if request.unread_notification_count %} {{ request.unread_notification_count }}{% endif %} {% else %} - My Account + My Account{% if request.unread_notification_count %} {{ request.unread_notification_count }}{% endif %} {% endif %} diff --git a/make_post_sell/templates/user_notifications.j2 b/make_post_sell/templates/user_notifications.j2 new file mode 100644 index 0000000..86addcc --- /dev/null +++ b/make_post_sell/templates/user_notifications.j2 @@ -0,0 +1,63 @@ +{% extends "base.j2" -%} + +{%- block append_to_head_tag_section %} + Notifications +{%- endblock %} + +{% block content %} +
+ +
+

Notifications

+ {% if notifications %} + {% set unread_count = notifications|selectattr('read', 'equalto', false)|list|length %} +

+ {% if unread_count %}{{ unread_count }} unread of {{ notifications|length }} total.{% else %}All caught up.{% endif %} +

+ {% if unread_count %} +
+ {% include "snippets/csrf.j2" %} + +
+ {% endif %} + {% else %} +

No notifications yet.

+ {% endif %} +
+ + {% if notifications %} +
+
    + {%- for n in notifications %} +
  • +
    + {{ n.kind|replace('_', ' ') }} + {{ human(n.created_timestamp) }} +
    + {{ n.subject }} + {% if n.body %} +

    {{ n.body }}

    + {% endif %} + {% set crumbs = n.breadcrumbs %} + {% if crumbs %} + + {% endif %} + {% if not n.read %} +
    + {% include "snippets/csrf.j2" %} + + +
    + {% endif %} +
  • + {%- endfor %} +
+
+ {% endif %} + +
+{% endblock %} diff --git a/make_post_sell/templates/user_settings.j2 b/make_post_sell/templates/user_settings.j2 index 916098d..753aed9 100644 --- a/make_post_sell/templates/user_settings.j2 +++ b/make_post_sell/templates/user_settings.j2 @@ -185,6 +185,10 @@
{% endif %} + Notifications{% if request.unread_notification_count %} {{ request.unread_notification_count }}{% endif %} +
+
+ {% if request.shop and request.shop.is_ready_for_payment(request) %} My Purchases
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index be2edf5..07e7724 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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//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 diff --git a/make_post_sell/views/offer.py b/make_post_sell/views/offer.py index 8103ca7..d2336b4 100644 --- a/make_post_sell/views/offer.py +++ b/make_post_sell/views/offer.py @@ -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 diff --git a/make_post_sell/views/user.py b/make_post_sell/views/user.py index d186705..aededad 100644 --- a/make_post_sell/views/user.py +++ b/make_post_sell/views/user.py @@ -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.",