Fix PDF export, inspection edit, photo upload issues and add user management access

This commit is contained in:
James Devine 2026-03-11 11:15:22 +01:00
parent 5b55d0e171
commit 08f7f0e890
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_wtf.csrf import CSRFProtect
from config import Config
from app.models import db
from app.models import db, Config as ConfigModel, User
import os
from datetime import datetime
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
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):
app = Flask(__name__)
app.config.from_object(config_class)
@ -35,6 +55,21 @@ def create_app(config_class=Config):
app.register_blueprint(admin_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
@app.errorhandler(404)
def not_found_error(error):

View file

@ -1,17 +1,17 @@
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
db = SQLAlchemy()
class User(db.Model):
class User(UserMixin, 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):
@ -20,9 +20,22 @@ class User(db.Model):
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def get_id(self):
"""Required by Flask-Login"""
return str(self.id)
def __repr__(self):
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):
id = db.Column(db.Integer, primary_key=True)
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_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 flask_wtf import FlaskForm
from wtforms import StringField, EmailField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
from wtforms import StringField, EmailField, PasswordField, BooleanField, SubmitField, FileField
from wtforms.validators import DataRequired, Length, EqualTo
import os
admin_bp = Blueprint('admin', __name__)
@ -18,15 +19,24 @@ def admin_required(f):
wrapper.__name__ = f.__name__
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):
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()])
email = EmailField('Email', validators=[DataRequired()])
password = PasswordField('Password', validators=[Length(min=6)])
is_admin = BooleanField('Administrator')
is_active = BooleanField('Active')
submit = SubmitField('Save')
class LogoForm(FlaskForm):
logo = FileField('Logo', validators=[])
submit = SubmitField('Save Logo')
@admin_bp.route('/admin/users')
@login_required
@admin_required
@ -34,6 +44,73 @@ def users():
users = User.query.all()
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'])
@login_required
@admin_required

View file

@ -1,8 +1,7 @@
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 werkzeug.security import generate_password_hash, check_password_hash
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
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
import os
from datetime import datetime
import io
export_bp = Blueprint('export', __name__)

View file

@ -5,41 +5,39 @@ 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 import StringField, TextAreaField, DateField, IntegerField, SelectField, SubmitField, FieldList
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):
# Basic inspection information
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()])
inspection_date = DateField('Inspection Date', validators=[DataRequired()])
reference_number = IntegerField('Reference Number', validators=[DataRequired()])
# Observations
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')
# Inspectors (multiple fields)
inspectors = FieldList(StringField('Inspector'), min_entries=1)
# Conclusion
conclusion_text = TextAreaField('Conclusion Text')
conclusion_status = SelectField('Conclusion Status',
choices=[('ok', 'OK'), ('minor', 'Minor Issue'), ('major', 'Major Issue')],
validators=[DataRequired()])
# Submit button
submit = SubmitField('Save Inspection')
update = SubmitField('Update Inspection')
@inspections_bp.route('/')
def index():
return redirect(url_for('auth.login'))
@inspections_bp.route('/dashboard')
@login_required
def dashboard():
# Get all inspections for the current user
@ -111,13 +109,21 @@ def inspection_edit(id):
# 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)
inspector_names = []
for inspector in inspection.inspectors:
if inspector.free_text_name:
inspector_names.append(inspector.free_text_name)
elif inspector.user:
inspector_names.append(inspector.user.full_name)
# 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():
# Update inspection
@ -179,14 +185,29 @@ def upload_photo():
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if file:
if file and file.filename and allowed_file(file.filename):
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)
# 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)
return jsonify({'filename': unique_filename, 'original_filename': filename})
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>
<div class="header">
<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 class="section">

View file

@ -15,3 +15,6 @@ class Config:
# 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')
# 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.

14
run.py
View file

@ -3,21 +3,11 @@
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')
from config import Config
# Create and run the application
app = create_app(config_name)
app = create_app(Config)
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')

View file

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