From 991f242d12e6d8e1f4e6780910b4f33e77800ebb Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 3 Aug 2026 13:40:11 -0400 Subject: [PATCH] Aug 3 - Add scheduled task confirm button in notification email, and separate pending/completed scheduled tasks UI --- .claude/settings.json | 5 +- CLAUDE.md | 6 +- app/routes/scheduled_inspections.py | 186 ++++++++++++++---- .../scheduled_inspections/confirm_result.html | 73 +++++++ app/templates/scheduled_inspections/list.html | 35 +++- app/utils/notifications.py | 27 ++- 6 files changed, 282 insertions(+), 50 deletions(-) create mode 100644 app/templates/scheduled_inspections/confirm_result.html diff --git a/.claude/settings.json b/.claude/settings.json index 7025c55..59b344b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,10 @@ "Bash(python -c \"import ast; ast.parse\\(open\\('app/utils/pdf_export.py'\\).read\\(\\)\\); print\\('OK'\\)\")", "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/utils/pdf_export.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/issues.py',encoding='utf-8'\\).read\\(\\)\\); print\\('OK'\\)\")", - "Bash(python -c \"print\\(len\\('phase47_sched_acknowledged'\\)\\)\")" + "Bash(python -c \"print\\(len\\('phase47_sched_acknowledged'\\)\\)\")", + "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/routes/scheduled_inspections.py',encoding='utf-8'\\).read\\(\\)\\); print\\('route OK'\\)\")", + "Bash(python -c \"import ast,io; ast.parse\\(io.open\\('app/utils/notifications.py',encoding='utf-8'\\).read\\(\\)\\); print\\('notif OK'\\)\")", + "Bash(SECRET_KEY=x DATABASE_URL=sqlite:///:memory: DIGEST_SECRET=x MAIL_SERVER=localhost MAIL_USERNAME=x MAIL_PASSWORD=x MAIL_PORT=587 APP_BASE_URL=http://localhost MAIL_DEFAULT_SENDER=x@x.com python -c ' *)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 5a201f0..615602f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -453,7 +453,9 @@ All are nullable and **legacy phase36 rows keep NULLs**, falling back to `_add_i - **Form validation.** `ScheduledInspectionForm.validate()` rejects an end date on a one-time schedule and one earlier than the due date. That is not sufficient alone: `align_due_date()` can push the picked date forward onto the rule (a Tuesday pick on a Mon/Wed/Fri schedule becomes Wednesday), so `_reject_if_past_end_date()` re-checks after `_apply_recurrence()` in both create and edit. Edit rolls back first — `sched` is persistent and already mutated at that point. - **Three status states** in the list: Active, **Ended** (`is_expired` — ran its course), Inactive (a manager switched it off). - **Assignment notification** (immediate): on **create**, the assigned inspector gets an in-app + email "assigned to you" notification; on **edit**, only when the inspector actually changes (a "reassigned to you" notification to the new assignee). Via `_notify_assignee()` in the blueprint using `event_type=EVENT_SCHEDULED_INSPECTION`. -- **Receipt acknowledgement (phase47).** `acknowledged_at` records when the assigned inspector confirms they received the request — a way for the manager to see the assignment was seen, distinct from starting it. `POST //acknowledge` is **assignee-only** (like Start; a manager cannot confirm on someone's behalf) and idempotent; on first confirm the schedule's **creator** is notified via `_notify_creator_acknowledged()` (`event_type=EVENT_SCHEDULED_INSPECTION`, skipped when creator is inactive or is the inspector). **Once per assignment, not per occurrence:** it is NOT reset when a recurring schedule rolls forward (`fulfill()` leaves it), but the **edit route resets it to NULL when the inspector changes** so a new assignee must re-confirm. Surfaced with a Confirmed/Awaiting badge + a "Confirm receipt" button (assignee only) on both the scheduled-inspections **list** and the **dashboard** panel. `ScheduledInspection.is_acknowledged` is the boolean helper. Exposed **read-only** in the API payload (`acknowledged_at`) — confirming stays on the web per rule 77; an iPad confirm action would be a follow-up. +- **Receipt acknowledgement (phase47).** `acknowledged_at` records when the assigned inspector confirms they received the request — a way for the manager to see the assignment was seen, distinct from starting it. `POST //acknowledge` is **assignee-only** (like Start; a manager cannot confirm on someone's behalf) and idempotent; on first confirm the schedule's **creator** is notified via `_notify_creator_acknowledged()` (`event_type=EVENT_SCHEDULED_INSPECTION`, skipped when creator is inactive or is the inspector). The stamp-log-notify body is factored into `_do_acknowledge(sched, actor_username)`, shared by the POST route and the email-token route. **Once per assignment, not per occurrence:** it is NOT reset when a recurring schedule rolls forward (`fulfill()` leaves it), but the **edit route resets it to NULL when the inspector changes** so a new assignee must re-confirm. Surfaced with a Confirmed/Awaiting badge + a "Confirm receipt" button (assignee only, active schedules) on both the scheduled-inspections **list** and the **dashboard** panel. `ScheduledInspection.is_acknowledged` is the boolean helper. Exposed **read-only** in the API payload (`acknowledged_at`) — confirming stays on the web per rule 77; an iPad confirm action would be a follow-up. + - **Confirm from the email (phase47).** The assignment email (and the advance/due reminder emails while still unconfirmed) carry a green **"Confirm receipt"** button beside "View Details". It links to `GET /scheduled-inspections/confirm/` — a **login-free** landing (no `@login_required`, same pattern as the `public` blueprint) authorised by an `itsdangerous.URLSafeTimedSerializer` token (salt `scheduled-inspection-ack`, 30-day max age, signed with `SECRET_KEY` — **no DB column**). The token binds `{sid, iid}` so a schedule **reassigned** to another inspector invalidates the previous assignee's emailed link (the route checks `sched.inspector_id == token iid`). The route renders the standalone `scheduled_inspections/confirm_result.html` with a `status` of confirmed / already / reassigned / inactive / expired / invalid / missing; the acknowledgement is idempotent so a re-click or email-client prefetch is harmless. The email button is built by `_confirm_action(sched)` (returns None once acknowledged), threaded into `notify(..., extra_action={'label','url'})` — an **email-only** second button; `extra_action.url` is absolute and is NOT prefixed with `base_url`. In-app notifications are unchanged. +- **List tabs (phase47).** The scheduled-inspections list (`index()`) has two tabs via `?tab=pending|completed` (default `pending`): **Pending** = active schedules (`active == True`, ordered by next due date), **Completed** = closed schedules (`active == False`, ordered by `last_completed_at` desc — fulfilled one-times, ended recurring, and manually-deactivated rows). Exhaustive, non-overlapping partition; the in-row Status badge (Active / Ended / Inactive) disambiguates the closed bucket, and a "last completed" date is shown when set. `pending_count` / `completed_count` drive the tab pill badges. - **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`. - Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own). - **Instructions (July 2026).** `ScheduledInspectionForm.notes` is labelled **"Instructions"** and `scheduled_inspections/form.html` explains that the text reaches the inspector. The *field name*, `ScheduledInspection.notes`, the `scheduled_inspections.notes` column and the API key `notes` are all unchanged — the rename is a label only (rule 84). The text is surfaced to the inspector in two places: `inspections/execute.html` renders an indigo panel between the header and the form grid, guarded on `inspection.scheduled_inspection and .notes` (NULL for ad-hoc work and for schedules deleted after the start); the iPad shows it on the scheduled row, on the start screen and above the form. @@ -521,7 +523,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi | `audit` | `/audit` | list (admin only), view, purge | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | -| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | +| `scheduled_inspections` | `/scheduled-inspections` | list (`?tab=pending\|completed` — Phase 47), new/edit/delete (PM+), `GET //start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST //acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/` (**login-free** one-click email confirm; signed `itsdangerous` token binding schedule+inspector — Phase 47), `POST /run` (cron reminders, `token=DIGEST_SECRET`) | | `support` | `/support` | `GET /chat` (loads latest saved session; `?new=1` to start fresh), `POST /chat/message` (AJAX→Groq; **persists** user+assistant turns, returns `session_id`), `GET /my-conversations`, `GET /my-conversations/` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/` (staff, read-only), `GET /admin/knowledge` + `/new`, `//edit`, `//delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | | `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py index 7bee8bc..4821b62 100644 --- a/app/routes/scheduled_inspections.py +++ b/app/routes/scheduled_inspections.py @@ -20,6 +20,7 @@ from datetime import timedelta from flask import (Blueprint, render_template, redirect, url_for, flash, request, abort, current_app) from flask_login import login_required, current_user +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from app import db from app.models.scheduled_inspection import (ScheduledInspection, @@ -40,6 +41,40 @@ logger = logging.getLogger(__name__) bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections') +# ── Email "Confirm receipt" one-click token (phase47) ───────────────────────── +# A signed, stateless token (no DB column) lets the assigned inspector confirm +# receipt straight from the assignment email, even when not logged in — the same +# login-free pattern the public QR pages use. The token binds the schedule id to +# the inspector id, so a schedule reassigned to someone else invalidates any +# link that was emailed to the previous assignee. +_ACK_SALT = 'scheduled-inspection-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(sched): + """Signed token embedding the schedule id + the assigned inspector id.""" + return _ack_serializer().dumps({'sid': sched.id, 'iid': sched.inspector_id}) + + +def _confirm_action(sched): + """`extra_action` dict for the email "Confirm receipt" button, or None when + there is nothing to confirm (no inspector, or already acknowledged). Requires + a request context for the external URL. Reused by the assignment email and + the advance/due reminder emails so an unconfirmed inspector can always + confirm from whichever email reaches them.""" + if not sched.inspector_id or sched.is_acknowledged: + return None + return { + 'label': 'Confirm receipt', + 'url': url_for('scheduled_inspections.confirm_email', + token=_make_ack_token(sched), _external=True), + } + + def _populate_choices(form): # facility_id choices are ALL active facilities so POST validation passes # regardless of which contract the UI-only contract selector had chosen @@ -69,14 +104,16 @@ def _notify_assignee(sched, reassigned=False): tpl = sched.template.name if sched.template else '—' verb = 'reassigned to you' if reassigned else 'assigned to you' notify( - recipient = inspector, - title = f'Scheduled inspection {verb} — {fac}', - body = (f'A "{tpl}" inspection at {fac} has been {verb} ' - f'({sched.frequency_label.lower()}), due ' - f'{sched.next_due_date:%b %d, %Y}.'), - link = url_for('scheduled_inspections.index'), - event_type = EVENT_SCHEDULED_INSPECTION, - send_email = True, + recipient = inspector, + title = f'Scheduled inspection {verb} — {fac}', + body = (f'A "{tpl}" inspection at {fac} has been {verb} ' + f'({sched.frequency_label.lower()}), due ' + f'{sched.next_due_date:%b %d, %Y}. ' + f'Please confirm you received this request.'), + link = url_for('scheduled_inspections.index'), + event_type = EVENT_SCHEDULED_INSPECTION, + send_email = True, + extra_action = _confirm_action(sched), ) @@ -186,20 +223,38 @@ def index(): if current_user.role == 'customer': abort(403) + # Two tabs (phase47): Pending = schedules still producing occurrences + # (active); Completed = closed schedules (fulfilled one-times, ended + # recurring, or manually deactivated). The partition is exhaustive and + # non-overlapping, so every schedule appears in exactly one tab; the in-row + # Status badge (Active / Ended / Inactive) disambiguates the closed ones. + tab = request.args.get('tab', 'pending') + if tab not in ('pending', 'completed'): + tab = 'pending' + today = now_eastern().date() - q = ScheduledInspection.query + base = ScheduledInspection.query # Inspectors see only their own assignments; managers see everything. if current_user.role == 'inspector': - q = q.filter(ScheduledInspection.inspector_id == current_user.id) + base = base.filter(ScheduledInspection.inspector_id == current_user.id) - schedules = q.order_by( - ScheduledInspection.active.desc(), - ScheduledInspection.next_due_date.asc(), - ).all() + pending_count = base.filter(ScheduledInspection.active.is_(True)).count() + completed_count = base.filter(ScheduledInspection.active.is_(False)).count() + + if tab == 'pending': + schedules = (base.filter(ScheduledInspection.active.is_(True)) + .order_by(ScheduledInspection.next_due_date.asc()).all()) + else: + # Most recently completed first; NULL last_completed (e.g. manually + # switched off before ever running) sorts last under MySQL DESC. + schedules = (base.filter(ScheduledInspection.active.is_(False)) + .order_by(ScheduledInspection.last_completed_at.desc(), + ScheduledInspection.next_due_date.desc()).all()) return render_template('scheduled_inspections/list.html', - schedules=schedules, today=today, + schedules=schedules, today=today, tab=tab, + pending_count=pending_count, completed_count=completed_count, open_inspections=_open_inspection_ids(schedules)) @@ -416,23 +471,72 @@ def acknowledge(schedule_id): if not sched.inspector_id or sched.inspector_id != current_user.id: abort(403) - if sched.acknowledged_at is None: - sched.acknowledged_at = now_eastern() - db.session.commit() - log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, - f'{sched.template.name if sched.template else "?"} @ ' - f'{sched.facility.name if sched.facility else "?"}', - 'inspector confirmed receipt') - logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s', - sched.id, current_user.username) - _notify_creator_acknowledged(sched) - db.session.commit() + if _do_acknowledge(sched, 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('scheduled_inspections.index')) +def _do_acknowledge(sched, actor_username): + """Stamp acknowledged_at, log the action, and notify the creator. 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. + The caller must have verified the actor is the assigned inspector.""" + if sched.acknowledged_at is not None: + return False + sched.acknowledged_at = now_eastern() + db.session.commit() + log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id, + f'{sched.template.name if sched.template else "?"} @ ' + f'{sched.facility.name if sched.facility else "?"}', + 'inspector confirmed receipt') + logger.info('SCHED INSP | acknowledge | schedule=%s | by=%s', + sched.id, actor_username) + _notify_creator_acknowledged(sched) + db.session.commit() + return True + + +# ── Confirm receipt from the assignment email (login-free, token-signed) ────── + +@bp.route('/confirm/') +def confirm_email(token): + """One-click "Confirm receipt" landing from the assignment email (phase47). + + Login-free: authorised by the signed token, which binds the schedule id to + the inspector id it was emailed to. Renders a standalone result page. The + acknowledgement is idempotent, so a re-click (or an email client prefetch) + is harmless.""" + try: + data = _ack_serializer().loads(token, max_age=_ACK_MAX_AGE) + except SignatureExpired: + return render_template('scheduled_inspections/confirm_result.html', + status='expired'), 400 + except BadSignature: + return render_template('scheduled_inspections/confirm_result.html', + status='invalid'), 400 + + sid = data.get('sid') + sched = db.session.get(ScheduledInspection, sid) if sid else None + if sched is None: + return render_template('scheduled_inspections/confirm_result.html', + status='missing'), 404 + # The token's inspector must still be the assigned inspector — a schedule + # reassigned to someone else invalidates the previous assignee's link. + if not sched.inspector_id or sched.inspector_id != data.get('iid'): + return render_template('scheduled_inspections/confirm_result.html', + status='reassigned', sched=sched), 409 + if not sched.active: + return render_template('scheduled_inspections/confirm_result.html', + status='inactive', sched=sched) + + newly = _do_acknowledge( + sched, sched.inspector.username if sched.inspector else 'inspector') + return render_template('scheduled_inspections/confirm_result.html', + status='confirmed' if newly else 'already', sched=sched) + + # ── Cron: reminders (advance / due / overdue) ───────────────────────────────── @bp.route('/run', methods=['POST']) @@ -474,13 +578,14 @@ def run_reminders(): if (not s.advance_notified and inspector and inspector.active and s.next_due_date == today + timedelta(days=1)): notify( - recipient = inspector, - title = f'Inspection due tomorrow — {fac_name}', - body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} ' - f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'), - link = link, - event_type = EVENT_SCHEDULED_INSPECTION, - send_email = True, + recipient = inspector, + title = f'Inspection due tomorrow — {fac_name}', + body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} ' + f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'), + link = link, + event_type = EVENT_SCHEDULED_INSPECTION, + send_email = True, + extra_action = _confirm_action(s), ) s.advance_notified = True sent['advance'] += 1 @@ -489,13 +594,14 @@ def run_reminders(): if (not s.due_notified and inspector and inspector.active and s.next_due_date <= today): notify( - recipient = inspector, - title = f'Inspection due today — {fac_name}', - body = (f'A "{tpl_name}" inspection at {fac_name} is due ' - f'({s.next_due_date:%b %d, %Y}). Please complete it.'), - link = link, - event_type = EVENT_SCHEDULED_INSPECTION, - send_email = True, + recipient = inspector, + title = f'Inspection due today — {fac_name}', + body = (f'A "{tpl_name}" inspection at {fac_name} is due ' + f'({s.next_due_date:%b %d, %Y}). Please complete it.'), + link = link, + event_type = EVENT_SCHEDULED_INSPECTION, + send_email = True, + extra_action = _confirm_action(s), ) s.due_notified = True sent['due'] += 1 diff --git a/app/templates/scheduled_inspections/confirm_result.html b/app/templates/scheduled_inspections/confirm_result.html new file mode 100644 index 0000000..3c508f3 --- /dev/null +++ b/app/templates/scheduled_inspections/confirm_result.html @@ -0,0 +1,73 @@ + + + + + + + Confirm Receipt — Janitorial QC + + + + + +
+
+ + {% set ok = status in ['confirmed', 'already'] %} +
+ +
+ + {% if status == 'confirmed' %} +

Receipt confirmed

+

Thank you — you've confirmed you received this scheduled inspection request.

+ {% elif status == 'already' %} +

Already confirmed

+

You had already confirmed receipt of this scheduled inspection. No further action is needed.

+ {% elif status == 'reassigned' %} +

This request was reassigned

+

This scheduled inspection is no longer assigned to you, so it can't be confirmed from this link.

+ {% elif status == 'inactive' %} +

No longer active

+

This scheduled inspection is no longer active, so there's nothing to confirm.

+ {% elif status == 'expired' %} +

Link expired

+

This confirmation link has expired. Please sign in to confirm receipt from the Scheduled Inspections page.

+ {% else %} +

Invalid link

+

This confirmation link is not valid. Please sign in to confirm receipt from the Scheduled Inspections page.

+ {% endif %} + + {% if sched %} +
+
+
Facility: + {{ sched.facility.name if sched.facility else '—' }}
+
Template: + {{ sched.template.name if sched.template else '—' }}
+
Due: + {{ sched.next_due_date.strftime('%b %d, %Y') if sched.next_due_date else '—' }} + · {{ sched.recurrence_label }}
+
+ {% endif %} + + +

+ You may be asked to sign in. +

+ +
+
+ + diff --git a/app/templates/scheduled_inspections/list.html b/app/templates/scheduled_inspections/list.html index cbbcf23..12397a7 100644 --- a/app/templates/scheduled_inspections/list.html +++ b/app/templates/scheduled_inspections/list.html @@ -19,7 +19,25 @@ -
+{# Pending / Completed tabs (phase47). #} + + +
{% if schedules %}
@@ -74,6 +92,11 @@ {% else %} Inactive {% endif %} + {% if s.last_completed_at %} +
+ {{ s.last_completed_at.strftime('%b %d, %Y') }} +
+ {% endif %} {# Receipt confirmation by the assigned inspector (phase47). #} @@ -88,8 +111,8 @@ Awaiting - {# The assignee can confirm right here. #} - {% if s.inspector_id == current_user.id %} + {# The assignee can confirm right here (only while still active). #} + {% if s.active and s.inspector_id == current_user.id %}
@@ -138,10 +161,14 @@
{% else %}
- No scheduled inspections yet. + {% if tab == 'completed' %} + No completed scheduled inspections yet. + {% else %} + No pending scheduled inspections. {% if current_user.role in ['admin','director','project_manager','auditor'] %} Create one. {% endif %} + {% endif %}
{% endif %}
diff --git a/app/utils/notifications.py b/app/utils/notifications.py index e504e0d..0a0bca4 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -44,13 +44,23 @@ _EMAIL_HTML_SINGLE = """\

{{ title }}

{{ body }}

- {% if link %} + {% if link or extra_action %}

+ {% if extra_action %} + + {{ extra_action.label }} + + {% endif %} + {% if link %} View Details + {% endif %}

{% endif %}
@@ -68,6 +78,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 +185,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 +203,11 @@ 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 + (green) button in the email, before "View Details". `url` must + be an absolute URL (it is NOT prefixed with base_url). Used for + the scheduled-inspection "Confirm receipt" email link. In-app + notifications are unaffected — this only shapes the email. """ # Determine digest flag before creating the record. # Digest mode is only respected when individual preferences are in effect. @@ -246,10 +265,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 +284,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}',