From 5b55d0e17142dadba1f946ba93d0937efce0c396 Mon Sep 17 00:00:00 2001 From: James Devine Date: Tue, 10 Mar 2026 13:16:41 +0100 Subject: [PATCH] I swapped the harness to opencode and the model to Qwen3-Coder. --- .gitignore | 26 ++++ README.md | 31 ++++ app/__init__.py | 48 ++++++ app/models.py | 69 +++++++++ app/routes/admin.py | 106 +++++++++++++ app/routes/auth.py | 43 +++++ app/routes/export.py | 35 +++++ app/routes/inspections.py | 192 +++++++++++++++++++++++ app/templates/admin/user_form.html | 57 +++++++ app/templates/admin/users.html | 51 ++++++ app/templates/base.html | 69 +++++++++ app/templates/dashboard.html | 65 ++++++++ app/templates/inspection_form.html | 191 +++++++++++++++++++++++ app/templates/inspection_view.html | 95 ++++++++++++ app/templates/login.html | 36 +++++ app/templates/pdf_template.html | 188 ++++++++++++++++++++++ config.py | 17 ++ prompt.txt | 241 +++++++++++++++++++++++++++++ requirements.txt | 9 ++ run.py | 23 +++ setup.py | 155 +++++++++++++++++++ 21 files changed, 1747 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/export.py create mode 100644 app/routes/inspections.py create mode 100644 app/templates/admin/user_form.html create mode 100644 app/templates/admin/users.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/inspection_form.html create mode 100644 app/templates/inspection_view.html create mode 100644 app/templates/login.html create mode 100644 app/templates/pdf_template.html create mode 100644 config.py create mode 100644 prompt.txt create mode 100644 requirements.txt create mode 100755 run.py create mode 100755 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67fcd34 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1df377 --- /dev/null +++ b/README.md @@ -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) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..cd128c3 --- /dev/null +++ b/app/__init__.py @@ -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 \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..0d1e5dc --- /dev/null +++ b/app/models.py @@ -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'' + +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'' + +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'' + +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'' \ No newline at end of file diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..00330a4 --- /dev/null +++ b/app/routes/admin.py @@ -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//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) \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..5dfa844 --- /dev/null +++ b/app/routes/auth.py @@ -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')) \ No newline at end of file diff --git a/app/routes/export.py b/app/routes/export.py new file mode 100644 index 0000000..d873cf6 --- /dev/null +++ b/app/routes/export.py @@ -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//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' + ) \ No newline at end of file diff --git a/app/routes/inspections.py b/app/routes/inspections.py new file mode 100644 index 0000000..50e0d42 --- /dev/null +++ b/app/routes/inspections.py @@ -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//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/') +@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 \ No newline at end of file diff --git a/app/templates/admin/user_form.html b/app/templates/admin/user_form.html new file mode 100644 index 0000000..7bfc3c4 --- /dev/null +++ b/app/templates/admin/user_form.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block title %}{% if user %}Edit User -{% else %}Add User -{% endif %} Admin - Inspection Reporting Tool{% endblock %} + +{% block content %} +
+

+ {% if user %}Edit User{% else %}Add New User{% endif %} +

+ +
+ {{ form.hidden_tag() }} + +
+
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+ +
+ {{ form.is_admin.label(class="block text-sm font-medium text-gray-700") }} + {{ form.is_admin(class="mt-1") }} +

Check if this user should have administrator privileges

+
+ +
+ {{ form.is_active.label(class="block text-sm font-medium text-gray-700") }} + {{ form.is_active(class="mt-1") }} +

Check if this user account is active

+
+ +
+ {{ 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") }} +

Leave blank to keep existing password

+
+
+ +
+ + Cancel + + {{ 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") }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..f4e66a9 --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}Admin - Users - Inspection Reporting Tool{% endblock %} + +{% block content %} +
+

User Management

+ + Add User + +
+ +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
NameUsernameEmailRoleStatusActions
+
{{ user.full_name }}
+
{{ user.username }}{{ user.email }} + + {% if user.is_admin %}Admin{% else %}User{% endif %} + + + + {% if user.is_active %}Active{% else %}Inactive{% endif %} + + + Edit +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..1419e02 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,69 @@ + + + + + + {% block title %}Inspection Reporting Tool{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+

© {{ moment().format('YYYY') }} Inspection Reporting Tool

+
+
+ + \ No newline at end of file diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..744189d --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Inspection Reporting Tool{% endblock %} + +{% block content %} +
+

Dashboard

+ + New Inspection + +
+ +{% if inspections %} +
+ + + + + + + + + + + + + + {% for inspection in inspections %} + + + + + + + + + + {% endfor %} + +
Reference No.Installation NameLocationDateVersionConclusion StatusActions
{{ inspection.reference_number }}{{ inspection.installation_name }}{{ inspection.location }}{{ inspection.inspection_date.strftime('%Y-%m-%d') }}{{ inspection.version }} + + {{ inspection.conclusion_status | title }} + + + View + Edit + PDF +
+
+{% else %} +
+ +

No inspections found

+

Get started by creating your first inspection report.

+ +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/inspection_form.html b/app/templates/inspection_form.html new file mode 100644 index 0000000..1589482 --- /dev/null +++ b/app/templates/inspection_form.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} + +{% block title %}{% if inspection %}Edit Inspection -{% else %}New Inspection -{% endif %} Inspection Reporting Tool{% endblock %} + +{% block content %} +
+

+ {% if inspection %}Edit Inspection Report{% else %}New Inspection Report{% endif %} +

+ +
+ {{ form.hidden_tag() }} + +
+
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+
+ +
+ {{ 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") }} +
+ +
+

Inspectors

+
+ {% for inspector in form.inspectors %} +
+ {{ 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") }} + +
+ {% endfor %} +
+ +
+ +
+

Photos

+
+
+
+ +

Upload photos

+
+ + +
+
+
+
+ +
+

Conclusion

+
+ {{ 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") }} + +
+ {{ form.conclusion_status.label(class="block text-sm font-medium text-gray-700") }} +
+ {% for choice in form.conclusion_status.choices %} +
+ + +
+ {% endfor %} +
+
+
+
+ +
+ {% if inspection %} + + Cancel + + {{ 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 %} + + Cancel + + {{ 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 %} +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/inspection_view.html b/app/templates/inspection_view.html new file mode 100644 index 0000000..2145be2 --- /dev/null +++ b/app/templates/inspection_view.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} + +{% block title %}Inspection Report {{ inspection.reference_number }} - Inspection Reporting Tool{% endblock %} + +{% block content %} +
+
+
+

Inspection Report

+

Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }}

+
+ +
+ +
+
+

Installation Details

+
+

Installation Name: {{ inspection.installation_name }}

+

Location: {{ inspection.location }}

+

Date of Inspection: {{ inspection.inspection_date.strftime('%Y-%m-%d') }}

+
+
+ +
+

Inspector(s)

+
+ {% for inspector in inspection.inspectors %} +

{{ inspector.free_text_name or inspector.user.full_name }}

+ {% endfor %} +
+
+
+ +
+

Observations

+
+ {{ inspection.observations or "No observations recorded." }} +
+
+ + {% if inspection.photos %} +
+

Photos

+
+ {% for photo in inspection.photos %} +
+ {{ photo.caption }} +
+

Caption: {{ photo.caption or "No caption" }}

+

Action Required: + + {{ photo.action_required.replace('_', ' ') | title }} + +

+
+
+ {% endfor %} +
+
+ {% endif %} + +
+

Conclusion

+
+
+

Conclusion Comments:

+
+ {{ inspection.conclusion_text or "No conclusion comments recorded." }} +
+
+ +
+

Conclusion Status:

+
+ {{ inspection.conclusion_status.replace('_', ' ') | title }} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..4941ea2 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Login - Inspection Reporting Tool{% endblock %} + +{% block content %} +
+
+ +

Sign in to your account

+
+ +
+ {{ form.hidden_tag() }} + +
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+ +
+ {{ 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") }} +
+
+ +
+

+ This is a secure application. Please ensure you are on the correct site. +

+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/pdf_template.html b/app/templates/pdf_template.html new file mode 100644 index 0000000..920ab12 --- /dev/null +++ b/app/templates/pdf_template.html @@ -0,0 +1,188 @@ + + + + + Inspection Report - {{ inspection.reference_number }} + + + +
+

Inspection Report

+

Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }} | Generated: {{ moment().format('YYYY-MM-DD HH:mm') }}

+
+ +
+
Installation Details
+
+
Installation Name:
+
{{ inspection.installation_name }}
+
+
+
Location:
+
{{ inspection.location }}
+
+
+
Date of Inspection:
+
{{ inspection.inspection_date.strftime('%Y-%m-%d') }}
+
+
+ +
+
Inspectors
+
+
Inspector(s):
+
+ {% for inspector in inspection.inspectors %} + {{ inspector.free_text_name or inspector.user.full_name }}{% if not loop.last %}, {% endif %} + {% endfor %} +
+
+
+ +
+
Observations
+
+ {% if inspection.observations %} + {{ inspection.observations }} + {% else %} + No observations recorded. + {% endif %} +
+
+ + {% if inspection.photos %} +
+
Photos
+
+ {% for photo in inspection.photos %} +
+ {{ photo.caption }} +
+ Caption: {{ photo.caption or "No caption" }}
+ Action Required: + + {{ photo.action_required.replace('_', ' ') | title }} + +
+
+ {% endfor %} +
+
+ {% endif %} + +
+
Conclusion
+
+
Conclusion Comments:
+
+ {% if inspection.conclusion_text %} + {{ inspection.conclusion_text }} + {% else %} + No conclusion comments recorded. + {% endif %} +
+
+
+
Conclusion Status:
+
+
+ {{ inspection.conclusion_status.replace('_', ' ') | title }} +
+
+
+
+ + \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..e2f5cac --- /dev/null +++ b/config.py @@ -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') \ No newline at end of file diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 0000000..a444130 --- /dev/null +++ b/prompt.txt @@ -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//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/) +- 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//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__v.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. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..de61b28 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100755 index 0000000..eaaaa3a --- /dev/null +++ b/run.py @@ -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') \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..8038f64 --- /dev/null +++ b/setup.py @@ -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() \ No newline at end of file