Jul 30 - Allow customer to flag follow-up a inspection
This commit is contained in:
@@ -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')
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user