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`
3. Run the application: `python run.py`
## Testing
Run tests with: `python tests.py`
## Usage
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_login import LoginManager
from flask_wtf.csrf import CSRFProtect
from config import Config
from app.models import db, Config as ConfigModel, User
import os
from datetime import datetime
import secrets
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
# Enhanced session security
login_manager.session_protection = "strong"
@login_manager.user_loader
def load_user(user_id):
@ -33,6 +38,24 @@ def create_app(config_class=Config):
app = Flask(__name__)
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
db.init_app(app)
login_manager.init_app(app)
@ -73,11 +96,21 @@ def create_app(config_class=Config):
# Error handlers
@app.errorhandler(404)
def not_found_error(error):
app.logger.warning(f'Page not found: {request.url}')
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def internal_error(error):
db.session.rollback()
app.logger.error(f'Server Error: {error}')
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

View file

@ -2,6 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from sqlalchemy import CheckConstraint
db = SQLAlchemy()
@ -14,6 +15,12 @@ class User(UserMixin, db.Model):
is_admin = db.Column(db.Boolean, default=False)
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):
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)
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):
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)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (
CheckConstraint('length(filename) >= 5', name='filename_length_check'),
)
def __repr__(self):
return f'<Photo {self.filename}>'

View file

@ -200,13 +200,6 @@ def upload_photo():
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'}

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>
<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="{{ url_for('static', filename='css/styles.css') }}">
</head>
<body class="bg-gray-50 min-h-screen">
<nav class="bg-white shadow-md">
@ -22,7 +23,7 @@
Dashboard
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('admin.users') }}" class="inline-flex items-center px-1 pt-1 border-b-2 border-transparent hover:border-gray-300 text-sm font-medium text-gray-500 hover:text-gray-700">
<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
</a>
{% endif %}

View file

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

View file

@ -1,10 +1,14 @@
<!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>
{% extends "base.html" %}
{% block title %}Page Not Found - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-8 text-center">
<div class="text-5xl font-bold text-gray-200 mb-4">404</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Page Not Found</h1>
<p class="text-gray-600 mb-6">The page you are looking for does not exist or has been moved.</p>
<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>
<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>
{% extends "base.html" %}
{% block title %}Server Error - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-8 text-center">
<div class="text-5xl font-bold text-gray-200 mb-4">500</div>
<h1 class="text-2xl font-bold text-gray-800 mb-2">Server Error</h1>
<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>
<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" %}
{% block title %}{% if inspection %}Edit Inspection -{% else %}New Inspection -{% endif %} Inspection Reporting Tool{% endblock %}
{% block title %}{{ title }} - Inspection Reporting Tool{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto bg-white p-6 rounded-lg shadow-md">
<h1 class="text-xl font-bold text-gray-800 mb-6">
{% if inspection %}Edit Inspection Report{% else %}New Inspection Report{% endif %}
</h1>
<form method="POST" class="space-y-8" id="inspectionForm">
{{ form.hidden_tag() }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{{ form.installation_name.label(class="block text-sm font-medium text-gray-700") }}
{{ form.installation_name(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.location.label(class="block text-sm font-medium text-gray-700") }}
{{ form.location(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.inspection_date.label(class="block text-sm font-medium text-gray-700") }}
{{ form.inspection_date(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
<div>
{{ form.reference_number.label(class="block text-sm font-medium text-gray-700") }}
{{ form.reference_number(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
</div>
</div>
<div>
{{ form.observations.label(class="block text-sm font-medium text-gray-700") }}
{{ form.observations(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500", rows="4") }}
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Inspectors</h3>
<div id="inspectors-container" class="space-y-2">
{% for inspector in form.inspectors %}
<div class="flex items-center">
{{ inspector(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500") }}
<button type="button" class="ml-2 text-red-600 hover:text-red-800 remove-inspector">
<i class="fas fa-times"></i>
</button>
</div>
{% endfor %}
</div>
<button type="button" id="add-inspector" class="mt-2 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-gray-600 hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500">
<i class="fas fa-plus mr-1"></i> Add Inspector
</button>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Photos</h3>
<div id="photos-container" class="space-y-4">
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<div class="flex items-center justify-center">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400"></i>
<p class="ml-2 text-gray-500">Upload photos</p>
</div>
<input type="file" id="photo-upload-input" class="hidden" multiple accept="image/*">
<button type="button" id="upload-photo-btn" class="mt-2 inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Select Photos
</button>
</div>
<div id="photo-thumbnails" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-900 mb-4">Conclusion</h3>
<div class="space-y-4">
{{ form.conclusion_text.label(class="block text-sm font-medium text-gray-700") }}
{{ form.conclusion_text(class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500", rows="3") }}
<div>
{{ form.conclusion_status.label(class="block text-sm font-medium text-gray-700") }}
<div class="mt-2 space-y-2">
{% for choice in form.conclusion_status.choices %}
<div class="flex items-center">
<input type="radio" id="status-{{ choice[0] }}" name="conclusion_status" value="{{ choice[0] }}"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" {% if form.conclusion_status.data == choice[0] %}checked{% endif %}>
<label for="status-{{ choice[0] }}" class="ml-3 block text-sm text-gray-700">
{{ choice[1] }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3">
{% if inspection %}
<a href="{{ url_for('inspections.inspection_view', id=inspection.id) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Cancel
</a>
{{ form.update(class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") }}
{% else %}
<a href="{{ url_for('inspections.dashboard') }}" class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Cancel
</a>
{{ form.submit(class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500") }}
{% endif %}
</div>
</form>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ title }}</h1>
</div>
<script>
// Add inspector functionality
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('inspectors-container');
const addInspectorBtn = document.getElementById('add-inspector');
<form method="POST" enctype="multipart/form-data" class="space-y-6">
{{ form.hidden_tag() }}
addInspectorBtn.addEventListener('click', function() {
const newInspector = document.createElement('div');
newInspector.className = 'flex items-center';
newInspector.innerHTML = `
<input type="text" name="inspectors" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" />
<button type="button" class="ml-2 text-red-600 hover:text-red-800 remove-inspector">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(newInspector);
});
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Inspection Details</h2>
</div>
<div class="card-body">
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div class="sm:col-span-3">
{{ form.inspection_date.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.inspection_date(class="form-input") }}
{% 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>
<div class="sm:col-span-3">
{{ form.inspection_type.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.inspection_type(class="form-input") }}
{% if form.inspection_type.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.inspection_type.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="sm:col-span-6">
{{ form.installation_name.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.installation_name(class="form-input") }}
{% 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>
<div class="sm:col-span-6">
{{ 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>
<div class="sm:col-span-3">
{{ form.version.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.version(class="form-input") }}
{% if form.version.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.version.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="sm:col-span-3">
{{ form.reference_number.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.reference_number(class="form-input") }}
{% if form.reference_number.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.reference_number.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
</div>
</div>
// Remove inspector functionality
container.addEventListener('click', function(e) {
if (e.target.closest('.remove-inspector')) {
const parent = e.target.closest('.flex');
parent.remove();
}
});
<div class="card">
<div class="card-header">
<h2 class="text-xl font-semibold text-gray-900">Inspection Results</h2>
</div>
<div class="card-body">
<div class="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div class="sm:col-span-6">
{{ form.conclusion_status.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.conclusion_status(class="form-input") }}
{% if form.conclusion_status.errors %}
<ul class="mt-2 text-sm text-red-600">
{% for error in form.conclusion_status.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="sm:col-span-6">
{{ 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>
// Photo upload functionality
const uploadBtn = document.getElementById('upload-photo-btn');
const photoInput = document.getElementById('photo-upload-input');
const photoThumbnails = document.getElementById('photo-thumbnails');
uploadBtn.addEventListener('click', function() {
photoInput.click();
});
photoInput.addEventListener('change', function(e) {
const files = e.target.files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const formData = new FormData();
formData.append('file', file);
fetch('/upload_photo', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.filename) {
const thumbnail = document.createElement('div');
thumbnail.className = 'border rounded-lg p-2';
thumbnail.innerHTML = `
<img src="/uploads/${data.filename}" alt="${data.original_filename}" class="w-full h-32 object-cover rounded">
<div class="mt-2">
<input type="hidden" name="photo_filenames" value="${data.filename}">
<input type="text" name="photo_captions" placeholder="Caption" class="w-full px-2 py-1 border border-gray-300 rounded text-sm">
<select name="photo_actions" class="w-full px-2 py-1 border border-gray-300 rounded text-sm mt-1">
<option value="none">No action required</option>
<option value="urgent">Urgent action required</option>
<option value="before_next">Action required before next inspection</option>
</select>
</div>
`;
photoThumbnails.appendChild(thumbnail);
} else {
alert('Upload failed: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error:', error);
alert('Upload failed');
});
}
photoInput.value = '';
});
});
</script>
<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 %}

View file

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

View file

@ -13,8 +13,8 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# Self-signed certificate paths
CERT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'cert.pem')
KEY_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'certs', 'key.pem')
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', 'private.key')
# Logo configuration
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()