Jul 30 - Allow customer to flag follow-up a inspection

This commit is contained in:
2026-07-30 20:44:38 -04:00
parent 5ed433aabe
commit 0808f8eaff
8 changed files with 256 additions and 21 deletions
+12
View File
@@ -81,6 +81,14 @@ class Inspection(db.Model):
)
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
follow_up_note = db.Column(db.Text, nullable=True)
# Who asked for the follow-up (phase46). NULL for legacy rows flagged before
# the column existed. Matters because customers can now raise the request
# themselves — staff need to see at a glance that the client is waiting on
# this one, not another internal reviewer.
follow_up_requested_by = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True
)
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
issues = db.relationship('Issue', backref='inspection', lazy='dynamic', cascade='all, delete-orphan')
@@ -88,6 +96,10 @@ class Inspection(db.Model):
# None for ad-hoc/manual inspections or if the schedule was later deleted.
scheduled_inspection = db.relationship('ScheduledInspection',
foreign_keys=[scheduled_inspection_id])
# The user who requested the follow-up (phase46) — a customer or a manager.
# Explicit foreign_keys: `inspector_id` also points at users.
follow_up_requester = db.relationship('User',
foreign_keys=[follow_up_requested_by])
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
+6
View File
@@ -33,6 +33,11 @@ EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all
# overdue to admin/director). Phase 36.
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection'
# Fired when someone asks for a follow-up re-inspection of a completed
# inspection. Raised by admin/director from the inspection page and — since
# phase46 — by CUSTOMERS for their own facilities. Phase 46.
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
ALL_EVENT_TYPES = {
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
EVENT_ISSUE_STATUS: 'Issue status changed',
@@ -49,6 +54,7 @@ ALL_EVENT_TYPES = {
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
# Scheduled inspection reminders (due/advance/overdue)
EVENT_SCHEDULED_INSPECTION: 'Scheduled inspection reminders (due / overdue)',
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
}
+12
View File
@@ -31,6 +31,7 @@ issue_flagged : admin ✓ director ✓ inspector ✗ pm ✗ cust
issue_created : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ director ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗
followup_requested : admin ✓ director ✓ inspector ✗ pm ✓ customer ✗ (inspection's own inspector implicit)
sla_alert : admin ✓ director ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
score_alert : admin ✓ director ✓ inspector ✗ pm ✗ customer ✗ (facility score drop cron)
"""
@@ -63,6 +64,7 @@ MATRIX_EVENTS = {
'issue_created': 'Issue created (standalone)',
'issue_updated_customer': 'Issue updated (customer)',
'verification_requested': 'Verification requested',
'followup_requested': 'Follow-up requested (incl. by customer)',
'sla_alert': 'SLA at-risk / breached',
'score_alert': 'Facility score trend alert (significant drop)',
}
@@ -147,6 +149,16 @@ MATRIX_DEFAULTS = {
('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): False,
('verification_requested', 'custom'): False,
# followup_requested — a customer (or manager) asks for a re-inspection.
# On for the roles who action it; the inspection's own inspector is
# notified directly by the route, so the inspector column stays off to
# avoid alerting the whole inspector pool.
('followup_requested', 'admin'): True,
('followup_requested', 'director'): True,
('followup_requested', 'inspector'): False,
('followup_requested', 'project_manager'): True,
('followup_requested', 'customer'): False,
('followup_requested', 'custom'): False,
# sla_alert (assignee + followers always notified implicitly)
('sla_alert', 'admin'): True,
('sla_alert', 'director'): False,
+6 -1
View File
@@ -34,7 +34,12 @@ class User(UserMixin, db.Model):
set_password_token_expires = db.Column(db.DateTime, nullable=True)
# Relationships
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
# Explicit foreign_keys: inspections now has a SECOND FK to users
# (follow_up_requested_by, phase46), so the join is otherwise ambiguous.
# This relationship means "inspections I performed" — inspector_id only.
inspections = db.relationship('Inspection', backref='inspector',
lazy='dynamic',
foreign_keys='Inspection.inspector_id')
# ── Flask-Login integration ────────────────────────────────────────────
# Override UserMixin.is_active so that disabled accounts are rejected
+71 -16
View File
@@ -21,6 +21,7 @@ from app.utils.notifications import notify, notify_customers_for_facility, notif
from app.models.notification import (
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
EVENT_FOLLOWUP_REQUESTED,
)
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT
from app.utils.scope import get_customer_scope, get_inspector_scope
@@ -1215,29 +1216,61 @@ def export_pdf(inspection_id):
@bp.route('/<int:inspection_id>/flag-followup', methods=['POST'])
@login_required
@supervisor_required
def flag_followup(inspection_id):
"""Mark an inspection as requiring a follow-up re-inspection."""
"""Mark an inspection as requiring a follow-up re-inspection.
Open to admin/director AND to customers for their own facilities — a client
unhappy with a result can ask for a re-inspection directly rather than
going through support. Every other role is refused.
Customers may only *request*: they cannot clear the flag (see
clear_followup, still admin/director) nor run the re-inspection itself.
"""
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
is_customer = current_user.role == 'customer'
if is_customer:
# Same facility scope as view() — a customer must not be able to reach
# another client's inspection with a crafted POST.
if inspection.facility_id not in (get_customer_scope(current_user) or []):
abort(403)
# Nothing to follow up on until the inspection has been submitted.
if inspection.status != 'completed':
flash('You can only request a follow-up on a completed inspection.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
# Don't let a repeat request overwrite the note/attribution of a pending
# one — the flag is already raised and staff are already on it.
if inspection.follow_up_required:
flash('A follow-up has already been requested for this inspection.', 'info')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
elif current_user.role not in ('admin', 'director'):
abort(403)
note = request.form.get('follow_up_note', '').strip() or None
inspection.follow_up_required = True
inspection.follow_up_note = note
inspection.follow_up_required = True
inspection.follow_up_note = note
inspection.follow_up_requested_by = current_user.id
inspection.follow_up_requested_at = now_eastern()
db.session.commit()
# Notify the original inspector so they see it on the iPad
note_suffix = f' Note: {note}' if note else ''
who = (f'The customer ({current_user.display_name})' if is_customer
else current_user.display_name)
body = (
f'{who} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
)
# Notify the original inspector so they see it on the iPad.
inspector = db.session.get(User, inspection.inspector_id)
if inspector and inspector.id != current_user.id:
note_suffix = f' Note: {note}' if note else ''
notify(
recipient = inspector,
title = f'Follow-Up Required: Inspection #{inspection_id}',
body = (
f'{current_user.username} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
),
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
event_type = EVENT_INSPECTION_DONE,
@@ -1245,14 +1278,34 @@ def flag_followup(inspection_id):
)
db.session.commit()
# Route to the staff who action follow-ups. Going through notify_by_matrix
# rather than notifying managers directly keeps recipients admin-configurable
# and lets per-contract recipients fire too (rule 73). This matters most for
# a customer request: without it only the inspector would hear about it and
# nobody would be accountable for scheduling the re-inspection.
notify_by_matrix(
event_type = EVENT_FOLLOWUP_REQUESTED,
title = f'Follow-Up Requested: Inspection #{inspection_id}',
body = body,
link = url_for('inspections.view', inspection_id=inspection_id),
inspection_id = inspection_id,
facility_id = inspection.facility_id,
exclude_user_ids = {current_user.id,
inspector.id if inspector else None} - {None},
)
db.session.commit()
current_app.logger.info(
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s | note=%r',
inspection_id, current_user.username, note,
'INSPECTION FOLLOW-UP FLAGGED | id=%s | by=%s (%s) | note=%r',
inspection_id, current_user.username, current_user.role, note,
)
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'follow_up_required=True; note={note!r}')
flash('Follow-up inspection required flag set.', 'warning')
f'follow_up_required=True; by_role={current_user.role}; note={note!r}')
if is_customer:
flash('Follow-up re-inspection requested. The team has been notified.', 'success')
else:
flash('Follow-up inspection required flag set.', 'warning')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
@@ -1264,8 +1317,10 @@ def clear_followup(inspection_id):
inspection = db.session.get(Inspection, inspection_id)
if inspection is None:
abort(404)
inspection.follow_up_required = False
inspection.follow_up_note = None
inspection.follow_up_required = False
inspection.follow_up_note = None
inspection.follow_up_requested_by = None
inspection.follow_up_requested_at = None
db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
+39 -3
View File
@@ -356,6 +356,16 @@
<i class="bi bi-arrow-repeat"></i> Re-inspect
</a>
{% endif %}
{# Customers may REQUEST a follow-up on their own completed inspections;
only admin/director can clear one. #}
{% if current_user.role == 'customer' and inspection.status == 'completed'
and not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
title="Ask the team to re-inspect this facility">
<i class="bi bi-flag"></i> Request Follow-up
</button>
{% endif %}
{% if current_user.role in ['admin','director'] %}
{% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
@@ -388,13 +398,27 @@
<i class="bi bi-flag-fill mt-1"></i>
<div>
<strong>Follow-up Inspection Required</strong>
{% if inspection.follow_up_requester %}
<span class="badge {{ 'bg-info text-dark' if inspection.follow_up_requester.role == 'customer' else 'bg-secondary' }} ms-1">
{{ 'Requested by customer' if inspection.follow_up_requester.role == 'customer' else 'Requested by staff' }}:
{{ inspection.follow_up_requester.display_name }}
</span>
{% endif %}
{% if inspection.follow_up_requested_at %}
<span class="small text-muted ms-1">{{ inspection.follow_up_requested_at.strftime('%b %d, %Y %I:%M %p') }}</span>
{% endif %}
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
{# Re-inspection is staff work — reinspect() already refuses customers. #}
{% if current_user.role != 'customer' %}
<div class="mt-2">
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
</a>
</div>
{% else %}
<div class="small mt-1">The team has been notified and will schedule the re-inspection.</div>
{% endif %}
</div>
</div>
{% endif %}
@@ -886,19 +910,31 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
{% set is_cust = current_user.role == 'customer' %}
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
<h5 class="modal-title">
<i class="bi bi-flag me-2"></i>{{ 'Request a Follow-up Inspection' if is_cust else 'Flag Follow-up Required' }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
{% if is_cust %}
<p class="small text-muted">
Ask the team to re-inspect this facility. Your request is sent to the
inspector and management right away.
</p>
{% endif %}
<label class="form-label fw-semibold">
{{ 'What still needs attention?' if is_cust else 'Reason / Notes' }}
<span class="text-muted small">(optional)</span>
</label>
<textarea name="follow_up_note" class="form-control" rows="3"
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-flag me-1"></i>Flag Follow-up
<i class="bi bi-flag me-1"></i>{{ 'Send Request' if is_cust else 'Flag Follow-up' }}
</button>
</div>
</div>