diff --git a/app/__init__.py b/app/__init__.py index cd128c3..fc9890f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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): diff --git a/app/models.py b/app/models.py index 0d1e5dc..5377269 100644 --- a/app/models.py +++ b/app/models.py @@ -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'' +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'' + class Inspection(db.Model): id = db.Column(db.Integer, primary_key=True) installation_name = db.Column(db.String(200), nullable=False) diff --git a/app/routes/admin.py b/app/routes/admin.py index 00330a4..d69deba 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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//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 diff --git a/app/routes/auth.py b/app/routes/auth.py index 5dfa844..337134f 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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 diff --git a/app/routes/export.py b/app/routes/export.py index d873cf6..df3fcdd 100644 --- a/app/routes/export.py +++ b/app/routes/export.py @@ -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__) diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 50e0d42..c24178b 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000..7cbc81d Binary files /dev/null and b/app/static/favicon.ico differ diff --git a/app/static/favicon.png b/app/static/favicon.png new file mode 100644 index 0000000..7cbc81d Binary files /dev/null and b/app/static/favicon.png differ diff --git a/app/templates/admin/logo.html b/app/templates/admin/logo.html new file mode 100644 index 0000000..dc7ff6c --- /dev/null +++ b/app/templates/admin/logo.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}Logo Configuration{% endblock %} + +{% block content %} +
+

Logo Configuration

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+ {{ form.hidden_tag() }} +
+ {{ form.logo.label(class="form-label") }} + {{ form.logo(class="form-control") }} + Upload a JPEG or PNG logo (max 10MB) +
+ {{ form.submit(class="btn btn-primary") }} +
+
+ +
+

Current Logo

+ {% if logo_filename %} + Current Logo +

Current logo: {{ logo_filename }}

+ {% else %} +

No logo uploaded yet.

+ {% endif %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 0000000..c532175 --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1,10 @@ + + + + Page Not Found + + +

404 - Page Not Found

+

The page you are looking for does not exist.

+ + \ No newline at end of file diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html new file mode 100644 index 0000000..101c6e0 --- /dev/null +++ b/app/templates/errors/500.html @@ -0,0 +1,10 @@ + + + + Server Error + + +

Server Error (500)

+

Something went wrong on our end. Please try again later.

+ + \ No newline at end of file diff --git a/app/templates/pdf_template.html b/app/templates/pdf_template.html index 920ab12..8b04f07 100644 --- a/app/templates/pdf_template.html +++ b/app/templates/pdf_template.html @@ -101,7 +101,7 @@

Inspection Report

-

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

+

Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }} | Generated: {{ moment() }}

diff --git a/config.py b/config.py index e2f5cac..98ea6ef 100644 --- a/config.py +++ b/config.py @@ -14,4 +14,7 @@ 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') \ No newline at end of file + 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') \ No newline at end of file diff --git a/instance/app.db b/instance/app.db new file mode 100644 index 0000000..e2e8928 Binary files /dev/null and b/instance/app.db differ diff --git a/run.py b/run.py index eaaaa3a..ba94e68 100755 --- a/run.py +++ b/run.py @@ -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') \ No newline at end of file + # Run with debug mode for development + app.run(debug=True, host='0.0.0.0', port=5000, ssl_context='adhoc') \ No newline at end of file diff --git a/setup.py b/setup.py index 8038f64..b5135d5 100755 --- a/setup.py +++ b/setup.py @@ -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 )