06/12 Add features: 1. Vendor/Contractor assignment 2. Period-over-period comparison 3. Trend Alerts (cron)

This commit is contained in:
2026-06-12 16:27:09 -04:00
parent cf22fe87fe
commit 0fe5954794
12 changed files with 447 additions and 2 deletions
+5
View File
@@ -79,6 +79,11 @@ class Issue(db.Model):
sla_notified = db.Column(db.String(10), nullable=True, default=None) 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 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 # Relationships
# NOTE: Issue.area is provided by the backref on Area.issues (facility.py). # 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 # Do NOT add a second explicit db.relationship('Area') here — it conflicts
+6
View File
@@ -23,6 +23,10 @@ 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'
# 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 = { 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',
@@ -34,6 +38,8 @@ ALL_EVENT_TYPES = {
# 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)',
# Score trend alert — admin/director management use
EVENT_SCORE_ALERT: 'Facility score trend alert (significant drop detected)',
} }
+24
View File
@@ -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'<FacilityScoreAlert facility={self.facility_id} delta={self.delta} sent={self.sent_at}>'
+6
View File
@@ -414,6 +414,12 @@ def view(issue_id):
issue.result_notes = form.result_notes.data or None 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 from app.routes.inspections import _save_photo
new_photos = [] new_photos = []
for file_obj in request.files.getlist('result_photos'): for file_obj in request.files.getlist('result_photos'):
+41 -1
View File
@@ -265,4 +265,44 @@ def cleanup_tokens():
db.session.commit() db.session.commit()
logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted) logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted)
return jsonify({'ok': True, 'deleted': deleted}) 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=<float> 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})
+36 -1
View File
@@ -162,6 +162,35 @@ def index():
facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\ facility_scores = fac_score_q.group_by(Facility.id, Facility.name)\
.order_by(func.avg(Inspection.overall_score).desc()).all() .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) # Score trend — daily averages (line chart)
daily_q = db.session.query( daily_q = db.session.query(
func.date(Inspection.inspection_date).label('day'), func.date(Inspection.inspection_date).label('day'),
@@ -226,13 +255,19 @@ def index():
inspectors = User.query.filter_by(role='inspector', active=True)\ inspectors = User.query.filter_by(role='inspector', active=True)\
.order_by(User.full_name, User.username).all() .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', return render_template('reports/index.html',
start=start, end=end, start=start, end=end,
total_inspections=total_inspections, total_inspections=total_inspections,
completed=completed, completed=completed,
flagged=flagged, flagged=flagged,
avg_score=round(float(avg_score), 2) if avg_score else None, 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], 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_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], issue_status=[{'status': r.status, 'count': r.count} for r in issue_status],
+37
View File
@@ -76,6 +76,20 @@
<dt class="col-sm-3">Assigned To</dt> <dt class="col-sm-3">Assigned To</dt>
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd> <dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.vendor_name %}
<dt class="col-sm-3">Contractor</dt>
<dd class="col-sm-9">
<i class="bi bi-person-gear text-secondary me-1"></i>
<strong>{{ issue.vendor_name }}</strong>
{% if issue.vendor_contact %}
<span class="text-muted ms-2">{{ issue.vendor_contact }}</span>
{% endif %}
{% if issue.vendor_notes %}
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.vendor_notes }}</div>
{% endif %}
</dd>
{% endif %}
{% if issue.resolved_at %} {% if issue.resolved_at %}
<dt class="col-sm-3">Resolved</dt> <dt class="col-sm-3">Resolved</dt>
<dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd> <dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd>
@@ -350,6 +364,29 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if current_user.role in ['admin','director','project_manager'] %}
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-person-gear me-1 text-secondary"></i>External Contractor
</p>
<div class="mb-2">
{{ 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 '') }}
</div>
<div class="mb-2">
{{ 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 '') }}
</div>
<div class="mb-3">
{{ 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…") }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">Save Update</button> <button type="submit" class="btn btn-primary w-100">Save Update</button>
</form> </form>
{% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %} {% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %}
+62
View File
@@ -117,6 +117,68 @@
</div> </div>
</div> </div>
{# ── Facility period-over-period comparison table ── #}
{% if facility_scores %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-building"></i> Facility Score Comparison</h6>
<span class="text-muted small">vs. prior equal-length period</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-end">Current Period</th>
<th class="text-end">Prior Period</th>
<th class="text-end">Change</th>
<th class="text-end">Inspections</th>
</tr>
</thead>
<tbody>
{% for row in facility_scores %}
<tr>
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-end">
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(row.avg_score|float) }}%
</span>
</td>
<td class="text-end text-muted">
{% if row.prior_avg is not none %}
{{ '%.1f'|format(row.prior_avg|float) }}%
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end">
{% if row.delta is not none %}
{% if row.delta > 0 %}
<span class="badge bg-success-subtle text-success border border-success fw-semibold">
<i class="bi bi-arrow-up-short"></i>+{{ '%.1f'|format(row.delta|float) }}
</span>
{% elif row.delta < 0 %}
<span class="badge bg-danger-subtle text-danger border border-danger fw-semibold">
<i class="bi bi-arrow-down-short"></i>{{ '%.1f'|format(row.delta|float) }}
</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border fw-semibold">
<i class="bi bi-dash"></i> 0.0
</span>
{% endif %}
{% else %}
<span class="text-muted small">No prior data</span>
{% endif %}
</td>
<td class="text-end text-muted small">{{ row.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Top inspectors table ── #} {# ── Top inspectors table ── #}
{% if top_inspectors %} {% if top_inspectors %}
<div class="row mb-4"> <div class="row mb-4">
+4
View File
@@ -184,6 +184,10 @@ class IssueUpdateForm(FlaskForm):
Optional(), Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.') 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 ───────────────────────────────────────────────────────────────── # ── Projects ─────────────────────────────────────────────────────────────────
+128
View File
@@ -214,4 +214,132 @@ def send_sla_alerts():
if total_sent: if total_sent:
db.session.commit() 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 return total_sent
@@ -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')
@@ -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'))