Aug 3 - Add scheduled task confirm button in notification email, and separate pending/completed scheduled tasks UI
This commit is contained in:
@@ -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/<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) ─────────────────────────────────
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user