diff --git a/CLAUDE.md b/CLAUDE.md index 6f55503..17fb780 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`) 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 @@ -429,6 +448,7 @@ contract_notification_recipients: | `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 | | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | +| `scheduled_inspections` | `/scheduled-inspections` | list, new/edit/delete (PM+), `GET //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/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `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) | @@ -634,6 +654,7 @@ EVENT_ISSUE_FLAGGED = 'issue_flagged' EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' EVENT_SCORE_ALERT = 'score_alert' ← Phase 27 +EVENT_SCHEDULED_INSPECTION = 'scheduled_inspection' ← Phase 36 ``` ### 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/cleanup-tokens` | Purge expired API tokens | `0 3 * * *` | | `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 → phase33_contract_recipients → phase34_facility_qr - → phase35_issue_handler ← HEAD + → phase35_issue_handler + → phase36_scheduled_insp ← HEAD ``` ### phase21_performance_indexes @@ -839,6 +862,19 @@ flask db upgrade 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:** ```bash flask db upgrade @@ -1096,6 +1132,8 @@ timeout = 30 -d "secret=SECRET" 0 8 * * * curl -s -X POST https://your-domain.com/notifications/check-score-trends \ -d "token=SECRET" +*/30 * * * * curl -s -X POST https://your-domain.com/scheduled-inspections/run \ + -d "token=SECRET" ``` --- diff --git a/app/__init__.py b/app/__init__.py index 4553198..9d20bd3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -177,6 +177,7 @@ def create_app(config_name='default'): from app.routes import broadcast # Admin broadcast notifications from app.routes import devices # Admin device registry 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(dashboard.bp) @@ -194,6 +195,7 @@ def create_app(config_name='default'): app.register_blueprint(broadcast.bp) app.register_blueprint(devices.bp) app.register_blueprint(public.bp) + app.register_blueprint(scheduled_inspections.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. diff --git a/app/models/__init__.py b/app/models/__init__.py index 0ca28c2..6c0cfc6 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,4 +6,5 @@ from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken from app.models.notification_matrix import NotificationMatrix -from app.models.notification_recipient import ContractNotificationRecipient \ No newline at end of file +from app.models.notification_recipient import ContractNotificationRecipient +from app.models.scheduled_inspection import ScheduledInspection \ No newline at end of file diff --git a/app/models/inspection.py b/app/models/inspection.py index 0789cc2..15f5912 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -68,6 +68,13 @@ class Inspection(db.Model): submit_latitude = 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 ──────────────────────────────── parent_inspection_id = db.Column( db.Integer, db.ForeignKey('inspections.id', ondelete='SET NULL'), nullable=True diff --git a/app/models/notification.py b/app/models/notification.py index 179bd4c..670f75e 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -29,6 +29,10 @@ EVENT_SCORE_ALERT = 'score_alert' 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 = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', 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)', # Score trend alert — admin/director management use EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)', + # Scheduled inspection reminders (due/advance/overdue) + EVENT_SCHEDULED_INSPECTION: 'Scheduled inspection reminders (due / overdue)', } diff --git a/app/models/scheduled_inspection.py b/app/models/scheduled_inspection.py new file mode 100644 index 0000000..15ed236 --- /dev/null +++ b/app/models/scheduled_inspection.py @@ -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'') diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 8c329d5..a280a1d 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -337,8 +337,27 @@ def index(): .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( 'dashboard.html', + sched_upcoming = sched_upcoming, + sched_overdue_count = sched_overdue_count, today_inspections = today_inspections, completed_today = completed_today, open_issues = open_issues, diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 6dc4eba..601b41d 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -579,6 +579,21 @@ def execute(inspection_id): inspection_id = inspection.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 log_action(ACTION_UPDATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', diff --git a/app/routes/scheduled_inspections.py b/app/routes/scheduled_inspections.py new file mode 100644 index 0000000..606c1c9 --- /dev/null +++ b/app/routes/scheduled_inspections.py @@ -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('//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('//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('//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 diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index a74b44a..b266431 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -11,6 +11,53 @@ +{# ── Scheduled inspections: upcoming / overdue (phase36) ─────────────────── #} +{% if current_user.role != 'customer' and (sched_upcoming or sched_overdue_count) %} +
+
+
+ Scheduled Inspections + View all +
+ {% if sched_overdue_count %} +
+ + {{ sched_overdue_count }} scheduled inspection{{ 's' if sched_overdue_count != 1 }} + {{ 'are' if sched_overdue_count != 1 else 'is' }} overdue. +
+ {% endif %} + {% if sched_upcoming %} +
+ + + + + + {% for s in sched_upcoming %} + + + + + + + + {% endfor %} + +
FacilityTemplateInspectorDue
{{ s.facility.name if s.facility else '—' }}{{ s.template.name if s.template else '—' }}{{ s.inspector.display_name if s.inspector else '—' }}{{ s.next_due_date.strftime('%b %d') }} + {% if current_user.role in ['admin','director','project_manager'] + or (current_user.role == 'inspector' and s.inspector_id == current_user.id) %} + Start + {% endif %} +
+
+ {% else %} +

No inspections due in the next 7 days.

+ {% endif %} +
+
+{% endif %} + {# ── Inspections section ─────────────────────────────────────────────────── #}
diff --git a/app/templates/inspections/list.html b/app/templates/inspections/list.html index 714db45..e2837b7 100644 --- a/app/templates/inspections/list.html +++ b/app/templates/inspections/list.html @@ -4,9 +4,14 @@

Inspections

{% if current_user.role != 'customer' %} - - New Inspection - + {% endif %}
diff --git a/app/templates/scheduled_inspections/form.html b/app/templates/scheduled_inspections/form.html new file mode 100644 index 0000000..dd7ac5d --- /dev/null +++ b/app/templates/scheduled_inspections/form.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
{{ title }}
+
+
+ {{ form.hidden_tag() }} + +
+ {{ form.facility_id.label(class="form-label fw-semibold") }} + {{ form.facility_id(class="form-select") }} + {% for e in form.facility_id.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.template_id.label(class="form-label fw-semibold") }} + {{ form.template_id(class="form-select") }} + {% for e in form.template_id.errors %}
{{ e }}
{% endfor %} +
+ +
+ {{ form.inspector_id.label(class="form-label fw-semibold") }} + {{ form.inspector_id(class="form-select") }} + {% for e in form.inspector_id.errors %}
{{ e }}
{% endfor %} +
+ +
+
+ {{ form.frequency.label(class="form-label fw-semibold") }} + {{ form.frequency(class="form-select") }} +
+
+ {{ 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 %}
{{ e }}
{% endfor %} +
+
+ +
+ {{ form.notes.label(class="form-label fw-semibold") }} + {{ form.notes(class="form-control", rows=2, placeholder="Optional instructions for the inspector…") }} +
+ +
+ {{ form.active(class="form-check-input") }} + {{ form.active.label(class="form-check-label") }} +
+ +
+ + Cancel +
+
+
+
+

+ 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. +

+
+
+{% endblock %} diff --git a/app/templates/scheduled_inspections/list.html b/app/templates/scheduled_inspections/list.html new file mode 100644 index 0000000..c857dee --- /dev/null +++ b/app/templates/scheduled_inspections/list.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Scheduled Inspections{% endblock %} + +{% block content %} +
+
+

Scheduled Inspections

+

Planned and recurring inspection assignments.

+
+
+ + Inspections + + {% if current_user.role in ['admin','director','project_manager'] %} + + New Schedule + + {% endif %} +
+
+ +
+
+ {% if schedules %} +
+ + + + + + + + + + + + + + {% 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 %} + + + + + + + + + + {% endfor %} + +
FacilityTemplateInspectorFrequencyNext DueStatus
{{ s.facility.name if s.facility else '—' }}{{ s.template.name if s.template else '—' }}{{ s.inspector.display_name if s.inspector else '— Unassigned —' }}{{ s.frequency_label }} + {{ s.next_due_date.strftime('%b %d, %Y') }} + {% if overdue %} + Overdue + {% elif due_soon %} + Due soon + {% endif %} + + {% if s.active %} + Active + {% else %} + Inactive + {% endif %} + + {% if s.active and (current_user.role in ['admin','director','project_manager'] + or (current_user.role == 'inspector' and s.inspector_id == current_user.id)) %} + + Start + + {% endif %} + {% if current_user.role in ['admin','director','project_manager'] %} + +
+ + +
+ {% endif %} +
+
+ {% else %} +
+ No scheduled inspections yet. + {% if current_user.role in ['admin','director','project_manager'] %} + Create one. + {% endif %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/utils/forms.py b/app/utils/forms.py index 456512a..6e74d7a 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -2,7 +2,7 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed, MultipleFileField from wtforms import (StringField, PasswordField, SelectField, TextAreaField, DecimalField, BooleanField, IntegerField, HiddenField, - RadioField) + RadioField, DateField) from wtforms.validators import (DataRequired, Email, Length, EqualTo, Optional, NumberRange, ValidationError) from app.models.user import User @@ -319,3 +319,18 @@ class PublicIssueReportForm(FlaskForm): FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only (jpg, png, gif).')]) 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) diff --git a/migrations/versions/phase36_scheduled_inspections.py b/migrations/versions/phase36_scheduled_inspections.py new file mode 100644 index 0000000..f2d8a95 --- /dev/null +++ b/migrations/versions/phase36_scheduled_inspections.py @@ -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')