diff --git a/CLAUDE.md b/CLAUDE.md index 82dbc6e..960c6c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,6 +389,19 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON) 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 ``` @@ -473,6 +486,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were ** | `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) | +| `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/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | | `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`) | @@ -675,6 +689,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_INSPECTION_SCHEDULED = 'inspection_scheduled' ← phase34 ``` ### 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/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 /inspection-schedules/run` | Materialise due recurring inspections (phase34) | `0 6 * * *` | --- @@ -741,7 +757,7 @@ limiter = Limiter( ## 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`. @@ -769,7 +785,20 @@ limiter = Limiter( → phase30_device_registry → phase31_device_registry → 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 @@ -1162,6 +1191,8 @@ set -a; . /etc/jqc/control.env; set +a -d "token=SECRET" 0 10 * * * curl -s -X POST https://your-domain.com/notifications/dunning-reminders \ -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 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/`. diff --git a/MULTI_TENANT_PLAN.md b/MULTI_TENANT_PLAN.md index 569407f..dddad18 100644 --- a/MULTI_TENANT_PLAN.md +++ b/MULTI_TENANT_PLAN.md @@ -114,7 +114,7 @@ db = SQLAlchemy(session_options={'class_': RoutingSession}) 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`). **CLI (always source env first):** diff --git a/app/__init__.py b/app/__init__.py index eaa673b..75aefca 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -201,6 +201,7 @@ def create_app(config_name='default'): from app.routes import projects # Phase 1/2 — Project management from app.routes import customers # Phase 5 — Customer management 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 broadcast # Admin broadcast messages 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(customers.bp) app.register_blueprint(scheduled_reports.bp) + app.register_blueprint(inspection_schedules.bp) app.register_blueprint(support.bp) app.register_blueprint(broadcast.bp) app.register_blueprint(devices.bp) diff --git a/app/models/__init__.py b/app/models/__init__.py index dd90aff..67ab3d1 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,4 +5,5 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, 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 \ No newline at end of file +from app.models.notification_matrix import NotificationMatrix +from app.models.inspection_schedule import InspectionSchedule \ No newline at end of file diff --git a/app/models/inspection_schedule.py b/app/models/inspection_schedule.py new file mode 100644 index 0000000..ac404d8 --- /dev/null +++ b/app/models/inspection_schedule.py @@ -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'' diff --git a/app/models/notification.py b/app/models/notification.py index 179bd4c..3a27143 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 when a recurring InspectionSchedule materialises a due inspection and +# notifies the assigned inspector (phase34). +EVENT_INSPECTION_SCHEDULED = 'inspection_scheduled' + ALL_EVENT_TYPES = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_STATUS: 'Issue status changed', @@ -38,6 +42,7 @@ ALL_EVENT_TYPES = { EVENT_INSPECTION_DONE: 'Inspection completed', EVENT_SLA_ALERT: 'SLA at-risk / breached alerts', EVENT_ADMIN_BROADCAST: 'Admin broadcast (system announcements)', + EVENT_INSPECTION_SCHEDULED: 'Scheduled inspection due (assigned to me)', # Customer-facing — only relevant for customer role accounts EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)', EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)', diff --git a/app/routes/inspection_schedules.py b/app/routes/inspection_schedules.py new file mode 100644 index 0000000..7d0908b --- /dev/null +++ b/app/routes/inspection_schedules.py @@ -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('//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('//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('//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= + """ + 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}) diff --git a/app/templates/base.html b/app/templates/base.html index 9d99195..d8a6e39 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -154,6 +154,11 @@ + {% if current_user.role in ['admin', 'director', 'project_manager'] %} + + {% endif %} diff --git a/app/templates/inspection_schedules/form.html b/app/templates/inspection_schedules/form.html new file mode 100644 index 0000000..7706c94 --- /dev/null +++ b/app/templates/inspection_schedules/form.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +
+
+

{{ title }}

+ +
+
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {% if schedule %} +
+ + +
+

+ 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 '—' }} +

+ {% endif %} +
+ + +
+
+
+ + +{% endblock %} diff --git a/app/templates/inspection_schedules/index.html b/app/templates/inspection_schedules/index.html new file mode 100644 index 0000000..b7dad51 --- /dev/null +++ b/app/templates/inspection_schedules/index.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% block title %}Inspection Schedules{% endblock %} +{% block content %} +
+

Inspection Schedules

+ + New Schedule + +
+ +

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

+ +{% if schedules %} +
+
+
+ + + + + + + + + + {% for s in schedules %} + + + + + + + + + + + + {% endfor %} + +
NameTemplateFacility / AreaInspectorFrequencyNext RunLast RunStatus
{{ s.name }}{{ s.template.name if s.template else '—' }} + {{ s.facility.name if s.facility else '—' }} + {% if s.area %}/ {{ s.area.name }}{% endif %} + {{ s.inspector.display_name if s.inspector else '—' }}{{ s.frequency|title }} + {{ s.next_run_at.strftime('%Y-%m-%d %H:%M') if s.next_run_at else '—' }} + + {{ s.last_run_at.strftime('%Y-%m-%d %H:%M') if s.last_run_at else 'Never' }} + + {% if s.active %}Active + {% else %}Paused{% endif %} + + + + +
+ + +
+
+ + +
+
+
+
+
+{% else %} +
+
+ +

No inspection schedules configured yet.

+ + Create First Schedule + +
+
+{% endif %} +{% endblock %} diff --git a/migrations/versions/phase34_inspection_schedules.py b/migrations/versions/phase34_inspection_schedules.py new file mode 100644 index 0000000..0b97a91 --- /dev/null +++ b/migrations/versions/phase34_inspection_schedules.py @@ -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')) diff --git a/tests/test_inspection_schedules.py b/tests/test_inspection_schedules.py new file mode 100644 index 0000000..a265555 --- /dev/null +++ b/tests/test_inspection_schedules.py @@ -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