Phase 3: initiatal code
This commit is contained in:
@@ -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
@@ -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'))
|
||||
|
||||
@@ -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
@@ -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}"'}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user