Jul 8 - Update scheduled inspection

This commit is contained in:
2026-07-08 15:46:14 -04:00
parent f08ca997d7
commit 4d661e9776
15 changed files with 795 additions and 6 deletions
+39 -1
View File
@@ -376,6 +376,25 @@ contract_notification_recipients:
`event_types` is a JSON list of `MATRIX_EVENTS` keys. A recipient fires only when the event is in its list. Managed admin-only on the **Contract detail page** (`/projects/<id>`) via `add_notify_recipient` / `remove_notify_recipient`. Dispatch is resolved centrally in `notify_by_matrix()` — see §11. `event_types` is a JSON list of `MATRIX_EVENTS` keys. A recipient fires only when the event is in its list. Managed admin-only on the **Contract detail page** (`/projects/<id>`) via `add_notify_recipient` / `remove_notify_recipient`. Dispatch is resolved centrally in `notify_by_matrix()` — see §11.
### ScheduledInspection
```
scheduled_inspections:
id, facility_id (FK→facilities CASCADE), template_id (FK→inspection_templates CASCADE),
inspector_id (FK→users SET NULL), frequency ENUM('once','daily','weekly','monthly'),
next_due_date DATE, active BOOL, notes TEXT, created_by, created_at,
last_completed_at DATETIME, advance_notified BOOL, due_notified BOOL, overdue_notified BOOL
inspections.scheduled_inspection_id FK→scheduled_inspections SET NULL ← Phase 36
```
**A plan, not an inspection.** Names a facility + template + assigned inspector + `next_due_date`. Lifecycle:
- The assigned inspector (or a manager) clicks **Start**`scheduled_inspections.start` creates a normal `in_progress` Inspection with `scheduled_inspection_id` set, then redirects to the execute flow.
- On **completion** (execute route, status → `completed`), `ScheduledInspection.fulfill()` runs in the same atomic commit: `once``active=False`; recurring → `next_due_date` rolls forward past today via `_add_interval()` and the three `*_notified` flags reset.
- **Reminders** are dispatched by the cron endpoint (see §11): advance (1 day before) + due-date to the inspector, overdue to admin/director — each fires at most once per occurrence via the `*_notified` flags. Uses `notify()` with `event_type=EVENT_SCHEDULED_INSPECTION`.
- Dashboard shows an **upcoming (next 7 days) / overdue** panel for non-customers (inspectors see only their own).
Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_required`; **Start** is the assigned inspector or a manager; inspectors' list/dashboard views are scoped to their own `inspector_id`.
--- ---
## 6. Role & Permission Matrix ## 6. Role & Permission Matrix
@@ -429,6 +448,7 @@ contract_notification_recipients:
| `audit` | `/audit` | list (admin only), view, purge | | `audit` | `/audit` | list (admin only), view, purge |
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF | | `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET /<id>/start` (assigned inspector or manager → creates linked inspection), `POST /run` (cron reminders, `token=DIGEST_SECRET`) |
| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` | | `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` (admin-only; fans out one Notification per targeted user) |
| `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) | | `devices` | `/admin/devices` | `GET /` (device list from `api_device_tokens`), `POST /notify` (admin-only) |
@@ -634,6 +654,7 @@ EVENT_ISSUE_FLAGGED = 'issue_flagged'
EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed'
EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27 EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36
``` ```
### Per-Contract Additional Recipients (Phase 33) ### Per-Contract Additional Recipients (Phase 33)
@@ -654,6 +675,7 @@ Contract recipients fire **regardless of matrix role toggles** — they are addi
| `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` | | `POST /notifications/check-sla` | SLA breach/at-risk alerts | `*/30 * * * *` |
| `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` | | `POST /notifications/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` |
| `POST /notifications/check-score-trends` | Facility score drop alerts (Phase 27) | `0 8 * * *` | | `POST /notifications/check-score-trends` | Facility score drop alerts (Phase 27) | `0 8 * * *` |
| `POST /scheduled-inspections/run` | Scheduled inspection reminders — advance/due to inspector, overdue to admin/director (Phase 36) | `*/30 * * * *` |
--- ---
@@ -733,7 +755,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase32_device_token_columns → phase32_device_token_columns
→ phase33_contract_recipients → phase33_contract_recipients
→ phase34_facility_qr → phase34_facility_qr
→ phase35_issue_handler ← HEAD → phase35_issue_handler
→ phase36_scheduled_insp ← HEAD
``` ```
### phase21_performance_indexes ### phase21_performance_indexes
@@ -839,6 +862,19 @@ flask db upgrade
sudo systemctl restart gunicorn sudo systemctl restart gunicorn
``` ```
### phase36_scheduled_insp
Revision id `phase36_scheduled_insp` (file `phase36_scheduled_inspections.py`). Creates `scheduled_inspections` (planned/recurring inspection assignments) and adds `inspections.scheduled_inspection_id` (FK → scheduled_inspections, SET NULL) that links a completed inspection back to the schedule that prompted it. See §5 `ScheduledInspection` and the Scheduled Inspections notes in §7/§11. `INFORMATION_SCHEMA` checks — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
# Add to cron (reminders — advance/due to inspector, overdue to admin/director):
# */30 * * * * curl -s -X POST https://yourdomain.com/scheduled-inspections/run \
# -d "token=YOUR_DIGEST_SECRET"
```
**Deploy order for phases 2432:** **Deploy order for phases 2432:**
```bash ```bash
flask db upgrade flask db upgrade
@@ -1096,6 +1132,8 @@ timeout = 30
-d "secret=SECRET" -d "secret=SECRET"
0 8 * * * curl -s -X POST https://your-domain.com/notifications/check-score-trends \ 0 8 * * * curl -s -X POST https://your-domain.com/notifications/check-score-trends \
-d "token=SECRET" -d "token=SECRET"
*/30 * * * * curl -s -X POST https://your-domain.com/scheduled-inspections/run \
-d "token=SECRET"
``` ```
--- ---
+2
View File
@@ -177,6 +177,7 @@ def create_app(config_name='default'):
from app.routes import broadcast # Admin broadcast notifications from app.routes import broadcast # Admin broadcast notifications
from app.routes import devices # Admin device registry from app.routes import devices # Admin device registry
from app.routes import public # Public facility QR pages (no login) from app.routes import public # Public facility QR pages (no login)
from app.routes import scheduled_inspections # Planned/recurring inspections
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp) app.register_blueprint(dashboard.bp)
@@ -194,6 +195,7 @@ def create_app(config_name='default'):
app.register_blueprint(broadcast.bp) app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.bp) app.register_blueprint(devices.bp)
app.register_blueprint(public.bp) app.register_blueprint(public.bp)
app.register_blueprint(scheduled_inspections.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
+1
View File
@@ -7,3 +7,4 @@ from app.models.project import Project, CustomerAssignment
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix from app.models.notification_matrix import NotificationMatrix
from app.models.notification_recipient import ContractNotificationRecipient from app.models.notification_recipient import ContractNotificationRecipient
from app.models.scheduled_inspection import ScheduledInspection
+7
View File
@@ -68,6 +68,13 @@ class Inspection(db.Model):
submit_latitude = db.Column(db.Numeric(10, 7), nullable=True) submit_latitude = db.Column(db.Numeric(10, 7), nullable=True)
submit_longitude = db.Column(db.Numeric(10, 7), nullable=True) submit_longitude = db.Column(db.Numeric(10, 7), nullable=True)
# Links a completed inspection back to the ScheduledInspection that
# prompted it (phase36). NULL for ad-hoc/manual inspections.
scheduled_inspection_id = db.Column(
db.Integer, db.ForeignKey('scheduled_inspections.id', ondelete='SET NULL'),
nullable=True, index=True
)
# ── Re-inspection / follow-up workflow ──────────────────────────────── # ── Re-inspection / follow-up workflow ────────────────────────────────
parent_inspection_id = db.Column( parent_inspection_id = db.Column(
db.Integer, db.ForeignKey('inspections.id', ondelete='SET NULL'), nullable=True db.Integer, db.ForeignKey('inspections.id', ondelete='SET NULL'), nullable=True
+6
View File
@@ -29,6 +29,10 @@ EVENT_SCORE_ALERT = 'score_alert'
EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all apps EVENT_ADMIN_BROADCAST = 'admin_broadcast' # bulk messages sent by admin to all apps
# Fired by the scheduled-inspection reminder cron (advance/due to the inspector,
# overdue to admin/director). Phase 36.
EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection'
ALL_EVENT_TYPES = { ALL_EVENT_TYPES = {
EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_ASSIGNED: 'Issue assigned to me',
EVENT_ISSUE_STATUS: 'Issue status changed', EVENT_ISSUE_STATUS: 'Issue status changed',
@@ -43,6 +47,8 @@ ALL_EVENT_TYPES = {
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)', EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
# Score trend alert — admin/director management use # Score trend alert — admin/director management use
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)', EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
# Scheduled inspection reminders (due/advance/overdue)
EVENT_SCHEDULED_INSPECTION: 'Scheduled inspection reminders (due / overdue)',
} }
+117
View File
@@ -0,0 +1,117 @@
"""
app/models/scheduled_inspection.py
-----------------------------------
Planned/recurring inspection assignments (phase36).
A ScheduledInspection is a PLAN, not an inspection: it names a facility, a
template, the responsible inspector, and a due date. The inspector opens it
("Start"), which creates a normal in_progress Inspection linked back via
Inspection.scheduled_inspection_id; when that inspection is completed the
schedule is marked fulfilled deactivated (one-time) or rolled forward to the
next occurrence (recurring).
Reminders are dispatched by the cron endpoint
POST /scheduled-inspections/run?token=DIGEST_SECRET:
- advance reminder to the inspector 1 day before the due date
- due reminder to the inspector on the due date
- overdue alert to admin/director once the due date passes uncompleted
The *_notified flags make each of those fire at most once per occurrence and
reset when a recurring schedule rolls forward.
"""
from datetime import timedelta
from app import db
from app.utils.time_utils import now_eastern
FREQUENCY_CHOICES = ('once', 'daily', 'weekly', 'monthly')
class ScheduledInspection(db.Model):
__tablename__ = 'scheduled_inspections'
id = db.Column(db.Integer, primary_key=True)
facility_id = db.Column(db.Integer,
db.ForeignKey('facilities.id', ondelete='CASCADE'),
nullable=False, index=True)
template_id = db.Column(db.Integer,
db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
nullable=False)
inspector_id = db.Column(db.Integer,
db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True, index=True)
frequency = db.Column(db.Enum(*FREQUENCY_CHOICES), nullable=False, default='once')
next_due_date = db.Column(db.Date, nullable=False, index=True)
active = db.Column(db.Boolean, nullable=False, default=True)
notes = db.Column(db.Text, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'),
nullable=True)
created_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
last_completed_at = db.Column(db.DateTime, nullable=True)
# Per-occurrence reminder de-dup flags (reset when a recurring one rolls forward)
advance_notified = db.Column(db.Boolean, nullable=False, default=False)
due_notified = db.Column(db.Boolean, nullable=False, default=False)
overdue_notified = db.Column(db.Boolean, nullable=False, default=False)
# Relationships
facility = db.relationship('Facility', foreign_keys=[facility_id])
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
inspector = db.relationship('User', foreign_keys=[inspector_id])
creator = db.relationship('User', foreign_keys=[created_by])
FREQUENCY_LABELS = {
'once': 'One-time',
'daily': 'Daily',
'weekly': 'Weekly',
'monthly': 'Monthly',
}
@property
def frequency_label(self):
return self.FREQUENCY_LABELS.get(self.frequency, self.frequency)
@staticmethod
def _add_interval(d, frequency):
"""Return d advanced by one interval of the given frequency."""
if frequency == 'daily':
return d + timedelta(days=1)
if frequency == 'weekly':
return d + timedelta(weeks=1)
if frequency == 'monthly':
# Add ~1 month by stepping 2831 days to the same day-of-month where possible.
month = d.month + 1
year = d.year + (1 if month > 12 else 0)
month = 1 if month > 12 else month
day = min(d.day, 28) # clamp to avoid invalid dates (e.g. Feb 30)
return d.replace(year=year, month=month, day=day)
return d # 'once' has no next interval
def is_overdue(self, today=None):
today = today or now_eastern().date()
return self.active and self.next_due_date < today
def fulfill(self):
"""Mark this occurrence complete. One-time schedules deactivate;
recurring ones roll their due date forward past today and reset the
reminder flags. Caller commits."""
self.last_completed_at = now_eastern()
if self.frequency == 'once':
self.active = False
return
# Recurring: advance until the next due date is in the future.
today = now_eastern().date()
nxt = self._add_interval(self.next_due_date, self.frequency)
guard = 0
while nxt <= today and guard < 400:
nxt = self._add_interval(nxt, self.frequency)
guard += 1
self.next_due_date = nxt
self.advance_notified = False
self.due_notified = False
self.overdue_notified = False
def __repr__(self):
return (f'<ScheduledInspection {self.id} facility={self.facility_id} '
f'freq={self.frequency} due={self.next_due_date}>')
+19
View File
@@ -337,8 +337,27 @@ def index():
.all() .all()
) )
# ── Scheduled inspections (phase36): upcoming / overdue ──────────────
sched_upcoming = []
sched_overdue_count = 0
if not is_customer:
from app.models.scheduled_inspection import ScheduledInspection
_today = now.date()
_sq = ScheduledInspection.query.filter_by(active=True)
if is_inspector:
_sq = _sq.filter(ScheduledInspection.inspector_id == current_user.id)
_all_sched = _sq.order_by(ScheduledInspection.next_due_date.asc()).all()
sched_overdue_count = sum(1 for s in _all_sched if s.next_due_date < _today)
# Upcoming = due today through the next 7 days (overdue shown separately)
sched_upcoming = [
s for s in _all_sched
if _today <= s.next_due_date <= _today + timedelta(days=7)
][:8]
return render_template( return render_template(
'dashboard.html', 'dashboard.html',
sched_upcoming = sched_upcoming,
sched_overdue_count = sched_overdue_count,
today_inspections = today_inspections, today_inspections = today_inspections,
completed_today = completed_today, completed_today = completed_today,
open_issues = open_issues, open_issues = open_issues,
+15
View File
@@ -579,6 +579,21 @@ def execute(inspection_id):
inspection_id = inspection.id, inspection_id = inspection.id,
facility_id = inspection.facility_id, facility_id = inspection.facility_id,
) )
# Fulfill the originating scheduled inspection, if any: one-time
# schedules deactivate; recurring ones roll their due date forward
# and reset reminder flags. Staged in the same atomic commit below.
if inspection.scheduled_inspection_id:
from app.models.scheduled_inspection import ScheduledInspection
sched = db.session.get(ScheduledInspection, inspection.scheduled_inspection_id)
if sched is not None:
sched.fulfill()
current_app.logger.info(
'SCHED INSP | fulfilled | schedule=%s | inspection=%s | next_due=%s',
sched.id, inspection.id,
sched.next_due_date if sched.active else 'deactivated',
)
db.session.commit() # Single atomic commit: inspection fields + notification rows db.session.commit() # Single atomic commit: inspection fields + notification rows
log_action(ACTION_UPDATE, 'Inspection', inspection.id, log_action(ACTION_UPDATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}', f'{inspection.template.name} @ {inspection.facility.name}',
+275
View File
@@ -0,0 +1,275 @@
"""
app/routes/scheduled_inspections.py
------------------------------------
Planned / recurring inspection assignments (phase36).
Management (list/new/edit/delete) : admin, director, project_manager
Start (execute the planned inspection): the assigned inspector, or admin/director/pm
Cron reminders : POST /run?token=DIGEST_SECRET (no login)
Fulfillment (marking a schedule done and rolling recurring ones forward) happens
in the inspection execute route when the linked inspection is completed
see app/routes/inspections.py.
"""
import logging
from datetime import timedelta
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, abort, current_app)
from flask_login import login_required, current_user
from app import db
from app.models.scheduled_inspection import ScheduledInspection
from app.models.facility import Facility
from app.models.inspection import Inspection, InspectionTemplate
from app.models.user import User
from app.utils.forms import ScheduledInspectionForm
from app.utils.decorators import project_manager_required
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
from app.models.notification import EVENT_SCHEDULED_INSPECTION
logger = logging.getLogger(__name__)
bp = Blueprint('scheduled_inspections', __name__, url_prefix='/scheduled-inspections')
def _populate_choices(form):
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
templates = (InspectionTemplate.query
.filter_by(active=True).order_by(InspectionTemplate.name).all())
inspectors = (User.query.filter_by(role='inspector', active=True)
.order_by(User.username).all())
form.facility_id.choices = [(f.id, f.name) for f in facilities]
form.template_id.choices = [(t.id, t.name) for t in templates]
form.inspector_id.choices = [(u.id, u.display_name) for u in inspectors]
# ── List ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
if current_user.role == 'customer':
abort(403)
today = now_eastern().date()
q = ScheduledInspection.query
# Inspectors see only their own assignments; managers see everything.
if current_user.role == 'inspector':
q = q.filter(ScheduledInspection.inspector_id == current_user.id)
schedules = q.order_by(
ScheduledInspection.active.desc(),
ScheduledInspection.next_due_date.asc(),
).all()
return render_template('scheduled_inspections/list.html',
schedules=schedules, today=today)
# ── Create ──────────────────────────────────────────────────────────────────
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@project_manager_required
def create():
form = ScheduledInspectionForm()
_populate_choices(form)
if not form.next_due_date.data:
form.next_due_date.data = now_eastern().date()
if form.validate_on_submit():
sched = ScheduledInspection(
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,
)
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}')
logger.info('SCHED INSP | create | by=%s | id=%s', current_user.username, sched.id)
flash('Scheduled inspection created.', 'success')
return redirect(url_for('scheduled_inspections.index'))
return render_template('scheduled_inspections/form.html',
form=form, title='New Scheduled Inspection')
# ── Edit ──────────────────────────────────────────────────────────────────────
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
@login_required
@project_manager_required
def edit(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
form = ScheduledInspectionForm(obj=sched)
_populate_choices(form)
if form.validate_on_submit():
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
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}')
flash('Scheduled inspection updated.', 'success')
return redirect(url_for('scheduled_inspections.index'))
return render_template('scheduled_inspections/form.html',
form=form, title='Edit Scheduled Inspection', schedule=sched)
# ── Delete ────────────────────────────────────────────────────────────────────
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
@login_required
@project_manager_required
def delete(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
label = f'{sched.template.name if sched.template else "?"} @ {sched.facility.name if sched.facility else "?"}'
sid = sched.id
db.session.delete(sched)
db.session.commit()
log_action(ACTION_DELETE, 'ScheduledInspection', sid, label)
flash('Scheduled inspection deleted.', 'success')
return redirect(url_for('scheduled_inspections.index'))
# ── Start (create the planned inspection and open the execute flow) ───────────
@bp.route('/<int:schedule_id>/start')
@login_required
def start(schedule_id):
sched = db.session.get(ScheduledInspection, schedule_id)
if sched is None:
abort(404)
if current_user.role == 'customer':
abort(403)
# Only the assigned inspector, or a manager, may start it.
if current_user.role == 'inspector' and sched.inspector_id != current_user.id:
abort(403)
if not sched.active:
flash('This scheduled inspection is no longer active.', 'warning')
return redirect(url_for('scheduled_inspections.index'))
template = sched.template
if template is None or not template.get_form_schema():
flash('The template for this schedule has no form fields yet.', 'warning')
return redirect(url_for('scheduled_inspections.index'))
inspection = Inspection(
template_id = sched.template_id,
facility_id = sched.facility_id,
area_id = None,
inspector_id = current_user.id,
inspection_date = now_eastern(),
status = 'in_progress',
scheduled_inspection_id = sched.id,
)
db.session.add(inspection)
db.session.commit()
log_action(ACTION_CREATE, 'Inspection', inspection.id,
f'{inspection.template.name} @ {inspection.facility.name}',
f'from scheduled_inspection_id={sched.id}')
logger.info('SCHED INSP | start | schedule=%s | inspection=%s | by=%s',
sched.id, inspection.id, current_user.username)
flash('Inspection started from schedule. Complete and submit the form below.', 'info')
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
# ── Cron: reminders (advance / due / overdue) ─────────────────────────────────
@bp.route('/run', methods=['POST'])
def run_reminders():
token = request.form.get('token') or request.args.get('token')
if not token or token != current_app.config.get('DIGEST_SECRET'):
abort(403)
today = now_eastern().date()
sent = {'advance': 0, 'due': 0, 'overdue': 0}
schedules = ScheduledInspection.query.filter_by(active=True).all()
# Cache admin/director recipients for overdue alerts
managers = User.query.filter(
User.role.in_(['admin', 'director']), User.active == True # noqa: E712
).all()
for s in schedules:
inspector = s.inspector
link = url_for('scheduled_inspections.index')
fac_name = s.facility.name if s.facility else ''
tpl_name = s.template.name if s.template else ''
# Advance reminder — 1 day before due
if (not s.advance_notified and inspector and inspector.active
and s.next_due_date == today + timedelta(days=1)):
notify(
recipient = inspector,
title = f'Inspection due tomorrow — {fac_name}',
body = (f'Reminder: a "{tpl_name}" inspection at {fac_name} '
f'is scheduled for tomorrow ({s.next_due_date:%b %d, %Y}).'),
link = link,
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
)
s.advance_notified = True
sent['advance'] += 1
# Due reminder — on/after due date
if (not s.due_notified and inspector and inspector.active
and s.next_due_date <= today):
notify(
recipient = inspector,
title = f'Inspection due today — {fac_name}',
body = (f'A "{tpl_name}" inspection at {fac_name} is due '
f'({s.next_due_date:%b %d, %Y}). Please complete it.'),
link = link,
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
)
s.due_notified = True
sent['due'] += 1
# Overdue alert — due date has passed, still not fulfilled
if not s.overdue_notified and s.next_due_date < today:
for m in managers:
notify(
recipient = m,
title = f'Overdue scheduled inspection — {fac_name}',
body = (f'The "{tpl_name}" inspection at {fac_name} assigned to '
f'{inspector.display_name if inspector else ""} was due '
f'{s.next_due_date:%b %d, %Y} and has not been completed.'),
link = link,
event_type = EVENT_SCHEDULED_INSPECTION,
send_email = True,
)
s.overdue_notified = True
sent['overdue'] += 1
db.session.commit()
logger.info('SCHED INSP | reminders | advance=%s due=%s overdue=%s',
sent['advance'], sent['due'], sent['overdue'])
return {'ok': True, 'sent': sent}, 200
+47
View File
@@ -11,6 +11,53 @@
</div> </div>
</div> </div>
{# ── Scheduled inspections: upcoming / overdue (phase36) ─────────────────── #}
{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %}
<div class="card shadow-sm mb-4 border-0" style="border-left:4px solid #6366f1 !important;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="fw-bold"><i class="bi bi-calendar-check text-primary"></i> Scheduled Inspections</span>
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-sm btn-outline-primary">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2 mb-2">
<i class="bi bi-alarm-fill"></i>
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
</div>
{% endif %}
{% if sched_upcoming %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 align-middle">
<thead class="table-light">
<tr><th>Facility</th><th>Template</th><th>Inspector</th><th>Due</th><th></th></tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
<td class="text-end">
{% if current_user.role in ['admin','director','project_manager']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted small mb-0">No inspections due in the next 7 days.</p>
{% endif %}
</div>
</div>
{% endif %}
{# ── Inspections section ─────────────────────────────────────────────────── #} {# ── Inspections section ─────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-clipboard-data-fill text-primary"></i> <i class="bi bi-clipboard-data-fill text-primary"></i>
+5
View File
@@ -4,9 +4,14 @@
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2> <h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% if current_user.role != 'customer' %} {% if current_user.role != 'customer' %}
<div class="d-flex gap-2">
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-calendar-check"></i> Scheduled
</a>
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary"> <a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection <i class="bi bi-plus-circle"></i> New Inspection
</a> </a>
</div>
{% endif %} {% endif %}
</div> </div>
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="mb-0">{{ title }}</h5></div>
<div class="card-body">
<form method="POST" novalidate>
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.facility_id.label(class="form-label fw-semibold") }}
{{ form.facility_id(class="form-select") }}
{% for e in form.facility_id.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.template_id.label(class="form-label fw-semibold") }}
{{ form.template_id(class="form-select") }}
{% for e in form.template_id.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.inspector_id.label(class="form-label fw-semibold") }}
{{ form.inspector_id(class="form-select") }}
{% for e in form.inspector_id.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.frequency.label(class="form-label fw-semibold") }}
{{ form.frequency(class="form-select") }}
</div>
<div class="col-md-6 mb-3">
{{ form.next_due_date.label(class="form-label fw-semibold") }}
{{ form.next_due_date(class="form-control", type="date") }}
{% for e in form.next_due_date.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
</div>
<div class="mb-3">
{{ form.notes.label(class="form-label fw-semibold") }}
{{ form.notes(class="form-control", rows=2, placeholder="Optional instructions for the inspector…") }}
</div>
<div class="form-check mb-3">
{{ form.active(class="form-check-input") }}
{{ form.active.label(class="form-check-label") }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save</button>
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
<p class="text-muted small mt-2">
Recurring schedules automatically roll their due date forward each time the
inspection is completed. The assigned inspector is reminded the day before
and on the due date; managers are alerted if it becomes overdue.
</p>
</div>
</div>
{% endblock %}
@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Scheduled Inspections{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-calendar-check"></i> Scheduled Inspections</h2>
<p class="text-muted mb-0">Planned and recurring inspection assignments.</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('inspections.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Inspections
</a>
{% if current_user.role in ['admin','director','project_manager'] %}
<a href="{{ url_for('scheduled_inspections.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
{% endif %}
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
{% if schedules %}
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Facility</th>
<th>Template</th>
<th>Inspector</th>
<th>Frequency</th>
<th>Next Due</th>
<th>Status</th>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for s in schedules %}
{% set overdue = s.active and s.next_due_date < today %}
{% set due_soon = s.active and not overdue and (s.next_due_date - today).days <= 7 %}
<tr class="{{ 'table-danger' if overdue else 'table-warning' if due_soon else '' }}">
<td><strong>{{ s.facility.name if s.facility else '—' }}</strong></td>
<td>{{ s.template.name if s.template else '—' }}</td>
<td>{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}</td>
<td><span class="badge bg-secondary">{{ s.frequency_label }}</span></td>
<td>
{{ s.next_due_date.strftime('%b %d, %Y') }}
{% if overdue %}
<span class="badge bg-danger ms-1"><i class="bi bi-alarm"></i> Overdue</span>
{% elif due_soon %}
<span class="badge bg-warning text-dark ms-1">Due soon</span>
{% endif %}
</td>
<td>
{% if s.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td class="text-end text-nowrap">
{% if s.active and (current_user.role in ['admin','director','project_manager']
or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success" title="Start this inspection">
<i class="bi bi-play-fill"></i> Start
</a>
{% endif %}
{% if current_user.role in ['admin','director','project_manager'] %}
<a href="{{ url_for('scheduled_inspections.edit', schedule_id=s.id) }}"
class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i></a>
<form method="POST" class="d-inline"
action="{{ url_for('scheduled_inspections.delete', schedule_id=s.id) }}"
onsubmit="return confirm('Delete this scheduled inspection?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-muted text-center">
No scheduled inspections yet.
{% if current_user.role in ['admin','director','project_manager'] %}
<a href="{{ url_for('scheduled_inspections.create') }}">Create one</a>.
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endblock %}
+16 -1
View File
@@ -2,7 +2,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField, from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField, DecimalField, BooleanField, IntegerField, HiddenField,
RadioField) RadioField, DateField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo, from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError) Optional, NumberRange, ValidationError)
from app.models.user import User from app.models.user import User
@@ -319,3 +319,18 @@ class PublicIssueReportForm(FlaskForm):
FileAllowed(['jpg', 'jpeg', 'png', 'gif'], FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
'Images only (jpg, png, gif).')]) 'Images only (jpg, png, gif).')])
website = StringField('Website') # honeypot — must stay empty website = StringField('Website') # honeypot — must stay empty
# ── Scheduled Inspections (phase36) ──────────────────────────────────────────
class ScheduledInspectionForm(FlaskForm):
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
template_id = SelectField('Template', coerce=int, validators=[DataRequired()])
inspector_id = SelectField('Assign Inspector', coerce=int, validators=[DataRequired()])
frequency = SelectField('Frequency', choices=[
('once', 'One-time'), ('daily', 'Daily'),
('weekly', 'Weekly'), ('monthly', 'Monthly'),
], validators=[DataRequired()])
next_due_date = DateField('Due Date', validators=[DataRequired()])
notes = TextAreaField('Notes', validators=[Optional(), Length(max=1000)])
active = BooleanField('Active', default=True)
@@ -0,0 +1,79 @@
"""phase36 — scheduled_inspections + inspections.scheduled_inspection_id
Planned/recurring inspection assignments (facility + template + inspector +
due date). Completed inspections link back via inspections.scheduled_inspection_id.
Uses INFORMATION_SCHEMA existence checks safe to re-run.
"""
revision = 'phase36_scheduled_insp'
down_revision = 'phase35_issue_handler'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
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 upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'scheduled_inspections'):
op.create_table(
'scheduled_inspections',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('facility_id', sa.Integer,
sa.ForeignKey('facilities.id', ondelete='CASCADE'), nullable=False),
sa.Column('template_id', sa.Integer,
sa.ForeignKey('inspection_templates.id', ondelete='CASCADE'), nullable=False),
sa.Column('inspector_id', sa.Integer,
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('frequency', sa.Enum('once', 'daily', 'weekly', 'monthly'),
nullable=False, server_default='once'),
sa.Column('next_due_date', sa.Date, nullable=False),
sa.Column('active', sa.Boolean, nullable=False, server_default='1'),
sa.Column('notes', sa.Text, nullable=True),
sa.Column('created_by', sa.Integer,
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False),
sa.Column('last_completed_at', sa.DateTime, nullable=True),
sa.Column('advance_notified', sa.Boolean, nullable=False, server_default='0'),
sa.Column('due_notified', sa.Boolean, nullable=False, server_default='0'),
sa.Column('overdue_notified', sa.Boolean, nullable=False, server_default='0'),
)
op.create_index('ix_sched_insp_facility', 'scheduled_inspections', ['facility_id'])
op.create_index('ix_sched_insp_inspector', 'scheduled_inspections', ['inspector_id'])
op.create_index('ix_sched_insp_due', 'scheduled_inspections', ['next_due_date'])
if not _column_exists(bind, 'inspections', 'scheduled_inspection_id'):
op.execute(sa.text(
"ALTER TABLE inspections ADD COLUMN scheduled_inspection_id INT NULL"
))
op.execute(sa.text(
"ALTER TABLE inspections ADD CONSTRAINT fk_inspection_scheduled "
"FOREIGN KEY (scheduled_inspection_id) REFERENCES scheduled_inspections(id) "
"ON DELETE SET NULL"
))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'inspections', 'scheduled_inspection_id'):
op.execute(sa.text("ALTER TABLE inspections DROP FOREIGN KEY fk_inspection_scheduled"))
op.execute(sa.text("ALTER TABLE inspections DROP COLUMN scheduled_inspection_id"))
if _table_exists(bind, 'scheduled_inspections'):
op.drop_table('scheduled_inspections')