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