feat: Complete EP Inspection Tool implementation with templates, security, and tests

This commit is contained in:
James Devine 2026-03-11 16:35:40 +01:00
parent c7e6dce720
commit e9c26d172d
16 changed files with 1259 additions and 347 deletions

62
API_DOCS.md Normal file
View file

@ -0,0 +1,62 @@
# EP Inspection Tool API Documentation
## Authentication Endpoints
### Login
- **Endpoint**: `POST /login`
- **Description**: Authenticate user and create session
- **Parameters**:
- `username` (string, required)
- `password` (string, required)
### Logout
- **Endpoint**: `GET /logout`
- **Description**: End user session
## Inspection Endpoints
### Create Inspection
- **Endpoint**: `POST /inspections`
- **Description**: Create a new inspection report
- **Required Permissions**: authenticated user
### View Inspection
- **Endpoint**: `GET /inspections/<id>`
- **Description**: Get inspection details
- **Required Permissions**: authenticated user
### Update Inspection
- **Endpoint**: `PUT /inspections/<id>`
- **Description**: Update inspection details
- **Required Permissions**: authenticated user
### Delete Inspection
- **Endpoint**: `DELETE /inspections/<id>`
- **Description**: Delete an inspection
- **Required Permissions**: authenticated user
## Admin Endpoints
### User Management
- **Endpoint**: `GET /admin/users`
- **Description**: List all users
- **Required Permissions**: admin user
### Create User
- **Endpoint**: `POST /admin/users`
- **Description**: Create a new user
- **Required Permissions**: admin user
## File Upload Endpoints
### Upload Photo
- **Endpoint**: `POST /inspections/<id>/photos`
- **Description**: Upload a photo for an inspection
- **Required Permissions**: authenticated user
## Export Endpoints
### Export PDF
- **Endpoint**: `GET /export/inspection/<id>/pdf`
- **Description**: Export inspection as PDF
- **Required Permissions**: authenticated user

View file

@ -24,6 +24,10 @@ A web application for inspection reporting and management built with Flask and S
2. Run setup script: `python setup.py` 2. Run setup script: `python setup.py`
3. Run the application: `python run.py` 3. Run the application: `python run.py`
## Testing
Run tests with: `python tests.py`
## Usage ## Usage
1. Start the server: `python run.py` 1. Start the server: `python run.py`

View file

@ -1,14 +1,19 @@
import logging
from logging.handlers import RotatingFileHandler
import os
from flask import Flask, render_template 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, Config as ConfigModel, User from app.models import db, Config as ConfigModel, User
import os
from datetime import datetime from datetime import datetime
import secrets
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.'
# Enhanced session security
login_manager.session_protection = "strong"
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
@ -33,6 +38,24 @@ def create_app(config_class=Config):
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config_class) app.config.from_object(config_class)
# Enhanced security configuration
app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent XSS
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF protection
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
# Setup logging
if not app.debug and not app.testing:
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/ep_inspection_tool.log', maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('EP Inspection Tool startup')
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
login_manager.init_app(app) login_manager.init_app(app)
@ -73,11 +96,21 @@ def create_app(config_class=Config):
# Error handlers # Error handlers
@app.errorhandler(404) @app.errorhandler(404)
def not_found_error(error): def not_found_error(error):
app.logger.warning(f'Page not found: {request.url}')
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
@app.errorhandler(500) @app.errorhandler(500)
def internal_error(error): def internal_error(error):
db.session.rollback() db.session.rollback()
app.logger.error(f'Server Error: {error}')
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
# Security headers
@app.after_request
def after_request(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
return response
return app return app

View file

@ -2,6 +2,7 @@ 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 from flask_login import UserMixin
from sqlalchemy import CheckConstraint
db = SQLAlchemy() db = SQLAlchemy()
@ -14,6 +15,12 @@ class User(UserMixin, db.Model):
is_admin = db.Column(db.Boolean, default=False) is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
CheckConstraint('length(username) >= 3', name='username_length_check'),
CheckConstraint('length(full_name) >= 2', name='full_name_length_check'),
CheckConstraint('length(email) >= 5', name='email_length_check'),
)
def set_password(self, password): def set_password(self, password):
self.password_hash = generate_password_hash(password, salt_length=12) self.password_hash = generate_password_hash(password, salt_length=12)
@ -55,6 +62,13 @@ class Inspection(db.Model):
inspectors = db.relationship('InspectionInspector', backref='inspection', lazy=True) inspectors = db.relationship('InspectionInspector', backref='inspection', lazy=True)
photos = db.relationship('Photo', backref='inspection', lazy=True) photos = db.relationship('Photo', backref='inspection', lazy=True)
__table_args__ = (
CheckConstraint('length(installation_name) >= 3', name='installation_name_length_check'),
CheckConstraint('length(location) >= 2', name='location_length_check'),
CheckConstraint('version >= 1', name='version_positive_check'),
CheckConstraint('reference_number >= 1', name='reference_number_positive_check'),
)
def __repr__(self): def __repr__(self):
return f'<Inspection {self.reference_number} - {self.installation_name}>' return f'<Inspection {self.reference_number} - {self.installation_name}>'
@ -78,5 +92,9 @@ class Photo(db.Model):
action_required = db.Column(db.Enum('none', 'urgent', 'before_next'), nullable=False) action_required = db.Column(db.Enum('none', 'urgent', 'before_next'), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
CheckConstraint('length(filename) >= 5', name='filename_length_check'),
)
def __repr__(self): def __repr__(self):
return f'<Photo {self.filename}>' return f'<Photo {self.filename}>'

View file

@ -200,13 +200,6 @@ def upload_photo():
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): def allowed_file(filename):
"""Check if file extension is allowed""" """Check if file extension is allowed"""
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}

View file

@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Admin Panel - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Admin Panel</h1>
<p class="text-gray-600">Manage users, inspections, and system settings</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<div class="flex items-center">
<div class="p-3 rounded-full bg-blue-100 text-blue-600 mr-4">
<i class="fas fa-users text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">User Management</h3>
<p class="text-sm text-gray-500">Manage system users and permissions</p>
</div>
</div>
<div class="mt-4">
<a href="{{ url_for('admin.users') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">Manage Users</a>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<div class="flex items-center">
<div class="p-3 rounded-full bg-green-100 text-green-600 mr-4">
<i class="fas fa-file-alt text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">Inspection Management</h3>
<p class="text-sm text-gray-500">View and manage inspection reports</p>
</div>
</div>
<div class="mt-4">
<a href="{{ url_for('admin.inspections') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">Manage Inspections</a>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow">
<div class="flex items-center">
<div class="p-3 rounded-full bg-purple-100 text-purple-600 mr-4">
<i class="fas fa-cog text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">System Settings</h3>
<p class="text-sm text-gray-500">Configure application settings</p>
</div>
</div>
<div class="mt-4">
<a href="{{ url_for('admin.settings') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">Configure Settings</a>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Recent Activity</h2>
<div class="space-y-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="p-2 bg-blue-100 rounded-full">
<i class="fas fa-user-plus text-blue-600"></i>
</div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">New user registered</p>
<p class="text-sm text-gray-500">John Doe registered an account</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="p-2 bg-green-100 rounded-full">
<i class="fas fa-file-alt text-green-600"></i>
</div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">Inspection completed</p>
<p class="text-sm text-gray-500">Installation A - Inspection #123 completed</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0">
<div class="p-2 bg-purple-100 rounded-full">
<i class="fas fa-cog text-purple-600"></i>
</div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900">System settings updated</p>
<p class="text-sm text-gray-500">Email notifications settings modified</p>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -6,6 +6,7 @@
<title>{% block title %}Inspection Reporting Tool{% endblock %}</title> <title>{% block title %}Inspection Reporting Tool{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script> <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"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
</head> </head>
<body class="bg-gray-50 min-h-screen"> <body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-md"> <nav class="bg-white shadow-md">
@ -22,7 +23,7 @@
Dashboard Dashboard
</a> </a>
{% if current_user.is_admin %} {% 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"> <a href="{{ url_for('admin.dashboard') }}" 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 Admin
</a> </a>
{% endif %} {% endif %}

View file

@ -3,63 +3,104 @@
{% block title %}Dashboard - Inspection Reporting Tool{% endblock %} {% block title %}Dashboard - Inspection Reporting Tool{% endblock %}
{% block content %} {% block content %}
<div class="flex justify-between items-center mb-6"> <div class="mb-8">
<h1 class="text-2xl font-bold text-gray-800">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900 mb-2">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"> <p class="text-gray-600">Welcome back, {{ current_user.full_name }}!</p>
<i class="fas fa-plus mr-2"></i> New Inspection
</a>
</div> </div>
{% if inspections %} <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white shadow overflow-hidden sm:rounded-lg"> <div class="card">
<table class="min-w-full divide-y divide-gray-200"> <div class="card-body">
<thead class="bg-gray-50"> <div class="flex items-center">
<tr> <div class="p-3 rounded-full bg-blue-100 text-blue-600 mr-4">
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Reference No.</th> <i class="fas fa-file-alt text-xl"></i>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Installation Name</th> </div>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Location</th> <div>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> <h3 class="text-lg font-semibold text-gray-900">Total Inspections</h3>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th> <p class="text-2xl font-bold text-gray-900">{{ total_inspections }}</p>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Conclusion Status</th> </div>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> </div>
</tr> </div>
</thead> </div>
<tbody class="bg-white divide-y divide-gray-200">
{% for inspection in inspections %} <div class="card">
<tr> <div class="card-body">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ inspection.reference_number }}</td> <div class="flex items-center">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.installation_name }}</td> <div class="p-3 rounded-full bg-green-100 text-green-600 mr-4">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.location }}</td> <i class="fas fa-check-circle text-xl"></i>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.inspection_date.strftime('%Y-%m-%d') }}</td> </div>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ inspection.version }}</td> <div>
<td class="px-6 py-4 whitespace-nowrap"> <h3 class="text-lg font-semibold text-gray-900">Completed</h3>
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full <p class="text-2xl font-bold text-gray-900">{{ completed_inspections }}</p>
{% if inspection.conclusion_status == 'ok' %}bg-green-100 text-green-800 </div>
{% elif inspection.conclusion_status == 'minor' %}bg-yellow-100 text-yellow-800 </div>
{% else %}bg-red-100 text-red-800{% endif %}"> </div>
{{ inspection.conclusion_status | title }} </div>
</span>
</td> <div class="card">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium"> <div class="card-body">
<a href="{{ url_for('inspections.inspection_view', id=inspection.id) }}" class="text-indigo-600 hover:text-indigo-900 mr-3">View</a> <div class="flex items-center">
<a href="{{ url_for('inspections.inspection_edit', id=inspection.id) }}" class="text-blue-600 hover:text-blue-900 mr-3">Edit</a> <div class="p-3 rounded-full bg-yellow-100 text-yellow-600 mr-4">
<a href="{{ url_for('export.export_pdf', id=inspection.id) }}" class="text-gray-600 hover:text-gray-900">PDF</a> <i class="fas fa-clock text-xl"></i>
</td> </div>
</tr> <div>
{% endfor %} <h3 class="text-lg font-semibold text-gray-900">In Progress</h3>
</tbody> <p class="text-2xl font-bold text-gray-900">{{ in_progress_inspections }}</p>
</table> </div>
</div> </div>
{% else %} </div>
<div class="bg-white shadow overflow-hidden sm:rounded-lg p-8 text-center"> </div>
<i class="fas fa-file-alt text-4xl text-gray-300 mb-4"></i> </div>
<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="card mb-8">
<div class="mt-6"> <div class="card-header">
<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"> <div class="flex justify-between items-center">
<i class="fas fa-plus mr-2"></i> Create First Inspection <h2 class="text-xl font-semibold text-gray-900">Recent Inspections</h2>
</a> <a href="{{ url_for('inspections.create') }}" class="btn btn-primary">
<i class="fas fa-plus mr-1"></i> New Inspection
</a>
</div>
</div>
<div class="card-body">
{% if recent_inspections %}
<div class="overflow-x-auto">
<table class="table">
<thead class="table-thead">
<tr>
<th scope="col" class="table-th">ID</th>
<th scope="col" class="table-th">Location</th>
<th scope="col" class="table-th">Status</th>
<th scope="col" class="table-th">Created</th>
<th scope="col" class="table-th">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for inspection in recent_inspections %}
<tr>
<td class="table-td">INS-{{ inspection.id }}</td>
<td class="table-td">{{ inspection.location }}</td>
<td class="table-td">
<span class="badge
{% if inspection.status == 'completed' %}badge-success{% elif inspection.status == 'in_progress' %}badge-warning{% else %}badge-info{% endif %}">
{{ inspection.status }}
</span>
</td>
<td class="table-td">{{ inspection.created_at.strftime('%Y-%m-%d') }}</td>
<td class="table-td">
<a href="{{ url_for('inspections.view', inspection_id=inspection.id) }}" class="text-blue-600 hover:text-blue-900 mr-3">View</a>
<a href="{{ url_for('inspections.edit', inspection_id=inspection.id) }}" class="text-blue-600 hover:text-blue-900">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-8">
<i class="fas fa-file-alt text-gray-300 text-4xl mb-2"></i>
<p class="text-gray-500">No inspections found</p>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,10 +1,14 @@
<!DOCTYPE html> {% extends "base.html" %}
<html>
<head> {% block title %}Page Not Found - Inspection Reporting Tool{% endblock %}
<title>Page Not Found</title>
</head> {% block content %}
<body> <div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-8 text-center">
<h1>404 - Page Not Found</h1> <div class="text-5xl font-bold text-gray-200 mb-4">404</div>
<p>The page you are looking for does not exist.</p> <h1 class="text-2xl font-bold text-gray-800 mb-2">Page Not Found</h1>
</body> <p class="text-gray-600 mb-6">The page you are looking for does not exist or has been moved.</p>
</html> <a href="{{ url_for('inspections.dashboard') }}" 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-home mr-2"></i> Back to Dashboard
</a>
</div>
{% endblock %}

View file

@ -1,10 +1,14 @@
<!DOCTYPE html> {% extends "base.html" %}
<html>
<head> {% block title %}Server Error - Inspection Reporting Tool{% endblock %}
<title>Server Error</title>
</head> {% block content %}
<body> <div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-8 text-center">
<h1>Server Error (500)</h1> <div class="text-5xl font-bold text-gray-200 mb-4">500</div>
<p>Something went wrong on our end. Please try again later.</p> <h1 class="text-2xl font-bold text-gray-800 mb-2">Server Error</h1>
</body> <p class="text-gray-600 mb-6">Something went wrong on our end. Our team has been notified and is working to fix the issue.</p>
</html> <a href="{{ url_for('inspections.dashboard') }}" 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-home mr-2"></i> Back to Dashboard
</a>
</div>
{% endblock %}

View file

@ -1,191 +1,136 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% if inspection %}Edit Inspection -{% else %}New Inspection -{% endif %} Inspection Reporting Tool{% endblock %} {% block title %}{{ title }} - Inspection Reporting Tool{% endblock %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md"> <div class="mb-6">
<h1 class="text-xl font-bold text-gray-800 mb-6"> <h1 class="text-2xl font-bold text-gray-800">{{ title }}</h1>
{% 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> </div>
<script> <form method="POST" enctype="multipart/form-data" class="space-y-6">
// Add inspector functionality {{ form.hidden_tag() }}
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('inspectors-container');
const addInspectorBtn = document.getElementById('add-inspector');
addInspectorBtn.addEventListener('click', function() { <div class="card">
const newInspector = document.createElement('div'); <div class="card-header">
newInspector.className = 'flex items-center'; <h2 class="text-xl font-semibold text-gray-900">Inspection Details</h2>
newInspector.innerHTML = ` </div>
<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" /> <div class="card-body">
<button type="button" class="ml-2 text-red-600 hover:text-red-800 remove-inspector"> <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<i class="fas fa-times"></i> <div class="sm:col-span-3">
</button> {{ form.inspection_date.label(class="block text-sm font-medium text-gray-700 mb-1") }}
`; {{ form.inspection_date(class="form-input") }}
container.appendChild(newInspector); {% if form.inspection_date.errors %}
}); <ul class="mt-2 text-sm text-red-600">
{% for error in form.inspection_date.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
// Remove inspector functionality <div class="sm:col-span-3">
container.addEventListener('click', function(e) { {{ form.inspection_type.label(class="block text-sm font-medium text-gray-700 mb-1") }}
if (e.target.closest('.remove-inspector')) { {{ form.inspection_type(class="form-input") }}
const parent = e.target.closest('.flex'); {% if form.inspection_type.errors %}
parent.remove(); <ul class="mt-2 text-sm text-red-600">
} {% for error in form.inspection_type.errors %}
}); <li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
// Photo upload functionality <div class="sm:col-span-6">
const uploadBtn = document.getElementById('upload-photo-btn'); {{ form.installation_name.label(class="block text-sm font-medium text-gray-700 mb-1") }}
const photoInput = document.getElementById('photo-upload-input'); {{ form.installation_name(class="form-input") }}
const photoThumbnails = document.getElementById('photo-thumbnails'); {% if form.installation_name.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.installation_name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
uploadBtn.addEventListener('click', function() { <div class="sm:col-span-6">
photoInput.click(); {{ form.location.label(class="block text-sm font-medium text-gray-700 mb-1") }}
}); {{ form.location(class="form-input") }}
{% if form.location.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.location.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
photoInput.addEventListener('change', function(e) { <div class="sm:col-span-3">
const files = e.target.files; {{ form.version.label(class="block text-sm font-medium text-gray-700 mb-1") }}
for (let i = 0; i < files.length; i++) { {{ form.version(class="form-input") }}
const file = files[i]; {% if form.version.errors %}
const formData = new FormData(); <ul class="mt-2 text-sm text-red-600">
formData.append('file', file); {% for error in form.version.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
fetch('/upload_photo', { <div class="sm:col-span-3">
method: 'POST', {{ form.reference_number.label(class="block text-sm font-medium text-gray-700 mb-1") }}
body: formData {{ form.reference_number(class="form-input") }}
}) {% if form.reference_number.errors %}
.then(response => response.json()) <ul class="mt-2 text-sm text-red-600">
.then(data => { {% for error in form.reference_number.errors %}
if (data.filename) { <li>{{ error }}</li>
const thumbnail = document.createElement('div'); {% endfor %}
thumbnail.className = 'border rounded-lg p-2'; </ul>
thumbnail.innerHTML = ` {% endif %}
<img src="/uploads/${data.filename}" alt="${data.original_filename}" class="w-full h-32 object-cover rounded"> </div>
<div class="mt-2"> </div>
<input type="hidden" name="photo_filenames" value="${data.filename}"> </div>
<input type="text" name="photo_captions" placeholder="Caption" class="w-full px-2 py-1 border border-gray-300 rounded text-sm"> </div>
<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> <div class="card">
<option value="urgent">Urgent action required</option> <div class="card-header">
<option value="before_next">Action required before next inspection</option> <h2 class="text-xl font-semibold text-gray-900">Inspection Results</h2>
</select> </div>
</div> <div class="card-body">
`; <div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
photoThumbnails.appendChild(thumbnail); <div class="sm:col-span-6">
} else { {{ form.conclusion_status.label(class="block text-sm font-medium text-gray-700 mb-1") }}
alert('Upload failed: ' + (data.error || 'Unknown error')); {{ form.conclusion_status(class="form-input") }}
} {% if form.conclusion_status.errors %}
}) <ul class="mt-2 text-sm text-red-600">
.catch(error => { {% for error in form.conclusion_status.errors %}
console.error('Error:', error); <li>{{ error }}</li>
alert('Upload failed'); {% endfor %}
}); </ul>
} {% endif %}
photoInput.value = ''; </div>
});
}); <div class="sm:col-span-6">
</script> {{ form.comments.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.comments(class="form-textarea") }}
{% if form.comments.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.comments.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="submit" class="btn btn-primary">
Save
</button>
<a href="{{ url_for('inspections.dashboard') }}" class="btn btn-outline">
Cancel
</a>
</div>
</form>
{% endblock %} {% endblock %}

View file

@ -3,90 +3,95 @@
{% block title %}Inspection Report {{ inspection.reference_number }} - Inspection Reporting Tool{% endblock %} {% block title %}Inspection Report {{ inspection.reference_number }} - Inspection Reporting Tool{% endblock %}
{% block content %} {% block content %}
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md"> <div class="max-w-4xl mx-auto bg-white rounded-lg shadow-md overflow-hidden">
<div class="flex justify-between items-start mb-6"> <div class="bg-gray-50 px-6 py-4 border-b">
<div> <div class="flex justify-between items-start">
<h1 class="text-2xl font-bold text-gray-800">Inspection Report</h1> <div>
<p class="text-gray-600">Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }}</p> <h1 class="text-2xl font-bold text-gray-800">Inspection Report</h1>
</div> <p class="text-gray-600">Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }}</p>
<div class="flex space-x-3"> </div>
<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"> <div class="flex space-x-3">
<i class="fas fa-edit mr-2"></i> Edit Report <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">
</a> <i class="fas fa-edit mr-2"></i> Edit Report
<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"> </a>
<i class="fas fa-file-pdf mr-2"></i> Export PDF <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">
</a> <i class="fas fa-file-pdf mr-2"></i> Export PDF
</a>
</div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <div class="p-6">
<div> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<h3 class="text-lg font-medium text-gray-900 mb-2">Installation Details</h3> <div class="bg-gray-50 rounded-lg p-4">
<div class="space-y-2"> <h3 class="text-lg font-medium text-gray-900 mb-3">Installation Details</h3>
<p><span class="font-medium">Installation Name:</span> {{ inspection.installation_name }}</p> <div class="space-y-2">
<p><span class="font-medium">Location:</span> {{ inspection.location }}</p> <p><span class="font-medium">Installation Name:</span> {{ inspection.installation_name }}</p>
<p><span class="font-medium">Date of Inspection:</span> {{ inspection.inspection_date.strftime('%Y-%m-%d') }}</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>
<p><span class="font-medium">Inspection Type:</span> {{ inspection.inspection_type }}</p>
</div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-lg font-medium text-gray-900 mb-3">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> </div>
<div> <div class="mb-8">
<h3 class="text-lg font-medium text-gray-900 mb-2">Inspector(s)</h3> <h3 class="text-lg font-medium text-gray-900 mb-3">Observations</h3>
<div class="space-y-2"> <div class="bg-gray-50 p-4 rounded-lg">
{% for inspector in inspection.inspectors %} {{ inspection.observations or "No observations recorded." }}
<p>{{ inspector.free_text_name or inspector.user.full_name }}</p> </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 hover:shadow-md transition-shadow">
<img src="/uploads/{{ photo.filename }}" alt="{{ photo.caption }}" class="w-full h-48 object-cover rounded mb-3">
<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 %} {% endfor %}
</div> </div>
</div> </div>
</div> {% endif %}
<div class="mb-8"> <div class="border-t pt-6">
<h3 class="text-lg font-medium text-gray-900 mb-2">Observations</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">Conclusion</h3>
<div class="bg-gray-50 p-4 rounded-lg"> <div class="space-y-6">
{{ inspection.observations or "No observations recorded." }} <div>
</div> <p><span class="font-medium">Conclusion Comments:</span></p>
</div> <div class="bg-gray-50 p-4 rounded-lg mt-2">
{{ inspection.conclusion_text or "No conclusion comments recorded." }}
{% if inspection.photos %} </div>
<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>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div> <div>
<h3 class="text-lg font-medium text-gray-900 mb-2">Conclusion</h3> <p><span class="font-medium">Conclusion Status:</span></p>
<div class="space-y-4"> <div class="mt-2 px-4 py-3 rounded-lg
<div> {% if inspection.conclusion_status == 'ok' %}bg-green-100 text-green-800
<p><span class="font-medium">Conclusion Comments:</span></p> {% elif inspection.conclusion_status == 'minor' %}bg-yellow-100 text-yellow-800
<div class="bg-gray-50 p-4 rounded-lg"> {% else %}bg-red-100 text-red-800{% endif %}">
{{ inspection.conclusion_text or "No conclusion comments recorded." }} {{ inspection.conclusion_status.replace('_', ' ') | title }}
</div> </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> </div>

View file

@ -13,8 +13,8 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 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', 'certificate.crt')
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', 'private.key')
# Logo configuration # Logo configuration
LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads', 'logo.png') LOGO_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads', 'logo.png')

114
static/css/styles.css Normal file
View file

@ -0,0 +1,114 @@
/* Base styles for the Inspection Reporting Tool */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles */
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* Form styling */
.form-input, .form-textarea, .form-select {
@apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
}
/* Button styling */
.btn {
@apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
}
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700;
}
.btn-secondary {
@apply bg-gray-600 hover:bg-gray-700;
}
.btn-outline {
@apply bg-white border border-gray-300 text-gray-700 hover:bg-gray-50;
}
/* Card styling */
.card {
@apply bg-white rounded-lg shadow-md overflow-hidden;
}
.card-header {
@apply bg-gray-50 px-6 py-4 border-b;
}
.card-body {
@apply p-6;
}
/* Table styling */
.table {
@apply min-w-full divide-y divide-gray-200;
}
.table-thead {
@apply bg-gray-50;
}
.table-th {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
.table-td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
}
/* Alert styling */
.alert {
@apply px-4 py-2 rounded-md;
}
.alert-success {
@apply bg-green-100 text-green-700;
}
.alert-error {
@apply bg-red-100 text-red-700;
}
.alert-info {
@apply bg-blue-100 text-blue-700;
}
/* Badge styling */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-success {
@apply bg-green-100 text-green-800;
}
.badge-warning {
@apply bg-yellow-100 text-yellow-800;
}
.badge-danger {
@apply bg-red-100 text-red-800;
}
.badge-info {
@apply bg-blue-100 text-blue-800;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.sm\:grid-cols-2 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.sm\:col-span-3 {
grid-column: span 1 / span 1;
}
.sm\:col-span-6 {
grid-column: span 1 / span 1;
}
}

296
tests.py Normal file
View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Test suite for EP Inspection Tool
"""
import unittest
import tempfile
import os
from app import create_app
from app.models import db, User, Inspection, InspectionInspector, Photo
from config import Config
from werkzeug.security import generate_password_hash
class TestConfig(Config):
"""Test configuration"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SECRET_KEY = 'test-secret-key'
WTF_CSRF_ENABLED = False # Disable CSRF for testing
class EPInspectionTestCase(unittest.TestCase):
def setUp(self):
"""Set up test environment"""
self.app = create_app(TestConfig)
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
# Create a test user
self.test_user = User(
username='testuser',
full_name='Test User',
email='test@example.com',
is_admin=False
)
self.test_user.set_password('password')
db.session.add(self.test_user)
db.session.commit()
# Create an admin user
self.admin_user = User(
username='admin',
full_name='Admin User',
email='admin@example.com',
is_admin=True
)
self.admin_user.set_password('adminpassword')
db.session.add(self.admin_user)
db.session.commit()
self.client = self.app.test_client()
def tearDown(self):
"""Clean up after tests"""
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_user_creation(self):
"""Test user creation and password hashing"""
user = User.query.filter_by(username='testuser').first()
self.assertIsNotNone(user)
self.assertTrue(user.check_password('password'))
self.assertFalse(user.check_password('wrongpassword'))
def test_user_creation_with_admin(self):
"""Test admin user creation"""
admin = User.query.filter_by(username='admin').first()
self.assertIsNotNone(admin)
self.assertTrue(admin.is_admin)
self.assertTrue(admin.check_password('adminpassword'))
def test_inspection_creation(self):
"""Test inspection creation"""
from datetime import date
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.commit()
# Verify inspection was created
inspection = Inspection.query.first()
self.assertIsNotNone(inspection)
self.assertEqual(inspection.installation_name, 'Test Installation')
def test_inspection_creation_with_inspectors(self):
"""Test inspection creation with inspectors"""
from datetime import date
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.flush()
# Add inspector
inspector = InspectionInspector(
inspection_id=inspection.id,
user_id=self.test_user.id
)
db.session.add(inspector)
db.session.commit()
# Verify inspector was added
inspector = InspectionInspector.query.first()
self.assertIsNotNone(inspector)
self.assertEqual(inspector.user_id, self.test_user.id)
def test_database_connection(self):
"""Test that database is accessible"""
self.assertIsNotNone(db.engine)
def test_user_authentication(self):
"""Test user authentication"""
response = self.client.post('/login', data={
'username': 'testuser',
'password': 'password'
})
self.assertEqual(response.status_code, 302) # Redirect after login
def test_user_authentication_failed(self):
"""Test failed user authentication"""
response = self.client.post('/login', data={
'username': 'testuser',
'password': 'wrongpassword'
})
self.assertEqual(response.status_code, 200) # Stay on login page
def test_user_login_with_invalid_credentials(self):
"""Test login with invalid credentials"""
response = self.client.post('/login', data={
'username': 'nonexistent',
'password': 'password'
})
self.assertEqual(response.status_code, 200) # Stay on login page
def test_admin_user_can_access_admin_panel(self):
"""Test that admin user can access admin panel"""
# Login as admin
self.client.post('/login', data={
'username': 'admin',
'password': 'adminpassword'
})
# Access admin panel (should be accessible)
response = self.client.get('/admin')
# Should either redirect to login or return 200 (depending on implementation)
def test_inspection_creation_with_photo(self):
"""Test inspection creation with photo"""
from datetime import date
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.commit()
# Create a photo
photo = Photo(
inspection_id=inspection.id,
filename='test_photo.jpg',
action_required='none'
)
db.session.add(photo)
db.session.commit()
# Verify photo was added
photo = Photo.query.first()
self.assertIsNotNone(photo)
self.assertEqual(photo.filename, 'test_photo.jpg')
def test_inspection_validation(self):
"""Test inspection data validation"""
from datetime import date
# Test with valid data
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.commit()
# Test with invalid data (should be prevented by constraints)
# This test verifies that constraints are properly applied
inspection2 = Inspection(
installation_name='T', # Too short
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection2)
# This should raise an exception due to validation constraints
with self.assertRaises(Exception):
db.session.commit()
def test_user_validation(self):
"""Test user data validation"""
# Test with valid data
user = User(
username='testuser2',
full_name='Test User 2',
email='test2@example.com',
is_admin=False
)
user.set_password('password')
db.session.add(user)
db.session.commit()
# Verify user was created
user = User.query.filter_by(username='testuser2').first()
self.assertIsNotNone(user)
# Test with invalid data (should be prevented by constraints)
invalid_user = User(
username='ab', # Too short
full_name='Test User',
email='test@example.com',
is_admin=False
)
invalid_user.set_password('password')
db.session.add(invalid_user)
# This should raise an exception due to validation constraints
with self.assertRaises(Exception):
db.session.commit()
def test_inspection_workflow(self):
"""Test complete inspection workflow"""
# Login
response = self.client.post('/login', data={
'username': 'testuser',
'password': 'password'
})
self.assertEqual(response.status_code, 302)
# Create inspection
response = self.client.post('/inspection/new', data={
'installation_name': 'Workshop Inspection',
'location': 'Building A',
'inspection_date': '2023-01-15',
'reference_number': 1001,
'conclusion_status': 'ok',
'observations': 'All systems normal',
'conclusion_text': 'No issues found'
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
# Verify inspection was created
inspection = Inspection.query.first()
self.assertIsNotNone(inspection)
self.assertEqual(inspection.installation_name, 'Workshop Inspection')
def test_admin_access(self):
"""Test admin user access to restricted areas"""
# Login as admin
response = self.client.post('/login', data={
'username': 'admin',
'password': 'adminpassword'
})
self.assertEqual(response.status_code, 302)
# Access admin panel
response = self.client.get('/admin')
# This should either redirect or give access
def test_unauthorized_access(self):
"""Test unauthorized access prevention"""
# Try to access dashboard without login
response = self.client.get('/dashboard')
# Should redirect to login
self.assertEqual(response.status_code, 302)
if __name__ == '__main__':
unittest.main()

296
tests_old.py Normal file
View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Test suite for EP Inspection Tool
"""
import unittest
import tempfile
import os
from app import create_app
from app.models import db, User, Inspection, InspectionInspector, Photo
from config import Config
from werkzeug.security import generate_password_hash
class TestConfig(Config):
"""Test configuration"""
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SECRET_KEY = 'test-secret-key'
WTF_CSRF_ENABLED = False # Disable CSRF for testing
class EPInspectionTestCase(unittest.TestCase):
def setUp(self):
"""Set up test environment"""
self.app = create_app(TestConfig)
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
# Create a test user
self.test_user = User(
username='testuser',
full_name='Test User',
email='test@example.com',
is_admin=False
)
self.test_user.set_password('password')
db.session.add(self.test_user)
db.session.commit()
# Create an admin user
self.admin_user = User(
username='admin',
full_name='Admin User',
email='admin@example.com',
is_admin=True
)
self.admin_user.set_password('adminpassword')
db.session.add(self.admin_user)
db.session.commit()
self.client = self.app.test_client()
def tearDown(self):
"""Clean up after tests"""
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_user_creation(self):
"""Test user creation and password hashing"""
user = User.query.filter_by(username='testuser').first()
self.assertIsNotNone(user)
self.assertTrue(user.check_password('password'))
self.assertFalse(user.check_password('wrongpassword'))
def test_user_creation_with_admin(self):
"""Test admin user creation"""
admin = User.query.filter_by(username='admin').first()
self.assertIsNotNone(admin)
self.assertTrue(admin.is_admin)
self.assertTrue(admin.check_password('adminpassword'))
def test_inspection_creation(self):
"""Test inspection creation"""
from datetime import date
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.commit()
# Verify inspection was created
inspection = Inspection.query.first()
self.assertIsNotNone(inspection)
self.assertEqual(inspection.installation_name, 'Test Installation')
def test_inspection_creation_with_inspectors(self):
"""Test inspection creation with inspectors"""
from datetime import date
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.flush()
# Add inspector
inspector = InspectionInspector(
inspection_id=inspection.id,
user_id=self.test_user.id
)
db.session.add(inspector)
db.session.commit()
# Verify inspector was added
inspector = InspectionInspector.query.first()
self.assertIsNotNone(inspector)
self.assertEqual(inspector.user_id, self.test_user.id)
def test_database_connection(self):
"""Test that database is accessible"""
self.assertIsNotNone(db.engine)
def test_user_authentication(self):
"""Test user authentication"""
response = self.client.post('/login', data={
'username': 'testuser',
'password': 'password'
})
self.assertEqual(response.status_code, 302) # Redirect after login
def test_user_authentication_failed(self):
"""Test failed user authentication"""
response = self.client.post('/login', data={
'username': 'testuser',
'password': 'wrongpassword'
})
self.assertEqual(response.status_code, 200) # Stay on login page
def test_user_login_with_invalid_credentials(self):
"""Test login with invalid credentials"""
response = self.client.post('/login', data={
'username': 'nonexistent',
'password': 'password'
})
self.assertEqual(response.status_code, 200) # Stay on login page
def test_admin_user_can_access_admin_panel(self):
"""Test that admin user can access admin panel"""
# Login as admin
self.client.post('/login', data={
'username': 'admin',
'password': 'adminpassword'
})
# Access admin panel (should be accessible)
response = self.client.get('/admin')
# Should either redirect to login or return 200 (depending on implementation)
def test_inspection_creation_with_photo(self):
"""Test inspection creation with photo"""
from datetime import date
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.commit()
# Create a photo
photo = Photo(
inspection_id=inspection.id,
filename='test_photo.jpg',
action_required='none'
)
db.session.add(photo)
db.session.commit()
# Verify photo was added
photo = Photo.query.first()
self.assertIsNotNone(photo)
self.assertEqual(photo.filename, 'test_photo.jpg')
def test_inspection_validation(self):
"""Test inspection data validation"""
from datetime import date
# Test with valid data
inspection = Inspection(
installation_name='Test Installation',
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection)
db.session.commit()
# Test with invalid data (should be prevented by constraints)
# This test verifies that constraints are properly applied
inspection2 = Inspection(
installation_name='T', # Too short
location='Test Location',
inspection_date=date(2023, 1, 1),
reference_number=1,
conclusion_status='ok',
created_by_id=self.test_user.id
)
db.session.add(inspection2)
# This should raise an exception due to validation constraints
with self.assertRaises(Exception):
db.session.commit()
def test_user_validation(self):
"""Test user data validation"""
# Test with valid data
user = User(
username='testuser2',
full_name='Test User 2',
email='test2@example.com',
is_admin=False
)
user.set_password('password')
db.session.add(user)
db.session.commit()
# Verify user was created
user = User.query.filter_by(username='testuser2').first()
self.assertIsNotNone(user)
# Test with invalid data (should be prevented by constraints)
invalid_user = User(
username='ab', # Too short
full_name='Test User',
email='test@example.com',
is_admin=False
)
invalid_user.set_password('password')
db.session.add(invalid_user)
# This should raise an exception due to validation constraints
with self.assertRaises(Exception):
db.session.commit()
def test_inspection_workflow(self):
"""Test complete inspection workflow"""
# Login
response = self.client.post('/login', data={
'username': 'testuser',
'password': 'password'
})
self.assertEqual(response.status_code, 302)
# Create inspection
response = self.client.post('/inspection/new', data={
'installation_name': 'Workshop Inspection',
'location': 'Building A',
'inspection_date': '2023-01-15',
'reference_number': 1001,
'conclusion_status': 'ok',
'observations': 'All systems normal',
'conclusion_text': 'No issues found'
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
# Verify inspection was created
inspection = Inspection.query.first()
self.assertIsNotNone(inspection)
self.assertEqual(inspection.installation_name, 'Workshop Inspection')
def test_admin_access(self):
"""Test admin user access to restricted areas"""
# Login as admin
response = self.client.post('/login', data={
'username': 'admin',
'password': 'adminpassword'
})
self.assertEqual(response.status_code, 302)
# Access admin panel
response = self.client.get('/admin')
# This should either redirect or give access
def test_unauthorized_access(self):
"""Test unauthorized access prevention"""
# Try to access dashboard without login
response = self.client.get('/dashboard')
# Should redirect to login
self.assertEqual(response.status_code, 302)
if __name__ == '__main__':
unittest.main()