Aug 18 - Fix customer roles's notification radio buttons issue

This commit is contained in:
2026-08-18 10:21:50 -04:00
parent 515aa07326
commit d8b5ce201d
5 changed files with 131 additions and 20 deletions
+22
View File
@@ -66,6 +66,28 @@ def overrides_for_user(user_id) -> dict:
}
def override_for(user_id, event_type):
"""One account's answer for one event: True, False, or None (inherit).
Used by notify() to enforce the override on EVERY delivery path, not just
matrix broadcasts. Best-effort: any failure returns None (inherit), so a
lookup problem can never silently swallow a notification.
"""
import logging
if not user_id or not event_type:
return None
try:
row = UserNotificationMatrix.query.filter_by(
user_id=user_id, event_type=event_type).first()
return row.enabled if row is not None else None
except Exception as exc:
logging.getLogger(__name__).error(
'USER MATRIX | single override lookup failed | user=%s event=%s | %s',
user_id, event_type, exc,
)
return None
def overrides_for_event(event_type) -> dict:
"""Return {user_id: bool} — every account's override for one event.
+20 -4
View File
@@ -13,15 +13,31 @@
/* Minimum 44px touch targets on interactive elements */
.btn,
.nav-link,
.dropdown-item,
input[type="checkbox"],
input[type="radio"],
.form-check-input {
.dropdown-item {
min-height: 44px;
display: inline-flex;
align-items: center;
}
/* Checkboxes and radios are deliberately NOT in the rule above.
`display: inline-flex` on a native <input type="radio"> replaces its
intrinsic box with a flex container: the control keeps painting its glyph
at its natural ~16px size while the element claims a 44px-tall box, so the
visible dot and the actual hit area stop coinciding. Taps land next to the
control and nothing happens — which is exactly how the per-account
notification matrix (Inherit / On / Off) came to look unclickable.
Grow the target without touching `display`: keep the native box, scale the
glyph up, and give it margin so neighbouring options stay separable. Any
control that needs a genuinely large tap area should wrap the input in a
<label> that fills the cell (see customers/manage.html). */
input[type="checkbox"],
input[type="radio"],
.form-check-input {
transform: scale(1.35);
margin: 6px;
}
/* Slightly larger form controls for finger input */
.form-control,
.form-select {
+63 -15
View File
@@ -318,6 +318,34 @@
action="{{ url_for('customers.save_notifications', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<style>
/* The whole cell is the control. Padding (not min-height on the
input) gives the touch target, so the native radio keeps its
own box — see the note in ipad_responsive.css. */
.matrix-opt {
display: block;
padding: .55rem .25rem;
margin: 0;
cursor: pointer;
text-align: center;
}
.matrix-opt:hover { background: rgba(13,110,253,.06); }
.matrix-opt input { cursor: pointer; }
.matrix-opt-sub {
display: block;
font-size: .62rem;
color: #6c757d;
margin-top: 2px;
}
</style>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<span class="small text-muted">Set every row:</span>
<button type="button" class="btn btn-sm btn-outline-secondary" data-matrix-all="inherit">Inherit</button>
<button type="button" class="btn btn-sm btn-outline-success" data-matrix-all="on">On</button>
<button type="button" class="btn btn-sm btn-outline-danger" data-matrix-all="off">Off</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-3">
<thead class="table-light">
@@ -337,23 +365,33 @@
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">custom</span>
{% endif %}
</td>
<td class="text-center">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="inherit"
{% if row.override is none %}checked{% endif %}>
<div class="text-muted" style="font-size:.62rem;">
{{ 'on' if row.global else 'off' }}
</div>
{# Each option is a <label> filling its whole cell, so the
click target is the cell rather than the ~16px glyph. A
bare <input> in a centred <td> was effectively unclickable
at touch/narrow widths. #}
<td class="p-0">
<label class="matrix-opt" title="Follow the global matrix">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="inherit"
{% if row.override is none %}checked{% endif %}>
<span class="matrix-opt-sub">
currently {{ 'on' if row.global else 'off' }}
</span>
</label>
</td>
<td class="text-center">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="on"
{% if row.override is true %}checked{% endif %}>
<td class="p-0">
<label class="matrix-opt" title="Always send this to this account">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="on"
{% if row.override is true %}checked{% endif %}>
</label>
</td>
<td class="text-center">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="off"
{% if row.override is false %}checked{% endif %}>
<td class="p-0">
<label class="matrix-opt" title="Never send this to this account">
<input class="form-check-input" type="radio"
name="event_{{ row.event }}" value="off"
{% if row.override is false %}checked{% endif %}>
</label>
</td>
</tr>
{% endfor %}
@@ -396,6 +434,16 @@
if (deselectAll) deselectAll.addEventListener('click', function () { setAll(false); });
checks.forEach(function (c) { c.addEventListener('change', refreshCount); });
// ── Notification matrix: set every row at once ──
document.querySelectorAll('[data-matrix-all]').forEach(function (btn) {
btn.addEventListener('click', function () {
var want = btn.getAttribute('data-matrix-all');
document.querySelectorAll('.matrix-opt input[type=radio]').forEach(function (r) {
if (r.value === want) { r.checked = true; }
});
});
});
// ── Customer Director: contract → facility cascade ──
// Both selects are absent on the inspector view, so bail out rather than
// throwing on addEventListener of null (which would kill the handlers above).
+22
View File
@@ -209,6 +209,28 @@ def notify(
the scheduled-inspection "Confirm receipt" email link. In-app
notifications are unaffected — this only shapes the email.
"""
# ── Per-account override (phase51) ───────────────────────────────────
# A customer-side account's own notification matrix governs EVERY path
# that reaches it, not just matrix broadcasts: follower fan-out
# (_notify_followers) and direct assignee notifications both call notify()
# straight, so without this the editor would offer rows — "Issue follow
# update", "Issue assigned" — that appeared to be off while the
# notifications kept arriving.
#
# Only an explicit `False` suppresses. No row means inherit, which is the
# default for every account and leaves behaviour exactly as before. The
# getattr fallback is deliberate: if the attribute is unavailable for any
# reason we send, never silently drop.
if event_type and getattr(recipient, 'is_customer_account', False):
from app.models.user_notification_matrix import override_for
if override_for(recipient.id, event_type) is False:
logger.info(
'NOTIFICATION SUPPRESSED | user=%s | event=%s | '
'reason=per_account_override_off',
recipient.username, event_type,
)
return
# Determine digest flag before creating the record.
# Digest mode is only respected when individual preferences are in effect.
hold_for_digest = (