Phase 3: initiatal code

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