Merge branch 'main' of https://gitea.ngodanguyen.tech/nngo/LT_Janitorial_Quality_Control
This commit is contained in:
@@ -361,8 +361,10 @@ def index():
|
||||
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
|
||||
sched_upcoming = []
|
||||
sched_overdue_count = 0
|
||||
sched_open_inspections = {}
|
||||
if not is_customer:
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.routes.scheduled_inspections import _open_inspection_ids
|
||||
_today = now.date()
|
||||
_sq = ScheduledInspection.query.filter_by(active=True)
|
||||
if is_inspector:
|
||||
@@ -374,11 +376,14 @@ def index():
|
||||
s for s in _all_sched
|
||||
if _today <= s.next_due_date <= _today + timedelta(days=7)
|
||||
][:8]
|
||||
# Offer Continue (not a duplicate Start) where one is already underway.
|
||||
sched_open_inspections = _open_inspection_ids(sched_upcoming)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
sched_upcoming = sched_upcoming,
|
||||
sched_overdue_count = sched_overdue_count,
|
||||
sched_open_inspections = sched_open_inspections,
|
||||
submitted_this_week = submitted_this_week,
|
||||
completed_today = completed_today,
|
||||
open_issues = open_issues,
|
||||
|
||||
+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}',
|
||||
|
||||
@@ -22,7 +22,8 @@ from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from app import db
|
||||
from app.models.scheduled_inspection import ScheduledInspection
|
||||
from app.models.scheduled_inspection import (ScheduledInspection,
|
||||
MONTH_MODE_DAY, MONTH_MODE_NTH)
|
||||
from app.models.facility import Facility
|
||||
from app.models.inspection import Inspection, InspectionTemplate
|
||||
from app.models.project import Project
|
||||
@@ -79,6 +80,72 @@ def _notify_assignee(sched, reassigned=False):
|
||||
)
|
||||
|
||||
|
||||
def _apply_recurrence(sched, form):
|
||||
"""Copy the recurrence block for the chosen frequency onto *sched* and
|
||||
clear the blocks that no longer apply, then snap next_due_date onto the
|
||||
rule. Keeping the unused columns NULL means `recurrence_label` and the
|
||||
date math never read stale settings after a frequency change."""
|
||||
sched.frequency = form.frequency.data
|
||||
|
||||
if sched.frequency == 'weekly':
|
||||
sched.set_weekdays(form.weekdays.data)
|
||||
else:
|
||||
sched.weekdays = None
|
||||
|
||||
if sched.frequency == 'monthly':
|
||||
sched.month_mode = form.month_mode.data or MONTH_MODE_DAY
|
||||
if sched.month_mode == MONTH_MODE_NTH:
|
||||
sched.day_of_month = None
|
||||
sched.nth_week = form.nth_week.data
|
||||
sched.nth_weekday = form.nth_weekday.data
|
||||
else:
|
||||
sched.day_of_month = form.day_of_month.data
|
||||
sched.nth_week = None
|
||||
sched.nth_weekday = None
|
||||
else:
|
||||
sched.month_mode = sched.day_of_month = None
|
||||
sched.nth_week = sched.nth_weekday = None
|
||||
|
||||
# End date (phase44) — a boundary, not a cadence setting. A one-time
|
||||
# schedule has none: it ends by deactivating when it is completed.
|
||||
sched.end_date = form.end_date.data if sched.frequency != 'once' else None
|
||||
|
||||
# Snap the picked date forward onto the first matching occurrence.
|
||||
sched.next_due_date = sched.align_due_date(form.next_due_date.data)
|
||||
|
||||
|
||||
def _reject_if_past_end_date(sched, form):
|
||||
"""True (and a form error set) if the aligned first occurrence falls past
|
||||
the end date.
|
||||
|
||||
The form already rejects an end date earlier than the *picked* due date, but
|
||||
align_due_date() can push that date forward onto the recurrence rule — pick
|
||||
a Tuesday for a Mon/Wed/Fri schedule and the first occurrence is Wednesday.
|
||||
Without this check that combination would save as active with no occurrence
|
||||
it is ever allowed to run.
|
||||
"""
|
||||
if sched.is_within_end_date(sched.next_due_date):
|
||||
return False
|
||||
form.end_date.errors.append(
|
||||
f'With this recurrence the first occurrence falls on '
|
||||
f'{sched.next_due_date:%b %d, %Y}, after the end date.')
|
||||
return True
|
||||
|
||||
|
||||
def _open_inspection_ids(schedules):
|
||||
"""{schedule_id: inspection_id} for schedules with an inspection already
|
||||
in progress, so the UI offers Continue instead of a duplicate Start."""
|
||||
ids = [s.id for s in schedules if s.id]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (Inspection.query
|
||||
.filter(Inspection.scheduled_inspection_id.in_(ids),
|
||||
Inspection.status == 'in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.all())
|
||||
return {r.scheduled_inspection_id: r.id for r in rows}
|
||||
|
||||
|
||||
def _selected_project_id(form):
|
||||
"""Contract of the submitted facility (for restoring the selector on
|
||||
re-render), or None."""
|
||||
@@ -110,7 +177,8 @@ def index():
|
||||
).all()
|
||||
|
||||
return render_template('scheduled_inspections/list.html',
|
||||
schedules=schedules, today=today)
|
||||
schedules=schedules, today=today,
|
||||
open_inspections=_open_inspection_ids(schedules))
|
||||
|
||||
|
||||
# ── Create ──────────────────────────────────────────────────────────────────
|
||||
@@ -121,6 +189,10 @@ def index():
|
||||
def create():
|
||||
form = ScheduledInspectionForm()
|
||||
_populate_choices(form)
|
||||
# On a new schedule this date IS the start; on edit it is whatever the next
|
||||
# occurrence happens to be. One field, two meanings — so the label follows
|
||||
# the context instead of saying both at once.
|
||||
form.next_due_date.label.text = 'Start Date'
|
||||
if not form.next_due_date.data:
|
||||
form.next_due_date.data = now_eastern().date()
|
||||
|
||||
@@ -129,17 +201,25 @@ def create():
|
||||
facility_id = form.facility_id.data,
|
||||
template_id = form.template_id.data,
|
||||
inspector_id = form.inspector_id.data,
|
||||
frequency = form.frequency.data,
|
||||
next_due_date = form.next_due_date.data,
|
||||
notes = (form.notes.data or '').strip() or None,
|
||||
active = form.active.data,
|
||||
created_by = current_user.id,
|
||||
)
|
||||
_apply_recurrence(sched, form)
|
||||
if _reject_if_past_end_date(sched, form):
|
||||
# sched was never added to the session — nothing to roll back.
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='New Scheduled Inspection',
|
||||
projects=_active_contracts(),
|
||||
selected_project_id=_selected_project_id(form))
|
||||
db.session.add(sched)
|
||||
db.session.commit()
|
||||
log_action(ACTION_CREATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; inspector={sched.inspector_id}')
|
||||
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
|
||||
f'end={sched.end_date or "—"}; '
|
||||
f'inspector={sched.inspector_id}')
|
||||
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
|
||||
|
||||
# Notify the assigned inspector immediately.
|
||||
@@ -166,20 +246,36 @@ def edit(schedule_id):
|
||||
abort(404)
|
||||
form = ScheduledInspectionForm(obj=sched)
|
||||
_populate_choices(form)
|
||||
form.next_due_date.label.text = 'Next Due Date'
|
||||
if request.method == 'GET':
|
||||
# obj= copies the raw CSV column into a multi-select field; hand it the
|
||||
# parsed int list instead so the checkboxes pre-tick correctly.
|
||||
form.weekdays.data = sched.weekday_list
|
||||
form.month_mode.data = sched.month_mode or MONTH_MODE_DAY
|
||||
|
||||
if form.validate_on_submit():
|
||||
old_inspector_id = sched.inspector_id
|
||||
sched.facility_id = form.facility_id.data
|
||||
sched.template_id = form.template_id.data
|
||||
sched.inspector_id = form.inspector_id.data
|
||||
sched.frequency = form.frequency.data
|
||||
sched.next_due_date = form.next_due_date.data
|
||||
sched.notes = (form.notes.data or '').strip() or None
|
||||
sched.active = form.active.data
|
||||
_apply_recurrence(sched, form)
|
||||
if _reject_if_past_end_date(sched, form):
|
||||
# sched is a persistent object and has already been mutated — discard
|
||||
# those pending changes before re-rendering so nothing leaks out on
|
||||
# the next flush.
|
||||
db.session.rollback()
|
||||
return render_template('scheduled_inspections/form.html',
|
||||
form=form, title='Edit Scheduled Inspection',
|
||||
schedule=sched, projects=_active_contracts(),
|
||||
selected_project_id=_selected_project_id(form))
|
||||
db.session.commit()
|
||||
log_action(ACTION_UPDATE, 'ScheduledInspection', sched.id,
|
||||
f'{sched.template.name} @ {sched.facility.name}',
|
||||
f'freq={sched.frequency}; due={sched.next_due_date}; active={sched.active}')
|
||||
f'freq={sched.recurrence_label}; due={sched.next_due_date}; '
|
||||
f'end={sched.end_date or "—"}; '
|
||||
f'active={sched.active}')
|
||||
|
||||
# Notify the inspector if the assignment changed to them.
|
||||
if sched.active and sched.inspector_id and sched.inspector_id != old_inspector_id:
|
||||
@@ -242,6 +338,16 @@ def start(schedule_id):
|
||||
flash('The template for this schedule has no form fields yet.', 'warning')
|
||||
return redirect(url_for('scheduled_inspections.index'))
|
||||
|
||||
# Already started but not submitted? Resume it rather than opening a second
|
||||
# inspection against the same occurrence.
|
||||
existing = (Inspection.query
|
||||
.filter_by(scheduled_inspection_id=sched.id, status='in_progress')
|
||||
.order_by(Inspection.id.desc())
|
||||
.first())
|
||||
if existing is not None:
|
||||
flash('Resuming the inspection you already started for this schedule.', 'info')
|
||||
return redirect(url_for('inspections.execute', inspection_id=existing.id))
|
||||
|
||||
inspection = Inspection(
|
||||
template_id = sched.template_id,
|
||||
facility_id = sched.facility_id,
|
||||
@@ -250,6 +356,12 @@ def start(schedule_id):
|
||||
inspection_date = now_eastern(),
|
||||
status = 'in_progress',
|
||||
scheduled_inspection_id = sched.id,
|
||||
# phase45 — a schedule created by "Schedule Follow-up" carries the
|
||||
# inspection it is a follow-up of. Inheriting it here is what makes the
|
||||
# run a real linked re-inspection: execute() pre-fills from the parent
|
||||
# and submit clears the parent's follow_up_required. NULL for ordinary
|
||||
# schedules, which is every pre-phase45 row.
|
||||
parent_inspection_id = sched.parent_inspection_id,
|
||||
)
|
||||
db.session.add(inspection)
|
||||
db.session.commit()
|
||||
@@ -271,10 +383,23 @@ def run_reminders():
|
||||
abort(403)
|
||||
|
||||
today = now_eastern().date()
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0}
|
||||
sent = {'advance': 0, 'due': 0, 'overdue': 0, 'expired': 0}
|
||||
|
||||
schedules = ScheduledInspection.query.filter_by(active=True).all()
|
||||
|
||||
# Expire schedules past their end date BEFORE any reminder work (phase44).
|
||||
# fulfill() closes out a schedule that reaches its boundary by being
|
||||
# completed; this covers the one that reaches it without ever being done —
|
||||
# otherwise it stays active and re-alerts as overdue indefinitely.
|
||||
live = []
|
||||
for s in schedules:
|
||||
if s.expire_if_past_end_date(today):
|
||||
sent['expired'] += 1
|
||||
logger.info('SCHED INSP | expired | id=%s | end=%s', s.id, s.end_date)
|
||||
else:
|
||||
live.append(s)
|
||||
schedules = live
|
||||
|
||||
# Cache admin/director recipients for overdue alerts
|
||||
managers = User.query.filter(
|
||||
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
|
||||
@@ -333,6 +458,6 @@ def run_reminders():
|
||||
sent['overdue'] += 1
|
||||
|
||||
db.session.commit()
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'])
|
||||
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s expired=%s',
|
||||
sent['advance'], sent['due'], sent['overdue'], sent['expired'])
|
||||
return {'ok': True, 'sent': sent}, 200
|
||||
|
||||
Reference in New Issue
Block a user