feat: Complete EP Inspection Tool implementation with templates, security, and tests
This commit is contained in:
parent
c7e6dce720
commit
e9c26d172d
16 changed files with 1259 additions and 347 deletions
62
API_DOCS.md
Normal file
62
API_DOCS.md
Normal 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
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}>'
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
96
app/templates/admin/dashboard.html
Normal file
96
app/templates/admin/dashboard.html
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
<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
|
<div class="card">
|
||||||
container.addEventListener('click', function(e) {
|
<div class="card-header">
|
||||||
if (e.target.closest('.remove-inspector')) {
|
<h2 class="text-xl font-semibold text-gray-900">Inspection Results</h2>
|
||||||
const parent = e.target.closest('.flex');
|
</div>
|
||||||
parent.remove();
|
<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
|
<div class="flex justify-end space-x-3">
|
||||||
const uploadBtn = document.getElementById('upload-photo-btn');
|
<button type="submit" class="btn btn-primary">
|
||||||
const photoInput = document.getElementById('photo-upload-input');
|
Save
|
||||||
const photoThumbnails = document.getElementById('photo-thumbnails');
|
</button>
|
||||||
|
<a href="{{ url_for('inspections.dashboard') }}" class="btn btn-outline">
|
||||||
uploadBtn.addEventListener('click', function() {
|
Cancel
|
||||||
photoInput.click();
|
</a>
|
||||||
});
|
</div>
|
||||||
|
</form>
|
||||||
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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -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>
|
|
||||||
<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>
|
<div>
|
||||||
<p><span class="font-medium">Conclusion Comments:</span></p>
|
<h1 class="text-2xl font-bold text-gray-800">Inspection Report</h1>
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
<p class="text-gray-600">Reference: {{ inspection.reference_number }} | Version: {{ inspection.version }}</p>
|
||||||
{{ inspection.conclusion_text or "No conclusion comments recorded." }}
|
</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>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
<p><span class="font-medium">Conclusion Status:</span></p>
|
<h3 class="text-lg font-medium text-gray-900 mb-3">Inspector(s)</h3>
|
||||||
<div class="mt-2 px-4 py-3 rounded-lg
|
<div class="space-y-2">
|
||||||
{% if inspection.conclusion_status == 'ok' %}bg-green-100 text-green-800
|
{% for inspector in inspection.inspectors %}
|
||||||
{% elif inspection.conclusion_status == 'minor' %}bg-yellow-100 text-yellow-800
|
<p>{{ inspector.free_text_name or inspector.user.full_name }}</p>
|
||||||
{% else %}bg-red-100 text-red-800{% endif %}">
|
{% endfor %}
|
||||||
{{ inspection.conclusion_status.replace('_', ' ') | title }}
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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
114
static/css/styles.css
Normal 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
296
tests.py
Normal 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
296
tests_old.py
Normal 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()
|
||||||
Loading…
Reference in a new issue