fix: dedupe flash messages — render-level + request.flash_once helper

Crypto status-poll endpoints flashed "Payment received! Waiting for
confirmations…" on every refresh while a quote was in the
received-but-not-confirmed state. The flash queue is a list, so
identical alerts piled up — fox's screenshot showed the same line
repeated 18+ times stacked down the page.

Two-layer fix:

1. Render-level dedupe in templates/snippets/flash-alerts.j2.
   Pop the queue once, walk it, render each unique (message, level)
   pair only the first time it's seen. Safe regardless of how the
   queue was populated — covers any other view that might flash
   duplicates without the helper.

2. New request.flash_once(message, level) helper (request_methods.py)
   that peeks the queue and skips the append if (message, level) is
   already there. Switch the two crypto status-poll handlers
   (views/crypto.py — Monero block ~820, Dogecoin block ~910) to use
   it. Both endpoints are hit on every poll, so the queue-level
   dedupe matters even with render-level dedupe (other consumers of
   the queue would still see duplicates, and the queue would balloon).

The flash queue itself stays a list (order-preserving) — set would
lose insertion order, which matters when multiple distinct alerts
need to render top-to-bottom. The peek-then-skip pattern keeps the
list semantics while preventing dup growth.
This commit is contained in:
russell@unturf.com 2026-05-13 20:00:54 -04:00
parent 89156a5d6c
commit 7870c9425e
No known key found for this signature in database
3 changed files with 57 additions and 27 deletions

View file

@ -506,3 +506,20 @@ def includeme(config):
config.add_request_method(
add_has_doge_refund_address, "has_doge_refund_address", reify=True
)
def flash_once(request, message, level="info", queue=""):
"""request.session.flash() that skips the append if (message,
level) is already in the queue. Use for messages emitted on
polling endpoints / redirect loops where the same status
otherwise piles up dozens of copies in the flash queue.
Pyramid's flash queue is a list; we peek first and bail on
duplicates.
"""
for queued in request.session.peek_flash(queue):
# queued is the (message, level) tuple as previously
# flashed (or just the message if level wasn't set).
if queued == (message, level):
return
request.session.flash((message, level), queue=queue)
config.add_request_method(flash_once, "flash_once")

View file

@ -5,12 +5,21 @@
</noscript>
<div id="alerts">
{%- if request.session.peek_flash() -%}
{%- for message, level in request.session.pop_flash() %}
{#- Dedupe identical (message, level) pairs queued during this session.
Redirect loops (e.g. the crypto-payment status poll flashing
"Payment received! Waiting for confirmations..." on every refresh)
could pile up dozens of copies of the same alert. Pop everything off
the queue once, then render each unique pair only the first time
it's seen. -#}
{%- set _queued = request.session.pop_flash() -%}
{%- set _seen = namespace(items=[]) -%}
{%- for message, level in _queued %}
{%- if (message, level) not in _seen.items %}
{%- if _seen.items.append((message, level)) %}{% endif %}
<div class="alert alert-{{level}}" onclick="this.style.display='none'" name="alert">
<p class="alert-message">{{message|safe}}</p>
<label class="close js-only" alt="dismiss" title="Mark as read">X</label>
</div>
{%- endif -%}
{%- endfor %}
{%- endif %}
</div>

View file

@ -815,11 +815,14 @@ def crypto_xmr_status(request):
if not crypto_payment:
return HTTPBadRequest("payment not found")
# Flash messages for status (without storing in session)
# Flash messages for status. flash_once dedupes at the queue
# level — this endpoint is hit by the front-end status poll on
# every refresh, so a plain flash() would stack the same alert
# dozens of times.
current_status = crypto_payment.status
if current_status == "received":
request.session.flash(
("Payment received! Waiting for confirmations...", "info")
request.flash_once(
"Payment received! Waiting for confirmations...", "info"
)
elif current_status == "confirmed":
# Check if it's a digital product by looking at line items
@ -831,27 +834,27 @@ def crypto_xmr_status(request):
break
if has_digital:
request.session.flash(
("Purchase completed! Download your files now!", "success")
request.flash_once(
"Purchase completed! Download your files now!", "success"
)
else:
request.session.flash(("Purchase completed!", "success"))
request.flash_once("Purchase completed!", "success")
elif current_status == "confirmed-overpay":
request.session.flash(
("Purchase completed! Overpayment will be refunded.", "success")
request.flash_once(
"Purchase completed! Overpayment will be refunded.", "success"
)
elif current_status == "expired":
request.session.flash(("Payment quote expired.", "warning"))
request.flash_once("Payment quote expired.", "warning")
elif current_status in [
"expired-refunded",
"underpaid-refunded",
"out-of-stock-refunded",
]:
request.session.flash(
("Refund initiated. Please allow time for processing.", "info")
request.flash_once(
"Refund initiated. Please allow time for processing.", "info"
)
elif current_status == "cancelled":
request.session.flash(("Payment cancelled.", "info"))
request.flash_once("Payment cancelled.", "info")
payload = {
"payment_id": str(crypto_payment.id),
@ -904,11 +907,12 @@ def crypto_doge_status(request):
if not crypto_payment:
return HTTPBadRequest("payment not found")
# Flash messages for status (without storing in session)
# Flash messages for status. flash_once dedupes the queue so the
# poll endpoint doesn't pile up identical alerts on every refresh.
current_status = crypto_payment.status
if current_status == "received":
request.session.flash(
("Payment received! Waiting for confirmations...", "info")
request.flash_once(
"Payment received! Waiting for confirmations...", "info"
)
elif current_status == "confirmed":
# Check if it's a digital product by looking at line items
@ -920,27 +924,27 @@ def crypto_doge_status(request):
break
if has_digital:
request.session.flash(
("Purchase completed! Download your files now!", "success")
request.flash_once(
"Purchase completed! Download your files now!", "success"
)
else:
request.session.flash(("Purchase completed!", "success"))
request.flash_once("Purchase completed!", "success")
elif current_status == "confirmed-overpay":
request.session.flash(
("Purchase completed! Overpayment will be refunded.", "success")
request.flash_once(
"Purchase completed! Overpayment will be refunded.", "success"
)
elif current_status == "expired":
request.session.flash(("Payment quote expired.", "warning"))
request.flash_once("Payment quote expired.", "warning")
elif current_status in [
"expired-refunded",
"underpaid-refunded",
"out-of-stock-refunded",
]:
request.session.flash(
("Refund initiated. Please allow time for processing.", "info")
request.flash_once(
"Refund initiated. Please allow time for processing.", "info"
)
elif current_status == "cancelled":
request.session.flash(("Payment cancelled.", "info"))
request.flash_once("Payment cancelled.", "info")
payload = {
"payment_id": str(crypto_payment.id),