I swapped the harness to opencode and the model to Qwen3-Coder.

This commit is contained in:
James Devine 2026-03-10 13:16:41 +01:00
parent a815ba3f36
commit 5b55d0e171
21 changed files with 1747 additions and 0 deletions

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# .gitignore
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache/
.pytest_cache/
.hypothesis/
.DS_Store
uploads/
certs/

31
README.md Normal file
View file

@ -0,0 +1,31 @@
# EP Inspection Tool
A web application for inspection reporting and management built with Flask and SQLite.
## Features
- User authentication and authorization
- Admin panel for user management
- Inspection form with multiple inspectors and photo uploads
- PDF export functionality
- Responsive web interface
## Requirements
- Python 3.11+
- Flask
- SQLAlchemy
- WeasyPrint
- bcrypt
## Setup
1. Install dependencies: `pip install -r requirements.txt`
2. Run setup script: `python setup.py`
3. Run the application: `python run.py`
## Usage
1. Start the server: `python run.py`
2. Access the application at `https://localhost:5000`
3. Login with the default admin account (admin/password)

48
app/__init__.py Normal file
View file

@ -0,0 +1,48 @@
from flask import Flask
from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from config import Config
from app.models import db
import os
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Initialize extensions
db.init_app(app)
login_manager.init_app(app)
csrf = CSRFProtect(app)
# Create uploads directory if it doesn't exist
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# Create certs directory if it doesn't exist
os.makedirs(os.path.dirname(app.config['CERT_PATH']), exist_ok=True)
# Register blueprints
from app.routes.auth import auth_bp
from app.routes.inspections import inspections_bp
from app.routes.admin import admin_bp
from app.routes.export import export_bp
app.register_blueprint(auth_bp)
app.register_blueprint(inspections_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(export_bp)
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
return render_template('errors/500.html'), 500
return app

69
app/models.py Normal file
View file

@ -0,0 +1,69 @@
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
db = SQLAlchemy()
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
full_name = db.Column(db.String(120), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(120), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password):
self.password_hash = generate_password_hash(password, salt_length=12)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class Inspection(db.Model):
id = db.Column(db.Integer, primary_key=True)
installation_name = db.Column(db.String(200), nullable=False)
location = db.Column(db.String(200), nullable=False)
inspection_date = db.Column(db.Date, nullable=False)
version = db.Column(db.Integer, nullable=False, default=1)
reference_number = db.Column(db.Integer, nullable=False)
observations = db.Column(db.Text)
conclusion_text = db.Column(db.Text)
conclusion_status = db.Column(db.Enum('ok', 'minor', 'major'), nullable=False)
created_by_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
created_by = db.relationship('User', backref=db.backref('inspections', lazy=True))
inspectors = db.relationship('InspectionInspector', backref='inspection', lazy=True)
photos = db.relationship('Photo', backref='inspection', lazy=True)
def __repr__(self):
return f'<Inspection {self.reference_number} - {self.installation_name}>'
class InspectionInspector(db.Model):
id = db.Column(db.Integer, primary_key=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspection.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
free_text_name = db.Column(db.String(120), nullable=True)
# Relationship to user (optional)
user = db.relationship('User')
def __repr__(self):
return f'<InspectionInspector {self.id}>'
class Photo(db.Model):
id = db.Column(db.Integer, primary_key=True)
inspection_id = db.Column(db.Integer, db.ForeignKey('inspection.id'), nullable=False)
filename = db.Column(db.String(200), nullable=False)
caption = db.Column(db.Text)
action_required = db.Column(db.Enum('none', 'urgent', 'before_next'), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Photo {self.filename}>'

106
app/routes/admin.py Normal file
View file

@ -0,0 +1,106 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from app.models import User, db
from werkzeug.security import generate_password_hash
from flask_wtf import FlaskForm
from wtforms import StringField, EmailField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
admin_bp = Blueprint('admin', __name__)
def admin_required(f):
"""Decorator to ensure user is admin"""
def wrapper(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Access denied. Administrator privileges required.', 'error')
return redirect(url_for('inspections.dashboard'))
return f(*args, **kwargs)
wrapper.__name__ = f.__name__
return wrapper
class UserForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=4, max=80)])
full_name = StringField('Full Name', validators=[DataRequired(), Length(min=2, max=120)])
email = EmailField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[Length(min=6)])
is_admin = BooleanField('Administrator')
is_active = BooleanField('Active')
submit = SubmitField('Save')
@admin_bp.route('/admin/users')
@login_required
@admin_required
def users():
users = User.query.all()
return render_template('admin/users.html', users=users)
@admin_bp.route('/admin/user/new', methods=['GET', 'POST'])
@login_required
@admin_required
def user_create():
form = UserForm()
if form.validate_on_submit():
# Check if username or email already exists
existing_user = User.query.filter(
(User.username == form.username.data) |
(User.email == form.email.data)
).first()
if existing_user:
flash('Username or email already exists', 'error')
return render_template('admin/user_form.html', form=form)
user = User(
username=form.username.data,
full_name=form.full_name.data,
email=form.email.data,
is_admin=form.is_admin.data,
is_active=form.is_active.data
)
if form.password.data:
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('User created successfully.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', form=form)
@admin_bp.route('/admin/user/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def user_edit(user_id):
user = User.query.get_or_404(user_id)
form = UserForm(obj=user)
# Remove the password field from the form for editing
form.password = PasswordField('Password')
if form.validate_on_submit():
# Check if username or email already exists (excluding the current user)
existing_user = User.query.filter(
(User.username == form.username.data) |
(User.email == form.email.data),
User.id != user_id
).first()
if existing_user:
flash('Username or email already exists', 'error')
return render_template('admin/user_form.html', form=form)
user.username = form.username.data
user.full_name = form.full_name.data
user.email = form.email.data
user.is_admin = form.is_admin.data
user.is_active = form.is_active.data
if form.password.data:
user.set_password(form.password.data)
db.session.commit()
flash('User updated successfully.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', form=form, user=user)

43
app/routes/auth.py Normal file
View file

@ -0,0 +1,43 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from app.models import User, db
from app.utils.security import generate_password_hash
from werkzeug.security import check_password_hash
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length
auth_bp = Blueprint('auth', __name__)
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('inspections.dashboard'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user and user.check_password(form.password.data):
if user.is_active:
login_user(user)
flash('Logged in successfully.', 'success')
next_page = request.args.get('next')
return redirect(next_page) if next_page else redirect(url_for('inspections.dashboard'))
else:
flash('Account is deactivated.', 'error')
else:
flash('Invalid username or password.', 'error')
return render_template('login.html', form=form)
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))

35
app/routes/export.py Normal file
View file

@ -0,0 +1,35 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app
from flask_login import login_required, current_user
from app.models import Inspection, InspectionInspector, Photo, User, db
from weasyprint import HTML
import os
from datetime import datetime
export_bp = Blueprint('export', __name__)
@export_bp.route('/inspection/<int:id>/pdf')
@login_required
def export_pdf(id):
inspection = Inspection.query.get_or_404(id)
# Verify user has permission to view
if not (current_user.is_admin or inspection.created_by_id == current_user.id):
flash('You do not have permission to export this inspection.', 'error')
return redirect(url_for('inspections.dashboard'))
# Render the PDF template
html_string = render_template('pdf_template.html', inspection=inspection)
# Generate PDF
pdf = HTML(string=html_string, base_url=request.url_root).write_pdf()
# Create filename
filename = f"inspection_report_{inspection.reference_number}_v{inspection.version}.pdf"
# Return PDF as download
return send_file(
io.BytesIO(pdf),
as_attachment=True,
download_name=filename,
mimetype='application/pdf'
)

192
app/routes/inspections.py Normal file
View file

@ -0,0 +1,192 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app.models import Inspection, InspectionInspector, Photo, User, db
from werkzeug.utils import secure_filename
import os
from datetime import datetime
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, DateField, IntegerField, SelectField, SubmitField, FieldList, FormField
from wtforms.validators import DataRequired, Length, Optional
inspections_bp = Blueprint('inspections', __name__)
# Form for adding photos
class PhotoForm(FlaskForm):
caption = StringField('Caption', validators=[Optional(), Length(max=200)])
action_required = SelectField('Action Required', choices=[
('none', 'No action required'),
('urgent', 'Urgent action required'),
('before_next', 'Action required before next inspection')
], validators=[DataRequired()])
file = StringField('File', validators=[DataRequired()])
# Form for inspection
class InspectionForm(FlaskForm):
installation_name = StringField('Installation Name', validators=[DataRequired(), Length(max=200)])
location = StringField('Location', validators=[DataRequired(), Length(max=200)])
inspection_date = DateField('Date of Inspection', validators=[DataRequired()])
reference_number = IntegerField('Reference Number', validators=[DataRequired()])
observations = TextAreaField('Observations')
conclusion_text = TextAreaField('Conclusion Comments')
conclusion_status = SelectField('Conclusion Status', choices=[
('ok', 'OK for operation in current state'),
('minor', 'Minor comments — Remedial actions required for continued operation'),
('major', 'Major comments — Operation suspended until resolution and satisfactory follow-up inspection')
], validators=[DataRequired()])
inspectors = FieldList(StringField('Inspector', validators=[Optional(), Length(max=120)]), min_entries=1)
photos = FieldList(FormField(PhotoForm), min_entries=0)
submit = SubmitField('Complete Report')
update = SubmitField('Update Report')
cancel = SubmitField('Cancel')
@inspections_bp.route('/')
@login_required
def dashboard():
# Get all inspections for the current user
inspections = Inspection.query.join(User, Inspection.created_by_id == User.id)\
.filter((User.id == current_user.id) | (User.is_admin == True))\
.order_by(Inspection.created_at.desc()).all()
return render_template('dashboard.html', inspections=inspections)
@inspections_bp.route('/inspection/new', methods=['GET', 'POST'])
@login_required
def inspection_new():
form = InspectionForm()
# Pre-fill inspectors with current user's full name
if form.inspectors.entries:
form.inspectors[0].data = current_user.full_name
if form.validate_on_submit():
# Create the inspection
inspection = Inspection(
installation_name=form.installation_name.data,
location=form.location.data,
inspection_date=form.inspection_date.data,
reference_number=form.reference_number.data,
observations=form.observations.data,
conclusion_text=form.conclusion_text.data,
conclusion_status=form.conclusion_status.data,
created_by_id=current_user.id
)
db.session.add(inspection)
db.session.flush() # Get the inspection ID without committing
# Add inspectors
for inspector in form.inspectors.data:
if inspector:
# Check if inspector is a registered user
user = User.query.filter_by(full_name=inspector).first()
if user:
inspector_entry = InspectionInspector(
inspection_id=inspection.id,
user_id=user.id
)
else:
inspector_entry = InspectionInspector(
inspection_id=inspection.id,
free_text_name=inspector
)
db.session.add(inspector_entry)
db.session.commit()
flash('Inspection report created successfully.', 'success')
return redirect(url_for('inspections.inspection_view', id=inspection.id))
return render_template('inspection_form.html', form=form)
@inspections_bp.route('/inspection/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def inspection_edit(id):
inspection = Inspection.query.get_or_404(id)
# Verify user has permission to edit
if not (current_user.is_admin or inspection.created_by_id == current_user.id):
flash('You do not have permission to edit this inspection.', 'error')
return redirect(url_for('inspections.dashboard'))
form = InspectionForm(obj=inspection)
# Pre-fill inspectors with existing inspectors
if inspection.inspectors:
form.inspectors.process_data([inspector.free_text_name or inspector.user.full_name
for inspector in inspection.inspectors if inspector.free_text_name or inspector.user])
# Pre-fill photos
if inspection.photos:
for photo in inspection.photos:
form.photos.append_entry(photo)
if form.validate_on_submit():
# Update inspection
inspection.installation_name = form.installation_name.data
inspection.location = form.location.data
inspection.inspection_date = form.inspection_date.data
inspection.reference_number = form.reference_number.data
inspection.observations = form.observations.data
inspection.conclusion_text = form.conclusion_text.data
inspection.conclusion_status = form.conclusion_status.data
inspection.updated_at = datetime.utcnow()
# Increment version
inspection.version += 1
# Update inspectors
InspectionInspector.query.filter_by(inspection_id=inspection.id).delete()
for inspector in form.inspectors.data:
if inspector:
# Check if inspector is a registered user
user = User.query.filter_by(full_name=inspector).first()
if user:
inspector_entry = InspectionInspector(
inspection_id=inspection.id,
user_id=user.id
)
else:
inspector_entry = InspectionInspector(
inspection_id=inspection.id,
free_text_name=inspector
)
db.session.add(inspector_entry)
db.session.commit()
flash('Inspection report updated successfully.', 'success')
return redirect(url_for('inspections.inspection_view', id=inspection.id))
return render_template('inspection_form.html', form=form, inspection=inspection)
@inspections_bp.route('/inspection/<int:id>')
@login_required
def inspection_view(id):
inspection = Inspection.query.get_or_404(id)
# Verify user has permission to view
if not (current_user.is_admin or inspection.created_by_id == current_user.id):
flash('You do not have permission to view this inspection.', 'error')
return redirect(url_for('inspections.dashboard'))
return render_template('inspection_view.html', inspection=inspection)
@inspections_bp.route('/upload_photo', methods=['POST'])
@login_required
def upload_photo():
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file:
filename = secure_filename(file.filename)
if filename:
# Generate unique filename
import uuid
unique_filename = f"{uuid.uuid4().hex}_{filename}"
file_path = os.path.join('uploads', unique_filename)
file.save(file_path)
return jsonify({'filename': unique_filename, 'original_filename': filename})
return jsonify({'error': 'Upload failed'}), 500

View file

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}{% if user %}Edit User -{% else %}Add User -{% endif %} Admin - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-xl font-bold text-gray-800 mb-6">
{% if user %}Edit User{% else %}Add New User{% endif %}
</h1>
<form method="POST" class="space-y-6">
{{ form.hidden_tag() }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{{ form.username.label(class="block text-sm font-medium text-gray-700") }}
{{ form.username(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.full_name.label(class="block text-sm font-medium text-gray-700") }}
{{ form.full_name(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.email.label(class="block text-sm font-medium text-gray-700") }}
{{ form.email(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.is_admin.label(class="block text-sm font-medium text-gray-700") }}
{{ form.is_admin(class="mt-1") }}
<p class="mt-1 text-sm text-gray-500">Check if this user should have administrator privileges</p>
</div>
<div>
{{ form.is_active.label(class="block text-sm font-medium text-gray-700") }}
{{ form.is_active(class="mt-1") }}
<p class="mt-1 text-sm text-gray-500">Check if this user account is active</p>
</div>
<div>
{{ form.password.label(class="block text-sm font-medium text-gray-700") }}
{{ form.password(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
<p class="mt-1 text-sm text-gray-500">Leave blank to keep existing password</p>
</div>
</div>
<div class="flex justify-end space-x-3">
<a href="{{ url_for('admin.users') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Cancel
</a>
{{ form.submit(class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") }}
</div>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Admin - Users - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">User Management</h1>
<a href="{{ url_for('admin.user_create') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-plus mr-2"></i> Add User
</a>
</div>
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for user in users %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ user.full_name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.username }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ user.email }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{% if user.is_admin %}Admin{% else %}User{% endif %}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if user.is_active %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}">
{% if user.is_active %}Active{% else %}Inactive{% endif %}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('admin.user_edit', user_id=user.id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

69
app/templates/base.html Normal file
View file

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Inspection Reporting Tool{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-md">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<i class="fas fa-wrench text-blue-600 mr-2"></i>
<span class="text-xl font-bold text-gray-800">Inspection Tool</span>
</div>
{% if current_user.is_authenticated %}
<div class="ml-6 flex space-x-2">
<a href="{{ url_for('inspections.dashboard') }}" class="inline-flex items-center px-1 pt-1 border-b-2 border-blue-500 text-sm font-medium text-gray-900">
Dashboard
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.users') }}" class="inline-flex items-center px-1 pt-1 border-b-2 border-transparent hover:border-gray-300 text-sm font-medium text-gray-500 hover:text-gray-700">
Admin
</a>
{% endif %}
</div>
{% endif %}
</div>
{% if current_user.is_authenticated %}
<div class="flex items-center">
<div class="ml-3 relative">
<div class="text-sm text-gray-700">
<span class="font-medium">{{ current_user.full_name }}</span>
{% if current_user.is_admin %} (Admin) {% endif %}
</div>
<a href="{{ url_for('auth.logout') }}" class="text-sm text-gray-500 hover:text-gray-700">Logout</a>
</div>
</div>
{% endif %}
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6">
{% for category, message in messages %}
<div class="mb-2 px-4 py-2 rounded-md {% if category == 'error' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="bg-white border-t mt-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<p class="text-center text-sm text-gray-500">© {{ moment().format('YYYY') }} Inspection Reporting Tool</p>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Dashboard - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1>
<a href="{{ url_for('inspections.inspection_new') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-plus mr-2"></i> New Inspection
</a>
</div>
{% if inspections %}
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reference No.</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Installation Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Conclusion Status</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for inspection in inspections %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ inspection.reference_number }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.installation_name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.location }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.inspection_date.strftime('%Y-%m-%d') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.version }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{% if inspection.conclusion_status == 'ok' %}bg-green-100 text-green-800
{% elif inspection.conclusion_status == 'minor' %}bg-yellow-100 text-yellow-800
{% else %}bg-red-100 text-red-800{% endif %}">
{{ inspection.conclusion_status | title }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('inspections.inspection_view', id=inspection.id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">View</a>
<a href="{{ url_for('inspections.inspection_edit', id=inspection.id) }}" class="text-blue-600 hover:text-blue-900 mr-3">Edit</a>
<a href="{{ url_for('export.export_pdf', id=inspection.id) }}" class="text-gray-600 hover:text-gray-900">PDF</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="bg-white shadow overflow-hidden sm:rounded-lg p-8 text-center">
<i class="fas fa-file-alt text-4xl text-gray-300 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-1">No inspections found</h3>
<p class="text-gray-500">Get started by creating your first inspection report.</p>
<div class="mt-6">
<a href="{{ url_for('inspections.inspection_new') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-plus mr-2"></i> Create First Inspection
</a>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}{% if inspection %}Edit Inspection -{% else %}New Inspection -{% endif %} Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-xl font-bold text-gray-800 mb-6">
{% if inspection %}Edit Inspection Report{% else %}New Inspection Report{% endif %}
</h1>
<form method="POST" class="space-y-8" id="inspectionForm">
{{ form.hidden_tag() }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{{ form.installation_name.label(class="block text-sm font-medium text-gray-700") }}
{{ form.installation_name(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.location.label(class="block text-sm font-medium text-gray-700") }}
{{ form.location(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.inspection_date.label(class="block text-sm font-medium text-gray-700") }}
{{ form.inspection_date(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.reference_number.label(class="block text-sm font-medium text-gray-700") }}
{{ form.reference_number(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
</div>
<div>
{{ form.observations.label(class="block text-sm font-medium text-gray-700") }}
{{ form.observations(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500", rows="4") }}
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Inspectors</h3>
<div id="inspectors-container" class="space-y-2">
{% for inspector in form.inspectors %}
<div class="flex items-center">
{{ inspector(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
<button type="button" class="ml-2 text-red-600 hover:text-red-800 remove-inspector">
<i class="fas fa-times"></i>
</button>
</div>
{% endfor %}
</div>
<button type="button" id="add-inspector" class="mt-2 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
<i class="fas fa-plus mr-1"></i> Add Inspector
</button>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Photos</h3>
<div id="photos-container" class="space-y-4">
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<div class="flex items-center justify-center">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400"></i>
<p class="ml-2 text-gray-500">Upload photos</p>
</div>
<input type="file" id="photo-upload-input" class="hidden" multiple accept="image/*">
<button type="button" id="upload-photo-btn" class="mt-2 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Select Photos
</button>
</div>
<div id="photo-thumbnails" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Conclusion</h3>
<div class="space-y-4">
{{ form.conclusion_text.label(class="block text-sm font-medium text-gray-700") }}
{{ form.conclusion_text(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500", rows="3") }}
<div>
{{ form.conclusion_status.label(class="block text-sm font-medium text-gray-700") }}
<div class="mt-2 space-y-2">
{% for choice in form.conclusion_status.choices %}
<div class="flex items-center">
<input type="radio" id="status-{{ choice[0] }}" name="conclusion_status" value="{{ choice[0] }}"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" {% if form.conclusion_status.data == choice[0] %}checked{% endif %}>
<label for="status-{{ choice[0] }}" class="ml-3 block text-sm text-gray-700">
{{ choice[1] }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
{% if inspection %}
<a href="{{ url_for('inspections.inspection_view', id=inspection.id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Cancel
</a>
{{ form.update(class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") }}
{% else %}
<a href="{{ url_for('inspections.dashboard') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Cancel
</a>
{{ form.submit(class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") }}
{% endif %}
</div>
</form>
</div>
<script>
// Add inspector functionality
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('inspectors-container');
const addInspectorBtn = document.getElementById('add-inspector');
addInspectorBtn.addEventListener('click', function() {
const newInspector = document.createElement('div');
newInspector.className = 'flex items-center';
newInspector.innerHTML = `
<input type="text" name="inspectors" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
<button type="button" class="ml-2 text-red-600 hover:text-red-800 remove-inspector">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(newInspector);
});
// Remove inspector functionality
container.addEventListener('click', function(e) {
if (e.target.closest('.remove-inspector')) {
const parent = e.target.closest('.flex');
parent.remove();
}
});
// Photo upload functionality
const uploadBtn = document.getElementById('upload-photo-btn');
const photoInput = document.getElementById('photo-upload-input');
const photoThumbnails = document.getElementById('photo-thumbnails');
uploadBtn.addEventListener('click', function() {
photoInput.click();
});
photoInput.addEventListener('change', function(e) {
const files = e.target.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append('file', file);
fetch('/upload_photo', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.filename) {
const thumbnail = document.createElement('div');
thumbnail.className = 'border rounded-lg p-2';
thumbnail.innerHTML = `
<img src="/uploads/${data.filename}" alt="${data.original_filename}" class="w-full h-32 object-cover rounded">
<div class="mt-2">
<input type="hidden" name="photo_filenames" value="${data.filename}">
<input type="text" name="photo_captions" placeholder="Caption" class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
<select name="photo_actions" class="w-full px-2 py-1 border border-gray-300 rounded text-sm mt-1">
<option value="none">No action required</option>
<option value="urgent">Urgent action required</option>
<option value="before_next">Action required before next inspection</option>
</select>
</div>
`;
photoThumbnails.appendChild(thumbnail);
} else {
alert('Upload failed: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Upload failed');
});
}
photoInput.value = '';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Inspection Report {{ inspection.reference_number }} - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md">
<div class="flex justify-between items-start mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">Inspection Report</h1>
<p class="text-gray-600">Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }}</p>
</div>
<div class="flex space-x-3">
<a href="{{ url_for('inspections.inspection_edit', id=inspection.id) }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-edit mr-2"></i> Edit Report
</a>
<a href="{{ url_for('export.export_pdf', id=inspection.id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-file-pdf mr-2"></i> Export PDF
</a>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Installation Details</h3>
<div class="space-y-2">
<p><span class="font-medium">Installation Name:</span> {{ inspection.installation_name }}</p>
<p><span class="font-medium">Location:</span> {{ inspection.location }}</p>
<p><span class="font-medium">Date of Inspection:</span> {{ inspection.inspection_date.strftime('%Y-%m-%d') }}</p>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Inspector(s)</h3>
<div class="space-y-2">
{% for inspector in inspection.inspectors %}
<p>{{ inspector.free_text_name or inspector.user.full_name }}</p>
{% endfor %}
</div>
</div>
</div>
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-900 mb-2">Observations</h3>
<div class="bg-gray-50 p-4 rounded-lg">
{{ inspection.observations or "No observations recorded." }}
</div>
</div>
{% if inspection.photos %}
<div class="mb-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Photos</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for photo in inspection.photos %}
<div class="border rounded-lg p-3">
<img src="/uploads/{{ photo.filename }}" alt="{{ photo.caption }}" class="w-full h-48 object-cover rounded mb-2">
<div class="text-sm">
<p><span class="font-medium">Caption:</span> {{ photo.caption or "No caption" }}</p>
<p><span class="font-medium">Action Required:</span>
<span class="px-2 py-1 rounded-full text-xs font-medium
{% if photo.action_required == 'none' %}bg-green-100 text-green-800
{% elif photo.action_required == 'urgent' %}bg-red-100 text-red-800
{% else %}bg-yellow-100 text-yellow-800{% endif %}">
{{ photo.action_required.replace('_', ' ') | title }}
</span>
</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Conclusion</h3>
<div class="space-y-4">
<div>
<p><span class="font-medium">Conclusion Comments:</span></p>
<div class="bg-gray-50 p-4 rounded-lg">
{{ inspection.conclusion_text or "No conclusion comments recorded." }}
</div>
</div>
<div>
<p><span class="font-medium">Conclusion Status:</span></p>
<div class="mt-2 px-4 py-3 rounded-lg
{% if inspection.conclusion_status == 'ok' %}bg-green-100 text-green-800
{% elif inspection.conclusion_status == 'minor' %}bg-yellow-100 text-yellow-800
{% else %}bg-red-100 text-red-800{% endif %}">
{{ inspection.conclusion_status.replace('_', ' ') | title }}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

36
app/templates/login.html Normal file
View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Login - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-md mx-auto bg-white p-8 rounded-lg shadow-md">
<div class="text-center mb-6">
<i class="fas fa-lock text-4xl text-blue-600 mb-4"></i>
<h2 class="text-2xl font-bold text-gray-800">Sign in to your account</h2>
</div>
<form method="POST" class="space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.username.label(class="block text-sm font-medium text-gray-700") }}
{{ form.username(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.password.label(class="block text-sm font-medium text-gray-700") }}
{{ form.password(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.submit(class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") }}
</div>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-gray-600">
This is a secure application. Please ensure you are on the correct site.
</p>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,188 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Inspection Report - {{ inspection.reference_number }}</title>
<style>
@page {
size: A4;
margin: 2cm;
@bottom-center {
content: "Page " counter(page) " of " counter(pages);
font-size: 10pt;
}
}
body {
font-family: Arial, sans-serif;
font-size: 11pt;
color: #333;
}
.header {
text-align: center;
margin-bottom: 20pt;
border-bottom: 1pt solid #000;
padding-bottom: 10pt;
}
.section {
margin-bottom: 20pt;
}
.section-title {
font-size: 14pt;
font-weight: bold;
margin-bottom: 10pt;
border-bottom: 1pt solid #000;
padding-bottom: 5pt;
}
.field-row {
display: flex;
margin-bottom: 8pt;
}
.field-label {
width: 30%;
font-weight: bold;
}
.field-value {
width: 70%;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10pt;
margin-top: 10pt;
}
.photo-item {
page-break-inside: avoid;
}
.photo-caption {
font-size: 9pt;
margin-top: 5pt;
}
.action-required {
display: inline-block;
padding: 2pt 6pt;
border-radius: 4pt;
font-size: 8pt;
font-weight: bold;
margin-top: 3pt;
}
.action-none {
background-color: #d1f7d1;
color: #228b22;
}
.action-urgent {
background-color: #ffd1d1;
color: #b22222;
}
.action-before-next {
background-color: #fff3cd;
color: #856404;
}
.conclusion-status {
padding: 8pt;
border-radius: 4pt;
font-weight: bold;
text-align: center;
margin-top: 10pt;
}
.status-ok {
background-color: #d1f7d1;
color: #228b22;
}
.status-minor {
background-color: #fff3cd;
color: #856404;
}
.status-major {
background-color: #ffd1d1;
color: #b22222;
}
</style>
</head>
<body>
<div class="header">
<h1>Inspection Report</h1>
<p>Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }} | Generated: {{ moment().format('YYYY-MM-DD HH:mm') }}</p>
</div>
<div class="section">
<div class="section-title">Installation Details</div>
<div class="field-row">
<div class="field-label">Installation Name:</div>
<div class="field-value">{{ inspection.installation_name }}</div>
</div>
<div class="field-row">
<div class="field-label">Location:</div>
<div class="field-value">{{ inspection.location }}</div>
</div>
<div class="field-row">
<div class="field-label">Date of Inspection:</div>
<div class="field-value">{{ inspection.inspection_date.strftime('%Y-%m-%d') }}</div>
</div>
</div>
<div class="section">
<div class="section-title">Inspectors</div>
<div class="field-row">
<div class="field-label">Inspector(s):</div>
<div class="field-value">
{% for inspector in inspection.inspectors %}
{{ inspector.free_text_name or inspector.user.full_name }}{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
</div>
</div>
<div class="section">
<div class="section-title">Observations</div>
<div class="field-value">
{% if inspection.observations %}
{{ inspection.observations }}
{% else %}
<em>No observations recorded.</em>
{% endif %}
</div>
</div>
{% if inspection.photos %}
<div class="section">
<div class="section-title">Photos</div>
<div class="photo-grid">
{% for photo in inspection.photos %}
<div class="photo-item">
<img src="{{ request.url_root }}uploads/{{ photo.filename }}" alt="{{ photo.caption }}" style="width: 100%; height: auto;">
<div class="photo-caption">
<strong>Caption:</strong> {{ photo.caption or "No caption" }}<br>
<strong>Action Required:</strong>
<span class="action-required action-{{ photo.action_required }}">
{{ photo.action_required.replace('_', ' ') | title }}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="section">
<div class="section-title">Conclusion</div>
<div class="field-row">
<div class="field-label">Conclusion Comments:</div>
<div class="field-value">
{% if inspection.conclusion_text %}
{{ inspection.conclusion_text }}
{% else %}
<em>No conclusion comments recorded.</em>
{% endif %}
</div>
</div>
<div class="field-row">
<div class="field-label">Conclusion Status:</div>
<div class="field-value">
<div class="conclusion-status status-{{ inspection.conclusion_status }}">
{{ inspection.conclusion_status.replace('_', ' ') | title }}
</div>
</div>
</div>
</div>
</body>
</html>

17
config.py Normal file
View file

@ -0,0 +1,17 @@
import os
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(24)
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///app.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB max file size
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# Self-signed certificate paths
CERT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'cert.pem')
KEY_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'key.pem')

241
prompt.txt Normal file
View file

@ -0,0 +1,241 @@
You are building a production-ready Inspection Reporting and Management web application from scratch. This is a complete restart — ignore all prior commits to the repository. Force-push the new codebase to the existing GitHub repository, overwriting all history.
---
## TECH STACK
- Language: Python 3.11+
- Web Framework: Flask (with Flask-Login, Flask-WTF, Flask-SQLAlchemy)
- Database: SQLite via SQLAlchemy ORM
- PDF Generation: WeasyPrint (A4-formatted output)
- TLS/HTTPS: Self-signed certificate via trustme or mkcert for local hosting
- Frontend: Jinja2 templates + Tailwind CSS (via CDN) + vanilla JS
- Auth: Bcrypt password hashing, session-based login
- File Storage: Local filesystem under /uploads/, referenced in DB
---
## PROJECT STRUCTURE
inspection-app/
├── app/
│ ├── __init__.py
│ ├── models.py
│ ├── routes/
│ │ ├── auth.py
│ │ ├── inspections.py
│ │ ├── admin.py
│ │ └── export.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── login.html
│ │ ├── dashboard.html
│ │ ├── inspection_form.html
│ │ ├── inspection_view.html
│ │ └── admin/
│ │ ├── users.html
│ │ └── user_form.html
│ ├── static/
│ │ ├── css/
│ │ └── js/
│ └── utils/
│ ├── pdf_generator.py
│ └── security.py
├── uploads/
├── certs/
├── setup.py
├── config.py
├── run.py
├── requirements.txt
└── .gitignore
---
## DATABASE MODELS
### User
- id, username, full_name, email, password_hash, is_admin, is_active, created_at
### Inspection
- id, installation_name, location, inspection_date, version (int, starts at 1),
reference_number (int), observations, conclusion_text,
conclusion_status (enum: ok / minor / major),
created_by (FK User), created_at, updated_at
### InspectionInspector
- id, inspection_id (FK), user_id (FK nullable), free_text_name (nullable)
(Supports both registered users and free-text names)
### Photo
- id, inspection_id (FK), filename, caption,
action_required (enum: none / urgent / before_next), uploaded_at
---
## SETUP SCRIPT (setup.py)
The setup script must:
1. Install all dependencies from requirements.txt using pip
2. Generate a self-signed TLS certificate and key, saved to certs/
3. Create the SQLite database and run all table migrations
4. Prompt the admin for: username, full name, email, password (with confirmation)
5. Create the admin account with is_admin=True
6. Print a success message with the local HTTPS URL (e.g. https://localhost:5000)
7. Be runnable with: python setup.py
---
## CORE FEATURES
### Authentication
- Login page (username + password)
- Session-based auth with Flask-Login
- All routes protected — redirect to login if not authenticated
- Logout route
- No self-registration — admin creates all accounts
### Admin Panel (/admin)
- List all users
- Create new user (username, full name, email, password, admin toggle)
- Edit user (change name, email, reset password, toggle active/admin)
- Deactivate (not delete) users
- Only accessible to is_admin=True users
### Dashboard (/)
- Table of all inspections the logged-in user has access to
- Columns: Reference No., Installation Name, Location, Date, Version, Conclusion Status, Actions
- Actions: View, Edit, Export PDF
- "New Inspection" button
### Inspection Form (/inspection/new and /inspection/<id>/edit)
Fields:
1. Installation Name — text input
2. Location — text input
3. Date of Inspection — date picker
4. Version — auto-incremented integer (display only, not editable)
5. Reference Number — integer input
6. Inspector(s) — pre-filled with logged-in user's full name; allow adding more via:
- Dropdown of registered users
- Free-text field for external individuals
- Display as removable tags/chips
7. Observations — large textarea
8. Photos section:
- Upload multiple photos
- For each uploaded photo display a thumbnail
- Per-photo fields: caption (text), action_required (radio buttons):
"No action required"
"Urgent action required"
"Action required before next inspection"
- Ability to remove photos
9. Conclusion section:
- Conclusion comments textarea
- Radio buttons (select exactly one):
OK for operation in current state
Minor comments — Remedial actions required for continued operation
Major comments — Operation suspended until resolution and satisfactory follow-up inspection
Buttons:
- New inspection: "Complete Report" → saves, sets version=1, redirects to view page
- Edit existing: "Update Report" → saves, increments version by 1, redirects to view page
- Cancel → returns to dashboard
### Inspection View (/inspection/<id>)
- Read-only formatted view of the report
- Shows all fields, photos (with captions and action status), inspectors, conclusion
- "Edit Report" button
- "Export as PDF" button
---
## PDF EXPORT (/inspection/<id>/pdf)
- Generated using WeasyPrint
- Formatted for A4 pages
- Include:
- App name / report title header
- All inspection fields in a clean two-column layout
- Inspector names listed
- Observations in a clearly delineated box
- Photos displayed in a grid (max 2 per row), each with caption and action status clearly labelled
- Conclusion section with selected status prominently displayed
- Footer with page number and generation timestamp
- Flows naturally across multiple A4 pages if content requires it
- Served as a file download: inspection_report_<ref>_v<version>.pdf
---
## SECURITY REQUIREMENTS
- All passwords hashed with bcrypt (min cost factor 12)
- CSRF protection on all forms via Flask-WTF
- File uploads validated: only JPEG, PNG, GIF, WEBP accepted; max 10MB per file
- Uploaded filenames sanitised with werkzeug.utils.secure_filename and stored with UUID prefix
- User input escaped in all templates (Jinja2 autoescaping enabled)
- Admin routes protected with both login_required and admin_required decorators
- Secret key loaded from environment variable SECRET_KEY or auto-generated and saved to .env on first run
- HTTPS enforced — Flask run with SSL context using certs from certs/
- .env and *.db and certs/ added to .gitignore
---
## GITHUB INSTRUCTIONS
- The repository already exists and has been initialised with prior commits
- Completely discard all prior history
- Use git checkout --orphan new-branch, add all files, commit, then force-push to main
- Commit message: "Initial commit: Inspection reporting app"
- Include a comprehensive README.md with:
- Project overview
- Requirements (Python version, OS)
- Setup instructions (python setup.py)
- How to run (python run.py)
- How to access (HTTPS URL)
- Notes on the self-signed certificate browser warning
---
## CODE QUALITY STANDARDS
- All Python files include docstrings
- Routes grouped into Blueprints
- No hardcoded secrets
- Database access only via SQLAlchemy ORM — no raw SQL
- Error pages for 403, 404, 500
- Flash messages for all user actions (success and error)
- Logging to a rotating file log (logs/app.log)
---
## EXECUTION ORDER
Build in this order:
1. requirements.txt and config.py
2. app/models.py
3. app/__init__.py (app factory)
4. Auth blueprint + templates
5. Admin blueprint + templates
6. Inspection blueprint + form + view templates
7. PDF export utility + route
8. setup.py
9. run.py
10. README.md
11. .gitignore
12. GitHub force-push
Do not proceed to the next step until the current one is complete and internally consistent.
---
## NOTES FOR THE OPERATOR
- Before running, ensure the GitHub remote URL is configured in your local repo,
or add it to this prompt as: "The GitHub remote URL is: https://github.com/pingud98/EP_inspection_tool_proto.git"
- WeasyPrint requires system-level dependencies. Install them before running setup.py:
Debian/Ubuntu: sudo apt install libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0
macOS: brew install pango
Windows: See https://doc.courtbouillon.org/weasyprint/stable/first_steps.html
If the model stalls mid-task, re-prompt with: "Continue from step N" using the
Execution Order numbers above.

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
Flask==2.3.3
Flask-Login==0.6.3
Flask-WTF==1.1.1
Flask-SQLAlchemy==3.0.5
WTForms==3.0.1
Werkzeug==2.3.7
bcrypt==4.0.1
WeasyPrint==58.1
python-dotenv==1.0.0

23
run.py Executable file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python3
import os
import sys
from app import create_app
# Get the current directory
current_dir = os.path.dirname(os.path.abspath(__file__))
# Set the configuration
config_name = os.environ.get('FLASK_ENV', 'development')
# Create and run the application
app = create_app(config_name)
if __name__ == '__main__':
# Check if we're in development mode
if config_name == 'development':
# Run with debug mode for development
app.run(debug=True, host='0.0.0.0', port=5000, ssl_context='adhoc')
else:
# Production mode
app.run(host='0.0.0.0', port=5000, ssl_context='adhoc')

155
setup.py Executable file
View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
import os
import sys
import subprocess
import hashlib
import ipaddress
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import datetime
def generate_self_signed_cert():
"""Generate a self-signed certificate for development"""
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Create certificate
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"),
x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "EP Inspection Tool"),
x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
])
cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
private_key.public_key()
).serial_number(
x509.random_serial_number()
).not_valid_before(
datetime.datetime.now(datetime.timezone.utc)
).not_valid_after(
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
]),
critical=False,
).sign(private_key, hashes.SHA256())
# Save private key
with open("certs/private.key", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
# Save certificate
with open("certs/certificate.crt", "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
print("Self-signed certificate generated successfully")
def create_directories():
"""Create required directories"""
dirs = ['uploads', 'certs']
for directory in dirs:
if not os.path.exists(directory):
os.makedirs(directory)
print(f"Created directory: {directory}")
def setup_database():
"""Initialize the database"""
try:
from app import create_app
from app.models import db
# Create a simple config object
class DevelopmentConfig:
SECRET_KEY = 'dev-secret-key'
SQLALCHEMY_DATABASE_URI = 'sqlite:///app.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
MAX_CONTENT_LENGTH = 10 * 1024 * 1024
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
CERT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'cert.pem')
KEY_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'key.pem')
app = create_app(DevelopmentConfig)
with app.app_context():
db.create_all()
print("Database initialized")
except Exception as e:
print(f"Error setting up database: {e}")
def create_admin_user():
"""Create the default admin user"""
try:
from app import create_app
from app.models import db, User
# Create a simple config object
class DevelopmentConfig:
SECRET_KEY = 'dev-secret-key'
SQLALCHEMY_DATABASE_URI = 'sqlite:///app.db'
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
MAX_CONTENT_LENGTH = 10 * 1024 * 1024
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
CERT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'cert.pem')
KEY_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'key.pem')
app = create_app(DevelopmentConfig)
with app.app_context():
# Check if admin user already exists
admin_user = User.query.filter_by(username='admin').first()
if not admin_user:
# Create admin user
admin_user = User(
username='admin',
email='admin@example.com',
is_admin=True
)
admin_user.set_password('password')
db.session.add(admin_user)
db.session.commit()
print("Admin user created successfully")
else:
print("Admin user already exists")
except Exception as e:
print(f"Error creating admin user: {e}")
def main():
"""Main setup function"""
print("Setting up EP Inspection Tool...")
# Create directories
create_directories()
# Generate certificates
generate_self_signed_cert()
# Setup database
setup_database()
# Create admin user
create_admin_user()
print("Setup completed successfully!")
if __name__ == '__main__':
main()