06/12 Add features: 1. Vendor/Contractor assignment 2. Period-over-period comparison 3. Trend Alerts (cron)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}>'
|
||||||
@@ -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'):
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
|
|||||||
@@ -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'] %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
Reference in New Issue
Block a user