diff --git a/app/models/issue.py b/app/models/issue.py index ca264f2..be56177 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -79,6 +79,11 @@ class Issue(db.Model): sla_notified = db.Column(db.String(10), nullable=True, default=None) mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions + # External vendor / contractor assignment (phase26) + vendor_name = db.Column(db.String(100), nullable=True) + vendor_contact = db.Column(db.String(200), nullable=True) # phone or email + vendor_notes = db.Column(db.Text, nullable=True) + # Relationships # NOTE: Issue.area is provided by the backref on Area.issues (facility.py). # Do NOT add a second explicit db.relationship('Area') here — it conflicts diff --git a/app/models/notification.py b/app/models/notification.py index 9e0db1c..1d1b1e4 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -23,6 +23,10 @@ EVENT_ISSUE_FLAGGED = 'issue_flagged' EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' +# Fired by the score-trend cron when a facility's rolling avg drops by +# more than the configured threshold vs. the prior period. +EVENT_SCORE_ALERT = 'score_alert' + ALL_EVENT_TYPES = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_STATUS: 'Issue status changed', @@ -34,6 +38,8 @@ ALL_EVENT_TYPES = { # 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)', + # Score trend alert — admin/director management use + EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)', } diff --git a/app/models/score_alert.py b/app/models/score_alert.py new file mode 100644 index 0000000..cfef6d8 --- /dev/null +++ b/app/models/score_alert.py @@ -0,0 +1,24 @@ +from app import db +from app.utils.time_utils import now_eastern + + +class FacilityScoreAlert(db.Model): + """Records each score-trend alert sent for a facility. + + Used by send_score_alerts() to deduplicate cron notifications: + if an alert row exists for a facility within the last 24 hours, + no new alert is sent even if the score is still below threshold. + """ + __tablename__ = 'facility_score_alerts' + + id = db.Column(db.Integer, primary_key=True) + facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='CASCADE'), nullable=False) + sent_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + current_avg = db.Column(db.Numeric(5, 2), nullable=False) + prior_avg = db.Column(db.Numeric(5, 2), nullable=False) + delta = db.Column(db.Numeric(5, 2), nullable=False) + + facility = db.relationship('Facility', foreign_keys=[facility_id]) + + def __repr__(self): + return f'' diff --git a/app/routes/issues.py b/app/routes/issues.py index 4be573b..e5f6179 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -414,6 +414,12 @@ def view(issue_id): issue.result_notes = form.result_notes.data or None + # Vendor / contractor assignment — admin, director, project_manager only + if current_user.role in ('admin', 'director', 'project_manager'): + issue.vendor_name = form.vendor_name.data.strip() or None + issue.vendor_contact = form.vendor_contact.data.strip() or None + issue.vendor_notes = form.vendor_notes.data.strip() or None + from app.routes.inspections import _save_photo new_photos = [] for file_obj in request.files.getlist('result_photos'): diff --git a/app/routes/notifications.py b/app/routes/notifications.py index dfb91f5..c8a9c0a 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -265,4 +265,44 @@ def cleanup_tokens(): db.session.commit() logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted) - return jsonify({'ok': True, 'deleted': deleted}) \ No newline at end of file + return jsonify({'ok': True, 'deleted': deleted}) + + +# ── Score trend alert trigger (called by cron) ──────────────────────────────── + +@bp.route('/check-score-trends', methods=['POST']) +@csrf.exempt +def check_score_trends(): + """Scan facility score trends and dispatch alerts for significant drops. + + Compares each active facility's avg inspection score for the last 30 days + against the prior 30-day period. Alerts fire when the drop exceeds the + configured threshold (default: 5 percentage points). + + Protected by the same DIGEST_SECRET token used by the other cron endpoints. + + Recommended cron schedule — once per day is sufficient: + + 0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \\ + -d "token=YOUR_DIGEST_SECRET" + + Optional param: + threshold= Override the default 5.0-point drop threshold. + """ + 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('SCORE TREND CHECK REJECTED | bad or missing token') + abort(403) + + threshold = request.form.get('threshold', type=float) or None + + from app.utils.sla import send_score_alerts + kwargs = {} + if threshold is not None: + kwargs['threshold'] = threshold + sent = send_score_alerts(**kwargs) + + logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent) + return jsonify({'ok': True, 'alerts_sent': sent}) \ No newline at end of file diff --git a/app/routes/reports.py b/app/routes/reports.py index 3a34b29..c327d0e 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -162,6 +162,35 @@ def index(): facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\ .order_by(func.avg(Inspection.overall_score).desc()).all() + # Prior-period facility scores for period-over-period delta badges + period_len = end - start + prior_end = start + prior_start = start - period_len + prior_fac_q = db.session.query( + Facility.name, + func.avg(Inspection.overall_score).label('avg_score'), + ).join(Inspection, Facility.id == Inspection.facility_id)\ + .filter( + Inspection.inspection_date >= prior_start, + Inspection.inspection_date <= prior_end, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ) + if inspector_filter: + prior_fac_q = prior_fac_q.filter(Inspection.inspector_id == inspector_filter) + if customer_facility_ids is not None: + prior_fac_q = prior_fac_q.filter( + Facility.id.in_(customer_facility_ids) if customer_facility_ids else False + ) + prior_scores_raw = prior_fac_q.group_by(Facility.id, Facility.name).all() + prior_scores_map = {r.name: round(float(r.avg_score), 2) for r in prior_scores_raw} + # Build delta map keyed by facility name: positive = improved, negative = declined + facility_deltas = {} + for row in facility_scores: + prior = prior_scores_map.get(row.name) + if prior is not None: + facility_deltas[row.name] = round(float(row.avg_score) - prior, 1) + # Score trend — daily averages (line chart) daily_q = db.session.query( func.date(Inspection.inspection_date).label('day'), @@ -226,13 +255,19 @@ def index(): inspectors = User.query.filter_by(role='inspector', active=True)\ .order_by(User.full_name, User.username).all() + facility_scores_list = [{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores] + # Attach prior avg and delta to each facility score dict for the template table + for row in facility_scores_list: + row['prior_avg'] = prior_scores_map.get(row['name']) + row['delta'] = facility_deltas.get(row['name']) + return render_template('reports/index.html', start=start, end=end, total_inspections=total_inspections, completed=completed, flagged=flagged, avg_score=round(float(avg_score), 2) if avg_score else None, - facility_scores=[{'name': r.name, 'avg_score': round(float(r.avg_score), 2), 'count': r.count} for r in facility_scores], + facility_scores=facility_scores_list, daily_scores=[{'day': str(r.day), 'avg': round(float(r.avg), 2), 'count': r.count} for r in daily_scores], issue_severity=[{'severity': r.severity, 'count': r.count} for r in issue_severity], issue_status=[{'status': r.status, 'count': r.count} for r in issue_status], diff --git a/app/templates/issues/view.html b/app/templates/issues/view.html index 932a3b4..826abf8 100644 --- a/app/templates/issues/view.html +++ b/app/templates/issues/view.html @@ -76,6 +76,20 @@
Assigned To
{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}
+ {% if issue.vendor_name %} +
Contractor
+
+ + {{ issue.vendor_name }} + {% if issue.vendor_contact %} + {{ issue.vendor_contact }} + {% endif %} + {% if issue.vendor_notes %} +
{{ issue.vendor_notes }}
+ {% endif %} +
+ {% endif %} + {% if issue.resolved_at %}
Resolved
{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}
@@ -350,6 +364,29 @@ {% endif %} + {% if current_user.role in ['admin','director','project_manager'] %} +
+

+ External Contractor +

+
+ {{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }} + {{ form.vendor_name(class="form-control form-control-sm", + placeholder="Contractor or vendor name", + value=issue.vendor_name or '') }} +
+
+ {{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }} + {{ form.vendor_contact(class="form-control form-control-sm", + placeholder="Phone or email", + value=issue.vendor_contact or '') }} +
+
+ {{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }} + {{ form.vendor_notes(class="form-control form-control-sm", rows=2, + placeholder="Notes about what the contractor is handling…") }} +
+ {% endif %} {% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %} diff --git a/app/templates/reports/index.html b/app/templates/reports/index.html index c33ed0e..83154f6 100644 --- a/app/templates/reports/index.html +++ b/app/templates/reports/index.html @@ -117,6 +117,68 @@ +{# ── Facility period-over-period comparison table ── #} +{% if facility_scores %} +
+
+
Facility Score Comparison
+ vs. prior equal-length period +
+
+ + + + + + + + + + + + {% for row in facility_scores %} + + + + + + + + {% endfor %} + +
FacilityCurrent PeriodPrior PeriodChangeInspections
{{ row.name }} + + {{ '%.1f'|format(row.avg_score|float) }}% + + + {% if row.prior_avg is not none %} + {{ '%.1f'|format(row.prior_avg|float) }}% + {% else %} + + {% endif %} + + {% if row.delta is not none %} + {% if row.delta > 0 %} + + +{{ '%.1f'|format(row.delta|float) }} + + {% elif row.delta < 0 %} + + {{ '%.1f'|format(row.delta|float) }} + + {% else %} + + 0.0 + + {% endif %} + {% else %} + No prior data + {% endif %} + {{ row.count }}
+
+
+{% endif %} + {# ── Top inspectors table ── #} {% if top_inspectors %}
diff --git a/app/utils/forms.py b/app/utils/forms.py index 50b8afe..a24d19c 100644 --- a/app/utils/forms.py +++ b/app/utils/forms.py @@ -184,6 +184,10 @@ class IssueUpdateForm(FlaskForm): Optional(), FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') ]) + # External contractor / vendor fields (phase26) + vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)]) + vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)]) + vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)]) # ── Projects ───────────────────────────────────────────────────────────────── diff --git a/app/utils/sla.py b/app/utils/sla.py index 5c58f8a..202abce 100644 --- a/app/utils/sla.py +++ b/app/utils/sla.py @@ -214,4 +214,132 @@ def send_sla_alerts(): if total_sent: db.session.commit() + return total_sent + + +# ── Score trend alert dispatcher ────────────────────────────────────────────── + +# Default drop threshold in percentage points that triggers an alert. +SCORE_DROP_THRESHOLD = 5.0 + + +def send_score_alerts(threshold=SCORE_DROP_THRESHOLD): + """ + Compare each active facility's avg inspection score for the last 30 days + against the prior 30-day period. When the score has dropped by more than + *threshold* points, dispatch an in-app + email alert via notify_by_matrix + and record the alert in facility_score_alerts for deduplication. + + A facility is skipped if it already received an alert within the last 24 + hours (prevents repeat storms on persistent low scores). + + Returns the number of alert notifications dispatched. + """ + from datetime import timedelta + from flask import current_app, url_for + from sqlalchemy import func + from app import db + from app.models.facility import Facility + from app.models.inspection import Inspection + from app.models.score_alert import FacilityScoreAlert + from app.utils.notifications import notify_by_matrix + import logging + + logger = logging.getLogger(__name__) + + now = now_eastern() + cur_start = now - timedelta(days=30) + pri_start = now - timedelta(days=60) + pri_end = cur_start + + # Current-period avg score per facility + cur_rows = db.session.query( + Facility.id, + Facility.name, + func.avg(Inspection.overall_score).label('avg'), + ).join(Inspection, Facility.id == Inspection.facility_id)\ + .filter( + Facility.active == True, + Inspection.inspection_date >= cur_start, + Inspection.inspection_date <= now, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ).group_by(Facility.id, Facility.name).all() + + # Prior-period avg score per facility + pri_rows = db.session.query( + Facility.id, + func.avg(Inspection.overall_score).label('avg'), + ).join(Inspection, Facility.id == Inspection.facility_id)\ + .filter( + Facility.active == True, + Inspection.inspection_date >= pri_start, + Inspection.inspection_date <= pri_end, + Inspection.status == 'completed', + Inspection.overall_score.isnot(None), + ).group_by(Facility.id).all() + + prior_map = {r.id: float(r.avg) for r in pri_rows} + + # Facilities that already received an alert in the last 24 hours + cutoff = now - timedelta(hours=24) + recent_alerts = db.session.query(FacilityScoreAlert.facility_id)\ + .filter(FacilityScoreAlert.sent_at >= cutoff).all() + already_alerted = {r.facility_id for r in recent_alerts} + + total_sent = 0 + + for row in cur_rows: + fid = row.id + cur_avg = float(row.avg) + pri_avg = prior_map.get(fid) + + if pri_avg is None: + continue # no prior period data — nothing to compare + + delta = cur_avg - pri_avg # negative = score dropped + + if delta >= -threshold: + continue # drop is within acceptable range + + if fid in already_alerted: + logger.debug('SCORE ALERT SKIPPED (already alerted) | facility_id=%s', fid) + continue + + title = f'📉 Score Drop Alert — {row.name}' + body = ( + f'{row.name} avg score has dropped {abs(delta):.1f} points ' + f'(from {pri_avg:.1f}% to {cur_avg:.1f}%) over the last 30 days ' + f'vs. the prior 30-day period.' + ) + + try: + link = url_for('reports.facility_scorecard', facility_id=fid) + except RuntimeError: + link = f'/reports/facility/{fid}/scorecard' + + notify_by_matrix( + event_type = 'score_alert', + title = title, + body = body, + link = link, + ) + total_sent += 1 + + db.session.add(FacilityScoreAlert( + facility_id = fid, + sent_at = now, + current_avg = round(cur_avg, 2), + prior_avg = round(pri_avg, 2), + delta = round(delta, 2), + )) + + logger.info( + 'SCORE ALERT SENT | facility_id=%s | facility=%s | cur=%.1f | prior=%.1f | delta=%.1f', + fid, row.name, cur_avg, pri_avg, delta, + ) + + if total_sent: + db.session.commit() + return total_sent \ No newline at end of file diff --git a/migrations/versions/phase26_issue_vendor.py b/migrations/versions/phase26_issue_vendor.py new file mode 100644 index 0000000..813a9d7 --- /dev/null +++ b/migrations/versions/phase26_issue_vendor.py @@ -0,0 +1,50 @@ +"""phase26 — vendor/contractor columns on issues + +Adds three nullable columns to the issues table: + vendor_name VARCHAR(100) — name of the external contractor or vendor + vendor_contact VARCHAR(200) — phone number or email for the vendor + vendor_notes TEXT — notes about what the vendor is handling + +These columns are populated only when a third-party contractor is +assigned to resolve an issue, separate from the internal assigned_to staff user. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase26_issue_vendor' +down_revision = 'phase25_inspection_gps' +branch_labels = None +depends_on = None + + +def _col_exists(bind, table: str, column: str) -> bool: + result = bind.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}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + + if not _col_exists(bind, 'issues', 'vendor_name'): + op.add_column('issues', + sa.Column('vendor_name', sa.String(100), nullable=True)) + + if not _col_exists(bind, 'issues', 'vendor_contact'): + op.add_column('issues', + sa.Column('vendor_contact', sa.String(200), nullable=True)) + + if not _col_exists(bind, 'issues', 'vendor_notes'): + op.add_column('issues', + sa.Column('vendor_notes', sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column('issues', 'vendor_notes') + op.drop_column('issues', 'vendor_contact') + op.drop_column('issues', 'vendor_name') diff --git a/migrations/versions/phase27_score_alerts.py b/migrations/versions/phase27_score_alerts.py new file mode 100644 index 0000000..1bd113d --- /dev/null +++ b/migrations/versions/phase27_score_alerts.py @@ -0,0 +1,48 @@ +"""phase27 — facility score alert tracking table + +Creates facility_score_alerts table used by the score-trend cron job to +deduplicate notifications: once an alert fires for a facility, a row is +inserted here. The cron skips the facility if an alert was sent within +the last 24 hours, preventing alert storms on persistent score drops. +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase27_score_alerts' +down_revision = 'phase26_issue_vendor' +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, 'facility_score_alerts'): + op.execute(sa.text(""" + CREATE TABLE facility_score_alerts ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + facility_id INT NOT NULL, + sent_at DATETIME NOT NULL, + current_avg DECIMAL(5,2) NOT NULL, + prior_avg DECIMAL(5,2) NOT NULL, + delta DECIMAL(5,2) NOT NULL, + CONSTRAINT fk_fsa_facility FOREIGN KEY (facility_id) + REFERENCES facilities(id) ON DELETE CASCADE, + INDEX ix_fsa_facility_sent (facility_id, sent_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'facility_score_alerts'): + op.execute(sa.text('DROP TABLE facility_score_alerts'))