Phase 3: initiatal code
This commit is contained in:
+8
-13
@@ -6,34 +6,29 @@ 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)
|
||||
@@ -41,8 +36,8 @@ def create_app(config_name='default'):
|
||||
app.register_blueprint(templates.bp)
|
||||
app.register_blueprint(reports.bp)
|
||||
app.register_blueprint(facilities.bp)
|
||||
app.register_blueprint(issues.bp)
|
||||
|
||||
# Create database tables
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-9
@@ -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'<Issue {self.id} - {self.severity}>'
|
||||
|
||||
@@ -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}"'}
|
||||
)
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('reports.index') }}">Reports</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('issues.index') }}">Issues</a>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.list_users') }}">Users</a>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Execute Inspection{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.item-card { border-left: 4px solid #dee2e6; transition: border-color .2s; }
|
||||
.item-card.answered { border-left-color: #198754; }
|
||||
.item-card.flagged { border-left-color: #dc3545; }
|
||||
.pass-fail-group .btn-check:checked + .btn-outline-success { background:#198754; color:#fff; }
|
||||
.pass-fail-group .btn-check:checked + .btn-outline-danger { background:#dc3545; color:#fff; }
|
||||
.progress-bar-label { font-size:.75rem; font-weight:600; }
|
||||
.sticky-toolbar { position:sticky; top:56px; z-index:20; background:#fff; border-bottom:1px solid #dee2e6; padding:.6rem 1rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data" id="inspectionForm">
|
||||
{{ csrf_token() | safe }}
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# Sticky toolbar #}
|
||||
<div class="sticky-toolbar d-flex align-items-center gap-3 mb-4">
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mb-0">{{ inspection.template.name }}</h5>
|
||||
<small class="text-muted">{{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %}</small>
|
||||
</div>
|
||||
<div class="text-center" style="min-width:120px;">
|
||||
<div class="progress" style="height:8px;">
|
||||
<div class="progress-bar bg-success" style="width:{{ (answered / results|length * 100)|int if results else 0 }}%"></div>
|
||||
</div>
|
||||
<span class="progress-bar-label text-muted">{{ answered }}/{{ results|length }} answered</span>
|
||||
</div>
|
||||
<a href="{{ url_for('inspections.flag_issue', inspection_id=inspection.id) }}"
|
||||
class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-exclamation-triangle"></i> Flag Issue
|
||||
</a>
|
||||
<button type="submit" name="action" value="save" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-floppy"></i> Save
|
||||
</button>
|
||||
<button type="submit" name="action" value="complete" class="btn btn-success"
|
||||
onclick="return confirm('Mark this inspection as complete?')">
|
||||
<i class="bi bi-check-circle"></i> Complete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if inspection.notes %}
|
||||
<div class="alert alert-info py-2"><i class="bi bi-sticky"></i> <strong>Notes:</strong> {{ inspection.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% set ns = namespace(current_cat='') %}
|
||||
{% for result in results %}
|
||||
{% set item = result.checklist_item %}
|
||||
{% if item.category != ns.current_cat %}
|
||||
{% set ns.current_cat = item.category %}
|
||||
<h5 class="text-primary border-bottom pb-1 mt-4 mb-3">
|
||||
<i class="bi bi-folder2"></i> {{ item.category or 'General' }}
|
||||
</h5>
|
||||
{% endif %}
|
||||
|
||||
{% set is_answered = result.passed is not none or result.score is not none %}
|
||||
<div class="card shadow-sm mb-3 item-card {{ 'answered' if is_answered }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<span class="fw-semibold">{{ item.item_description }}</span>
|
||||
{% if item.requires_photo %}
|
||||
<span class="badge bg-warning text-dark ms-1"><i class="bi bi-camera"></i> Photo required</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="badge bg-secondary text-uppercase" style="font-size:.65rem;">{{ item.scoring_type|replace('_',' ') }}</span>
|
||||
</div>
|
||||
|
||||
{# ── Pass/Fail ── #}
|
||||
{% if item.scoring_type == 'pass_fail' %}
|
||||
<div class="pass-fail-group d-flex gap-2 mb-2">
|
||||
<input class="btn-check" type="radio" name="item_{{ result.id }}_passed"
|
||||
id="pass_{{ result.id }}" value="pass" {{ 'checked' if result.passed == true }}>
|
||||
<label class="btn btn-outline-success btn-sm" for="pass_{{ result.id }}">
|
||||
<i class="bi bi-check-lg"></i> Pass
|
||||
</label>
|
||||
<input class="btn-check" type="radio" name="item_{{ result.id }}_passed"
|
||||
id="fail_{{ result.id }}" value="fail" {{ 'checked' if result.passed == false }}>
|
||||
<label class="btn btn-outline-danger btn-sm" for="fail_{{ result.id }}">
|
||||
<i class="bi bi-x-lg"></i> Fail
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# ── Rating 5 ── #}
|
||||
{% elif item.scoring_type == 'rating_5' %}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Rating (1–5)</label>
|
||||
<div class="d-flex gap-2">
|
||||
{% for v in [1,2,3,4,5] %}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="item_{{ result.id }}_score"
|
||||
id="r5_{{ result.id }}_{{ v }}" value="{{ v }}"
|
||||
{{ 'checked' if result.score == v }}>
|
||||
<label class="form-check-label" for="r5_{{ result.id }}_{{ v }}">{{ v }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Rating 10 ── #}
|
||||
{% elif item.scoring_type == 'rating_10' %}
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Rating (1–10)</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for v in range(1,11) %}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="item_{{ result.id }}_score"
|
||||
id="r10_{{ result.id }}_{{ v }}" value="{{ v }}"
|
||||
{{ 'checked' if result.score == v }}>
|
||||
<label class="form-check-label" for="r10_{{ result.id }}_{{ v }}">{{ v }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<textarea class="form-control form-control-sm" name="item_{{ result.id }}_comments"
|
||||
rows="2" placeholder="Comments (optional)">{{ result.comments or '' }}</textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% if result.photo_path %}
|
||||
<div class="mb-1">
|
||||
<img src="{{ url_for('static', filename=result.photo_path) }}"
|
||||
class="img-thumbnail" style="max-height:80px;" alt="Photo">
|
||||
</div>
|
||||
{% endif %}
|
||||
<input type="file" class="form-control form-control-sm"
|
||||
name="item_{{ result.id }}_photo" accept="image/*">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Bottom submit bar #}
|
||||
<div class="d-flex justify-content-end gap-2 mt-4 pb-4">
|
||||
<button type="submit" name="action" value="save" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-floppy"></i> Save Progress
|
||||
</button>
|
||||
<button type="submit" name="action" value="complete" class="btn btn-success btn-lg"
|
||||
onclick="return confirm('Mark this inspection as complete? This cannot be undone.')">
|
||||
<i class="bi bi-check-circle-fill"></i> Complete Inspection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Auto-mark item cards as answered on interaction
|
||||
document.querySelectorAll('input[type=radio]').forEach(r => {
|
||||
r.addEventListener('change', () => {
|
||||
const card = r.closest('.item-card');
|
||||
if (card) card.classList.add('answered');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Flag Issue{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Flag Issue During Inspection</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-light border mb-3">
|
||||
<strong>Inspection:</strong> {{ inspection.template.name }}<br>
|
||||
<strong>Facility:</strong> {{ inspection.facility.name }}
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.area_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.area_id(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.severity.label(class="form-label fw-semibold") }}
|
||||
{{ form.severity(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.description.label(class="form-label fw-semibold") }}
|
||||
{{ form.description(class="form-control", rows=4, placeholder="Describe the issue in detail…") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.photo.label(class="form-label fw-semibold") }}
|
||||
{{ form.photo(class="form-control") }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger"><i class="bi bi-flag"></i> Log Issue</button>
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=inspection.id) }}"
|
||||
class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspections{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
|
||||
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Start Inspection
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Filters #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
{% for s in ['in_progress','completed','flagged'] %}
|
||||
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Facility</label>
|
||||
<select name="facility_id" class="form-select form-select-sm">
|
||||
<option value="">All Facilities</option>
|
||||
{% for f in facilities %}
|
||||
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if inspections.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th><th>Facility</th><th>Area</th>
|
||||
<th>Template</th><th>Inspector</th><th>Score</th>
|
||||
<th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in inspections.items %}
|
||||
<tr>
|
||||
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>{{ ins.facility.name }}</td>
|
||||
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td>{{ ins.template.name }}</td>
|
||||
<td>{{ ins.inspector.username }}</td>
|
||||
<td>
|
||||
{% if ins.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
|
||||
{{ ins.overall_score }}%
|
||||
</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
||||
{{ ins.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
|
||||
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary">Continue</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{# Pagination #}
|
||||
{% if inspections.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == inspections.page }}">
|
||||
<a class="page-link" href="{{ url_for('inspections.index', page=p, status=status_filter, facility_id=facility_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No inspections found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Start Inspection{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-play-circle"></i> Start New Inspection</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.template_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
|
||||
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.facility_id.label(class="form-label fw-semibold") }}
|
||||
{{ form.facility_id(class="form-select", id="facilitySelect") }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.area_id.label(class="form-label fw-semibold") }}
|
||||
<select name="area_id" id="areaSelect" class="form-select">
|
||||
<option value="0">— No specific area —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.notes.label(class="form-label fw-semibold") }}
|
||||
{{ form.notes(class="form-control", rows=3, placeholder="Optional notes for this inspection…") }}
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-play-fill"></i> Begin Inspection
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const AREAS_URL = "{{ url_for('inspections.areas_for_facility', facility_id=0) }}".replace('/0', '/');
|
||||
const facilitySelect = document.getElementById('facilitySelect');
|
||||
const areaSelect = document.getElementById('areaSelect');
|
||||
|
||||
function loadAreas(facilityId) {
|
||||
fetch(AREAS_URL + facilityId)
|
||||
.then(r => r.json())
|
||||
.then(areas => {
|
||||
areaSelect.innerHTML = '<option value="0">— No specific area —</option>';
|
||||
areas.forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a.id; opt.textContent = a.name;
|
||||
areaSelect.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
facilitySelect.addEventListener('change', () => loadAreas(facilitySelect.value));
|
||||
if (facilitySelect.value) loadAreas(facilitySelect.value);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Inspection #{{ inspection.id }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2>Inspection Report</h2>
|
||||
<p class="text-muted mb-0">{{ inspection.template.name }} · {{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if current_user.role in ['admin','supervisor'] %}
|
||||
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
|
||||
onsubmit="return confirm('Delete this inspection?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Summary cards #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Overall Score</p>
|
||||
{% if inspection.overall_score %}
|
||||
<h2 class="fw-bold text-{{ 'success' if inspection.overall_score >= 90 else 'warning' if inspection.overall_score >= 70 else 'danger' }}">
|
||||
{{ inspection.overall_score }}%
|
||||
</h2>
|
||||
{% else %}<h2 class="text-muted">—</h2>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Status</p>
|
||||
<span class="badge fs-6 bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }}">
|
||||
{{ inspection.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Facility / Area</p>
|
||||
<p class="fw-semibold mb-0">{{ inspection.facility.name }}</p>
|
||||
<small class="text-muted">{{ inspection.area.name if inspection.area else '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Inspector</p>
|
||||
<p class="fw-semibold mb-0">{{ inspection.inspector.username }}</p>
|
||||
{% if inspection.completed_at %}
|
||||
<small class="text-muted">Completed {{ inspection.completed_at.strftime('%Y-%m-%d %H:%M') }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if inspection.notes %}
|
||||
<div class="alert alert-light border mb-4"><strong>Notes:</strong> {{ inspection.notes }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# Checklist results by category #}
|
||||
{% for category, results in categories.items() %}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-light">
|
||||
<h6 class="mb-0"><i class="bi bi-folder2"></i> {{ category }}</h6>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Item</th><th>Type</th><th>Result</th><th>Comments</th><th>Photo</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>{{ r.checklist_item.item_description }}</td>
|
||||
<td><span class="badge bg-secondary" style="font-size:.65rem;">{{ r.checklist_item.scoring_type|replace('_',' ') }}</span></td>
|
||||
<td>
|
||||
{% if r.checklist_item.scoring_type == 'pass_fail' %}
|
||||
{% if r.passed is none %}<span class="text-muted">—</span>
|
||||
{% elif r.passed %}<span class="badge bg-success">Pass</span>
|
||||
{% else %}<span class="badge bg-danger">Fail</span>{% endif %}
|
||||
{% else %}
|
||||
{% if r.score is not none %}<strong>{{ r.score }}</strong>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><small class="text-muted">{{ r.comments or '—' }}</small></td>
|
||||
<td>
|
||||
{% if r.photo_path %}
|
||||
<a href="{{ url_for('static', filename=r.photo_path) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=r.photo_path) }}" style="max-height:40px;" class="img-thumbnail">
|
||||
</a>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Issues #}
|
||||
{% if issues %}
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issues Logged ({{ issues|length }})</h6>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues %}
|
||||
<tr>
|
||||
<td><span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">{{ issue.severity|title }}</span></td>
|
||||
<td>{{ issue.area.name }}</td>
|
||||
<td>{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
|
||||
<td><span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">{{ issue.status|replace('_',' ')|title }}</span></td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> {{ title }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% for field in [form.area_id, form.severity, form.description, form.photo, form.assigned_to] %}
|
||||
<div class="mb-3">
|
||||
{{ field.label(class="form-label fw-semibold") }}
|
||||
{{ field(class="form-select" if field.type == 'SelectField' else "form-control", rows=4 if field.type == 'TextAreaField' else none) }}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-danger">Log Issue</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issues{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
|
||||
{% if current_user.role in ['admin','supervisor'] %}
|
||||
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
|
||||
<i class="bi bi-plus-circle"></i> Log Issue
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Severity</label>
|
||||
<select name="severity" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['critical','high','medium','low'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
{% for s in ['open','in_progress','resolved'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
{% if issues.items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Reported</th><th>Severity</th><th>Facility / Area</th>
|
||||
<th>Description</th><th>Status</th><th>Assigned</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in issues.items %}
|
||||
<tr>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">
|
||||
{{ issue.severity|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ issue.area.facility.name }}<br>
|
||||
<small class="text-muted">{{ issue.area.name }}</small>
|
||||
</td>
|
||||
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
|
||||
{{ issue.status|replace('_',' ')|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if issue.assigned_user %}{{ issue.assigned_user.username }}
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if issues.pages > 1 %}
|
||||
<div class="d-flex justify-content-center py-3">
|
||||
<nav><ul class="pagination pagination-sm mb-0">
|
||||
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
|
||||
{% if p %}
|
||||
<li class="page-item {{ 'active' if p == issues.page }}">
|
||||
<a class="page-link" href="{{ url_for('issues.index', page=p, severity=severity_filter, status=status_filter) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||
{% endfor %}
|
||||
</ul></nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No issues found.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Issue #{{ issue.id }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning' if issue.severity == 'medium' else 'secondary' }}
|
||||
text-{{ 'white' if issue.severity in ['critical','high','low'] else 'dark' }}">
|
||||
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issue #{{ issue.id }} — {{ issue.severity|title }} Severity</h5>
|
||||
<span class="badge bg-light text-dark">{{ issue.status|replace('_',' ')|title }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Reported</dt>
|
||||
<dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Facility</dt>
|
||||
<dd class="col-sm-9">{{ issue.area.facility.name }}</dd>
|
||||
|
||||
<dt class="col-sm-3">Area</dt>
|
||||
<dd class="col-sm-9">{{ issue.area.name }}</dd>
|
||||
|
||||
{% if issue.inspection %}
|
||||
<dt class="col-sm-3">Inspection</dt>
|
||||
<dd class="col-sm-9">
|
||||
<a href="{{ url_for('inspections.view', inspection_id=issue.inspection_id) }}">#{{ issue.inspection_id }}</a>
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-3">Assigned To</dt>
|
||||
<dd class="col-sm-9">{{ issue.assigned_user.username if issue.assigned_user else '— Unassigned —' }}</dd>
|
||||
|
||||
{% if issue.resolved_at %}
|
||||
<dt class="col-sm-3">Resolved</dt>
|
||||
<dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
<hr>
|
||||
<h6>Description</h6>
|
||||
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
|
||||
|
||||
{% if issue.photo_path %}
|
||||
<hr>
|
||||
<h6>Photo Evidence</h6>
|
||||
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
|
||||
<img src="{{ url_for('static', filename=issue.photo_path) }}" class="img-fluid rounded" style="max-height:300px;">
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
{% if current_user.role in ['admin','supervisor'] %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
{{ form.status.label(class="form-label fw-semibold") }}
|
||||
{{ form.status(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.assigned_to.label(class="form-label fw-semibold") }}
|
||||
{{ form.assigned_to(class="form-select") }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form.comments.label(class="form-label fw-semibold") }}
|
||||
{{ form.comments(class="form-control", rows=3, placeholder="Optional update notes…") }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Save Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to Issues
|
||||
</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ facility.name }} — Facility Report{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>.chart-container { position:relative; height:240px; }</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
|
||||
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('reports.export_inspections', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> Export CSV</a>
|
||||
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Back to Reports
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# KPI row #}
|
||||
{% set completed = inspections | selectattr('status','eq','completed') | list %}
|
||||
{% set avg = (completed | map(attribute='overall_score') | select | list) %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Total Inspections</p>
|
||||
<h3 class="fw-bold">{{ inspections|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Completed</p>
|
||||
<h3 class="fw-bold text-success">{{ completed|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Open Issues</p>
|
||||
<h3 class="fw-bold text-danger">{{ open_issues|length }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm text-center h-100">
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-1">Avg Score</p>
|
||||
{% if avg %}
|
||||
{% set avg_val = (avg | map('float') | sum) / avg|length %}
|
||||
<h3 class="fw-bold text-{{ 'success' if avg_val >= 90 else 'warning' if avg_val >= 70 else 'danger' }}">
|
||||
{{ '%.1f'|format(avg_val) }}%
|
||||
</h3>
|
||||
{% else %}<h3 class="text-muted">—</h3>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Area scores chart #}
|
||||
{% if area_scores %}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Avg Score by Area</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="areaChart"></canvas></div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Inspection history #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light"><h6 class="mb-0">Inspection History</h6></div>
|
||||
<div class="table-responsive">
|
||||
{% if inspections %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Date</th><th>Area</th><th>Template</th><th>Inspector</th><th>Score</th><th>Status</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ins in inspections %}
|
||||
<tr>
|
||||
<td><small>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
|
||||
<td>{{ ins.area.name if ins.area else '—' }}</td>
|
||||
<td>{{ ins.template.name }}</td>
|
||||
<td>{{ ins.inspector.username }}</td>
|
||||
<td>
|
||||
{% if ins.overall_score %}
|
||||
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">
|
||||
{{ ins.overall_score }}%
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td><span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
|
||||
{{ ins.status|replace('_',' ')|title }}</span></td>
|
||||
<td><a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="card-body"><p class="text-muted mb-0">No inspections in this date range.</p></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Open issues #}
|
||||
{% if open_issues %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-danger text-white"><h6 class="mb-0">Open Issues</h6></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Severity</th><th>Area</th><th>Description</th><th>Reported</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for issue in open_issues %}
|
||||
<tr>
|
||||
<td><span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">{{ issue.severity|title }}</span></td>
|
||||
<td>{{ issue.area.name }}</td>
|
||||
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
|
||||
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d') }}</small></td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if area_scores %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
new Chart(document.getElementById('areaChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ area_scores | map(attribute='name') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ area_scores | map(attribute='avg_score') | list | tojson }},
|
||||
backgroundColor: {{ area_scores | map(attribute='avg_score') | list | tojson }}
|
||||
.map(s => s >= 90 ? '#198754' : s >= 70 ? '#ffc107' : '#dc3545'),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,242 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Reports & Analytics{% endblock %}
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card { border-left: 4px solid; }
|
||||
.stat-card.primary { border-color: #0d6efd; }
|
||||
.stat-card.success { border-color: #198754; }
|
||||
.stat-card.danger { border-color: #dc3545; }
|
||||
.stat-card.info { border-color: #0dcaf0; }
|
||||
.chart-container { position:relative; height:280px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{# ── Header + date filter ── #}
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-graph-up"></i> Reports & Analytics</h2>
|
||||
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('reports.export_inspections', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> Export Inspections CSV</a>
|
||||
<a href="{{ url_for('reports.export_issues', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm btn-outline-danger"><i class="bi bi-download"></i> Export Issues CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">From</label>
|
||||
<input type="date" name="start" class="form-control form-control-sm"
|
||||
value="{{ start.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">To</label>
|
||||
<input type="date" name="end" class="form-control form-control-sm"
|
||||
value="{{ end.strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary">Apply</button>
|
||||
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── KPI cards ── #}
|
||||
<div class="row mb-4">
|
||||
{% for label, value, color, icon in [
|
||||
('Total Inspections', total_inspections, 'primary', 'bi-clipboard-data'),
|
||||
('Completed', completed, 'success', 'bi-check-circle'),
|
||||
('Flagged', flagged, 'danger', 'bi-flag'),
|
||||
('Avg Score', (avg_score|string + '%') if avg_score else '—', 'info', 'bi-graph-up'),
|
||||
] %}
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card shadow-sm stat-card {{ color }} h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi {{ icon }} text-{{ color }}" style="font-size:2rem;"></i>
|
||||
<div>
|
||||
<p class="text-muted small mb-0">{{ label }}</p>
|
||||
<h3 class="mb-0 fw-bold">{{ value }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# ── Charts row 1 ── #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-graph-up-arrow"></i> Score Trend</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="trendChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issues by Severity</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="severityChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Charts row 2 ── #}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-8 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 mb-3">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-pie-chart"></i> Issue Status</h6></div>
|
||||
<div class="card-body"><div class="chart-container"><canvas id="statusChart"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Top inspectors table ── #}
|
||||
{% if top_inspectors %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-person-check"></i> Top Inspectors</h6></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Inspector</th><th>Inspections</th><th>Avg Score</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in top_inspectors %}
|
||||
<tr>
|
||||
<td>{{ row.username }}</td>
|
||||
<td>{{ row.count }}</td>
|
||||
<td>
|
||||
{% if row.avg_score %}
|
||||
<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>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Critical / High open issues ── #}
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-danger text-white"><h6 class="mb-0"><i class="bi bi-fire"></i> Open Critical / High Issues</h6></div>
|
||||
{% if critical_issues %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light"><tr><th>Severity</th><th>Area</th><th>Description</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for issue in critical_issues %}
|
||||
<tr>
|
||||
<td><span class="badge bg-danger">{{ issue.severity|title }}</span></td>
|
||||
<td>{{ issue.area.name }}</td>
|
||||
<td>{{ issue.description[:50] }}{% if issue.description|length > 50 %}…{% endif %}</td>
|
||||
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body"><p class="text-muted mb-0">No open critical or high issues. 🎉</p></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
Chart.defaults.font.family = "'Segoe UI', system-ui, sans-serif";
|
||||
Chart.defaults.color = '#6c757d';
|
||||
|
||||
const BLUE = '#0d6efd', GREEN = '#198754', RED = '#dc3545',
|
||||
AMBER = '#ffc107', TEAL = '#0dcaf0', GRAY = '#adb5bd';
|
||||
|
||||
// ── Trend chart ──────────────────────────────────────────────────────────────
|
||||
const trendData = {{ daily_scores | map(attribute=0) | list | tojson }}; {# dates #}
|
||||
new Chart(document.getElementById('trendChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ daily_scores | map(attribute='day') | map('string') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ daily_scores | map(attribute='avg') | list | tojson }},
|
||||
borderColor: BLUE, backgroundColor: 'rgba(13,110,253,.1)',
|
||||
tension: .3, fill: true, pointRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Facility bar chart ────────────────────────────────────────────────────────
|
||||
new Chart(document.getElementById('facilityChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
|
||||
datasets: [{
|
||||
label: 'Avg Score (%)',
|
||||
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
|
||||
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
|
||||
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── Severity doughnut ─────────────────────────────────────────────────────────
|
||||
const sevData = {{ issue_severity | tojson }};
|
||||
new Chart(document.getElementById('severityChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: sevData.map(r => r.severity ? r.severity.charAt(0).toUpperCase() + r.severity.slice(1) : 'Unknown'),
|
||||
datasets: [{
|
||||
data: sevData.map(r => r.count),
|
||||
backgroundColor: sevData.map(r => ({critical:RED,high:'#fd7e14',medium:AMBER,low:GRAY}[r.severity] || GRAY)),
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } } }
|
||||
});
|
||||
|
||||
// ── Status pie ────────────────────────────────────────────────────────────────
|
||||
const stData = {{ issue_status | tojson }};
|
||||
new Chart(document.getElementById('statusChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: stData.map(r => r.status ? r.status.replace('_',' ').replace(/\b\w/g,c=>c.toUpperCase()) : 'Unknown'),
|
||||
datasets: [{
|
||||
data: stData.map(r => r.count),
|
||||
backgroundColor: stData.map(r => ({open:RED, in_progress:AMBER, resolved:GREEN}[r.status] || GRAY)),
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } } }
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+92
-48
@@ -1,86 +1,130 @@
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, PasswordField, SelectField, TextAreaField, DecimalField, BooleanField, IntegerField
|
||||
from wtforms.validators import DataRequired, Email, Length, EqualTo, Optional, NumberRange, ValidationError
|
||||
from flask_wtf.file import FileField, FileAllowed
|
||||
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
|
||||
DecimalField, BooleanField, IntegerField, HiddenField,
|
||||
RadioField)
|
||||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||||
Optional, NumberRange, ValidationError)
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
password = PasswordField('Password', validators=[DataRequired()])
|
||||
|
||||
|
||||
class UserForm(FlaskForm):
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
password = PasswordField('Password', validators=[Length(min=6, max=100)])
|
||||
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
|
||||
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
||||
password = PasswordField('Password', validators=[Length(min=6, max=100)])
|
||||
confirm_password = PasswordField('Confirm Password', validators=[EqualTo('password')])
|
||||
role = SelectField('Role', choices=[
|
||||
('admin', 'Administrator'),
|
||||
('supervisor', 'Supervisor'),
|
||||
('inspector', 'Inspector')
|
||||
role = SelectField('Role', choices=[
|
||||
('admin', 'Administrator'), ('supervisor', 'Supervisor'), ('inspector', 'Inspector')
|
||||
], validators=[DataRequired()])
|
||||
|
||||
def __init__(self, user=None, *args, **kwargs):
|
||||
super(UserForm, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user = user
|
||||
|
||||
def validate_username(self, field):
|
||||
q = User.query.filter_by(username=field.data).first()
|
||||
if self.user:
|
||||
# Editing existing user
|
||||
if field.data != self.user.username:
|
||||
if User.query.filter_by(username=field.data).first():
|
||||
raise ValidationError('Username already exists.')
|
||||
else:
|
||||
# Creating new user
|
||||
if User.query.filter_by(username=field.data).first():
|
||||
if field.data != self.user.username and q:
|
||||
raise ValidationError('Username already exists.')
|
||||
elif q:
|
||||
raise ValidationError('Username already exists.')
|
||||
|
||||
def validate_email(self, field):
|
||||
q = User.query.filter_by(email=field.data).first()
|
||||
if self.user:
|
||||
if field.data != self.user.email:
|
||||
if User.query.filter_by(email=field.data).first():
|
||||
raise ValidationError('Email already registered.')
|
||||
else:
|
||||
if User.query.filter_by(email=field.data).first():
|
||||
if field.data != self.user.email and q:
|
||||
raise ValidationError('Email already registered.')
|
||||
elif q:
|
||||
raise ValidationError('Email already registered.')
|
||||
|
||||
|
||||
# ── Facility / Area ──────────────────────────────────────────────────────────
|
||||
|
||||
class FacilityForm(FlaskForm):
|
||||
name = StringField('Facility Name', validators=[DataRequired(), Length(max=255)])
|
||||
address = TextAreaField('Address', validators=[Optional()])
|
||||
name = StringField('Facility Name', validators=[DataRequired(), Length(max=255)])
|
||||
address = TextAreaField('Address', validators=[Optional()])
|
||||
contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)])
|
||||
contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)])
|
||||
active = BooleanField('Active', default=True)
|
||||
contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)])
|
||||
active = BooleanField('Active', default=True)
|
||||
|
||||
|
||||
class AreaForm(FlaskForm):
|
||||
name = StringField('Area Name', validators=[DataRequired(), Length(max=255)])
|
||||
name = StringField('Area Name', validators=[DataRequired(), Length(max=255)])
|
||||
area_type = SelectField('Area Type', choices=[
|
||||
('restroom', 'Restroom'),
|
||||
('lobby', 'Lobby'),
|
||||
('hallway', 'Hallway'),
|
||||
('office', 'Office'),
|
||||
('kitchen', 'Kitchen'),
|
||||
('storage', 'Storage'),
|
||||
('outdoor', 'Outdoor'),
|
||||
('other', 'Other')
|
||||
('restroom','Restroom'), ('lobby','Lobby'), ('hallway','Hallway'),
|
||||
('office','Office'), ('kitchen','Kitchen'), ('storage','Storage'),
|
||||
('outdoor','Outdoor'), ('other','Other'),
|
||||
], validators=[Optional()])
|
||||
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
|
||||
|
||||
|
||||
# ── Templates ────────────────────────────────────────────────────────────────
|
||||
|
||||
class InspectionTemplateForm(FlaskForm):
|
||||
name = StringField('Template Name', validators=[DataRequired(), Length(max=255)])
|
||||
name = StringField('Template Name', validators=[DataRequired(), Length(max=255)])
|
||||
description = TextAreaField('Description', validators=[Optional()])
|
||||
frequency = SelectField('Inspection Frequency', choices=[
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('monthly', 'Monthly'),
|
||||
('quarterly', 'Quarterly')
|
||||
frequency = SelectField('Inspection Frequency', choices=[
|
||||
('daily','Daily'), ('weekly','Weekly'),
|
||||
('monthly','Monthly'), ('quarterly','Quarterly'),
|
||||
], validators=[DataRequired()])
|
||||
|
||||
|
||||
class ChecklistItemForm(FlaskForm):
|
||||
category = StringField('Category', validators=[DataRequired(), Length(max=100)])
|
||||
category = StringField('Category', validators=[DataRequired(), Length(max=100)])
|
||||
item_description = TextAreaField('Item Description', validators=[DataRequired()])
|
||||
scoring_type = SelectField('Scoring Type', choices=[
|
||||
('pass_fail', 'Pass/Fail'),
|
||||
('rating_5', '5-Point Rating'),
|
||||
('rating_10', '10-Point Rating')
|
||||
scoring_type = SelectField('Scoring Type', choices=[
|
||||
('pass_fail','Pass/Fail'), ('rating_5','5-Point Rating'), ('rating_10','10-Point Rating'),
|
||||
], validators=[DataRequired()])
|
||||
weight = DecimalField('Weight', validators=[Optional(), NumberRange(min=0.1, max=10.0)], default=1.00)
|
||||
weight = DecimalField('Weight', validators=[Optional(), NumberRange(min=0.1, max=10.0)], default=1.00)
|
||||
requires_photo = BooleanField('Requires Photo Evidence', default=False)
|
||||
display_order = IntegerField('Display Order', validators=[Optional()], default=0)
|
||||
display_order = IntegerField('Display Order', validators=[Optional()], default=0)
|
||||
|
||||
|
||||
# ── Inspections ──────────────────────────────────────────────────────────────
|
||||
|
||||
class StartInspectionForm(FlaskForm):
|
||||
template_id = SelectField('Template', coerce=int, validators=[DataRequired()])
|
||||
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
|
||||
area_id = SelectField('Area (optional)', coerce=int, validators=[Optional()])
|
||||
notes = TextAreaField('Notes', validators=[Optional()])
|
||||
|
||||
|
||||
class ChecklistResultForm(FlaskForm):
|
||||
"""Dynamically rendered per checklist item — base validators only."""
|
||||
score = DecimalField('Score', validators=[Optional(), NumberRange(min=0, max=10)])
|
||||
passed = HiddenField('Passed') # 'true' / 'false' / ''
|
||||
comments = TextAreaField('Comments', validators=[Optional(), Length(max=1000)])
|
||||
photo = FileField('Photo', validators=[
|
||||
Optional(),
|
||||
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
|
||||
])
|
||||
|
||||
|
||||
# ── Issues ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class IssueForm(FlaskForm):
|
||||
area_id = SelectField('Area', coerce=int, validators=[DataRequired()])
|
||||
severity = SelectField('Severity', choices=[
|
||||
('low','Low'), ('medium','Medium'), ('high','High'), ('critical','Critical'),
|
||||
], validators=[DataRequired()])
|
||||
description = TextAreaField('Description', validators=[DataRequired(), Length(max=2000)])
|
||||
photo = FileField('Photo Evidence', validators=[
|
||||
Optional(),
|
||||
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
|
||||
])
|
||||
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
|
||||
|
||||
|
||||
class IssueUpdateForm(FlaskForm):
|
||||
status = SelectField('Status', choices=[
|
||||
('open','Open'), ('in_progress','In Progress'), ('resolved','Resolved'),
|
||||
], validators=[DataRequired()])
|
||||
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
|
||||
comments = TextAreaField('Update Notes', validators=[Optional(), Length(max=1000)])
|
||||
|
||||
Reference in New Issue
Block a user