Aug 4 - Update code to follow up - MT13b
This commit is contained in:
@@ -81,11 +81,33 @@ class Inspection(db.Model):
|
||||
)
|
||||
follow_up_required = db.Column(db.Boolean, nullable=False, default=False)
|
||||
follow_up_note = db.Column(db.Text, nullable=True)
|
||||
# phase49 — WHO asked for the follow-up and when. `follow_up_required` alone
|
||||
# cannot distinguish a client request from an internal one, and staff need to
|
||||
# know who is waiting. Set by flag_followup(), nulled by clear_followup().
|
||||
# NULL on every pre-phase49 row, which the UI renders as an unattributed
|
||||
# follow-up exactly as before.
|
||||
follow_up_requested_by = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='SET NULL',
|
||||
name='fk_inspections_follow_up_requested_by'),
|
||||
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')
|
||||
follow_ups = db.relationship('Inspection', backref=db.backref('parent', remote_side='Inspection.id'),
|
||||
lazy='dynamic', foreign_keys='Inspection.parent_inspection_id')
|
||||
# phase49. Explicit foreign_keys is required: inspector_id also points at
|
||||
# users.id, so SQLAlchemy cannot infer which column this relationship uses.
|
||||
follow_up_requester = db.relationship('User',
|
||||
foreign_keys=[follow_up_requested_by])
|
||||
# The schedule this inspection was started from / materialised by, so the
|
||||
# detail view can show the cadence and who set it up. Explicit foreign_keys
|
||||
# again: inspection_schedules.parent_inspection_id points back here (phase48),
|
||||
# so neither side's join is inferable.
|
||||
inspection_schedule = db.relationship(
|
||||
'InspectionSchedule', foreign_keys=[inspection_schedule_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Inspection {self.id} - {self.inspection_date}>'
|
||||
|
||||
@@ -37,6 +37,12 @@ EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
|
||||
# order via the tokenized public link (phase36).
|
||||
EVENT_WORK_ORDER = 'work_order_update'
|
||||
|
||||
# Fired when a follow-up re-inspection is requested — by a manager, or (phase49)
|
||||
# by a customer against their own facility. Routed through notify_by_matrix so
|
||||
# recipients stay admin-configurable; the inspection's own inspector is notified
|
||||
# directly by the route rather than through the matrix.
|
||||
EVENT_FOLLOWUP_REQUESTED = 'followup_requested'
|
||||
|
||||
ALL_EVENT_TYPES = {
|
||||
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
|
||||
EVENT_ISSUE_STATUS: 'Issue status changed',
|
||||
@@ -48,6 +54,7 @@ ALL_EVENT_TYPES = {
|
||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
||||
EVENT_WORK_ORDER: 'Contractor updated a work order',
|
||||
EVENT_FOLLOWUP_REQUESTED: 'Follow-up re-inspection requested',
|
||||
# Customer-facing — only relevant for customer role accounts
|
||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||
|
||||
@@ -27,6 +27,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)
|
||||
"""
|
||||
@@ -59,6 +60,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)',
|
||||
}
|
||||
@@ -143,6 +145,16 @@ MATRIX_DEFAULTS = {
|
||||
('verification_requested', 'project_manager'): False,
|
||||
('verification_requested', 'customer'): False,
|
||||
('verification_requested', 'custom'): False,
|
||||
# followup_requested (phase49) — 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,
|
||||
|
||||
+7
-1
@@ -43,7 +43,13 @@ class User(UserMixin, db.Model):
|
||||
mfa_recovery_codes = db.Column(db.JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
inspections = db.relationship('Inspection', backref='inspector', lazy='dynamic')
|
||||
# phase49: inspections now has TWO foreign keys to users.id — inspector_id
|
||||
# and follow_up_requested_by — so the join is otherwise ambiguous and every
|
||||
# mapper configuration fails with AmbiguousForeignKeysError. 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
|
||||
|
||||
+73
-14
@@ -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.tenancy.gates import quota_soft_check
|
||||
@@ -1216,29 +1217,62 @@ 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.
|
||||
|
||||
phase49: no longer @supervisor_required. 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, so inspectors and auditors are no worse off than before.
|
||||
|
||||
Customers may only *request*: they cannot clear the flag (clear_followup is
|
||||
still @supervisor_required) 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,
|
||||
@@ -1246,14 +1280,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. 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))
|
||||
|
||||
|
||||
@@ -1267,6 +1321,11 @@ def clear_followup(inspection_id):
|
||||
abort(404)
|
||||
inspection.follow_up_required = False
|
||||
inspection.follow_up_note = None
|
||||
# phase49 — clear the attribution with the flag. Leaving it behind would
|
||||
# make the next unattributed follow-up appear to have been requested by
|
||||
# whoever raised the previous one.
|
||||
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 %}
|
||||
{# phase49 — 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,28 @@
|
||||
<i class="bi bi-flag-fill mt-1"></i>
|
||||
<div>
|
||||
<strong>Follow-up Inspection Required</strong>
|
||||
{# phase49 — who asked, and whether it was the client or our own staff. #}
|
||||
{% 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 %}
|
||||
@@ -475,6 +500,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{# Parity with ST phase43: show that this run came from a schedule. Uses
|
||||
MT's own column/relationship names (inspection_schedule_id). #}
|
||||
{% if inspection.inspection_schedule_id %}
|
||||
<span class="badge bg-info text-dark fs-6" title="Created from a scheduled inspection">
|
||||
<i class="bi bi-calendar-check"></i> Scheduled{% if inspection.inspection_schedule %} · {{ inspection.inspection_schedule.recurrence_label }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
|
||||
{{ inspection.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
@@ -508,6 +540,12 @@
|
||||
<span class="lbl">Frequency</span>
|
||||
<span class="val">{{ inspection.template.frequency|title }}</span>
|
||||
</div>
|
||||
{% if inspection.inspection_schedule and inspection.inspection_schedule.creator %}
|
||||
<div class="meta-item">
|
||||
<span class="lbl">Scheduled By</span>
|
||||
<span class="val">{{ inspection.inspection_schedule.creator.display_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
|
||||
@@ -881,19 +919,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>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""phase49 — follow-up request attribution (customer-raised follow-ups)
|
||||
|
||||
Ports single-tenant phase46 onto the multi-tenant chain. Adds to `inspections`:
|
||||
|
||||
follow_up_requested_by INT NULL FK → users(id) ON DELETE SET NULL
|
||||
follow_up_requested_at DATETIME NULL
|
||||
|
||||
Customers can now request a follow-up re-inspection of a completed inspection at
|
||||
their own facilities (previously admin/director only), so `follow_up_required`
|
||||
alone is no longer enough — staff need to see WHO is waiting on the
|
||||
re-inspection, and a client request must be visibly distinct from an internal
|
||||
one. `flag_followup()` sets both columns; `clear_followup()` nulls them.
|
||||
|
||||
No backfill: legacy rows keep NULL, which the UI renders as an unattributed
|
||||
follow-up exactly as it did before. FK is SET NULL so deleting a user never
|
||||
deletes inspection history.
|
||||
|
||||
Revision id note
|
||||
----------------
|
||||
`alembic_version.version_num` is VARCHAR(32); the id below is 23 characters.
|
||||
The filename stays descriptive — Alembic keys on the `revision` string.
|
||||
|
||||
Uses INFORMATION_SCHEMA checks — safe to re-run on every tenant DB. Additive
|
||||
only: nothing is renamed, retyped or dropped.
|
||||
"""
|
||||
|
||||
revision = 'phase49_followup_req_by'
|
||||
down_revision = 'phase48_sched_parent_insp'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
_TABLE = 'inspections'
|
||||
_FK_NAME = 'fk_inspections_follow_up_requested_by'
|
||||
|
||||
|
||||
def _table_exists(conn, table):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
|
||||
), {"t": table}).scalar() > 0
|
||||
|
||||
|
||||
def _column_exists(conn, table, column):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
|
||||
), {"t": table, "c": column}).scalar() > 0
|
||||
|
||||
|
||||
def _fk_exists(conn, table, name):
|
||||
return conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t "
|
||||
"AND CONSTRAINT_NAME = :n AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
|
||||
), {"t": table, "n": name}).scalar() > 0
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, _TABLE):
|
||||
return
|
||||
|
||||
if not _column_exists(bind, _TABLE, 'follow_up_requested_by'):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE {_TABLE} "
|
||||
f"ADD COLUMN follow_up_requested_by INT NULL AFTER follow_up_note"
|
||||
))
|
||||
if not _column_exists(bind, _TABLE, 'follow_up_requested_at'):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE {_TABLE} "
|
||||
f"ADD COLUMN follow_up_requested_at DATETIME NULL "
|
||||
f"AFTER follow_up_requested_by"
|
||||
))
|
||||
if not _fk_exists(bind, _TABLE, _FK_NAME):
|
||||
op.execute(sa.text(
|
||||
f"ALTER TABLE {_TABLE} ADD CONSTRAINT {_FK_NAME} "
|
||||
f"FOREIGN KEY (follow_up_requested_by) REFERENCES users(id) "
|
||||
f"ON DELETE SET NULL"
|
||||
))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
if not _table_exists(bind, _TABLE):
|
||||
return
|
||||
|
||||
# FK first — MySQL refuses to drop a column still referenced by one.
|
||||
if _fk_exists(bind, _TABLE, _FK_NAME):
|
||||
op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP FOREIGN KEY {_FK_NAME}"))
|
||||
for col in ('follow_up_requested_at', 'follow_up_requested_by'):
|
||||
if _column_exists(bind, _TABLE, col):
|
||||
op.execute(sa.text(f"ALTER TABLE {_TABLE} DROP COLUMN {col}"))
|
||||
+21
-1
@@ -36,7 +36,10 @@ import sys
|
||||
|
||||
# ── Import-time env (must be set BEFORE `import app`) ────────────────────────
|
||||
# Assigned, NOT setdefault — see the module docstring.
|
||||
os.environ['SECRET_KEY'] = 'test-secret-key'
|
||||
# SECRET_KEY doubles as the JWT signing key (app/api/jwt_utils.py), and PyJWT
|
||||
# warns below 32 bytes for HMAC-SHA256 (RFC 7518 §3.2). Throwaway, but sized so
|
||||
# the API tests do not emit InsecureKeyLengthWarning on every token.
|
||||
os.environ['SECRET_KEY'] = 'test-secret-key-not-for-production-use-0123456789'
|
||||
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
|
||||
os.environ['MULTI_TENANT_ENABLED'] = 'false'
|
||||
os.environ['BILLING_ENABLED'] = 'false'
|
||||
@@ -65,6 +68,23 @@ import pytest # noqa: E402
|
||||
@pytest.fixture(scope='session')
|
||||
def app():
|
||||
"""A minimal single-tenant app on in-memory SQLite (multi-tenancy inert)."""
|
||||
# Flask-Limiter uses in-memory storage keyed on the remote address, and this
|
||||
# fixture is session-scoped — so every login across the WHOLE suite shares
|
||||
# one counter against /auth/login's '20 per minute'. Past that the login
|
||||
# returns 429, the test client stays anonymous, and whatever the test does
|
||||
# next is redirected to the login page. The failure surfaces as an unrelated
|
||||
# assertion ("the edit did not apply"), only in full runs, and only once
|
||||
# enough tests have logged in — so it moves around as tests are added or
|
||||
# reordered.
|
||||
#
|
||||
# This MUST happen before create_app(). Limiter.init_app() does
|
||||
# `self.enabled = config.setdefault('RATELIMIT_ENABLED', self.enabled)` and
|
||||
# returns early when false, registering no request hooks — and `enabled` is
|
||||
# never consulted again at request time (flask-limiter 4.x). Setting it
|
||||
# afterwards is silently a no-op. Production limits are untouched.
|
||||
from app import limiter
|
||||
limiter.enabled = False
|
||||
|
||||
from app import create_app
|
||||
application = create_app('default')
|
||||
application.config.update(TESTING=True, WTF_CSRF_ENABLED=False, SQLALCHEMY_ECHO=False)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
tests/test_followup_requests.py
|
||||
--------------------------------
|
||||
Behaviour tests for phase49 — follow-up request attribution and
|
||||
customer-raised follow-ups.
|
||||
|
||||
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Covers:
|
||||
|
||||
* a customer can request a follow-up on a completed inspection at THEIR
|
||||
facility, and both attribution columns are set
|
||||
* a customer cannot reach another client's inspection with a crafted POST,
|
||||
cannot request on a draft, and cannot overwrite a pending request
|
||||
* a customer cannot CLEAR a follow-up — request-only
|
||||
* admin/director keep their existing flag behaviour, now attributed
|
||||
* inspector and auditor are refused (they were before too, via
|
||||
@supervisor_required; phase49 must not widen access to them)
|
||||
* clear_followup() nulls the attribution along with the flag
|
||||
* the request routes through notify_by_matrix as EVENT_FOLLOWUP_REQUESTED,
|
||||
reaching the managers who action it while excluding the actor and the
|
||||
inspection's own inspector (who is notified directly instead)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Fresh schema + test client for each test (isolated in-memory DB)."""
|
||||
with app.app_context():
|
||||
from app import db
|
||||
# get_inspector_scope() imports this model lazily, so the mapper is not
|
||||
# registered at create_all() time and the table is missing when a
|
||||
# logged-in inspector hits the dashboard. Import it up front.
|
||||
from app.models import inspector_assignment # noqa: F401
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
yield app.test_client()
|
||||
db.session.remove()
|
||||
|
||||
|
||||
def _user(username, role, **kw):
|
||||
from app import db
|
||||
from app.models.user import User
|
||||
u = User(username=username, full_name=username.title(), role=role,
|
||||
email=f'{username}@example.com', active=True, **kw)
|
||||
u.set_password('pw-correct1')
|
||||
db.session.add(u)
|
||||
db.session.commit()
|
||||
return u
|
||||
|
||||
|
||||
def _seed():
|
||||
"""Two facilities, an inspector, a manager pool, and two customers."""
|
||||
from app import db
|
||||
from app.models.facility import Facility
|
||||
from app.models.inspection import InspectionTemplate
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
|
||||
# MT scopes customers through a Project; customer_assignments.project_id is
|
||||
# NOT NULL, so each facility needs one even for a facility-level assignment.
|
||||
proj_a = Project(name='Contract A', active=True)
|
||||
proj_b = Project(name='Contract B', active=True)
|
||||
tmpl = InspectionTemplate(name='Restroom Check', active=True,
|
||||
form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean',
|
||||
'row': 0, 'col': 0,
|
||||
'rowSpan': 1, 'colSpan': 1}])
|
||||
db.session.add_all([proj_a, proj_b, tmpl])
|
||||
db.session.commit()
|
||||
|
||||
fac_a = Facility(name='Client A Site', active=True, project_id=proj_a.id)
|
||||
fac_b = Facility(name='Client B Site', active=True, project_id=proj_b.id)
|
||||
db.session.add_all([fac_a, fac_b])
|
||||
db.session.commit()
|
||||
|
||||
inspector = _user('ivy', 'inspector')
|
||||
admin = _user('ada', 'admin')
|
||||
director = _user('dan', 'director')
|
||||
pm = _user('pat', 'project_manager')
|
||||
cust_a = _user('cara', 'customer')
|
||||
cust_b = _user('carl', 'customer')
|
||||
|
||||
db.session.add_all([
|
||||
CustomerAssignment(user_id=cust_a.id, project_id=proj_a.id,
|
||||
facility_id=fac_a.id),
|
||||
CustomerAssignment(user_id=cust_b.id, project_id=proj_b.id,
|
||||
facility_id=fac_b.id),
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
return dict(tmpl=tmpl, fac_a=fac_a, fac_b=fac_b, inspector=inspector,
|
||||
admin=admin, director=director, pm=pm,
|
||||
cust_a=cust_a, cust_b=cust_b)
|
||||
|
||||
|
||||
def _inspection(env, facility, status='completed'):
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
from app.utils.time_utils import now_eastern
|
||||
|
||||
insp = Inspection(template_id=env['tmpl'].id, facility_id=facility.id,
|
||||
inspector_id=env['inspector'].id,
|
||||
inspection_date=now_eastern(), status=status,
|
||||
completed_at=now_eastern() if status == 'completed' else None,
|
||||
overall_score=71.0)
|
||||
db.session.add(insp)
|
||||
db.session.commit()
|
||||
return insp
|
||||
|
||||
|
||||
def _login(client, user):
|
||||
return client.post('/auth/login',
|
||||
data={'username': user.username, 'password': 'pw-correct1'},
|
||||
follow_redirects=True)
|
||||
|
||||
|
||||
def _flag(client, inspection_id, note=None):
|
||||
data = {'follow_up_note': note} if note else {}
|
||||
return client.post(f'/inspections/{inspection_id}/flag-followup',
|
||||
data=data, follow_redirects=False)
|
||||
|
||||
|
||||
# ── Customer requests ────────────────────────────────────────────────────────
|
||||
|
||||
def test_customer_can_request_follow_up_at_their_own_facility(client):
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env['cust_a'])
|
||||
|
||||
resp = _flag(client, insp.id, note='Stalls still dirty')
|
||||
assert resp.status_code == 302
|
||||
|
||||
db.session.expire_all()
|
||||
insp = db.session.get(Inspection, insp.id)
|
||||
assert insp.follow_up_required is True
|
||||
assert insp.follow_up_note == 'Stalls still dirty'
|
||||
assert insp.follow_up_requested_by == env['cust_a'].id
|
||||
assert insp.follow_up_requested_at is not None
|
||||
# The relationship is what the template renders the badge from.
|
||||
assert insp.follow_up_requester.role == 'customer'
|
||||
|
||||
|
||||
def test_customer_cannot_reach_another_clients_inspection(client):
|
||||
"""A crafted POST must not cross the facility scope."""
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_b']) # Client B's facility
|
||||
_login(client, env['cust_a']) # Client A's customer
|
||||
|
||||
assert _flag(client, insp.id).status_code == 403
|
||||
db.session.expire_all()
|
||||
assert db.session.get(Inspection, insp.id).follow_up_required is False
|
||||
|
||||
|
||||
def test_customer_cannot_request_on_a_draft(client):
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'], status='in_progress')
|
||||
_login(client, env['cust_a'])
|
||||
|
||||
_flag(client, insp.id)
|
||||
db.session.expire_all()
|
||||
assert db.session.get(Inspection, insp.id).follow_up_required is False
|
||||
|
||||
|
||||
def test_repeat_customer_request_does_not_overwrite_the_pending_one(client):
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env['cust_a'])
|
||||
|
||||
_flag(client, insp.id, note='First note')
|
||||
db.session.expire_all()
|
||||
first_at = db.session.get(Inspection, insp.id).follow_up_requested_at
|
||||
|
||||
_flag(client, insp.id, note='Second note')
|
||||
db.session.expire_all()
|
||||
reloaded = db.session.get(Inspection, insp.id)
|
||||
assert reloaded.follow_up_note == 'First note'
|
||||
assert reloaded.follow_up_requested_at == first_at
|
||||
|
||||
|
||||
def test_customer_cannot_clear_a_follow_up(client):
|
||||
"""Customers may REQUEST only — clearing stays @supervisor_required."""
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env['cust_a'])
|
||||
_flag(client, insp.id)
|
||||
|
||||
resp = client.post(f'/inspections/{insp.id}/clear-followup',
|
||||
follow_redirects=False)
|
||||
assert resp.status_code in (302, 403)
|
||||
|
||||
db.session.expire_all()
|
||||
# Whether it redirected or 403'd, the flag must still be up.
|
||||
assert db.session.get(Inspection, insp.id).follow_up_required is True
|
||||
|
||||
|
||||
# ── Staff behaviour is preserved ─────────────────────────────────────────────
|
||||
|
||||
def test_admin_flag_still_works_and_is_now_attributed(client):
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env['admin'])
|
||||
|
||||
_flag(client, insp.id, note='Rework required')
|
||||
db.session.expire_all()
|
||||
insp = db.session.get(Inspection, insp.id)
|
||||
assert insp.follow_up_required is True
|
||||
assert insp.follow_up_requested_by == env['admin'].id
|
||||
assert insp.follow_up_requester.role == 'admin'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role_key', ['inspector', 'pm'])
|
||||
def test_roles_without_permission_are_refused(client, role_key):
|
||||
"""phase49 removed @supervisor_required from this route. It must still
|
||||
refuse everyone who could not flag before."""
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env[role_key])
|
||||
|
||||
assert _flag(client, insp.id).status_code == 403
|
||||
db.session.expire_all()
|
||||
assert db.session.get(Inspection, insp.id).follow_up_required is False
|
||||
|
||||
|
||||
def test_auditor_is_refused(client):
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
auditor = _user('aud', 'auditor')
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, auditor)
|
||||
|
||||
assert _flag(client, insp.id).status_code == 403
|
||||
db.session.expire_all()
|
||||
assert db.session.get(Inspection, insp.id).follow_up_required is False
|
||||
|
||||
|
||||
def test_clear_followup_nulls_the_attribution(client):
|
||||
"""Leaving it behind would make the NEXT unattributed follow-up appear to
|
||||
have been requested by whoever raised the previous one."""
|
||||
from app import db
|
||||
from app.models.inspection import Inspection
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
|
||||
_login(client, env['cust_a'])
|
||||
_flag(client, insp.id, note='Please recheck')
|
||||
client.get('/auth/logout', follow_redirects=True)
|
||||
|
||||
_login(client, env['admin'])
|
||||
client.post(f'/inspections/{insp.id}/clear-followup', follow_redirects=True)
|
||||
|
||||
db.session.expire_all()
|
||||
insp = db.session.get(Inspection, insp.id)
|
||||
assert insp.follow_up_required is False
|
||||
assert insp.follow_up_note is None
|
||||
assert insp.follow_up_requested_by is None
|
||||
assert insp.follow_up_requested_at is None
|
||||
assert insp.follow_up_requester is None
|
||||
|
||||
|
||||
# ── Notification routing ─────────────────────────────────────────────────────
|
||||
|
||||
def test_request_notifies_managers_via_the_matrix(client):
|
||||
from app import db
|
||||
from app.models.notification import Notification, EVENT_FOLLOWUP_REQUESTED
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env['cust_a'])
|
||||
_flag(client, insp.id, note='Still dirty')
|
||||
|
||||
rows = Notification.query.filter_by(event_type=EVENT_FOLLOWUP_REQUESTED).all()
|
||||
recipients = {n.user_id for n in rows}
|
||||
|
||||
# Defaults: admin ✓ director ✓ project_manager ✓
|
||||
assert env['admin'].id in recipients
|
||||
assert env['director'].id in recipients
|
||||
assert env['pm'].id in recipients
|
||||
# The actor never notifies themselves.
|
||||
assert env['cust_a'].id not in recipients
|
||||
# The inspection's own inspector is notified DIRECTLY instead, so the matrix
|
||||
# must exclude them rather than double-notifying.
|
||||
assert env['inspector'].id not in recipients
|
||||
# ...and that direct notification did happen.
|
||||
direct = Notification.query.filter_by(user_id=env['inspector'].id).all()
|
||||
assert any('Follow-Up Required' in n.title for n in direct)
|
||||
|
||||
|
||||
def test_matrix_defaults_include_followup_requested(client):
|
||||
from app.models.notification_matrix import MATRIX_EVENTS, MATRIX_DEFAULTS
|
||||
|
||||
assert 'followup_requested' in MATRIX_EVENTS
|
||||
assert MATRIX_DEFAULTS[('followup_requested', 'admin')] is True
|
||||
assert MATRIX_DEFAULTS[('followup_requested', 'director')] is True
|
||||
assert MATRIX_DEFAULTS[('followup_requested', 'project_manager')] is True
|
||||
# Off, or every request would alert the entire inspector pool.
|
||||
assert MATRIX_DEFAULTS[('followup_requested', 'inspector')] is False
|
||||
assert MATRIX_DEFAULTS[('followup_requested', 'customer')] is False
|
||||
|
||||
|
||||
def test_notification_body_marks_a_customer_request_as_such(client):
|
||||
"""Staff must be able to tell a client request from an internal one at a
|
||||
glance, not just from the badge on the detail page."""
|
||||
from app.models.notification import Notification, EVENT_FOLLOWUP_REQUESTED
|
||||
|
||||
env = _seed()
|
||||
insp = _inspection(env, env['fac_a'])
|
||||
_login(client, env['cust_a'])
|
||||
_flag(client, insp.id)
|
||||
|
||||
row = Notification.query.filter_by(
|
||||
event_type=EVENT_FOLLOWUP_REQUESTED, user_id=env['admin'].id).first()
|
||||
assert row is not None
|
||||
assert 'customer' in row.body.lower()
|
||||
Reference in New Issue
Block a user