Merge pull request #8 from pingud98/continuous-claude/iteration-1/2026-03-10-ef846925
I swapped the harness to opencode and the model to Qwen3-Coder.
This commit is contained in:
commit
0c8501f650
21 changed files with 1747 additions and 0 deletions
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
31
README.md
Normal 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
48
app/__init__.py
Normal 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
69
app/models.py
Normal 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
106
app/routes/admin.py
Normal 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
43
app/routes/auth.py
Normal 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
35
app/routes/export.py
Normal 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
192
app/routes/inspections.py
Normal 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
|
||||
57
app/templates/admin/user_form.html
Normal file
57
app/templates/admin/user_form.html
Normal 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 %}
|
||||
51
app/templates/admin/users.html
Normal file
51
app/templates/admin/users.html
Normal 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
69
app/templates/base.html
Normal 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>
|
||||
65
app/templates/dashboard.html
Normal file
65
app/templates/dashboard.html
Normal 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 %}
|
||||
191
app/templates/inspection_form.html
Normal file
191
app/templates/inspection_form.html
Normal 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 %}
|
||||
95
app/templates/inspection_view.html
Normal file
95
app/templates/inspection_view.html
Normal 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
36
app/templates/login.html
Normal 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 %}
|
||||
188
app/templates/pdf_template.html
Normal file
188
app/templates/pdf_template.html
Normal 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
17
config.py
Normal 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
241
prompt.txt
Normal 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
9
requirements.txt
Normal 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
23
run.py
Executable 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
155
setup.py
Executable 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()
|
||||
Loading…
Reference in a new issue