Aug 25 - Implement new function allow Director (internal & customer) to assign and inspection to another inspector

This commit is contained in:
2026-08-25 12:24:34 -04:00
parent b31f5f03da
commit aced3d0602
6 changed files with 286 additions and 8 deletions
+31 -3
View File
@@ -210,6 +210,11 @@ def _inspection_payload(inspection):
# ── Follow-up / re-inspection fields ──────────────────────────────
'follow_up_required': inspection.follow_up_required,
'follow_up_note': inspection.follow_up_note,
# phase53 — who is to perform the follow-up. NULL means the
# inspection's own inspector, which is what it always meant.
'follow_up_assigned_to': inspection.follow_up_assigned_to,
'follow_up_assigned_to_name': (inspection.follow_up_assignee.display_name
if inspection.follow_up_assignee else None),
'parent_inspection_id': inspection.parent_inspection_id,
# Set when this inspection was started from a ScheduledInspection —
# drives the "Scheduled" badge on the web list and lets the iPad show
@@ -267,8 +272,15 @@ def list_inspections():
query = Inspection.query
# Inspectors only see their own inspections
if user.is_inspector:
# Inspectors only see their own inspections.
#
# EXCEPT when asking for follow-up requests: a follow-up can now be handed
# to a different inspector (phase53), and that request lives on an
# inspection somebody ELSE performed. Applying this filter first would hide
# exactly the rows the assignee needs, so it is deferred to the follow-up
# block below, which applies ownership instead of authorship.
wants_follow_ups = request.args.get('follow_up_required', '').lower() in ('true', '1')
if user.is_inspector and not wants_follow_ups:
query = query.filter(Inspection.inspector_id == user.id)
# Optional filters
@@ -280,7 +292,7 @@ def list_inspections():
if status:
query = query.filter(Inspection.status == status)
if request.args.get('follow_up_required', '').lower() in ('true', '1'):
if wants_follow_ups:
# Must mean exactly what "Follow-up" means everywhere on the web
# (inspections.list / reports status_filter == 'follow_up'): flagged,
# completed, and not yet answered by a linked re-inspection.
@@ -298,6 +310,22 @@ def list_inspections():
Inspection.status == 'completed',
).filter(~Inspection.follow_ups.any())
# Ownership, not authorship (phase53). Mirrors
# Inspection.follow_up_owner: an assigned follow-up belongs to the
# assignee ALONE, an unassigned one to the inspection's own inspector.
#
# The two arms are mutually exclusive on purpose. Without the second
# arm's `is_(None)` an inspector would keep seeing a follow-up that had
# been handed to someone else, and two people would turn up to do it.
if user.is_inspector:
query = query.filter(db.or_(
Inspection.follow_up_assigned_to == user.id,
db.and_(
Inspection.follow_up_assigned_to.is_(None),
Inspection.inspector_id == user.id,
),
))
from_date_str = request.args.get('from_date')
if from_date_str:
try:
+28
View File
@@ -202,6 +202,21 @@ class Inspection(db.Model):
)
follow_up_requested_at = db.Column(db.DateTime, nullable=True)
# phase53 — who is to PERFORM the follow-up re-inspection.
#
# NULL keeps the original behaviour: the follow-up belongs to the
# inspection's own inspector. When set, that person owns it instead — they
# are the one notified, and the one it appears for on the iPad. Lets a
# director (or a Customer Director) hand a re-inspection to someone other
# than whoever did the original.
#
# This is the THIRD FK from inspections to users (rule 86): every
# relationship spanning the two must pin foreign_keys explicitly, or the
# mapper is ambiguous and blows up on first ORM USE rather than at import.
follow_up_assigned_to = db.Column(
db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), 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')
# The ScheduledInspection this inspection was started from (phase36), if any.
@@ -212,6 +227,19 @@ class Inspection(db.Model):
# Explicit foreign_keys: `inspector_id` also points at users.
follow_up_requester = db.relationship('User',
foreign_keys=[follow_up_requested_by])
follow_up_assignee = db.relationship('User',
foreign_keys=[follow_up_assigned_to])
@property
def follow_up_owner(self):
"""Who is expected to carry out the follow-up.
The explicit assignee when one is set, otherwise the inspection's own
inspector — the single definition of ownership, so the web display, the
notification and the mobile API filter cannot disagree about who owns a
follow-up.
"""
return self.follow_up_assignee or self.inspector
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
+73 -3
View File
@@ -969,7 +969,9 @@ def view(inspection_id):
'unchanged': sum(1 for r in rows if r['delta'] == 0),
}
followup_assignees = _followup_assignees_for(inspection, current_user)
return render_template('inspections/view.html',
followup_assignees=followup_assignees,
inspection=inspection,
form_fields=form_fields,
form_data=form_data,
@@ -1394,6 +1396,46 @@ def _view_url(inspection_id):
return url_for('inspections.view', inspection_id=inspection_id)
def _followup_assignees_for(inspection, actor):
"""Inspectors who may be handed this inspection's follow-up.
Contract-scoped, for the same reason the flag-issue list is (rule 93): a
Customer Director must never see — let alone assign work to — another
client's inspector, and one of our own directors picking the wrong name
would leak this facility to an outsider.
Only the two INSPECTOR roles are offered: a follow-up is an inspection, and
directors/PMs/auditors hold no InspectorAssignment, so they cannot be
scoped to a contract and could not open the re-inspection anyway.
A facility with no contract yields nobody — fail-closed, leaving the
follow-up with the original inspector.
"""
from app.models.inspector_assignment import InspectorAssignment
project_id = inspection.facility.project_id if inspection.facility else None
if not project_id:
return []
users = (
User.query
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
.filter(
InspectorAssignment.project_id == project_id,
User.role.in_(User.INSPECTOR_ROLES),
User.active == True,
)
.order_by(User.full_name, User.username)
.all()
)
seen, out = set(), []
for u in users: # the join repeats across assignments
if u.id not in seen:
seen.add(u.id)
out.append(u)
return out
def _collect_inspection_photos(inspection):
"""Relative storage keys owned by an inspection, for cleanup after delete.
@@ -1597,6 +1639,7 @@ def bulk_action():
insp.follow_up_note = None
insp.follow_up_requested_by = None
insp.follow_up_requested_at = None
insp.follow_up_assigned_to = None
cleared.append(insp)
changed += 1
db.session.commit()
@@ -1661,22 +1704,48 @@ def flag_followup(inspection_id):
note = request.form.get('follow_up_note', '').strip() or None
# ── Assignee (phase53) ────────────────────────────────────────────────
# Optional. Blank keeps the original behaviour: the follow-up belongs to
# the inspection's own inspector. Validated against the contract-scoped
# list rather than trusted, so a crafted id cannot hand work to another
# customer's inspector (and tell them this facility's name in the email).
assignee_id = request.form.get('follow_up_assigned_to', type=int) or None
if assignee_id:
allowed = {u.id for u in _followup_assignees_for(inspection, current_user)}
if assignee_id not in allowed:
current_app.logger.warning(
'FOLLOW-UP | out-of-contract assignee blocked | inspection=%s | '
'assignee=%s | by=%s',
inspection_id, assignee_id, current_user.username)
flash('That inspector is not assigned to this facility\'s contract.',
'danger')
return redirect(_view_url(inspection_id))
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()
inspection.follow_up_assigned_to = assignee_id
db.session.commit()
note_suffix = f' Note: {note}' if note else ''
who = (f'The customer ({current_user.display_name})' if is_customer
else current_user.display_name)
assigned_suffix = ''
if inspection.follow_up_assignee:
assigned_suffix = (f' It has been assigned to '
f'{inspection.follow_up_assignee.display_name}.')
body = (
f'{who} has requested a follow-up re-inspection '
f'of "{inspection.template.name}" at {inspection.facility.name}.{note_suffix}'
f'of "{inspection.template.name}" at {inspection.facility.name}.'
f'{assigned_suffix}{note_suffix}'
)
# Notify the original inspector so they see it on the iPad.
inspector = db.session.get(User, inspection.inspector_id)
# Notify whoever now OWNS the follow-up — the assignee when one was named,
# otherwise the original inspector (Inspection.follow_up_owner). Notifying
# the original inspector for work that has been handed to someone else is
# noise, and worse, it implies they are expected to do it.
inspector = inspection.follow_up_owner
if inspector and inspector.id != current_user.id:
notify(
recipient = inspector,
@@ -1732,6 +1801,7 @@ def clear_followup(inspection_id):
inspection.follow_up_note = None
inspection.follow_up_requested_by = None
inspection.follow_up_requested_at = None
inspection.follow_up_assigned_to = None
db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection_id,
f'{inspection.template.name} @ {inspection.facility.name}',
+45
View File
@@ -413,6 +413,19 @@
{% 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 %}
{# Who is expected to DO it — the assignee when one was named, otherwise
the original inspector (Inspection.follow_up_owner). #}
{% if inspection.follow_up_owner %}
<div class="small mt-1">
<i class="bi bi-person-check me-1"></i>Assigned to
<strong>{{ inspection.follow_up_owner.display_name }}</strong>
{% if not inspection.follow_up_assignee %}
<span class="text-muted">(original inspector)</span>
{% elif inspection.follow_up_owner.id == current_user.id %}
<span class="badge bg-warning text-dark ms-1">You</span>
{% endif %}
</div>
{% 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' %}
@@ -947,6 +960,38 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia()
</label>
<textarea name="follow_up_note" class="form-control" rows="3"
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
{# ── Assign it (phase53) ────────────────────────────────────────
Optional. Left blank, the follow-up stays with whoever performed
the original inspection — the behaviour before this existed. The
list is contract-scoped in _followup_assignees_for(), so a
Customer Director only ever sees inspectors on their own
contracts. #}
{% if followup_assignees %}
<div class="mt-3">
<label class="form-label fw-semibold">
Assign to
<span class="text-muted small">(optional)</span>
</label>
<select name="follow_up_assigned_to" class="form-select">
<option value="">
— {{ inspection.inspector.display_name }} (original inspector) —
</option>
{% for u in followup_assignees %}
{% if u.id != inspection.inspector_id %}
<option value="{{ u.id }}">
{{ u.display_name }}{{ ' (Customer)' if u.is_external_inspector }}
</option>
{% endif %}
{% endfor %}
</select>
<div class="form-text">
Choose someone else to carry out the re-inspection. They are
notified and it appears in their list on the web and the iPad;
the original inspector is not asked to do it.
</div>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>