July 4 - Update with recurring/scheduled inspections
This commit is contained in:
@@ -389,6 +389,19 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
|
|||||||
UniqueConstraint(event_type, role_key)
|
UniqueConstraint(event_type, role_key)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### InspectionSchedule (phase34)
|
||||||
|
|
||||||
|
```
|
||||||
|
inspection_schedules: id, name VARCHAR(255), template_id (FK→inspection_templates CASCADE),
|
||||||
|
facility_id (FK→facilities CASCADE), area_id (FK→areas SET NULL, nullable),
|
||||||
|
inspector_id (FK→users CASCADE), frequency ENUM(daily/weekly/monthly/quarterly),
|
||||||
|
active BOOL, created_by (FK→users SET NULL), created_at DATETIME,
|
||||||
|
last_run_at DATETIME NULL, next_run_at DATETIME NULL
|
||||||
|
INDEX ix_ischd_active_next (active, next_run_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
Recurring inspection generator. `POST /inspection-schedules/run` (token-protected cron) walks active schedules where `next_run_at <= now`, creates one `in_progress` Inspection per due schedule (assigned to `inspector_id`, dated now), notifies the inspector (`event_type='inspection_scheduled'`), then advances `next_run_at`. Managed at `/inspection-schedules` by admin/director/project_manager. Purely additive — a schedule is an automated `inspections.start()`.
|
||||||
|
|
||||||
### AuditLog
|
### AuditLog
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -473,6 +486,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
|||||||
| `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) |
|
||||||
|
| `inspection_schedules` | `/inspection-schedules` | phase34 — recurring inspection CRUD (`@project_manager_required`) + `POST /run-now` (manual) + `POST /run` (token-protected cron materialiser) |
|
||||||
| `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 push to iOS via Notification rows (phase29) |
|
| `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` — admin-only push to iOS via Notification rows (phase29) |
|
||||||
| `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) |
|
| `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) |
|
||||||
@@ -675,6 +689,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_INSPECTION_SCHEDULED = 'inspection_scheduled' ← phase34
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
### Cron Endpoints (all require `token=DIGEST_SECRET`)
|
||||||
@@ -687,6 +702,7 @@ EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
|
|||||||
| `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 /notifications/trial-reminders` | Trial-ending warning emails (≤3 days left) | `0 9 * * *` |
|
| `POST /notifications/trial-reminders` | Trial-ending warning emails (≤3 days left) | `0 9 * * *` |
|
||||||
| `POST /notifications/dunning-reminders` | Payment-failure escalation emails (day 3/7/14) | `0 10 * * *` |
|
| `POST /notifications/dunning-reminders` | Payment-failure escalation emails (day 3/7/14) | `0 10 * * *` |
|
||||||
|
| `POST /inspection-schedules/run` | Materialise due recurring inspections (phase34) | `0 6 * * *` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -741,7 +757,7 @@ limiter = Limiter(
|
|||||||
|
|
||||||
## 17. Alembic Migration Chain
|
## 17. Alembic Migration Chain
|
||||||
|
|
||||||
**Current HEAD:** `phase33_tenant_settings` (31 migrations total).
|
**Current HEAD:** `phase34_inspection_schedules` (32 migrations total).
|
||||||
|
|
||||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||||
|
|
||||||
@@ -769,7 +785,20 @@ limiter = Limiter(
|
|||||||
→ phase30_device_registry
|
→ phase30_device_registry
|
||||||
→ phase31_device_registry
|
→ phase31_device_registry
|
||||||
→ phase32_device_token_columns
|
→ phase32_device_token_columns
|
||||||
→ phase33_tenant_settings ← HEAD
|
→ phase33_tenant_settings
|
||||||
|
→ phase34_inspection_schedules ← HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
### phase34_inspection_schedules
|
||||||
|
|
||||||
|
Creates the `inspection_schedules` table backing recurring/automated inspections (see §5 model + the `inspection_schedules` blueprint). A schedule pairs a template + facility (+ optional area) + inspector + cadence; the cron endpoint `POST /inspection-schedules/run` materialises a real `in_progress` Inspection per due schedule and notifies the inspector (`event_type='inspection_scheduled'`). Uses an `INFORMATION_SCHEMA` table-existence check — safe to re-run.
|
||||||
|
|
||||||
|
**Deploy order:**
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
# Add to cron (materialise due schedules daily at 06:00):
|
||||||
|
# 0 6 * * * curl -s -X POST https://yourdomain.com/inspection-schedules/run -d "token=YOUR_DIGEST_SECRET"
|
||||||
```
|
```
|
||||||
|
|
||||||
### phase28_fix_inspection_notify
|
### phase28_fix_inspection_notify
|
||||||
@@ -1162,6 +1191,8 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
-d "token=SECRET"
|
-d "token=SECRET"
|
||||||
0 10 * * * curl -s -X POST https://your-domain.com/notifications/dunning-reminders \
|
0 10 * * * curl -s -X POST https://your-domain.com/notifications/dunning-reminders \
|
||||||
-d "token=SECRET"
|
-d "token=SECRET"
|
||||||
|
0 6 * * * curl -s -X POST https://your-domain.com/inspection-schedules/run \
|
||||||
|
-d "token=SECRET"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1596,7 +1627,7 @@ Ask: Does this change break any other code path that uses the modified function,
|
|||||||
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
**Rule 13 — List every file changed** with the exact location of each change (function name and what was modified).
|
||||||
|
|
||||||
**Rule 14 — Migrations are required for any schema change.**
|
**Rule 14 — Migrations are required for any schema change.**
|
||||||
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase33_tenant_settings`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
Follow the `phase{N}_description.py` naming convention. The new migration's `down_revision` must point to the current HEAD (`phase34_inspection_schedules`). Use `INFORMATION_SCHEMA` existence checks so migrations are safe to re-run. Never use `batch_alter_table` for MySQL.
|
||||||
|
|
||||||
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
Self-contained package, own `ControlBase` + engine/session, own Alembic chain. No imports from `app/`.
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ db = SQLAlchemy(session_options={'class_': RoutingSession})
|
|||||||
|
|
||||||
Two independent Alembic chains:
|
Two independent Alembic chains:
|
||||||
|
|
||||||
1. **Tenant schema** — existing chain (HEAD: **`phase33_tenant_settings`**). Runs per-tenant DB. New tenant features continue as `phase34_…` per existing naming.
|
1. **Tenant schema** — existing chain (HEAD: **`phase34_inspection_schedules`**). Runs per-tenant DB. New tenant features continue as `phase35_…` per existing naming.
|
||||||
2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0004_dunning_tracking` (`control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking`).
|
2. **Control schema** — chain `control{N}_…`, runs once against `jqc_control`. HEAD: `control0004_dunning_tracking` (`control0001_init → control0002_billing → control0003_trial_reminder_sent → control0004_dunning_tracking`).
|
||||||
|
|
||||||
**CLI (always source env first):**
|
**CLI (always source env first):**
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ def create_app(config_name='default'):
|
|||||||
from app.routes import projects # Phase 1/2 — Project management
|
from app.routes import projects # Phase 1/2 — Project management
|
||||||
from app.routes import customers # Phase 5 — Customer management
|
from app.routes import customers # Phase 5 — Customer management
|
||||||
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
|
||||||
|
from app.routes import inspection_schedules # phase34 — recurring inspections
|
||||||
from app.routes import support # Support chat + admin tickets
|
from app.routes import support # Support chat + admin tickets
|
||||||
from app.routes import broadcast # Admin broadcast messages
|
from app.routes import broadcast # Admin broadcast messages
|
||||||
from app.routes import devices # Admin device management
|
from app.routes import devices # Admin device management
|
||||||
@@ -221,6 +222,7 @@ def create_app(config_name='default'):
|
|||||||
app.register_blueprint(projects.bp)
|
app.register_blueprint(projects.bp)
|
||||||
app.register_blueprint(customers.bp)
|
app.register_blueprint(customers.bp)
|
||||||
app.register_blueprint(scheduled_reports.bp)
|
app.register_blueprint(scheduled_reports.bp)
|
||||||
|
app.register_blueprint(inspection_schedules.bp)
|
||||||
app.register_blueprint(support.bp)
|
app.register_blueprint(support.bp)
|
||||||
app.register_blueprint(broadcast.bp)
|
app.register_blueprint(broadcast.bp)
|
||||||
app.register_blueprint(devices.bp)
|
app.register_blueprint(devices.bp)
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ from app.models.issue import Issue
|
|||||||
from app.models.project import Project, CustomerAssignment
|
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.inspection_schedule import InspectionSchedule
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
app/models/inspection_schedule.py
|
||||||
|
---------------------------------
|
||||||
|
Recurring inspection schedules (phase34).
|
||||||
|
|
||||||
|
An InspectionSchedule declares that a given template should be inspected at a
|
||||||
|
given facility (optionally scoped to one area) by a given inspector on a fixed
|
||||||
|
cadence. A token-protected cron endpoint (`/inspection-schedules/run`) walks
|
||||||
|
all active, due schedules and *materialises* a real `Inspection` row in
|
||||||
|
`in_progress` status — exactly as if the inspector had clicked "Start
|
||||||
|
Inspection" — then notifies the assigned inspector. The inspector opens it from
|
||||||
|
their queue and fills it out through the normal execute flow.
|
||||||
|
|
||||||
|
This is purely additive: no existing inspection behaviour changes. A schedule is
|
||||||
|
just an automated `inspections.start()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
|
||||||
|
class InspectionSchedule(db.Model):
|
||||||
|
__tablename__ = 'inspection_schedules'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
template_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('inspection_templates.id', ondelete='CASCADE'),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
facility_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
area_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('areas.id', ondelete='SET NULL'),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
inspector_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# daily | weekly | monthly | quarterly — mirrors InspectionTemplate.frequency
|
||||||
|
frequency = db.Column(
|
||||||
|
db.Enum('daily', 'weekly', 'monthly', 'quarterly'),
|
||||||
|
nullable=False, default='weekly'
|
||||||
|
)
|
||||||
|
active = db.Column(db.Boolean, nullable=False, default=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_run_at = db.Column(db.DateTime, nullable=True) # last successful materialisation
|
||||||
|
next_run_at = db.Column(db.DateTime, nullable=True) # when the next inspection is due
|
||||||
|
|
||||||
|
# Relationships — explicit foreign_keys because two columns point at users.id.
|
||||||
|
template = db.relationship('InspectionTemplate', foreign_keys=[template_id])
|
||||||
|
facility = db.relationship('Facility', foreign_keys=[facility_id])
|
||||||
|
area = db.relationship('Area', foreign_keys=[area_id])
|
||||||
|
inspector = db.relationship('User', foreign_keys=[inspector_id])
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<InspectionSchedule {self.id} {self.name!r} {self.frequency}>'
|
||||||
@@ -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 when a recurring InspectionSchedule materialises a due inspection and
|
||||||
|
# notifies the assigned inspector (phase34).
|
||||||
|
EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled'
|
||||||
|
|
||||||
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',
|
||||||
@@ -38,6 +42,7 @@ ALL_EVENT_TYPES = {
|
|||||||
EVENT_INSPECTION_DONE: 'Inspection completed',
|
EVENT_INSPECTION_DONE: 'Inspection completed',
|
||||||
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
EVENT_SLA_ALERT: 'SLA at-risk / breached alerts',
|
||||||
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)',
|
||||||
|
EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)',
|
||||||
# Customer-facing — only relevant for customer role accounts
|
# Customer-facing — only relevant for customer role accounts
|
||||||
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)',
|
||||||
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)',
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""
|
||||||
|
app/routes/inspection_schedules.py
|
||||||
|
-----------------------------------
|
||||||
|
CRUD management for recurring InspectionSchedule configs + a cron-triggered
|
||||||
|
materialisation endpoint (phase34).
|
||||||
|
|
||||||
|
Management is admin / director / project_manager (@project_manager_required),
|
||||||
|
mirroring who may start inspections. The /run route is token-protected with the
|
||||||
|
same DIGEST_SECRET used by the other cron endpoints.
|
||||||
|
|
||||||
|
Cron example
|
||||||
|
------------
|
||||||
|
# Every day at 06:00 — materialises all schedules that have come due.
|
||||||
|
0 6 * * * curl -s -X POST https://yourdomain.com/inspection-schedules/run \
|
||||||
|
-d "token=YOUR_DIGEST_SECRET"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for, flash,
|
||||||
|
request, jsonify, current_app, abort)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
|
from app import db, csrf
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
from app.models.inspection import Inspection, InspectionTemplate
|
||||||
|
from app.models.facility import Facility, Area
|
||||||
|
from app.models.user import User
|
||||||
|
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_INSPECTION_SCHEDULED
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
bp = Blueprint('inspection_schedules', __name__, url_prefix='/inspection-schedules')
|
||||||
|
|
||||||
|
_FREQUENCIES = ('daily', 'weekly', 'monthly', 'quarterly')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _compute_next_run(frequency: str, from_dt: datetime = None) -> datetime:
|
||||||
|
"""Return the next due datetime for a given cadence, at 06:00 local.
|
||||||
|
|
||||||
|
Monthly/quarterly advance by calendar months (targeting the same day-of-month
|
||||||
|
is avoided — we simply add 30/90 days, which is predictable and never raises
|
||||||
|
the datetime.replace(month=13) ValueError).
|
||||||
|
"""
|
||||||
|
now = from_dt or now_eastern()
|
||||||
|
if frequency == 'daily':
|
||||||
|
base = now + timedelta(days=1)
|
||||||
|
elif frequency == 'weekly':
|
||||||
|
base = now + timedelta(weeks=1)
|
||||||
|
elif frequency == 'monthly':
|
||||||
|
base = now + timedelta(days=30)
|
||||||
|
else: # quarterly
|
||||||
|
base = now + timedelta(days=90)
|
||||||
|
return base.replace(hour=6, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _active_inspectors():
|
||||||
|
"""Users who can be assigned inspections (inspector-capable roles)."""
|
||||||
|
return User.query.filter(
|
||||||
|
User.active.is_(True),
|
||||||
|
User.role.in_(['inspector', 'project_manager', 'director', 'admin']),
|
||||||
|
).order_by(User.full_name, User.username).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _materialise(schedule: InspectionSchedule, when: datetime) -> Inspection:
|
||||||
|
"""Create an in_progress Inspection from a schedule and notify the inspector.
|
||||||
|
|
||||||
|
Does NOT commit — the caller commits after advancing schedule bookkeeping so
|
||||||
|
the new inspection and the schedule update land in one transaction.
|
||||||
|
"""
|
||||||
|
inspection = Inspection(
|
||||||
|
template_id = schedule.template_id,
|
||||||
|
facility_id = schedule.facility_id,
|
||||||
|
area_id = schedule.area_id,
|
||||||
|
inspector_id = schedule.inspector_id,
|
||||||
|
inspection_date = when,
|
||||||
|
status = 'in_progress',
|
||||||
|
notes = None,
|
||||||
|
)
|
||||||
|
db.session.add(inspection)
|
||||||
|
db.session.flush() # assign inspection.id without committing
|
||||||
|
|
||||||
|
inspector = schedule.inspector
|
||||||
|
if inspector is not None:
|
||||||
|
fac_name = schedule.facility.name if schedule.facility else 'a facility'
|
||||||
|
notify(
|
||||||
|
inspector,
|
||||||
|
title='Scheduled inspection due',
|
||||||
|
body=(f'A recurring inspection "{schedule.name}" at {fac_name} '
|
||||||
|
f'is due. Open it from your inspections list to begin.'),
|
||||||
|
link=url_for('inspections.execute', inspection_id=inspection.id),
|
||||||
|
inspection_id=inspection.id,
|
||||||
|
event_type=EVENT_INSPECTION_SCHEDULED,
|
||||||
|
)
|
||||||
|
return inspection
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
@project_manager_required
|
||||||
|
def index():
|
||||||
|
schedules = InspectionSchedule.query.order_by(
|
||||||
|
InspectionSchedule.active.desc(), InspectionSchedule.name
|
||||||
|
).all()
|
||||||
|
return render_template('inspection_schedules/index.html',
|
||||||
|
schedules=schedules, now=now_eastern())
|
||||||
|
|
||||||
|
|
||||||
|
def _form_choices():
|
||||||
|
templates = InspectionTemplate.query.filter_by(active=True).order_by(InspectionTemplate.name).all()
|
||||||
|
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
|
||||||
|
inspectors = _active_inspectors()
|
||||||
|
return templates, facilities, inspectors
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@project_manager_required
|
||||||
|
def create():
|
||||||
|
templates, facilities, inspectors = _form_choices()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
template_id = request.form.get('template_id', type=int)
|
||||||
|
facility_id = request.form.get('facility_id', type=int)
|
||||||
|
area_id = request.form.get('area_id', type=int) or None
|
||||||
|
inspector_id = request.form.get('inspector_id', type=int)
|
||||||
|
frequency = request.form.get('frequency', 'weekly')
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if not name:
|
||||||
|
errors.append('A schedule name is required.')
|
||||||
|
if not template_id or db.session.get(InspectionTemplate, template_id) is None:
|
||||||
|
errors.append('Please choose a valid template.')
|
||||||
|
if not facility_id or db.session.get(Facility, facility_id) is None:
|
||||||
|
errors.append('Please choose a valid facility.')
|
||||||
|
if not inspector_id or db.session.get(User, inspector_id) is None:
|
||||||
|
errors.append('Please choose a valid inspector.')
|
||||||
|
if frequency not in _FREQUENCIES:
|
||||||
|
errors.append('Invalid frequency.')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
flash(e, 'warning')
|
||||||
|
return render_template('inspection_schedules/form.html',
|
||||||
|
templates=templates, facilities=facilities,
|
||||||
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
|
title='New Inspection Schedule')
|
||||||
|
|
||||||
|
schedule = InspectionSchedule(
|
||||||
|
name = name,
|
||||||
|
template_id = template_id,
|
||||||
|
facility_id = facility_id,
|
||||||
|
area_id = area_id,
|
||||||
|
inspector_id = inspector_id,
|
||||||
|
frequency = frequency,
|
||||||
|
active = True,
|
||||||
|
created_by = current_user.id,
|
||||||
|
created_at = now_eastern(),
|
||||||
|
next_run_at = _compute_next_run(frequency),
|
||||||
|
)
|
||||||
|
db.session.add(schedule)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_CREATE, 'InspectionSchedule', schedule.id, schedule.name,
|
||||||
|
f'frequency={frequency}; template_id={template_id}; facility_id={facility_id}')
|
||||||
|
flash(f'Inspection schedule "{schedule.name}" created.', 'success')
|
||||||
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
|
return render_template('inspection_schedules/form.html',
|
||||||
|
templates=templates, facilities=facilities,
|
||||||
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
|
title='New Inspection Schedule')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:schedule_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
@project_manager_required
|
||||||
|
def edit(schedule_id):
|
||||||
|
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||||
|
if schedule is None:
|
||||||
|
abort(404)
|
||||||
|
templates, facilities, inspectors = _form_choices()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
schedule.name = request.form.get('name', '').strip() or schedule.name
|
||||||
|
template_id = request.form.get('template_id', type=int)
|
||||||
|
facility_id = request.form.get('facility_id', type=int)
|
||||||
|
inspector_id = request.form.get('inspector_id', type=int)
|
||||||
|
frequency = request.form.get('frequency', schedule.frequency)
|
||||||
|
|
||||||
|
if template_id and db.session.get(InspectionTemplate, template_id):
|
||||||
|
schedule.template_id = template_id
|
||||||
|
if facility_id and db.session.get(Facility, facility_id):
|
||||||
|
schedule.facility_id = facility_id
|
||||||
|
if inspector_id and db.session.get(User, inspector_id):
|
||||||
|
schedule.inspector_id = inspector_id
|
||||||
|
schedule.area_id = request.form.get('area_id', type=int) or None
|
||||||
|
if frequency in _FREQUENCIES:
|
||||||
|
schedule.frequency = frequency
|
||||||
|
schedule.active = bool(request.form.get('active'))
|
||||||
|
# Recompute the next run from now against the (possibly changed) cadence.
|
||||||
|
schedule.next_run_at = _compute_next_run(schedule.frequency)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
|
||||||
|
f'frequency={schedule.frequency}; active={schedule.active}')
|
||||||
|
flash(f'Inspection schedule "{schedule.name}" updated.', 'success')
|
||||||
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
|
return render_template('inspection_schedules/form.html', schedule=schedule,
|
||||||
|
templates=templates, facilities=facilities,
|
||||||
|
inspectors=inspectors, frequencies=_FREQUENCIES,
|
||||||
|
title='Edit Inspection Schedule')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:schedule_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@project_manager_required
|
||||||
|
def delete(schedule_id):
|
||||||
|
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||||
|
if schedule is None:
|
||||||
|
abort(404)
|
||||||
|
name = schedule.name
|
||||||
|
sid = schedule.id
|
||||||
|
db.session.delete(schedule)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_DELETE, 'InspectionSchedule', sid, name)
|
||||||
|
flash(f'Inspection schedule "{name}" deleted.', 'success')
|
||||||
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:schedule_id>/run-now', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@project_manager_required
|
||||||
|
def run_now(schedule_id):
|
||||||
|
"""Manually materialise one inspection from a schedule — for testing / ad-hoc."""
|
||||||
|
schedule = db.session.get(InspectionSchedule, schedule_id)
|
||||||
|
if schedule is None:
|
||||||
|
abort(404)
|
||||||
|
now = now_eastern()
|
||||||
|
inspection = _materialise(schedule, now)
|
||||||
|
schedule.last_run_at = now
|
||||||
|
schedule.next_run_at = _compute_next_run(schedule.frequency, now)
|
||||||
|
db.session.commit()
|
||||||
|
log_action(ACTION_UPDATE, 'InspectionSchedule', schedule.id, schedule.name,
|
||||||
|
f'manual run_now by {current_user.username}; inspection_id={inspection.id}')
|
||||||
|
flash(f'Inspection created from "{schedule.name}". It is now in the inspector\'s queue.',
|
||||||
|
'success')
|
||||||
|
return redirect(url_for('inspection_schedules.index'))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cron endpoint ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@bp.route('/run', methods=['POST'])
|
||||||
|
@csrf.exempt
|
||||||
|
def run():
|
||||||
|
"""Token-protected endpoint called by cron to materialise all due schedules.
|
||||||
|
|
||||||
|
POST body: token=<DIGEST_SECRET>
|
||||||
|
"""
|
||||||
|
token = request.form.get('token') or request.args.get('token')
|
||||||
|
expected = current_app.config.get('DIGEST_SECRET')
|
||||||
|
|
||||||
|
if not expected or token != expected:
|
||||||
|
logger.warning('INSPECTION SCHEDULES RUN REJECTED | bad/missing token')
|
||||||
|
return jsonify({'ok': False, 'error': 'unauthorized'}), 403
|
||||||
|
|
||||||
|
now = now_eastern()
|
||||||
|
schedules = InspectionSchedule.query.filter_by(active=True).all()
|
||||||
|
due = [s for s in schedules if s.next_run_at is None or s.next_run_at <= now]
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
for schedule in due:
|
||||||
|
try:
|
||||||
|
inspection = _materialise(schedule, now)
|
||||||
|
schedule.last_run_at = now
|
||||||
|
schedule.next_run_at = _compute_next_run(schedule.frequency, now)
|
||||||
|
created += 1
|
||||||
|
logger.info('INSPECTION SCHEDULE MATERIALISED | schedule_id=%s | inspection_id=%s',
|
||||||
|
schedule.id, inspection.id)
|
||||||
|
except Exception as exc:
|
||||||
|
# Advance next_run_at anyway so one broken schedule can't wedge the
|
||||||
|
# whole cron run on every subsequent tick.
|
||||||
|
schedule.next_run_at = _compute_next_run(schedule.frequency, now)
|
||||||
|
logger.error('INSPECTION SCHEDULE FAILED | schedule_id=%s | error=%s',
|
||||||
|
schedule.id, exc)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
logger.info('INSPECTION SCHEDULES CRON | due=%s | created=%s', len(due), created)
|
||||||
|
return jsonify({'ok': True, 'due': len(due), 'created': created})
|
||||||
@@ -154,6 +154,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
|
||||||
</li>
|
</li>
|
||||||
|
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspection_schedules.') }}" href="{{ url_for('inspection_schedules.index') }}">Schedules</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<h2 class="mb-4"><i class="bi bi-calendar2-week"></i> {{ title }}</h2>
|
||||||
|
|
||||||
|
<form method="POST" class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Schedule name</label>
|
||||||
|
<input type="text" name="name" class="form-control" required
|
||||||
|
value="{{ schedule.name if schedule else '' }}"
|
||||||
|
placeholder="e.g. Weekly restroom check — Main Office">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Template</label>
|
||||||
|
<select name="template_id" class="form-select" required>
|
||||||
|
<option value="">— Choose a template —</option>
|
||||||
|
{% for t in templates %}
|
||||||
|
<option value="{{ t.id }}"
|
||||||
|
{{ 'selected' if schedule and schedule.template_id == t.id }}>{{ t.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Frequency</label>
|
||||||
|
<select name="frequency" class="form-select" required>
|
||||||
|
{% for f in frequencies %}
|
||||||
|
<option value="{{ f }}"
|
||||||
|
{{ 'selected' if (schedule and schedule.frequency == f) or (not schedule and f == 'weekly') }}>
|
||||||
|
{{ f|title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Facility</label>
|
||||||
|
<select name="facility_id" id="facilitySelect" class="form-select" required>
|
||||||
|
<option value="">— Choose a facility —</option>
|
||||||
|
{% for f in facilities %}
|
||||||
|
<option value="{{ f.id }}"
|
||||||
|
{{ 'selected' if schedule and schedule.facility_id == f.id }}>{{ f.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Area <span class="text-muted small">(optional)</span></label>
|
||||||
|
<select name="area_id" id="areaSelect" class="form-select">
|
||||||
|
<option value="">— Whole facility —</option>
|
||||||
|
{% if schedule and schedule.area %}
|
||||||
|
<option value="{{ schedule.area.id }}" selected>{{ schedule.area.name }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Assign to inspector</label>
|
||||||
|
<select name="inspector_id" class="form-select" required>
|
||||||
|
<option value="">— Choose an inspector —</option>
|
||||||
|
{% for u in inspectors %}
|
||||||
|
<option value="{{ u.id }}"
|
||||||
|
{{ 'selected' if schedule and schedule.inspector_id == u.id }}>
|
||||||
|
{{ u.display_name }} ({{ u.role }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if schedule %}
|
||||||
|
<div class="form-check form-switch mb-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" id="activeSwitch"
|
||||||
|
{{ 'checked' if schedule.active }}>
|
||||||
|
<label class="form-check-label" for="activeSwitch">Active</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small">
|
||||||
|
Saving recomputes the next run from now. Next run:
|
||||||
|
{{ schedule.next_run_at.strftime('%Y-%m-%d %H:%M') if schedule.next_run_at else '—' }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer d-flex justify-content-between">
|
||||||
|
<a href="{{ url_for('inspection_schedules.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg"></i> Save Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Facility → Area cascade, reusing the existing inspections AJAX endpoint.
|
||||||
|
(function () {
|
||||||
|
var facilitySelect = document.getElementById('facilitySelect');
|
||||||
|
var areaSelect = document.getElementById('areaSelect');
|
||||||
|
if (!facilitySelect || !areaSelect) return;
|
||||||
|
|
||||||
|
var preselectedAreaId = {{ (schedule.area_id if schedule and schedule.area_id else 0) | tojson }};
|
||||||
|
|
||||||
|
function loadAreas(facilityId, keepSelection) {
|
||||||
|
areaSelect.innerHTML = '<option value="">— Whole facility —</option>';
|
||||||
|
if (!facilityId) return;
|
||||||
|
fetch('{{ url_for('inspections.areas_for_facility', facility_id=0) }}'.replace('/0', '/' + facilityId))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (areas) {
|
||||||
|
areas.forEach(function (a) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = a.name;
|
||||||
|
if (keepSelection && a.id === preselectedAreaId) opt.selected = true;
|
||||||
|
areaSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { /* leave the whole-facility default in place */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
facilitySelect.addEventListener('change', function () {
|
||||||
|
preselectedAreaId = 0;
|
||||||
|
loadAreas(this.value, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// On edit load, refresh the area list for the saved facility and keep the saved area.
|
||||||
|
if (facilitySelect.value) loadAreas(facilitySelect.value, true);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Inspection Schedules{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2><i class="bi bi-calendar2-week"></i> Inspection Schedules</h2>
|
||||||
|
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> New Schedule
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted small mb-4">
|
||||||
|
Recurring schedules automatically create an in-progress inspection for the
|
||||||
|
assigned inspector each period. The inspector is notified and opens it from
|
||||||
|
their Inspections list to complete it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if schedules %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Template</th><th>Facility / Area</th>
|
||||||
|
<th>Inspector</th><th>Frequency</th><th>Next Run</th>
|
||||||
|
<th>Last Run</th><th>Status</th><th width="150"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in schedules %}
|
||||||
|
<tr class="{{ 'text-muted' if not s.active else '' }}">
|
||||||
|
<td><strong>{{ s.name }}</strong></td>
|
||||||
|
<td>{{ s.template.name if s.template else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ s.facility.name if s.facility else '—' }}
|
||||||
|
{% if s.area %}<span class="text-muted small">/ {{ s.area.name }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.inspector.display_name if s.inspector else '—' }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ s.frequency|title }}</span></td>
|
||||||
|
<td class="small {{ 'text-danger fw-semibold' if s.active and s.next_run_at and s.next_run_at <= now else 'text-muted' }}">
|
||||||
|
{{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted">
|
||||||
|
{{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if s.active %}<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<a href="{{ url_for('inspection_schedules.edit', schedule_id=s.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('inspection_schedules.run_now', schedule_id=s.id) }}"
|
||||||
|
class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary" title="Create inspection now"
|
||||||
|
onclick="return confirm('Create an inspection from this schedule now?')">
|
||||||
|
<i class="bi bi-play-circle"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('inspection_schedules.delete', schedule_id=s.id) }}"
|
||||||
|
class="d-inline"
|
||||||
|
onsubmit="return confirm('Delete this schedule? Existing inspections are not affected.')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||||
|
<i class="bi bi-trash3"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
|
||||||
|
<p class="mb-3">No inspection schedules configured yet.</p>
|
||||||
|
<a href="{{ url_for('inspection_schedules.create') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create First Schedule
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""phase34 — recurring inspection schedules
|
||||||
|
|
||||||
|
Creates the inspection_schedules table. A schedule materialises a real
|
||||||
|
Inspection row on a fixed cadence via the token-protected cron endpoint
|
||||||
|
POST /inspection-schedules/run (see app/routes/inspection_schedules.py).
|
||||||
|
|
||||||
|
Idempotent: guarded by an INFORMATION_SCHEMA table-existence check so it is
|
||||||
|
safe to re-run across every tenant DB (CLAUDE.md rule 14).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = 'phase34_inspection_schedules'
|
||||||
|
down_revision = 'phase33_tenant_settings'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(bind, table: str) -> bool:
|
||||||
|
result = bind.execute(sa.text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||||
|
), {'t': table})
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not _table_exists(bind, 'inspection_schedules'):
|
||||||
|
op.execute(sa.text("""
|
||||||
|
CREATE TABLE inspection_schedules (
|
||||||
|
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
template_id INT NOT NULL,
|
||||||
|
facility_id INT NOT NULL,
|
||||||
|
area_id INT NULL,
|
||||||
|
inspector_id INT NOT NULL,
|
||||||
|
frequency ENUM('daily','weekly','monthly','quarterly')
|
||||||
|
NOT NULL DEFAULT 'weekly',
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
last_run_at DATETIME NULL,
|
||||||
|
next_run_at DATETIME NULL,
|
||||||
|
CONSTRAINT fk_ischd_template FOREIGN KEY (template_id)
|
||||||
|
REFERENCES inspection_templates(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ischd_facility FOREIGN KEY (facility_id)
|
||||||
|
REFERENCES facilities(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ischd_area FOREIGN KEY (area_id)
|
||||||
|
REFERENCES areas(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_ischd_inspector FOREIGN KEY (inspector_id)
|
||||||
|
REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_ischd_creator FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX ix_ischd_active_next (active, next_run_at),
|
||||||
|
INDEX ix_ischd_facility (facility_id),
|
||||||
|
INDEX ix_ischd_inspector (inspector_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
if _table_exists(bind, 'inspection_schedules'):
|
||||||
|
op.execute(sa.text('DROP TABLE inspection_schedules'))
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
tests/test_inspection_schedules.py
|
||||||
|
----------------------------------
|
||||||
|
End-to-end behaviour test for phase34 recurring inspection schedules.
|
||||||
|
|
||||||
|
Runs on the in-memory SQLite app fixture (multi-tenancy inert). Drives the real
|
||||||
|
token-protected cron endpoint (POST /inspection-schedules/run) so the full path
|
||||||
|
— including request-context url_for link building inside notify() — is exercised
|
||||||
|
exactly as it runs in production. Asserts:
|
||||||
|
|
||||||
|
* a due schedule materialises a real in_progress Inspection for the inspector
|
||||||
|
* an in-app Notification is fired for the assigned inspector
|
||||||
|
* a not-yet-due schedule is left untouched
|
||||||
|
* a bad/missing token is rejected with 403
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Fresh schema + test client for each test (isolated in-memory DB)."""
|
||||||
|
app.config['DIGEST_SECRET'] = 'test-digest'
|
||||||
|
with app.app_context():
|
||||||
|
from app import db
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
yield app.test_client()
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(suffix='a'):
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.facility import Facility
|
||||||
|
from app.models.inspection import InspectionTemplate
|
||||||
|
|
||||||
|
inspector = User(username=f'insp_{suffix}', full_name='Ivy Inspector',
|
||||||
|
email=f'insp_{suffix}@example.com', role='inspector')
|
||||||
|
inspector.set_password('x')
|
||||||
|
tmpl = InspectionTemplate(name='Restroom Check', active=True,
|
||||||
|
form_schema=[{'id': 'f1', 'type': 'rating_5', 'label': 'Clean'}])
|
||||||
|
fac = Facility(name='Main Office', active=True)
|
||||||
|
db.session.add_all([inspector, tmpl, fac])
|
||||||
|
db.session.commit()
|
||||||
|
return inspector, tmpl, fac
|
||||||
|
|
||||||
|
|
||||||
|
def test_cron_materialises_due_schedule_and_notifies(client):
|
||||||
|
from app import db
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.notification import Notification
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
inspector, tmpl, fac = _seed('due')
|
||||||
|
sched = InspectionSchedule(
|
||||||
|
name='Weekly restroom', template_id=tmpl.id, facility_id=fac.id,
|
||||||
|
inspector_id=inspector.id, frequency='weekly', active=True,
|
||||||
|
created_at=now_eastern(), next_run_at=now_eastern() - timedelta(days=1),
|
||||||
|
)
|
||||||
|
db.session.add(sched)
|
||||||
|
db.session.commit()
|
||||||
|
sched_id, insp_user_id, fac_id, tmpl_id = sched.id, inspector.id, fac.id, tmpl.id
|
||||||
|
|
||||||
|
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {'ok': True, 'due': 1, 'created': 1}
|
||||||
|
|
||||||
|
# A real in_progress inspection now exists for the assigned inspector.
|
||||||
|
insp = Inspection.query.filter_by(inspector_id=insp_user_id).first()
|
||||||
|
assert insp is not None
|
||||||
|
assert insp.status == 'in_progress'
|
||||||
|
assert insp.facility_id == fac_id
|
||||||
|
assert insp.template_id == tmpl_id
|
||||||
|
|
||||||
|
# The inspector got an in-app notification linked to the new inspection.
|
||||||
|
notif = Notification.query.filter_by(user_id=insp_user_id,
|
||||||
|
inspection_id=insp.id).first()
|
||||||
|
assert notif is not None
|
||||||
|
assert notif.event_type == 'inspection_scheduled'
|
||||||
|
|
||||||
|
# The schedule's cadence advanced into the future.
|
||||||
|
sched = db.session.get(InspectionSchedule, sched_id)
|
||||||
|
assert sched.last_run_at is not None
|
||||||
|
assert sched.next_run_at > now_eastern()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cron_skips_future_schedule(client):
|
||||||
|
from app import db
|
||||||
|
from app.models.inspection_schedule import InspectionSchedule
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
inspector, tmpl, fac = _seed('future')
|
||||||
|
sched = InspectionSchedule(
|
||||||
|
name='Not yet due', template_id=tmpl.id, facility_id=fac.id,
|
||||||
|
inspector_id=inspector.id, frequency='weekly', active=True,
|
||||||
|
created_at=now_eastern(), next_run_at=now_eastern() + timedelta(days=3),
|
||||||
|
)
|
||||||
|
db.session.add(sched)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
resp = client.post('/inspection-schedules/run', data={'token': 'test-digest'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()['created'] == 0
|
||||||
|
assert Inspection.query.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cron_rejects_bad_token(client):
|
||||||
|
resp = client.post('/inspection-schedules/run', data={'token': 'wrong'})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.get_json()['ok'] is False
|
||||||
Reference in New Issue
Block a user