Phase 3: initiatal code

This commit is contained in:
2026-02-21 12:29:26 -05:00
parent c2fe59da0d
commit 55a1ef053b
19 changed files with 1975 additions and 87 deletions
+4 -1
View File
@@ -66,7 +66,10 @@ def index():
avg_score_query = avg_score_query.filter(Inspection.inspector_id == current_user.id)
avg_score = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.id.in_([i.id for i in avg_score_query.all()])
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
Inspection.inspection_date >= thirty_days_ago,
*([Inspection.inspector_id == current_user.id] if current_user.role == 'inspector' else [])
).scalar()
# Recent inspections
+327 -2
View File
@@ -1,7 +1,332 @@
from flask import Blueprint
import os
import uuid
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify)
from flask_login import login_required, current_user
from app import db
from app.models.inspection import (Inspection, InspectionTemplate,
ChecklistItem, InspectionResult)
from app.models.facility import Facility, Area
from app.models.issue import Issue
from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required
bp = Blueprint('inspections', __name__, url_prefix='/inspections')
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
def _save_photo(file_obj, subfolder='inspection_photos'):
"""Save an uploaded photo; return the relative path or None."""
if not file_obj or not file_obj.filename:
return None
ext = file_obj.filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
return None
filename = f"{uuid.uuid4().hex}.{ext}"
dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder)
os.makedirs(dest_dir, exist_ok=True)
file_obj.save(os.path.join(dest_dir, filename))
return f"uploads/{subfolder}/{filename}"
def _compute_score(inspection):
"""
Weighted average of all scored checklist results.
pass_fail → 100 if passed else 0
rating_5 → (score / 5) * 100
rating_10 → (score / 10) * 100
Returns a Decimal-compatible float or None if no results.
"""
results = inspection.results.join(ChecklistItem).all()
if not results:
return None
total_weight = 0.0
weighted_sum = 0.0
for r in results:
item = r.checklist_item
weight = float(item.weight or 1.0)
if item.scoring_type == 'pass_fail':
pts = 100.0 if r.passed else 0.0
elif item.scoring_type == 'rating_5':
pts = (float(r.score) / 5.0 * 100.0) if r.score is not None else 0.0
elif item.scoring_type == 'rating_10':
pts = (float(r.score) / 10.0 * 100.0) if r.score is not None else 0.0
else:
pts = 100.0 if r.passed else 0.0
weighted_sum += pts * weight
total_weight += weight
return round(weighted_sum / total_weight, 2) if total_weight else None
# ── List ─────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
return "Inspections module - Coming in Phase 2"
page = request.args.get('page', 1, type=int)
q = Inspection.query.order_by(Inspection.inspection_date.desc())
# Inspectors only see their own
if current_user.role == 'inspector':
q = q.filter(Inspection.inspector_id == current_user.id)
# Optional filters
status_filter = request.args.get('status', '')
facility_filter = request.args.get('facility_id', '', type=str)
if status_filter:
q = q.filter(Inspection.status == status_filter)
if facility_filter.isdigit():
q = q.filter(Inspection.facility_id == int(facility_filter))
inspections = q.paginate(page=page, per_page=20, error_out=False)
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
return render_template('inspections/list.html',
inspections=inspections,
facilities=facilities,
status_filter=status_filter,
facility_filter=facility_filter)
# ── Start ─────────────────────────────────────────────────────────────────────
@bp.route('/start', methods=['GET', 'POST'])
@login_required
def start():
form = StartInspectionForm()
templates = InspectionTemplate.query.order_by(InspectionTemplate.name).all()
facilities = Facility.query.filter_by(active=True).order_by(Facility.name).all()
form.template_id.choices = [(t.id, t.name) for t in templates]
form.facility_id.choices = [(f.id, f.name) for f in facilities]
# Area choices populated via AJAX based on selected facility
selected_fid = form.facility_id.data or (facilities[0].id if facilities else None)
areas = Area.query.filter_by(facility_id=selected_fid).order_by(Area.name).all() if selected_fid else []
form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas]
if form.validate_on_submit():
inspection = Inspection(
template_id = form.template_id.data,
facility_id = form.facility_id.data,
area_id = form.area_id.data or None,
inspector_id = current_user.id,
inspection_date = datetime.utcnow(),
status = 'in_progress',
notes = form.notes.data or None,
)
db.session.add(inspection)
db.session.flush() # get inspection.id
# Pre-create blank InspectionResult rows for every checklist item
template = InspectionTemplate.query.get(form.template_id.data)
for item in template.checklist_items.order_by(ChecklistItem.display_order).all():
db.session.add(InspectionResult(
inspection_id = inspection.id,
checklist_item_id = item.id,
))
db.session.commit()
flash(f'Inspection started. Complete each item below.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection.id))
return render_template('inspections/start.html', form=form, facilities=facilities)
# ── AJAX: areas for a given facility ─────────────────────────────────────────
@bp.route('/areas/<int:facility_id>')
@login_required
def areas_for_facility(facility_id):
areas = Area.query.filter_by(facility_id=facility_id).order_by(Area.name).all()
return jsonify([{'id': a.id, 'name': a.name} for a in areas])
# ── Execute ───────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/execute', methods=['GET', 'POST'])
@login_required
def execute(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id)
# Inspectors can only work on their own inspections
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
if inspection.status == 'completed':
return redirect(url_for('inspections.view', inspection_id=inspection_id))
# Ordered checklist items with their result rows
results = (
InspectionResult.query
.join(ChecklistItem)
.filter(InspectionResult.inspection_id == inspection_id)
.order_by(ChecklistItem.display_order)
.all()
)
if request.method == 'POST':
action = request.form.get('action', 'save')
for result in results:
item = result.checklist_item
prefix = f"item_{result.id}_"
if item.scoring_type == 'pass_fail':
passed_val = request.form.get(f"{prefix}passed", '')
result.passed = True if passed_val == 'pass' else \
False if passed_val == 'fail' else None
result.score = None
elif item.scoring_type in ('rating_5', 'rating_10'):
raw = request.form.get(f"{prefix}score", '')
try:
result.score = float(raw)
result.passed = result.score > 0
except (ValueError, TypeError):
result.score = None
result.passed = None
else:
result.passed = None
result.score = None
result.comments = request.form.get(f"{prefix}comments", '').strip() or None
# Photo upload
photo_file = request.files.get(f"{prefix}photo")
if photo_file and photo_file.filename:
path = _save_photo(photo_file)
if path:
result.photo_path = path
if action == 'complete':
# Validate all required-photo items have a photo
missing_photos = [
r for r in results
if r.checklist_item.requires_photo and not r.photo_path
]
if missing_photos:
db.session.commit()
flash(f'{len(missing_photos)} item(s) require a photo before completing.', 'warning')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
inspection.overall_score = _compute_score(inspection)
inspection.status = 'completed'
inspection.completed_at = datetime.utcnow()
db.session.commit()
flash('Inspection completed successfully!', 'success')
return redirect(url_for('inspections.view', inspection_id=inspection_id))
db.session.commit()
flash('Progress saved.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
# Count answered vs total
answered = sum(1 for r in results if r.passed is not None or r.score is not None)
staff = User.query.filter(User.role.in_(['supervisor', 'inspector'])).order_by(User.username).all()
return render_template('inspections/execute.html',
inspection=inspection,
results=results,
answered=answered,
staff=staff)
# ── View (completed) ──────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>')
@login_required
def view(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
results = (
InspectionResult.query
.join(ChecklistItem)
.filter(InspectionResult.inspection_id == inspection_id)
.order_by(ChecklistItem.display_order)
.all()
)
# Group by category
categories = {}
for r in results:
cat = r.checklist_item.category or 'General'
categories.setdefault(cat, []).append(r)
issues = inspection.issues.order_by(Issue.reported_at.desc()).all()
return render_template('inspections/view.html',
inspection=inspection,
categories=categories,
issues=issues)
# ── Flag issue during inspection ──────────────────────────────────────────────
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
@login_required
def flag_issue(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id)
if current_user.role == 'inspector' and inspection.inspector_id != current_user.id:
flash('Access denied.', 'danger')
return redirect(url_for('inspections.index'))
form = IssueForm()
areas = Area.query.filter_by(facility_id=inspection.facility_id).order_by(Area.name).all()
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
form.area_id.choices = [(a.id, a.name) for a in areas]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
if form.validate_on_submit():
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
issue = Issue(
inspection_id = inspection_id,
area_id = form.area_id.data,
severity = form.severity.data,
description = form.description.data,
photo_path = photo_path,
status = 'open',
assigned_to = form.assigned_to.data or None,
reported_at = datetime.utcnow(),
)
db.session.add(issue)
# Auto-flag the inspection if a high/critical issue is logged
if form.severity.data in ('high', 'critical') and inspection.status != 'completed':
inspection.status = 'flagged'
db.session.commit()
flash('Issue logged successfully.', 'success')
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
return render_template('inspections/flag_issue.html',
form=form, inspection=inspection)
# ── Delete ────────────────────────────────────────────────────────────────────
@bp.route('/<int:inspection_id>/delete', methods=['POST'])
@login_required
@supervisor_required
def delete(inspection_id):
inspection = Inspection.query.get_or_404(inspection_id)
db.session.delete(inspection)
db.session.commit()
flash('Inspection deleted.', 'success')
return redirect(url_for('inspections.index'))
+103
View File
@@ -0,0 +1,103 @@
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request)
from flask_login import login_required, current_user
from app import db
from app.models.issue import Issue
from app.models.facility import Facility, Area
from app.models.user import User
from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required
bp = Blueprint('issues', __name__, url_prefix='/issues')
# ── List ──────────────────────────────────────────────────────────────────────
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = Issue.query.order_by(Issue.reported_at.desc())
# Inspectors only see issues they reported (linked to their inspections)
if current_user.role == 'inspector':
from app.models.inspection import Inspection
q = q.join(Inspection, Issue.inspection_id == Inspection.id)\
.filter(Inspection.inspector_id == current_user.id)
severity_filter = request.args.get('severity', '')
status_filter = request.args.get('status', '')
if severity_filter:
q = q.filter(Issue.severity == severity_filter)
if status_filter:
q = q.filter(Issue.status == status_filter)
issues = q.paginate(page=page, per_page=25, error_out=False)
return render_template('issues/list.html',
issues=issues,
severity_filter=severity_filter,
status_filter=status_filter)
# ── View / Update ─────────────────────────────────────────────────────────────
@bp.route('/<int:issue_id>', methods=['GET', 'POST'])
@login_required
def view(issue_id):
issue = Issue.query.get_or_404(issue_id)
form = IssueUpdateForm(obj=issue)
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
form.status.data = form.status.data or issue.status
if form.validate_on_submit():
issue.status = form.status.data
issue.assigned_to = form.assigned_to.data or None
if form.status.data == 'resolved' and not issue.resolved_at:
issue.resolved_at = datetime.utcnow()
elif form.status.data != 'resolved':
issue.resolved_at = None
db.session.commit()
flash('Issue updated.', 'success')
return redirect(url_for('issues.view', issue_id=issue_id))
return render_template('issues/view.html', issue=issue, form=form)
# ── Standalone create (not from an inspection) ────────────────────────────────
@bp.route('/new', methods=['GET', 'POST'])
@login_required
@supervisor_required
def create():
form = IssueForm()
areas = Area.query.join(Facility).filter(Facility.active == True).order_by(Facility.name, Area.name).all()
staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all()
form.area_id.choices = [(a.id, f"{a.facility.name}{a.name}") for a in areas]
form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff]
if form.validate_on_submit():
from app.routes.inspections import _save_photo
photo_path = _save_photo(form.photo.data, subfolder='issue_photos')
issue = Issue(
area_id = form.area_id.data,
severity = form.severity.data,
description = form.description.data,
photo_path = photo_path,
status = 'open',
assigned_to = form.assigned_to.data or None,
reported_at = datetime.utcnow(),
)
db.session.add(issue)
db.session.commit()
flash('Issue created.', 'success')
return redirect(url_for('issues.index'))
return render_template('issues/form.html', form=form, title='Log New Issue')
+274 -2
View File
@@ -1,7 +1,279 @@
from flask import Blueprint
import csv
import io
from datetime import datetime, timedelta
from flask import (Blueprint, render_template, request,
Response, stream_with_context)
from flask_login import login_required, current_user
from sqlalchemy import func
from app import db
from app.models.inspection import Inspection, InspectionTemplate
from app.models.facility import Facility, Area
from app.models.issue import Issue
from app.models.user import User
from app.utils.decorators import supervisor_required
bp = Blueprint('reports', __name__, url_prefix='/reports')
def _date_range():
"""Parse ?start= and ?end= query params; default to last 30 days."""
end_default = datetime.utcnow()
start_default = end_default - timedelta(days=30)
try:
start = datetime.strptime(request.args.get('start', ''), '%Y-%m-%d')
except ValueError:
start = start_default
try:
end = datetime.strptime(request.args.get('end', ''), '%Y-%m-%d')
end = end.replace(hour=23, minute=59, second=59)
except ValueError:
end = end_default
return start, end
# ── Overview dashboard ────────────────────────────────────────────────────────
@bp.route('/')
@login_required
@supervisor_required
def index():
return "Reports module - Coming in Phase 2"
start, end = _date_range()
base = Inspection.query.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
)
total_inspections = base.count()
completed = base.filter(Inspection.status == 'completed').count()
flagged = base.filter(Inspection.status == 'flagged').count()
avg_score = db.session.query(func.avg(Inspection.overall_score)).filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).scalar()
# Scores by facility (for bar chart)
facility_scores = db.session.query(
Facility.name,
func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'),
).join(Inspection, Facility.id == Inspection.facility_id)\
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(Facility.id, Facility.name)\
.order_by(func.avg(Inspection.overall_score).desc()).all()
# Score trend — daily averages (line chart)
daily_scores = db.session.query(
func.date(Inspection.inspection_date).label('day'),
func.avg(Inspection.overall_score).label('avg'),
func.count(Inspection.id).label('count'),
).filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
Inspection.overall_score.isnot(None),
).group_by(func.date(Inspection.inspection_date))\
.order_by(func.date(Inspection.inspection_date)).all()
# Issue breakdown by severity
issue_severity = db.session.query(
Issue.severity,
func.count(Issue.id).label('count'),
).filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
).group_by(Issue.severity).all()
# Issue status breakdown
issue_status = db.session.query(
Issue.status,
func.count(Issue.id).label('count'),
).filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
).group_by(Issue.status).all()
# Top inspectors by inspection count
top_inspectors = db.session.query(
User.username,
func.count(Inspection.id).label('count'),
func.avg(Inspection.overall_score).label('avg_score'),
).join(Inspection, User.id == Inspection.inspector_id)\
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
).group_by(User.id, User.username)\
.order_by(func.count(Inspection.id).desc()).limit(10).all()
# Recent issues (critical/high)
critical_issues = Issue.query.filter(
Issue.severity.in_(['critical', 'high']),
Issue.status != 'resolved',
Issue.reported_at >= start,
Issue.reported_at <= end,
).order_by(Issue.reported_at.desc()).limit(10).all()
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=facility_scores,
daily_scores=daily_scores,
issue_severity=issue_severity,
issue_status=issue_status,
top_inspectors=top_inspectors,
critical_issues=critical_issues,
)
# ── Facility detail report ────────────────────────────────────────────────────
@bp.route('/facility/<int:facility_id>')
@login_required
@supervisor_required
def facility_report(facility_id):
facility = Facility.query.get_or_404(facility_id)
start, end = _date_range()
inspections = Inspection.query.filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
).order_by(Inspection.inspection_date.desc()).all()
area_scores = db.session.query(
Area.name,
func.avg(Inspection.overall_score).label('avg_score'),
func.count(Inspection.id).label('count'),
).join(Inspection, Area.id == Inspection.area_id)\
.filter(
Inspection.facility_id == facility_id,
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
Inspection.status == 'completed',
).group_by(Area.id, Area.name).all()
open_issues = Issue.query.join(Area)\
.filter(Area.facility_id == facility_id, Issue.status != 'resolved')\
.order_by(Issue.severity.desc()).all()
return render_template('reports/facility.html',
facility=facility, inspections=inspections,
area_scores=area_scores, open_issues=open_issues,
start=start, end=end)
# ── CSV export ────────────────────────────────────────────────────────────────
@bp.route('/export/inspections')
@login_required
@supervisor_required
def export_inspections():
start, end = _date_range()
rows = db.session.query(
Inspection.id,
Inspection.inspection_date,
Facility.name.label('facility'),
Area.name.label('area'),
User.username.label('inspector'),
InspectionTemplate.name.label('template'),
Inspection.overall_score,
Inspection.status,
Inspection.completed_at,
Inspection.notes,
).join(Facility, Inspection.facility_id == Facility.id)\
.outerjoin(Area, Inspection.area_id == Area.id)\
.join(User, Inspection.inspector_id == User.id)\
.join(InspectionTemplate, Inspection.template_id == InspectionTemplate.id)\
.filter(
Inspection.inspection_date >= start,
Inspection.inspection_date <= end,
).order_by(Inspection.inspection_date.desc()).all()
def generate():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(['ID','Date','Facility','Area','Inspector','Template',
'Score','Status','Completed At','Notes'])
yield buf.getvalue(); buf.seek(0); buf.truncate()
for r in rows:
writer.writerow([
r.id,
r.inspection_date.strftime('%Y-%m-%d %H:%M') if r.inspection_date else '',
r.facility, r.area or '',
r.inspector, r.template,
r.overall_score or '',
r.status,
r.completed_at.strftime('%Y-%m-%d %H:%M') if r.completed_at else '',
(r.notes or '').replace('\n', ' '),
])
yield buf.getvalue(); buf.seek(0); buf.truncate()
filename = f"inspections_{start.strftime('%Y%m%d')}_{end.strftime('%Y%m%d')}.csv"
return Response(
stream_with_context(generate()),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
)
@bp.route('/export/issues')
@login_required
@supervisor_required
def export_issues():
start, end = _date_range()
rows = db.session.query(
Issue.id,
Issue.reported_at,
Facility.name.label('facility'),
Area.name.label('area'),
Issue.severity,
Issue.description,
Issue.status,
Issue.resolved_at,
User.username.label('assigned_to'),
).join(Area, Issue.area_id == Area.id)\
.join(Facility, Area.facility_id == Facility.id)\
.outerjoin(User, Issue.assigned_to == User.id)\
.filter(
Issue.reported_at >= start,
Issue.reported_at <= end,
).order_by(Issue.reported_at.desc()).all()
def generate():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(['ID','Reported At','Facility','Area','Severity',
'Description','Status','Resolved At','Assigned To'])
yield buf.getvalue(); buf.seek(0); buf.truncate()
for r in rows:
writer.writerow([
r.id,
r.reported_at.strftime('%Y-%m-%d %H:%M') if r.reported_at else '',
r.facility, r.area, r.severity,
r.description.replace('\n', ' '),
r.status,
r.resolved_at.strftime('%Y-%m-%d %H:%M') if r.resolved_at else '',
r.assigned_to or '',
])
yield buf.getvalue(); buf.seek(0); buf.truncate()
filename = f"issues_{start.strftime('%Y%m%d')}_{end.strftime('%Y%m%d')}.csv"
return Response(
stream_with_context(generate()),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment; filename="{filename}"'}
)