Aug 5 - Update code to follow up ST - MT14b
This commit is contained in:
@@ -79,6 +79,10 @@ def _scheduled_payload(s):
|
||||
# completed inspection. The iPad uses it to badge the row and to open
|
||||
# the parent from the schedule detail.
|
||||
'parent_inspection_id': s.parent_inspection_id,
|
||||
# phase50 — receipt acknowledgement, per assignment rather than per
|
||||
# occurrence. Lets the iPad badge unconfirmed assignments.
|
||||
'is_acknowledged': s.is_acknowledged,
|
||||
'acknowledged_at': s.acknowledged_at.isoformat() if s.acknowledged_at else None,
|
||||
'notes': s.notes or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,21 @@ class InspectionSchedule(db.Model):
|
||||
nullable=True, index=True,
|
||||
)
|
||||
|
||||
# ── Receipt acknowledgement (phase50) ────────────────────────────────────
|
||||
# Stamped when the assigned inspector confirms they received the request.
|
||||
# NULL = awaiting confirmation.
|
||||
#
|
||||
# Per ASSIGNMENT, not per occurrence: fulfill() and advance_due_date()
|
||||
# deliberately leave this alone as the schedule rolls forward, so an
|
||||
# inspector who confirmed "yes, this weekly round is mine" is not asked
|
||||
# again every week. The edit route resets it to NULL on reassignment to a
|
||||
# DIFFERENT inspector, who has confirmed nothing.
|
||||
#
|
||||
# The acknowledger is always `inspector` — the only person the routes let
|
||||
# confirm — so no separate acknowledged_by column is needed. Only meaningful
|
||||
# for mode='plan'; an auto schedule has no request to receive.
|
||||
acknowledged_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
created_by = db.Column(
|
||||
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
@@ -228,6 +243,11 @@ class InspectionSchedule(db.Model):
|
||||
"""True when this schedule was created to follow up an inspection."""
|
||||
return self.parent_inspection_id is not None
|
||||
|
||||
@property
|
||||
def is_acknowledged(self):
|
||||
"""True once the assigned inspector has confirmed receipt (phase50)."""
|
||||
return self.acknowledged_at is not None
|
||||
|
||||
FREQUENCY_LABELS = {
|
||||
'once': 'One-time',
|
||||
'daily': 'Daily',
|
||||
|
||||
@@ -30,6 +30,7 @@ Cron example
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
request, jsonify, current_app, abort)
|
||||
from flask_login import login_required, current_user
|
||||
@@ -192,6 +193,72 @@ def _active_inspectors():
|
||||
).order_by(User.full_name, User.username).all()
|
||||
|
||||
|
||||
# ── Email "Confirm receipt" one-click token (phase50) ─────────────────────────
|
||||
# A signed, STATELESS token — no DB column, nothing to clean up — lets the
|
||||
# assigned inspector confirm receipt straight from the assignment email without
|
||||
# logging in, the same login-free pattern the public QR scan pages use.
|
||||
#
|
||||
# The token binds the schedule id to the inspector id, so reassigning a schedule
|
||||
# to someone else silently invalidates any link already emailed to the previous
|
||||
# assignee. That check happens at redemption, not issuance, which is what makes
|
||||
# a stateless token safe here.
|
||||
_ACK_SALT = 'inspection-schedule-ack'
|
||||
_ACK_MAX_AGE = 60 * 60 * 24 * 30 # 30 days — a link older than this is expired
|
||||
|
||||
|
||||
def _ack_serializer():
|
||||
return URLSafeTimedSerializer(current_app.config['SECRET_KEY'], salt=_ACK_SALT)
|
||||
|
||||
|
||||
def _make_ack_token(schedule):
|
||||
"""Signed token embedding the schedule id + the assigned inspector id."""
|
||||
return _ack_serializer().dumps({'sid': schedule.id, 'iid': schedule.inspector_id})
|
||||
|
||||
|
||||
def _confirm_action(schedule):
|
||||
"""`extra_action` dict for the email "Confirm receipt" button, or None.
|
||||
|
||||
None when there is nothing to confirm — no inspector, already acknowledged,
|
||||
or an auto-mode schedule (which materialises its own inspection, so there is
|
||||
no request to receive). Requires a request context for the external URL.
|
||||
|
||||
Reused by the assignment email AND the advance/due reminders, so an inspector
|
||||
who missed the first email can still confirm from whichever one reaches them.
|
||||
"""
|
||||
if not schedule.inspector_id or schedule.is_acknowledged or schedule.mode != 'plan':
|
||||
return None
|
||||
return {
|
||||
'label': 'Confirm receipt',
|
||||
'url': url_for('inspection_schedules.confirm_email',
|
||||
token=_make_ack_token(schedule), _external=True),
|
||||
}
|
||||
|
||||
|
||||
def _notify_creator_acknowledged(schedule):
|
||||
"""Tell the schedule's creator that the inspector confirmed receipt.
|
||||
|
||||
No-op when there is no creator, the creator is inactive, or the creator IS
|
||||
the inspector (self-assigned — they do not need telling). Caller commits.
|
||||
"""
|
||||
creator = schedule.creator
|
||||
if not creator or not creator.active or creator.id == schedule.inspector_id:
|
||||
return
|
||||
fac = schedule.facility.name if schedule.facility else '—'
|
||||
tpl = schedule.template.name if schedule.template else '—'
|
||||
who = schedule.inspector.display_name if schedule.inspector else 'The inspector'
|
||||
due = schedule.next_run_at.strftime('%b %d, %Y') if schedule.next_run_at else 'soon'
|
||||
notify(
|
||||
creator,
|
||||
title = f'Inspector confirmed receipt — {fac}',
|
||||
body = (f'{who} confirmed receipt of the "{tpl}" scheduled '
|
||||
f'inspection at {fac} ({schedule.recurrence_label.lower()}), '
|
||||
f'due {due}.'),
|
||||
link = url_for('inspection_schedules.index'),
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
)
|
||||
|
||||
|
||||
def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False):
|
||||
"""Tell the assigned inspector a schedule was assigned (or reassigned) to them.
|
||||
|
||||
@@ -208,10 +275,13 @@ def _notify_assignee(schedule: InspectionSchedule, reassigned: bool = False):
|
||||
inspector,
|
||||
title = f'Scheduled inspection {verb} — {fac}',
|
||||
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
|
||||
f'({schedule.frequency_label.lower()}), due {due}.'),
|
||||
link = url_for('inspection_schedules.index'),
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
f'({schedule.recurrence_label.lower()}), due {due}.'
|
||||
+ (' Please confirm you received this request.'
|
||||
if _confirm_action(schedule) else '')),
|
||||
link = url_for('inspection_schedules.index'),
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(schedule),
|
||||
)
|
||||
|
||||
|
||||
@@ -420,6 +490,12 @@ def edit(schedule_id):
|
||||
if facility_id and db.session.get(Facility, facility_id):
|
||||
schedule.facility_id = facility_id
|
||||
if inspector_id and db.session.get(User, inspector_id):
|
||||
# Reassigning to a DIFFERENT inspector invalidates any prior
|
||||
# confirmation — the new assignee has acknowledged nothing (phase50).
|
||||
# Comparison before assignment, and only on a real change, so an
|
||||
# unrelated save does not silently re-open a confirmed assignment.
|
||||
if inspector_id != schedule.inspector_id:
|
||||
schedule.acknowledged_at = None
|
||||
schedule.inspector_id = inspector_id
|
||||
schedule.area_id = request.form.get('area_id', type=int) or None
|
||||
mode = request.form.get('mode', schedule.mode)
|
||||
@@ -588,6 +664,100 @@ def start(schedule_id):
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
|
||||
|
||||
|
||||
# ── Acknowledge (inspector confirms receipt of the request) ───────────────────
|
||||
|
||||
@bp.route('/<int:schedule_id>/acknowledge', methods=['POST'])
|
||||
@login_required
|
||||
def acknowledge(schedule_id):
|
||||
"""The assigned inspector confirms they received the scheduled request.
|
||||
|
||||
Assignee-only, exactly like Start: a manager cannot confirm on someone's
|
||||
behalf, because the whole point of the record is that THIS person saw it.
|
||||
Idempotent — confirming twice is a no-op. On the first confirmation the
|
||||
schedule's creator is notified.
|
||||
"""
|
||||
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||
if schedule is None:
|
||||
abort(404)
|
||||
if not schedule.inspector_id or schedule.inspector_id != current_user.id:
|
||||
abort(403)
|
||||
|
||||
if _do_acknowledge(schedule, current_user.username):
|
||||
flash('You have confirmed receipt of this scheduled inspection.', 'success')
|
||||
else:
|
||||
flash('You have already confirmed this scheduled inspection.', 'info')
|
||||
return redirect(url_for('inspection_schedules.index'))
|
||||
|
||||
|
||||
def _do_acknowledge(schedule, actor_username):
|
||||
"""Stamp acknowledged_at, log, and notify the creator. Commits.
|
||||
|
||||
Idempotent: returns True if this call newly confirmed, False if it was
|
||||
already confirmed. Shared by the logged-in POST route and the login-free
|
||||
email-token GET route, so the two can never drift apart. The caller must
|
||||
have already verified the actor is the assigned inspector.
|
||||
"""
|
||||
if schedule.acknowledged_at is not None:
|
||||
return False
|
||||
schedule.acknowledged_at = now_eastern()
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
|
||||
'inspector confirmed receipt')
|
||||
logger.info('INSPECTION SCHEDULE ACKNOWLEDGED | schedule=%s | by=%s',
|
||||
schedule.id, actor_username)
|
||||
_notify_creator_acknowledged(schedule)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── Confirm receipt from the assignment email (login-free, token-signed) ──────
|
||||
|
||||
@bp.route('/confirm/<token>')
|
||||
def confirm_email(token):
|
||||
"""One-click "Confirm receipt" landing from the assignment email (phase50).
|
||||
|
||||
Deliberately login-free and NOT @login_required: authorisation comes from the
|
||||
signed token, which binds the schedule id to the inspector id it was issued
|
||||
for. Inspectors read this on a phone that is usually not logged in, and a
|
||||
login wall is exactly what stops them confirming.
|
||||
|
||||
Every failure mode renders the same standalone page with a different status
|
||||
rather than a bare 4xx, because the audience is a non-technical user who
|
||||
clicked a link in an email. GET is safe to repeat: the acknowledgement is
|
||||
idempotent, so a re-click or an email client's link prefetch is harmless.
|
||||
"""
|
||||
try:
|
||||
data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE)
|
||||
except SignatureExpired:
|
||||
return render_template('inspection_schedules/confirm_result.html',
|
||||
status='expired'), 400
|
||||
except BadSignature:
|
||||
return render_template('inspection_schedules/confirm_result.html',
|
||||
status='invalid'), 400
|
||||
|
||||
sid = data.get('sid')
|
||||
schedule = db.session.get(InspectionSchedule, sid) if sid else None
|
||||
if schedule is None:
|
||||
return render_template('inspection_schedules/confirm_result.html',
|
||||
status='missing'), 404
|
||||
# The token's inspector must STILL be the assigned inspector. This is what
|
||||
# makes a stateless token safe: reassignment invalidates the old link at
|
||||
# redemption without needing to track issued tokens anywhere.
|
||||
if not schedule.inspector_id or schedule.inspector_id != data.get('iid'):
|
||||
return render_template('inspection_schedules/confirm_result.html',
|
||||
status='reassigned', schedule=schedule), 409
|
||||
if not schedule.active:
|
||||
return render_template('inspection_schedules/confirm_result.html',
|
||||
status='inactive', schedule=schedule)
|
||||
|
||||
newly = _do_acknowledge(
|
||||
schedule,
|
||||
schedule.inspector.username if schedule.inspector else 'inspector')
|
||||
return render_template('inspection_schedules/confirm_result.html',
|
||||
status='confirmed' if newly else 'already',
|
||||
schedule=schedule)
|
||||
|
||||
|
||||
# ── Cron endpoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
@bp.route('/run', methods=['POST'])
|
||||
@@ -684,11 +854,15 @@ def _dispatch_reminders(plans, now):
|
||||
notify(
|
||||
inspector,
|
||||
title = f'Inspection due tomorrow — {fac_name}',
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||
f'is scheduled for tomorrow ({due_date:%b %d, %Y}).'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
# phase50 — still unconfirmed? Offer the button again here, so an
|
||||
# inspector who missed the assignment email can confirm from
|
||||
# whichever reminder reaches them. Returns None once confirmed.
|
||||
extra_action = _confirm_action(s),
|
||||
)
|
||||
s.advance_notified = True
|
||||
sent['advance'] += 1
|
||||
@@ -699,11 +873,12 @@ def _dispatch_reminders(plans, now):
|
||||
notify(
|
||||
inspector,
|
||||
title = f'Inspection due today — {fac_name}',
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||
f'({due_date:%b %d, %Y}). Please complete it.'),
|
||||
link = link,
|
||||
event_type = EVENT_INSPECTION_SCHEDULED,
|
||||
send_email = True,
|
||||
extra_action = _confirm_action(s), # phase50 — see above
|
||||
)
|
||||
s.due_notified = True
|
||||
sent['due'] += 1
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{# ── "Confirm receipt" result page (phase50) ─────────────────────────────────
|
||||
Landing page for the one-click link in a scheduled-inspection assignment or
|
||||
reminder email. Standalone — NOT extending base.html — because the viewer is
|
||||
typically not logged in and base.html's nav assumes current_user. Same shape
|
||||
as the public QR scan pages (facility_qr/area.html).
|
||||
|
||||
`status` is always set; `schedule` only for the statuses that found one.
|
||||
confirmed — newly acknowledged (the happy path)
|
||||
already — acknowledged before; a re-click or an email prefetch
|
||||
reassigned — the token's inspector is no longer the assignee
|
||||
inactive — schedule paused or ended since the email went out
|
||||
expired — token older than 30 days
|
||||
invalid — bad signature / mangled link
|
||||
missing — schedule deleted since the email went out
|
||||
#}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Confirm Receipt — Scheduled Inspection</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background:#f1f5f9; color:#1f2937; }
|
||||
.cf-wrap { max-width:520px; margin:3rem auto; padding:0 1rem; }
|
||||
.cf-card { background:#fff; border-radius:.75rem; padding:2rem 1.5rem; text-align:center;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,.08); }
|
||||
.cf-icon { font-size:3.5rem; line-height:1; }
|
||||
.cf-meta { background:#f8fafc; border-radius:.5rem; padding:.9rem 1rem; text-align:left;
|
||||
font-size:.9rem; margin-top:1.25rem; }
|
||||
.cf-meta .lbl { color:#64748b; font-size:.75rem; text-transform:uppercase;
|
||||
letter-spacing:.03em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cf-wrap">
|
||||
<div class="cf-card">
|
||||
|
||||
{% if status == 'confirmed' %}
|
||||
<div class="cf-icon text-success"><i class="bi bi-check-circle-fill"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">Receipt confirmed</h1>
|
||||
<p class="text-muted mb-0">
|
||||
Thanks — we've let the scheduler know you've seen this request.
|
||||
Nothing else to do right now.
|
||||
</p>
|
||||
|
||||
{% elif status == 'already' %}
|
||||
<div class="cf-icon text-success"><i class="bi bi-check-circle"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">Already confirmed</h1>
|
||||
<p class="text-muted mb-0">
|
||||
You confirmed this one earlier. No need to do anything else.
|
||||
</p>
|
||||
|
||||
{% elif status == 'reassigned' %}
|
||||
<div class="cf-icon text-warning"><i class="bi bi-person-x"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">No longer assigned to you</h1>
|
||||
<p class="text-muted mb-0">
|
||||
This scheduled inspection has been reassigned to someone else since the
|
||||
email was sent, so there's nothing for you to confirm.
|
||||
</p>
|
||||
|
||||
{% elif status == 'inactive' %}
|
||||
<div class="cf-icon text-secondary"><i class="bi bi-pause-circle"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">This schedule is no longer active</h1>
|
||||
<p class="text-muted mb-0">
|
||||
It has been paused or has reached its end date. No action is needed.
|
||||
</p>
|
||||
|
||||
{% elif status == 'expired' %}
|
||||
<div class="cf-icon text-secondary"><i class="bi bi-hourglass-bottom"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">This link has expired</h1>
|
||||
<p class="text-muted mb-0">
|
||||
Confirmation links are good for 30 days. Sign in to the schedules page to
|
||||
confirm, or ask your supervisor to resend the assignment.
|
||||
</p>
|
||||
|
||||
{% elif status == 'missing' %}
|
||||
<div class="cf-icon text-secondary"><i class="bi bi-question-circle"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">Schedule not found</h1>
|
||||
<p class="text-muted mb-0">
|
||||
This scheduled inspection has since been removed. No action is needed.
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
<div class="cf-icon text-danger"><i class="bi bi-x-circle"></i></div>
|
||||
<h1 class="h4 mt-3 mb-2">This link isn't valid</h1>
|
||||
<p class="text-muted mb-0">
|
||||
The link may have been copied incompletely. Try tapping it directly from
|
||||
the email, or sign in to confirm from the schedules page.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if schedule %}
|
||||
<div class="cf-meta">
|
||||
<div class="lbl">Inspection</div>
|
||||
<div class="mb-2">
|
||||
{{ schedule.template.name if schedule.template else '—' }}
|
||||
at {{ schedule.facility.name if schedule.facility else '—' }}
|
||||
</div>
|
||||
<div class="lbl">Schedule</div>
|
||||
<div>
|
||||
{{ schedule.recurrence_label }}{% if schedule.due_date %} · due
|
||||
{{ schedule.due_date.strftime('%b %d, %Y') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
<p class="text-center text-muted small mt-3 mb-0">
|
||||
Janitorial QC System — automated message. Do not reply to the email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -84,8 +84,37 @@
|
||||
{% if s.active %}<span class="badge bg-success">Active</span>
|
||||
{% elif s.is_expired %}<span class="badge bg-dark">Ended</span>
|
||||
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
||||
{# phase50 — receipt acknowledgement. Only meaningful for plan
|
||||
mode: an auto schedule materialises itself, so there is no
|
||||
request for anyone to receive. #}
|
||||
{% if s.mode == 'plan' and s.inspector_id %}
|
||||
{% if s.is_acknowledged %}
|
||||
<span class="badge bg-light text-success border border-success ms-1"
|
||||
title="Inspector confirmed receipt on {{ s.acknowledged_at.strftime('%b %d, %Y %I:%M %p') }}">
|
||||
<i class="bi bi-check-circle"></i> Confirmed
|
||||
</span>
|
||||
{% elif s.active %}
|
||||
<span class="badge bg-light text-warning border border-warning ms-1"
|
||||
title="The assigned inspector has not confirmed receipt yet">
|
||||
<i class="bi bi-hourglass-split"></i> Awaiting confirmation
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{# Assignee-only, like Start: a manager must not be able to confirm
|
||||
on someone's behalf, since the record means "this person saw it". #}
|
||||
{% if s.active and s.mode == 'plan' and not s.is_acknowledged
|
||||
and s.inspector_id == current_user.id %}
|
||||
<form method="POST" class="d-inline"
|
||||
action="{{ url_for('inspection_schedules.acknowledge', schedule_id=s.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success"
|
||||
title="Confirm you have received this request">
|
||||
<i class="bi bi-check-lg"></i> Confirm
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if s.active and s.mode == 'plan'
|
||||
and (current_user.role != 'inspector' or s.inspector_id == current_user.id) %}
|
||||
<a href="{{ url_for('inspection_schedules.start', schedule_id=s.id) }}"
|
||||
|
||||
@@ -44,13 +44,26 @@ _EMAIL_HTML_SINGLE = """\
|
||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||
<h2 style="color:#0d6efd;">{{ title }}</h2>
|
||||
<p>{{ body }}</p>
|
||||
{% if link %}
|
||||
{% if link or extra_action %}
|
||||
<p>
|
||||
{# extra_action (phase50) renders BEFORE "View Details" and in green: it is
|
||||
the one-click action the email is asking for (e.g. "Confirm receipt"),
|
||||
so it must be the primary button, not a footnote after the generic link. #}
|
||||
{% if extra_action %}
|
||||
<a href="{{ extra_action.url }}"
|
||||
style="background:#198754;color:#fff;padding:10px 20px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;
|
||||
margin-right:8px;">
|
||||
{{ extra_action.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if link %}
|
||||
<a href="{{ base_url }}{{ link }}"
|
||||
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
||||
text-decoration:none;border-radius:4px;display:inline-block;">
|
||||
View Details
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||
@@ -68,6 +81,9 @@ _EMAIL_TEXT_SINGLE = """\
|
||||
{{ title }}
|
||||
|
||||
{{ body }}
|
||||
{% if extra_action %}
|
||||
{{ extra_action.label }}: {{ extra_action.url }}
|
||||
{% endif %}
|
||||
{% if link %}
|
||||
View: {{ base_url }}{{ link }}
|
||||
{% endif %}
|
||||
@@ -172,6 +188,7 @@ def notify(
|
||||
event_type: str = None,
|
||||
send_email: bool = True,
|
||||
respect_preferences: bool = True,
|
||||
extra_action: dict = None,
|
||||
):
|
||||
"""Create an in-app Notification record and optionally send an email.
|
||||
|
||||
@@ -189,6 +206,13 @@ def notify(
|
||||
respect_preferences : When True (default), per-user email preferences gate delivery.
|
||||
Set False for matrix-routed broadcasts — the matrix is the
|
||||
authority; individual opt-out should not override admin config.
|
||||
extra_action : Optional dict {'label': str, 'url': str} rendered as a second,
|
||||
primary button in the email (phase50). The URL must be
|
||||
ABSOLUTE — unlike `link`, it is not prefixed with base_url,
|
||||
because it typically points at a login-free tokenised route
|
||||
built with url_for(..., _external=True). EMAIL ONLY: the
|
||||
in-app Notification row is unchanged, so a recipient reading
|
||||
it in the bell menu simply follows `link` as before.
|
||||
"""
|
||||
# Determine digest flag before creating the record.
|
||||
# Digest mode is only respected when individual preferences are in effect.
|
||||
@@ -246,10 +270,10 @@ def notify(
|
||||
elif should_send:
|
||||
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
|
||||
recipient.username, event_type, recipient.email)
|
||||
_send_single_email(recipient, title, body, link)
|
||||
_send_single_email(recipient, title, body, link, extra_action)
|
||||
|
||||
|
||||
def _send_single_email(recipient, title, body, link):
|
||||
def _send_single_email(recipient, title, body, link, extra_action=None):
|
||||
"""Dispatch a single immediate notification email in a background thread.
|
||||
|
||||
Sending is offloaded to a daemon thread so SMTP latency never blocks the
|
||||
@@ -265,9 +289,11 @@ def _send_single_email(recipient, title, body, link):
|
||||
)
|
||||
html_body = render_template_string(
|
||||
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||
extra_action=extra_action,
|
||||
)
|
||||
text_body = render_template_string(
|
||||
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||
extra_action=extra_action,
|
||||
)
|
||||
msg = Message(
|
||||
subject = f'[JQC] {title}',
|
||||
|
||||
Reference in New Issue
Block a user