Jul 8 - Update scheduled inspection
This commit is contained in:
@@ -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 24–32:**
|
**Deploy order for phases 24–32:**
|
||||||
```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"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 28–31 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}>')
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
Reference in New Issue
Block a user