From 55a1ef053bb657ab6f6adff7413d2a232dfd3ab8 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sat, 21 Feb 2026 12:29:26 -0500 Subject: [PATCH] Phase 3: initiatal code --- app/__init__.py | 37 ++- app/models/__init__.py | 3 +- app/models/issue.py | 22 +- app/routes/dashboard.py | 5 +- app/routes/inspections.py | 329 +++++++++++++++++++++- app/routes/issues.py | 103 +++++++ app/routes/reports.py | 276 +++++++++++++++++- app/templates/base.html | 3 + app/templates/inspections/execute.html | 162 +++++++++++ app/templates/inspections/flag_issue.html | 48 ++++ app/templates/inspections/list.html | 106 +++++++ app/templates/inspections/start.html | 71 +++++ app/templates/inspections/view.html | 141 ++++++++++ app/templates/issues/form.html | 29 ++ app/templates/issues/list.html | 98 +++++++ app/templates/issues/view.html | 85 ++++++ app/templates/reports/facility.html | 156 ++++++++++ app/templates/reports/index.html | 242 ++++++++++++++++ app/utils/forms.py | 146 ++++++---- 19 files changed, 1975 insertions(+), 87 deletions(-) create mode 100644 app/routes/issues.py create mode 100644 app/templates/inspections/execute.html create mode 100644 app/templates/inspections/flag_issue.html create mode 100644 app/templates/inspections/list.html create mode 100644 app/templates/inspections/start.html create mode 100644 app/templates/inspections/view.html create mode 100644 app/templates/issues/form.html create mode 100644 app/templates/issues/list.html create mode 100644 app/templates/issues/view.html create mode 100644 app/templates/reports/facility.html create mode 100644 app/templates/reports/index.html diff --git a/app/__init__.py b/app/__init__.py index 5a16c5b..3957ddd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -6,44 +6,39 @@ from flask_mail import Mail from config import config import os -# Initialize extensions -db = SQLAlchemy() +db = SQLAlchemy() login_manager = LoginManager() -migrate = Migrate() -mail = Mail() +migrate = Migrate() +mail = Mail() + def create_app(config_name='default'): app = Flask(__name__) - - # Load configuration app.config.from_object(config[config_name]) - - # Initialize extensions with app + db.init_app(app) login_manager.init_app(app) migrate.init_app(app, db) mail.init_app(app) - - # Configure login manager - login_manager.login_view = 'auth.login' - login_manager.login_message = 'Please log in to access this page.' + + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Please log in to access this page.' login_manager.login_message_category = 'info' - - # Create upload directory if it doesn't exist + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) - - # Register blueprints (import here to avoid circular imports) + from app.routes import auth, dashboard, inspections, templates, reports, facilities - + from app.routes import issues # Phase 3 + app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) app.register_blueprint(inspections.bp) app.register_blueprint(templates.bp) app.register_blueprint(reports.bp) app.register_blueprint(facilities.bp) - - # Create database tables + app.register_blueprint(issues.bp) + with app.app_context(): db.create_all() - - return app \ No newline at end of file + + return app diff --git a/app/models/__init__.py b/app/models/__init__.py index 577a663..4ef9753 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.user import User from app.models.facility import Facility, Area -from app.models.inspection import InspectionTemplate, ChecklistItem, Inspection, InspectionResult +from app.models.inspection import (InspectionTemplate, ChecklistItem, + Inspection, InspectionResult) from app.models.issue import Issue diff --git a/app/models/issue.py b/app/models/issue.py index d96f6c1..b68f1fc 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -1,19 +1,23 @@ from app import db from datetime import datetime + class Issue(db.Model): __tablename__ = 'issues' - id = db.Column(db.Integer, primary_key=True) + id = db.Column(db.Integer, primary_key=True) inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id')) - area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=False) - severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False) - description = db.Column(db.Text, nullable=False) - photo_path = db.Column(db.String(255)) - status = db.Column(db.Enum('open', 'in_progress', 'resolved'), default='open') - assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) - reported_at = db.Column(db.DateTime, default=datetime.utcnow) - resolved_at = db.Column(db.DateTime) + area_id = db.Column(db.Integer, db.ForeignKey('areas.id'), nullable=False) + severity = db.Column(db.Enum('low', 'medium', 'high', 'critical'), nullable=False) + description = db.Column(db.Text, nullable=False) + photo_path = db.Column(db.String(255)) + status = db.Column(db.Enum('open', 'in_progress', 'resolved'), default='open') + assigned_to = db.Column(db.Integer, db.ForeignKey('users.id')) + reported_at = db.Column(db.DateTime, default=datetime.utcnow) + resolved_at = db.Column(db.DateTime) + + # Relationships + assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_issues') def __repr__(self): return f'' diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index a365f23..c66c79a 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -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 diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 6309720..aebfd19 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -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/') +@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('//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('/') +@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('//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('//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')) diff --git a/app/routes/issues.py b/app/routes/issues.py new file mode 100644 index 0000000..3ecb93a --- /dev/null +++ b/app/routes/issues.py @@ -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('/', 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') diff --git a/app/routes/reports.py b/app/routes/reports.py index a1836f5..090712a 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -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/') +@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}"'} + ) diff --git a/app/templates/base.html b/app/templates/base.html index 78dda0c..626e638 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -35,6 +35,9 @@ + {% if current_user.role == 'admin' %}