Aug 3 - Add scheduled task confirm button in notification email, and separate pending/completed scheduled tasks UI
This commit is contained in:
@@ -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; 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/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 \"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 ' *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
- **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).
|
- **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`.
|
- **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 /<id>/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 /<id>/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/<token>` — 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`.
|
- **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).
|
- 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.
|
- **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 |
|
| `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 |
|
| `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_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
|
||||||
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/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 /<id>/start` (**assigned inspector only** → creates linked inspection; 403 for non-assignees incl. managers), `POST /<id>/acknowledge` (**assigned inspector only** → confirms receipt, sets `acknowledged_at`, notifies creator; idempotent — Phase 47), `GET /confirm/<token>` (**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/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
| `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/<id>` (customer chat history), `GET /admin/conversations`, `GET /admin/conversations/<id>` (staff, read-only), `GET /admin/knowledge` + `/new`, `/<id>/edit`, `/<id>/delete` (admin/director — chatbot knowledge base), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
|
||||||
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
|
| `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) |
|
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from datetime import timedelta
|
|||||||
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||||
request, abort, current_app)
|
request, abort, current_app)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
|
||||||
|
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.scheduled_inspection import (ScheduledInspection,
|
from app.models.scheduled_inspection import (ScheduledInspection,
|
||||||
@@ -40,6 +41,40 @@ logger = logging.getLogger(__name__)
|
|||||||
bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections')
|
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):
|
def _populate_choices(form):
|
||||||
# facility_id choices are ALL active facilities so POST validation passes
|
# facility_id choices are ALL active facilities so POST validation passes
|
||||||
# regardless of which contract the UI-only contract selector had chosen
|
# 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 '—'
|
tpl = sched.template.name if sched.template else '—'
|
||||||
verb = 'reassigned to you' if reassigned else 'assigned to you'
|
verb = 'reassigned to you' if reassigned else 'assigned to you'
|
||||||
notify(
|
notify(
|
||||||
recipient = inspector,
|
recipient = inspector,
|
||||||
title = f'Scheduled inspection {verb} — {fac}',
|
title = f'Scheduled inspection {verb} — {fac}',
|
||||||
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
|
body = (f'A "{tpl}" inspection at {fac} has been {verb} '
|
||||||
f'({sched.frequency_label.lower()}), due '
|
f'({sched.frequency_label.lower()}), due '
|
||||||
f'{sched.next_due_date:%b %d, %Y}.'),
|
f'{sched.next_due_date:%b %d, %Y}. '
|
||||||
link = url_for('scheduled_inspections.index'),
|
f'Please confirm you received this request.'),
|
||||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
link = url_for('scheduled_inspections.index'),
|
||||||
send_email = True,
|
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||||
|
send_email = True,
|
||||||
|
extra_action = _confirm_action(sched),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,20 +223,38 @@ def index():
|
|||||||
if current_user.role == 'customer':
|
if current_user.role == 'customer':
|
||||||
abort(403)
|
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()
|
today = now_eastern().date()
|
||||||
q = ScheduledInspection.query
|
base = ScheduledInspection.query
|
||||||
|
|
||||||
# Inspectors see only their own assignments; managers see everything.
|
# Inspectors see only their own assignments; managers see everything.
|
||||||
if current_user.role == 'inspector':
|
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(
|
pending_count = base.filter(ScheduledInspection.active.is_(True)).count()
|
||||||
ScheduledInspection.active.desc(),
|
completed_count = base.filter(ScheduledInspection.active.is_(False)).count()
|
||||||
ScheduledInspection.next_due_date.asc(),
|
|
||||||
).all()
|
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',
|
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))
|
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:
|
if not sched.inspector_id or sched.inspector_id != current_user.id:
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
if sched.acknowledged_at is None:
|
if _do_acknowledge(sched, current_user.username):
|
||||||
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()
|
|
||||||
flash('You have confirmed receipt of this scheduled inspection.', 'success')
|
flash('You have confirmed receipt of this scheduled inspection.', 'success')
|
||||||
else:
|
else:
|
||||||
flash('You have already confirmed this scheduled inspection.', 'info')
|
flash('You have already confirmed this scheduled inspection.', 'info')
|
||||||
return redirect(url_for('scheduled_inspections.index'))
|
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/<token>')
|
||||||
|
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) ─────────────────────────────────
|
# ── Cron: reminders (advance / due / overdue) ─────────────────────────────────
|
||||||
|
|
||||||
@bp.route('/run', methods=['POST'])
|
@bp.route('/run', methods=['POST'])
|
||||||
@@ -474,13 +578,14 @@ def run_reminders():
|
|||||||
if (not s.advance_notified and inspector and inspector.active
|
if (not s.advance_notified and inspector and inspector.active
|
||||||
and s.next_due_date == today + timedelta(days=1)):
|
and s.next_due_date == today + timedelta(days=1)):
|
||||||
notify(
|
notify(
|
||||||
recipient = inspector,
|
recipient = inspector,
|
||||||
title = f'Inspection due tomorrow — {fac_name}',
|
title = f'Inspection due tomorrow — {fac_name}',
|
||||||
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
|
||||||
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
|
||||||
link = link,
|
link = link,
|
||||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
|
extra_action = _confirm_action(s),
|
||||||
)
|
)
|
||||||
s.advance_notified = True
|
s.advance_notified = True
|
||||||
sent['advance'] += 1
|
sent['advance'] += 1
|
||||||
@@ -489,13 +594,14 @@ def run_reminders():
|
|||||||
if (not s.due_notified and inspector and inspector.active
|
if (not s.due_notified and inspector and inspector.active
|
||||||
and s.next_due_date <= today):
|
and s.next_due_date <= today):
|
||||||
notify(
|
notify(
|
||||||
recipient = inspector,
|
recipient = inspector,
|
||||||
title = f'Inspection due today — {fac_name}',
|
title = f'Inspection due today — {fac_name}',
|
||||||
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
|
||||||
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
|
||||||
link = link,
|
link = link,
|
||||||
event_type = EVENT_SCHEDULED_INSPECTION,
|
event_type = EVENT_SCHEDULED_INSPECTION,
|
||||||
send_email = True,
|
send_email = True,
|
||||||
|
extra_action = _confirm_action(s),
|
||||||
)
|
)
|
||||||
s.due_notified = True
|
s.due_notified = True
|
||||||
sent['due'] += 1
|
sent['due'] += 1
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!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 — Janitorial QC</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { background:#eef1f5; }
|
||||||
|
.wrap { max-width: 520px; margin: 8vh auto 0; padding: 16px; }
|
||||||
|
.card-soft { background:#fff; border:1px solid #e6e9ef; border-radius:.85rem; }
|
||||||
|
.badge-icon { width:64px; height:64px; border-radius:50%; display:flex; align-items:center;
|
||||||
|
justify-content:center; font-size:2rem; margin:0 auto 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card-soft shadow-sm p-4 text-center">
|
||||||
|
|
||||||
|
{% set ok = status in ['confirmed', 'already'] %}
|
||||||
|
<div class="badge-icon"
|
||||||
|
style="background:{{ '#e6f7ec' if ok else '#fdecec' }};color:{{ '#198754' if ok else '#dc3545' }};">
|
||||||
|
<i class="bi bi-{{ 'check-circle-fill' if ok else 'exclamation-triangle-fill' }}"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if status == 'confirmed' %}
|
||||||
|
<h4 class="mb-2">Receipt confirmed</h4>
|
||||||
|
<p class="text-muted mb-0">Thank you — you've confirmed you received this scheduled inspection request.</p>
|
||||||
|
{% elif status == 'already' %}
|
||||||
|
<h4 class="mb-2">Already confirmed</h4>
|
||||||
|
<p class="text-muted mb-0">You had already confirmed receipt of this scheduled inspection. No further action is needed.</p>
|
||||||
|
{% elif status == 'reassigned' %}
|
||||||
|
<h4 class="mb-2">This request was reassigned</h4>
|
||||||
|
<p class="text-muted mb-0">This scheduled inspection is no longer assigned to you, so it can't be confirmed from this link.</p>
|
||||||
|
{% elif status == 'inactive' %}
|
||||||
|
<h4 class="mb-2">No longer active</h4>
|
||||||
|
<p class="text-muted mb-0">This scheduled inspection is no longer active, so there's nothing to confirm.</p>
|
||||||
|
{% elif status == 'expired' %}
|
||||||
|
<h4 class="mb-2">Link expired</h4>
|
||||||
|
<p class="text-muted mb-0">This confirmation link has expired. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
|
||||||
|
{% else %}
|
||||||
|
<h4 class="mb-2">Invalid link</h4>
|
||||||
|
<p class="text-muted mb-0">This confirmation link is not valid. Please sign in to confirm receipt from the Scheduled Inspections page.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sched %}
|
||||||
|
<hr class="my-3">
|
||||||
|
<div class="text-start small">
|
||||||
|
<div class="mb-1"><span class="text-muted">Facility:</span>
|
||||||
|
<strong>{{ sched.facility.name if sched.facility else '—' }}</strong></div>
|
||||||
|
<div class="mb-1"><span class="text-muted">Template:</span>
|
||||||
|
{{ sched.template.name if sched.template else '—' }}</div>
|
||||||
|
<div><span class="text-muted">Due:</span>
|
||||||
|
{{ sched.next_due_date.strftime('%b %d, %Y') if sched.next_due_date else '—' }}
|
||||||
|
<span class="text-muted">·</span> {{ sched.recurrence_label }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i> Open Scheduled Inspections
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-3 mb-0" style="font-size:.75rem;">
|
||||||
|
You may be asked to sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,7 +19,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card shadow-sm">
|
{# Pending / Completed tabs (phase47). #}
|
||||||
|
<ul class="nav nav-tabs mb-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if tab == 'pending' }}"
|
||||||
|
href="{{ url_for('scheduled_inspections.index', tab='pending') }}">
|
||||||
|
<i class="bi bi-hourglass-split"></i> Pending
|
||||||
|
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'pending' else 'secondary' }} ms-1">{{ pending_count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if tab == 'completed' }}"
|
||||||
|
href="{{ url_for('scheduled_inspections.index', tab='completed') }}">
|
||||||
|
<i class="bi bi-check2-circle"></i> Completed
|
||||||
|
<span class="badge rounded-pill bg-{{ 'primary' if tab == 'completed' else 'secondary' }} ms-1">{{ completed_count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="card shadow-sm border-top-0" style="border-top-left-radius:0;border-top-right-radius:0;">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
{% if schedules %}
|
{% if schedules %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -74,6 +92,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Inactive</span>
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if s.last_completed_at %}
|
||||||
|
<div class="small text-muted" title="Last completed">
|
||||||
|
<i class="bi bi-check2"></i> {{ s.last_completed_at.strftime('%b %d, %Y') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{# Receipt confirmation by the assigned inspector (phase47). #}
|
{# Receipt confirmation by the assigned inspector (phase47). #}
|
||||||
@@ -88,8 +111,8 @@
|
|||||||
<span class="badge bg-warning text-dark" title="The inspector has not confirmed receipt yet">
|
<span class="badge bg-warning text-dark" title="The inspector has not confirmed receipt yet">
|
||||||
<i class="bi bi-hourglass-split"></i> Awaiting
|
<i class="bi bi-hourglass-split"></i> Awaiting
|
||||||
</span>
|
</span>
|
||||||
{# The assignee can confirm right here. #}
|
{# The assignee can confirm right here (only while still active). #}
|
||||||
{% if s.inspector_id == current_user.id %}
|
{% if s.active and s.inspector_id == current_user.id %}
|
||||||
<form method="POST" class="d-inline"
|
<form method="POST" class="d-inline"
|
||||||
action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
|
action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
@@ -138,10 +161,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-4 text-muted text-center">
|
<div class="p-4 text-muted text-center">
|
||||||
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'] %}
|
{% if current_user.role in ['admin','director','project_manager','auditor'] %}
|
||||||
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
|
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,13 +44,23 @@ _EMAIL_HTML_SINGLE = """\
|
|||||||
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
|
||||||
<h2 style="color:#0d6efd;">{{ title }}</h2>
|
<h2 style="color:#0d6efd;">{{ title }}</h2>
|
||||||
<p>{{ body }}</p>
|
<p>{{ body }}</p>
|
||||||
{% if link %}
|
{% if link or extra_action %}
|
||||||
<p>
|
<p>
|
||||||
|
{% 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 }}"
|
<a href="{{ base_url }}{{ link }}"
|
||||||
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
style="background:#0d6efd;color:#fff;padding:10px 20px;
|
||||||
text-decoration:none;border-radius:4px;display:inline-block;">
|
text-decoration:none;border-radius:4px;display:inline-block;">
|
||||||
View Details
|
View Details
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
<hr style="border:none;border-top:1px solid #eee;margin-top:32px;">
|
||||||
@@ -68,6 +78,9 @@ _EMAIL_TEXT_SINGLE = """\
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
|
|
||||||
{{ body }}
|
{{ body }}
|
||||||
|
{% if extra_action %}
|
||||||
|
{{ extra_action.label }}: {{ extra_action.url }}
|
||||||
|
{% endif %}
|
||||||
{% if link %}
|
{% if link %}
|
||||||
View: {{ base_url }}{{ link }}
|
View: {{ base_url }}{{ link }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -172,6 +185,7 @@ def notify(
|
|||||||
event_type: str = None,
|
event_type: str = None,
|
||||||
send_email: bool = True,
|
send_email: bool = True,
|
||||||
respect_preferences: bool = True,
|
respect_preferences: bool = True,
|
||||||
|
extra_action: dict = None,
|
||||||
):
|
):
|
||||||
"""Create an in-app Notification record and optionally send an email.
|
"""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.
|
respect_preferences : When True (default), per-user email preferences gate delivery.
|
||||||
Set False for matrix-routed broadcasts — the matrix is the
|
Set False for matrix-routed broadcasts — the matrix is the
|
||||||
authority; individual opt-out should not override admin config.
|
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.
|
# Determine digest flag before creating the record.
|
||||||
# Digest mode is only respected when individual preferences are in effect.
|
# Digest mode is only respected when individual preferences are in effect.
|
||||||
@@ -246,10 +265,10 @@ def notify(
|
|||||||
elif should_send:
|
elif should_send:
|
||||||
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
|
logger.info('EMAIL SEND | user=%s | event=%s | to=%s',
|
||||||
recipient.username, event_type, recipient.email)
|
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.
|
"""Dispatch a single immediate notification email in a background thread.
|
||||||
|
|
||||||
Sending is offloaded to a daemon thread so SMTP latency never blocks the
|
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(
|
html_body = render_template_string(
|
||||||
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
_EMAIL_HTML_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||||
|
extra_action=extra_action,
|
||||||
)
|
)
|
||||||
text_body = render_template_string(
|
text_body = render_template_string(
|
||||||
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
_EMAIL_TEXT_SINGLE, title=title, body=body, link=link, base_url=base_url,
|
||||||
|
extra_action=extra_action,
|
||||||
)
|
)
|
||||||
msg = Message(
|
msg = Message(
|
||||||
subject = f'[JQC] {title}',
|
subject = f'[JQC] {title}',
|
||||||
|
|||||||
Reference in New Issue
Block a user