Merge pull request #9 from pingud98/continuous-claude/iteration-1/2026-03-10-ef846925

Fix PDF export, inspection edit, photo upload issues and add user man…
This commit is contained in:
James Devine 2026-03-11 11:15:55 +01:00 committed by GitHub
commit d05097a283
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 269 additions and 84 deletions

View file

@ -1,14 +1,34 @@
from flask import Flask from flask import Flask, render_template
from flask_login import LoginManager from flask_login import LoginManager
from flask_wtf.csrf import CSRFProtect from flask_wtf.csrf import CSRFProtect
from config import Config from config import Config
from app.models import db from app.models import db, Config as ConfigModel, User
import os import os
from datetime import datetime
login_manager = LoginManager() login_manager = LoginManager()
login_manager.login_view = 'auth.login' login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.' login_manager.login_message = 'Please log in to access this page.'
@login_manager.user_loader
def load_user(user_id):
"""Load user for Flask-Login"""
return User.query.get(int(user_id))
def get_logo_filename():
"""Get the logo filename from database configuration"""
try:
logo_config = ConfigModel.query.filter_by(key='logo_filename').first()
return logo_config.value if logo_config else None
except:
return None
def format_date(value, format='%Y'):
"""Format date for Jinja2 templates"""
if value:
return value.strftime(format)
return ''
def create_app(config_class=Config): def create_app(config_class=Config):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class) app.config.from_object(config_class)
@ -35,6 +55,21 @@ def create_app(config_class=Config):
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(export_bp) app.register_blueprint(export_bp)
# Add logo filename to template context
@app.context_processor
def inject_logo():
return dict(logo_filename=get_logo_filename())
# Add custom filters
@app.template_filter('format_date')
def format_date_filter(value, format='%Y'):
return format_date(value, format)
# Add current date function
@app.context_processor
def inject_current_date():
return dict(moment=lambda: datetime.now().strftime('%Y-%m-%d %H:%M'))
# Error handlers # Error handlers
@app.errorhandler(404) @app.errorhandler(404)
def not_found_error(error): def not_found_error(error):

View file

@ -1,17 +1,17 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from datetime import datetime from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
db = SQLAlchemy() db = SQLAlchemy()
class User(db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
full_name = db.Column(db.String(120), nullable=False) full_name = db.Column(db.String(120), nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(120), nullable=False) password_hash = db.Column(db.String(120), nullable=False)
is_admin = db.Column(db.Boolean, default=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) created_at = db.Column(db.DateTime, default=datetime.utcnow)
def set_password(self, password): def set_password(self, password):
@ -20,9 +20,22 @@ class User(db.Model):
def check_password(self, password): def check_password(self, password):
return check_password_hash(self.password_hash, password) return check_password_hash(self.password_hash, password)
def get_id(self):
"""Required by Flask-Login"""
return str(self.id)
def __repr__(self): def __repr__(self):
return f'<User {self.username}>' return f'<User {self.username}>'
class Config(db.Model):
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(100), unique=True, nullable=False)
value = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Config {self.key}>'
class Inspection(db.Model): class Inspection(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
installation_name = db.Column(db.String(200), nullable=False) installation_name = db.Column(db.String(200), nullable=False)

View file

@ -1,10 +1,11 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app.models import User, db from app.models import User, db, Config
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, EmailField, PasswordField, BooleanField, SubmitField from wtforms import StringField, EmailField, PasswordField, BooleanField, SubmitField, FileField
from wtforms.validators import DataRequired, Email, Length, EqualTo from wtforms.validators import DataRequired, Length, EqualTo
import os
admin_bp = Blueprint('admin', __name__) admin_bp = Blueprint('admin', __name__)
@ -18,15 +19,24 @@ def admin_required(f):
wrapper.__name__ = f.__name__ wrapper.__name__ = f.__name__
return wrapper return wrapper
def get_logo_filename():
"""Get the logo filename from configuration"""
logo_config = Config.query.filter_by(key='logo_filename').first()
return logo_config.value if logo_config else None
class UserForm(FlaskForm): class UserForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=4, max=80)]) username = StringField('Username', validators=[DataRequired(), Length(min=4, max=80)])
full_name = StringField('Full Name', validators=[DataRequired(), Length(min=2, max=120)]) full_name = StringField('Full Name', validators=[DataRequired(), Length(min=2, max=120)])
email = EmailField('Email', validators=[DataRequired(), Email()]) email = EmailField('Email', validators=[DataRequired()])
password = PasswordField('Password', validators=[Length(min=6)]) password = PasswordField('Password', validators=[Length(min=6)])
is_admin = BooleanField('Administrator') is_admin = BooleanField('Administrator')
is_active = BooleanField('Active') is_active = BooleanField('Active')
submit = SubmitField('Save') submit = SubmitField('Save')
class LogoForm(FlaskForm):
logo = FileField('Logo', validators=[])
submit = SubmitField('Save Logo')
@admin_bp.route('/admin/users') @admin_bp.route('/admin/users')
@login_required @login_required
@admin_required @admin_required
@ -34,6 +44,73 @@ def users():
users = User.query.all() users = User.query.all()
return render_template('admin/users.html', users=users) return render_template('admin/users.html', users=users)
@admin_bp.route('/admin/user/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def user_delete(user_id):
user = User.query.get_or_404(user_id)
# Prevent deleting the last admin user
if user.is_admin:
admin_count = User.query.filter_by(is_admin=True).count()
if admin_count <= 1:
flash('Cannot delete the last administrator user.', 'error')
return redirect(url_for('admin.users'))
# Prevent deleting self
if user.id == current_user.id:
flash('You cannot delete yourself.', 'error')
return redirect(url_for('admin.users'))
db.session.delete(user)
db.session.commit()
flash('User deleted successfully.', 'success')
return redirect(url_for('admin.users'))
@admin_bp.route('/admin/logo', methods=['GET', 'POST'])
@login_required
@admin_required
def logo():
form = LogoForm()
logo_filename = get_logo_filename()
if form.validate_on_submit():
if form.logo.data:
# Save the uploaded logo
filename = form.logo.data.filename
if filename and '.' in filename:
# Only allow jpeg and png files
ext = filename.rsplit('.', 1)[1].lower()
if ext in ['jpeg', 'jpg', 'png']:
# Create uploads directory if it doesn't exist
upload_dir = 'uploads'
os.makedirs(upload_dir, exist_ok=True)
# Save file with a unique name
logo_filename = f"logo.{ext}"
file_path = os.path.join(upload_dir, logo_filename)
form.logo.data.save(file_path)
# Save filename to config
logo_config = Config.query.filter_by(key='logo_filename').first()
if logo_config:
logo_config.value = logo_filename
else:
logo_config = Config(key='logo_filename', value=logo_filename)
db.session.add(logo_config)
db.session.commit()
flash('Logo uploaded successfully.', 'success')
return redirect(url_for('admin.logo'))
else:
flash('Invalid file type. Only JPEG and PNG files are allowed.', 'error')
else:
flash('Invalid filename.', 'error')
else:
flash('No file selected.', 'error')
return render_template('admin/logo.html', form=form, logo_filename=logo_filename)
@admin_bp.route('/admin/user/new', methods=['GET', 'POST']) @admin_bp.route('/admin/user/new', methods=['GET', 'POST'])
@login_required @login_required
@admin_required @admin_required

View file

@ -1,8 +1,7 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from app.models import User, db from app.models import User, db
from app.utils.security import generate_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.security import check_password_hash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, Length from wtforms.validators import DataRequired, Email, Length

View file

@ -4,6 +4,7 @@ from app.models import Inspection, InspectionInspector, Photo, User, db
from weasyprint import HTML from weasyprint import HTML
import os import os
from datetime import datetime from datetime import datetime
import io
export_bp = Blueprint('export', __name__) export_bp = Blueprint('export', __name__)

View file

@ -5,41 +5,39 @@ from werkzeug.utils import secure_filename
import os import os
from datetime import datetime from datetime import datetime
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, DateField, IntegerField, SelectField, SubmitField, FieldList, FormField from wtforms import StringField, TextAreaField, DateField, IntegerField, SelectField, SubmitField, FieldList
from wtforms.validators import DataRequired, Length, Optional from wtforms.validators import DataRequired, Length, Optional
inspections_bp = Blueprint('inspections', __name__) 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): class InspectionForm(FlaskForm):
# Basic inspection information
installation_name = StringField('Installation Name', validators=[DataRequired(), Length(max=200)]) installation_name = StringField('Installation Name', validators=[DataRequired(), Length(max=200)])
location = StringField('Location', validators=[DataRequired(), Length(max=200)]) location = StringField('Location', validators=[DataRequired(), Length(max=200)])
inspection_date = DateField('Date of Inspection', validators=[DataRequired()]) inspection_date = DateField('Inspection Date', validators=[DataRequired()])
reference_number = IntegerField('Reference Number', validators=[DataRequired()]) reference_number = IntegerField('Reference Number', validators=[DataRequired()])
# Observations
observations = TextAreaField('Observations') observations = TextAreaField('Observations')
conclusion_text = TextAreaField('Conclusion Comments')
conclusion_status = SelectField('Conclusion Status', choices=[ # Inspectors (multiple fields)
('ok', 'OK for operation in current state'), inspectors = FieldList(StringField('Inspector'), min_entries=1)
('minor', 'Minor comments — Remedial actions required for continued operation'),
('major', 'Major comments — Operation suspended until resolution and satisfactory follow-up inspection') # Conclusion
], validators=[DataRequired()]) conclusion_text = TextAreaField('Conclusion Text')
inspectors = FieldList(StringField('Inspector', validators=[Optional(), Length(max=120)]), min_entries=1) conclusion_status = SelectField('Conclusion Status',
photos = FieldList(FormField(PhotoForm), min_entries=0) choices=[('ok', 'OK'), ('minor', 'Minor Issue'), ('major', 'Major Issue')],
submit = SubmitField('Complete Report') validators=[DataRequired()])
update = SubmitField('Update Report')
cancel = SubmitField('Cancel') # Submit button
submit = SubmitField('Save Inspection')
update = SubmitField('Update Inspection')
@inspections_bp.route('/') @inspections_bp.route('/')
def index():
return redirect(url_for('auth.login'))
@inspections_bp.route('/dashboard')
@login_required @login_required
def dashboard(): def dashboard():
# Get all inspections for the current user # Get all inspections for the current user
@ -111,13 +109,21 @@ def inspection_edit(id):
# Pre-fill inspectors with existing inspectors # Pre-fill inspectors with existing inspectors
if inspection.inspectors: if inspection.inspectors:
form.inspectors.process_data([inspector.free_text_name or inspector.user.full_name inspector_names = []
for inspector in inspection.inspectors if inspector.free_text_name or inspector.user]) for inspector in inspection.inspectors:
if inspector.free_text_name:
# Pre-fill photos inspector_names.append(inspector.free_text_name)
if inspection.photos: elif inspector.user:
for photo in inspection.photos: inspector_names.append(inspector.user.full_name)
form.photos.append_entry(photo) # Fill the first inspector field
if inspector_names:
form.inspectors[0].data = inspector_names[0]
# Add additional fields if needed
while len(form.inspectors) < len(inspector_names):
form.inspectors.append_entry()
# Fill remaining fields
for i, name in enumerate(inspector_names[1:], 1):
form.inspectors[i].data = name
if form.validate_on_submit(): if form.validate_on_submit():
# Update inspection # Update inspection
@ -179,14 +185,29 @@ def upload_photo():
if file.filename == '': if file.filename == '':
return jsonify({'error': 'No file selected'}), 400 return jsonify({'error': 'No file selected'}), 400
if file: if file and file.filename and allowed_file(file.filename):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
if filename: if filename:
# Generate unique filename # Generate unique filename
import uuid import uuid
unique_filename = f"{uuid.uuid4().hex}_{filename}" unique_filename = f"{uuid.uuid4().hex}_{filename}"
file_path = os.path.join('uploads', unique_filename) # Create uploads directory if it doesn't exist
upload_dir = 'uploads'
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, unique_filename)
file.save(file_path) file.save(file_path)
return jsonify({'filename': unique_filename, 'original_filename': filename}) return jsonify({'filename': unique_filename, 'original_filename': filename})
return jsonify({'error': 'Upload failed'}), 500 return jsonify({'error': 'Upload failed'}), 500
def allowed_file(filename):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
if not filename:
return False
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def allowed_file(filename):
"""Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

BIN
app/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
app/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Logo Configuration{% endblock %}
{% block content %}
<div class="container">
<h2>Logo Configuration</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row">
<div class="col-md-6">
<form method="POST" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<div class="form-group">
{{ form.logo.label(class="form-label") }}
{{ form.logo(class="form-control") }}
<small class="form-text text-muted">Upload a JPEG or PNG logo (max 10MB)</small>
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
<div class="col-md-6">
<h4>Current Logo</h4>
{% if logo_filename %}
<img src="{{ url_for('static', filename='uploads/' + logo_filename) }}"
alt="Current Logo"
style="max-width: 200px; max-height: 100px;">
<p>Current logo: {{ logo_filename }}</p>
{% else %}
<p>No logo uploaded yet.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Page Not Found</title>
</head>
<body>
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
</body>
</html>

View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Server Error</title>
</head>
<body>
<h1>Server Error (500)</h1>
<p>Something went wrong on our end. Please try again later.</p>
</body>
</html>

View file

@ -101,7 +101,7 @@
<body> <body>
<div class="header"> <div class="header">
<h1>Inspection Report</h1> <h1>Inspection Report</h1>
<p>Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }} | Generated: {{ moment().format('YYYY-MM-DD HH:mm') }}</p> <p>Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }} | Generated: {{ moment() }}</p>
</div> </div>
<div class="section"> <div class="section">

View file

@ -15,3 +15,6 @@ class Config:
# Self-signed certificate paths # Self-signed certificate paths
CERT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'cert.pem') 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') KEY_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'key.pem')
# Logo configuration
LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads', 'logo.png')

BIN
instance/app.db Normal file

Binary file not shown.

18
run.py
View file

@ -3,21 +3,11 @@
import os import os
import sys import sys
from app import create_app from app import create_app
from config import Config
# 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 # Create and run the application
app = create_app(config_name) app = create_app(Config)
if __name__ == '__main__': if __name__ == '__main__':
# Check if we're in development mode # Run with debug mode for development
if config_name == 'development': app.run(debug=True, host='0.0.0.0', port=5000, ssl_context='adhoc')
# 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')

View file

@ -73,21 +73,12 @@ def create_directories():
def setup_database(): def setup_database():
"""Initialize the database""" """Initialize the database"""
try: try:
# Import the actual config class and create app with it
from config import Config
from app import create_app from app import create_app
from app.models import db from app.models import db
# Create a simple config object app = create_app(Config)
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(): with app.app_context():
db.create_all() db.create_all()
@ -98,21 +89,12 @@ def setup_database():
def create_admin_user(): def create_admin_user():
"""Create the default admin user""" """Create the default admin user"""
try: try:
# Import the actual config class and create app with it
from config import Config
from app import create_app from app import create_app
from app.models import db, User from app.models import db, User
# Create a simple config object app = create_app(Config)
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(): with app.app_context():
# Check if admin user already exists # Check if admin user already exists
@ -121,6 +103,7 @@ def create_admin_user():
# Create admin user # Create admin user
admin_user = User( admin_user = User(
username='admin', username='admin',
full_name='Administrator',
email='admin@example.com', email='admin@example.com',
is_admin=True is_admin=True
) )