Initial commit
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Database
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -0,0 +1,36 @@
|
||||
from flask import Flask
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__, template_folder='../templates')
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import auth_bp
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
|
||||
# Register probes blueprint
|
||||
from app.routes.probes import probes_bp
|
||||
app.register_blueprint(probes_bp, url_prefix='/probes')
|
||||
|
||||
# Register calibrations blueprint
|
||||
from app.routes.calibrations import calibrations_bp
|
||||
app.register_blueprint(calibrations_bp, url_prefix='/calibrations')
|
||||
|
||||
# Register work orders blueprint
|
||||
from app.routes.work_orders import work_orders_bp
|
||||
app.register_blueprint(work_orders_bp, url_prefix='/work_orders')
|
||||
|
||||
# Register locations blueprint
|
||||
from app.routes.locations import locations_bp
|
||||
app.register_blueprint(locations_bp, url_prefix='/locations')
|
||||
|
||||
# Register channels blueprint
|
||||
from app.routes.channels import channels_bp
|
||||
app.register_blueprint(channels_bp, url_prefix='/channels')
|
||||
|
||||
return app
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""User model representing application users"""
|
||||
id: str # uuid
|
||||
name: str
|
||||
email: str
|
||||
can_calibrate: bool
|
||||
can_review: bool
|
||||
signature_image: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def get_by_email(cls, email: str):
|
||||
"""Get a single user by email"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('users').select("*").eq('email', email).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls):
|
||||
"""List all users"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('users').select("*").execute()
|
||||
return [cls(**row) for row in result.data]
|
||||
|
||||
@dataclass
|
||||
class Probe:
|
||||
"""Probe model representing physical measurement probes"""
|
||||
id: str # uuid
|
||||
model_id: str # uuid
|
||||
serial_number: str
|
||||
description: str
|
||||
created_at: datetime
|
||||
retired_at: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, probe_id: str):
|
||||
"""Get a single probe by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probes').select("*").eq('id', probe_id).execute()
|
||||
if not result.data:
|
||||
return None
|
||||
row = result.data[0]
|
||||
# Parse datetime strings
|
||||
row['created_at'] = datetime.fromisoformat(row['created_at']) if row['created_at'] else None
|
||||
row['retired_at'] = datetime.fromisoformat(row['retired_at']) if row['retired_at'] else None
|
||||
return cls(**row)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all probes with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probes').select("*").limit(limit).execute()
|
||||
probes = []
|
||||
for row in result.data:
|
||||
# Parse datetime strings
|
||||
row['created_at'] = datetime.fromisoformat(row['created_at']) if row['created_at'] else None
|
||||
row['retired_at'] = datetime.fromisoformat(row['retired_at']) if row['retired_at'] else None
|
||||
probes.append(cls(**row))
|
||||
return probes
|
||||
|
||||
@classmethod
|
||||
def create(cls, model_id: str, serial_number: str, description: str = ''):
|
||||
"""Create a new probe"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probes').insert({
|
||||
'model_id': model_id,
|
||||
'serial_number': serial_number,
|
||||
'description': description
|
||||
}).execute()
|
||||
if not result.data:
|
||||
raise Exception('Failed to create probe')
|
||||
return cls(**result.data[0])
|
||||
|
||||
@dataclass
|
||||
class Channel:
|
||||
"""Channel model representing individual measurement channels"""
|
||||
id: str # uuid
|
||||
probe_id: str # uuid
|
||||
serial_number: str # [0-9A-F]{16}
|
||||
parameter_id: str # uuid
|
||||
created_at: datetime
|
||||
|
||||
@property
|
||||
def parameter(self):
|
||||
"""Get the associated Parameter object"""
|
||||
return Parameter.get_by_id(self.parameter_id)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, channel_id: str):
|
||||
"""Get a single channel by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('channels').select("*").eq('id', channel_id).execute()
|
||||
if not result.data:
|
||||
return None
|
||||
row = result.data[0]
|
||||
# Parse datetime string
|
||||
row['created_at'] = datetime.fromisoformat(row['created_at']) if row['created_at'] else None
|
||||
return cls(**row)
|
||||
|
||||
@classmethod
|
||||
def get_by_probe(cls, probe_id: str):
|
||||
"""Get all channels for a specific probe"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('channels').select("*").eq('probe_id', probe_id).execute()
|
||||
channels = []
|
||||
for row in result.data:
|
||||
# Parse datetime string
|
||||
row['created_at'] = datetime.fromisoformat(row['created_at']) if row['created_at'] else None
|
||||
channels.append(cls(**row))
|
||||
return channels
|
||||
|
||||
@classmethod
|
||||
def create(cls, probe_id: str, serial_number: str, parameter_id: str):
|
||||
"""Create a new channel"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('channels').insert({
|
||||
'probe_id': probe_id,
|
||||
'serial_number': serial_number,
|
||||
'parameter_id': parameter_id
|
||||
}).execute()
|
||||
if not result.data:
|
||||
raise Exception('Failed to create channel')
|
||||
return cls(**result.data[0])
|
||||
|
||||
@dataclass
|
||||
class WorkOrder:
|
||||
"""Work order model representing calibration work orders"""
|
||||
id: str # uuid
|
||||
order_number: str
|
||||
customer_id: str # uuid
|
||||
assigned_to: str # uuid (FK to users.id)
|
||||
due_date: datetime
|
||||
status: str
|
||||
redmine: Optional[int] = None
|
||||
cal_type: Optional[str] = None # uuid (FK to calibration_types.id)
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, work_order_id: str):
|
||||
"""Get a single work order by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('work_orders').select("*").eq('id', work_order_id).execute()
|
||||
if not result.data:
|
||||
return None
|
||||
|
||||
row = result.data[0]
|
||||
# Parse due_date if it exists
|
||||
if 'due_date' in row and row['due_date']:
|
||||
if isinstance(row['due_date'], str):
|
||||
row['due_date'] = datetime.fromisoformat(row['due_date'])
|
||||
elif not isinstance(row['due_date'], datetime):
|
||||
row['due_date'] = None
|
||||
|
||||
return cls(**row)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all work orders with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('work_orders').select("*").limit(limit).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@classmethod
|
||||
def create(cls, order_number: str, customer_id: str, assigned_to: str,
|
||||
due_date: str, status: str, redmine: Optional[int] = None,
|
||||
cal_type: Optional[str] = None):
|
||||
"""Create a new work order"""
|
||||
supabase = get_supabase()
|
||||
# Convert due_date string to ISO format if not already
|
||||
try:
|
||||
due_date_obj = datetime.fromisoformat(due_date)
|
||||
due_date_iso = due_date_obj.isoformat()
|
||||
except ValueError:
|
||||
due_date_iso = due_date # Fallback to original if parsing fails
|
||||
|
||||
result = supabase.table('work_orders').insert({
|
||||
'order_number': order_number,
|
||||
'customer_id': customer_id,
|
||||
'assigned_to': assigned_to,
|
||||
'due_date': due_date_iso,
|
||||
'status': status,
|
||||
'redmine': redmine,
|
||||
'cal_type': cal_type
|
||||
}).execute()
|
||||
if not result.data:
|
||||
raise Exception('Failed to create work order')
|
||||
|
||||
# Parse dates in the returned data
|
||||
row = result.data[0]
|
||||
row['due_date'] = datetime.fromisoformat(row['due_date']) if row['due_date'] else None
|
||||
return cls(**row)
|
||||
|
||||
@dataclass
|
||||
class Calibration:
|
||||
"""Calibration model representing individual channel calibrations"""
|
||||
id: str # uuid
|
||||
channel_id: str # uuid (FK to channels.id)
|
||||
work_order_id: str # uuid (FK to work_orders.id)
|
||||
calibrated_by: str # uuid (FK to users.id)
|
||||
std_used: str # uuid (FK to standards.id)
|
||||
std_cal_date: datetime
|
||||
std_cal_due: datetime
|
||||
date: datetime
|
||||
scale: float
|
||||
offset: float
|
||||
deviation_high: float
|
||||
deviation_mid: float
|
||||
deviation_low: float
|
||||
set_high: float
|
||||
set_mid: float
|
||||
set_low: float
|
||||
passed: bool
|
||||
reviewed_by: Optional[str] = None # uuid (FK to users.id)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all work orders with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('work_orders').select("*").limit(limit).execute()
|
||||
work_orders = []
|
||||
for row in result.data if result.data else []:
|
||||
try:
|
||||
# Debug print raw data
|
||||
print(f"Raw work order data: {row}")
|
||||
|
||||
# Ensure due_date is properly parsed
|
||||
if 'due_date' in row and row['due_date']:
|
||||
if isinstance(row['due_date'], str):
|
||||
row['due_date'] = datetime.fromisoformat(row['due_date'])
|
||||
elif not isinstance(row['due_date'], datetime):
|
||||
row['due_date'] = None
|
||||
|
||||
# Create instance and verify date type
|
||||
wo = cls(**row)
|
||||
print(f"WorkOrder instance due_date type: {type(wo.due_date)}")
|
||||
work_orders.append(wo)
|
||||
except Exception as e:
|
||||
print(f"Error parsing work order data: {e}")
|
||||
continue
|
||||
return work_orders
|
||||
|
||||
@classmethod
|
||||
def get_by_work_order(cls, work_order_id: str):
|
||||
"""Get all calibrations for a specific work order"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('calibrations').select("*").eq('work_order_id', work_order_id).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class Customer:
|
||||
"""Customer model representing clients who request calibrations"""
|
||||
id: str # uuid
|
||||
name: str
|
||||
contact_name: str
|
||||
contact_email: str
|
||||
contact_phone: str
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, customer_id: str):
|
||||
"""Get a single customer by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('customers').select("*").eq('id', customer_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all customers with optional limit"""
|
||||
supabase = get_supabase()
|
||||
try:
|
||||
# First try with all expected fields
|
||||
result = supabase.table('customers').select(
|
||||
"id,name,contact_name,contact_email,contact_phone"
|
||||
).limit(limit).execute()
|
||||
except Exception as e:
|
||||
if 'column customers.contact_email does not exist' in str(e) or 'column customers.contact_phone does not exist' in str(e):
|
||||
# Fall back to simpler field names if expected ones don't exist
|
||||
result = supabase.table('customers').select(
|
||||
"id,name,contact_name,email,phone"
|
||||
).limit(limit).execute()
|
||||
for row in result.data:
|
||||
if 'email' in row:
|
||||
row['contact_email'] = row.pop('email')
|
||||
if 'phone' in row:
|
||||
row['contact_phone'] = row.pop('phone')
|
||||
else:
|
||||
raise
|
||||
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class Standard:
|
||||
"""Standard model representing calibration standards"""
|
||||
id: str # uuid
|
||||
make: str
|
||||
model: str
|
||||
description: str
|
||||
uncertainty: str
|
||||
calibrated_on: datetime
|
||||
calibration_due: datetime
|
||||
support_name: str
|
||||
support_email: str
|
||||
support_phone: str
|
||||
support_address: str
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, standard_id: str):
|
||||
"""Get a single standard by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('standards').select("*").eq('id', standard_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all standards with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('standards').select("*").limit(limit).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class ProbeModel:
|
||||
"""Probe model definition representing different probe types/models"""
|
||||
id: str # uuid
|
||||
model_name: str
|
||||
specifications: dict # jsonb
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, model_id: str):
|
||||
"""Get a single probe model by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probe_models').select("*").eq('id', model_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all probe models with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probe_models').select("*").limit(limit).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class CalibrationType:
|
||||
"""CalibrationType model representing different types of calibrations"""
|
||||
id: str # uuid
|
||||
type_name: str
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, type_id: str):
|
||||
"""Get a single calibration type by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('calibration_types').select("*").eq('id', type_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all calibration types with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('calibration_types').select("*").limit(limit).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
"""Location model representing physical locations where probes are deployed"""
|
||||
id: str # uuid
|
||||
name: str
|
||||
address: str
|
||||
contact_name: str
|
||||
contact_email: str
|
||||
contact_phone: str
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, location_id: str):
|
||||
"""Get a single location by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('locations').select("*").eq('id', location_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all locations with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('locations').select("*").limit(limit).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class ProbeLocation:
|
||||
"""ProbeLocation model representing probe assignments to locations"""
|
||||
id: str # uuid
|
||||
probe_id: str # uuid (FK to probes.id)
|
||||
location_id: str # uuid (FK to locations.id)
|
||||
start_date: datetime
|
||||
end_date: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, probe_location_id: str):
|
||||
"""Get a single probe location by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probe_locations').select("*").eq('id', probe_location_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_by_probe(cls, probe_id: str):
|
||||
"""Get all location assignments for a specific probe"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probe_locations').select("*").eq('probe_id', probe_id).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@classmethod
|
||||
def get_by_location(cls, location_id: str):
|
||||
"""Get all probe assignments for a specific location"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('probe_locations').select("*").eq('location_id', location_id).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
@dataclass
|
||||
class Parameter:
|
||||
"""Parameter model representing measurement parameters"""
|
||||
id: str # uuid
|
||||
parameter_name: str
|
||||
|
||||
@classmethod
|
||||
def get_by_id(cls, parameter_id: str):
|
||||
"""Get a single parameter by ID"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('parameters').select("*").eq('id', parameter_id).execute()
|
||||
return cls(**result.data[0]) if result.data else None
|
||||
|
||||
@classmethod
|
||||
def get_all(cls, limit: int = 100):
|
||||
"""List all parameters with optional limit"""
|
||||
supabase = get_supabase()
|
||||
result = supabase.table('parameters').select("*").limit(limit).execute()
|
||||
return [cls(**row) for row in result.data] if result.data else []
|
||||
|
||||
def get_supabase():
|
||||
"""Local import of supabase client to avoid circular imports"""
|
||||
from app.supabase_client import get_supabase as supabase_func
|
||||
return supabase_func()
|
||||
@@ -0,0 +1,63 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, abort
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from app.models import User
|
||||
from app.supabase_client import get_supabase
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
def log_auth_event(user_id: str, action: str, details: str = None):
|
||||
"""Log authentication events to audit trail"""
|
||||
supabase = get_supabase()
|
||||
event_data = {
|
||||
'user_id': user_id,
|
||||
'action': action,
|
||||
'timestamp': datetime.utcnow().isoformat(),
|
||||
'details': details or '',
|
||||
'ip_address': request.remote_addr
|
||||
}
|
||||
try:
|
||||
supabase.table('auth_audit').insert(event_data).execute()
|
||||
except Exception as e:
|
||||
print(f"Failed to log auth event: {e}")
|
||||
|
||||
def role_required(role):
|
||||
"""Decorator to require specific role"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if role == 'calibrate' and not session.get('can_calibrate'):
|
||||
abort(403)
|
||||
if role == 'review' and not session.get('can_review'):
|
||||
abort(403)
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
email = request.form.get('email')
|
||||
user = User.get_by_email(email)
|
||||
if user:
|
||||
session['user_id'] = user.id
|
||||
session['user_name'] = user.name
|
||||
session['can_calibrate'] = user.can_calibrate
|
||||
session['can_review'] = user.can_review
|
||||
log_auth_event(user.id, 'login', f'IP: {request.remote_addr}')
|
||||
return redirect(url_for('index'))
|
||||
log_auth_event('unknown', 'failed_login', f'Email: {email}, IP: {request.remote_addr}')
|
||||
return render_template('auth/login.html', error="Invalid user")
|
||||
|
||||
# GET request - show login form
|
||||
users = User.get_all()
|
||||
if not users:
|
||||
return render_template('auth/login.html', users=[], error="No users exist in database")
|
||||
return render_template('auth/login.html', users=users)
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
if 'user_id' in session:
|
||||
log_auth_event(session['user_id'], 'logout')
|
||||
session.clear()
|
||||
return redirect(url_for('auth.login'))
|
||||
@@ -0,0 +1,283 @@
|
||||
from flask import Blueprint, request, redirect, url_for, flash, render_template
|
||||
from datetime import datetime
|
||||
import re
|
||||
from app.models import Calibration, Channel, WorkOrder, Standard, User
|
||||
from app.supabase_client import get_supabase
|
||||
|
||||
calibrations_bp = Blueprint('calibrations', __name__, url_prefix='/calibrations')
|
||||
|
||||
@calibrations_bp.route('/create', methods=['POST'])
|
||||
def create_calibrations():
|
||||
# Validate form data
|
||||
work_order_id = request.form.get('work_order_id')
|
||||
calibrated_by = request.form.get('calibrated_by')
|
||||
std_used = request.form.get('std_used')
|
||||
date_str = request.form.get('date')
|
||||
|
||||
if not all([work_order_id, calibrated_by, std_used, date_str]):
|
||||
flash('Missing required fields', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
try:
|
||||
date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash('Invalid date format', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
# Get channel serials and calibration data
|
||||
channel_serials = request.form.getlist('channel_serial[]')
|
||||
scales = request.form.getlist('scale[]')
|
||||
offsets = request.form.getlist('offset[]')
|
||||
deviation_highs = request.form.getlist('deviation_high[]')
|
||||
deviation_mids = request.form.getlist('deviation_mid[]')
|
||||
deviation_lows = request.form.getlist('deviation_low[]')
|
||||
set_highs = request.form.getlist('set_high[]')
|
||||
set_mids = request.form.getlist('set_mid[]')
|
||||
set_lows = request.form.getlist('set_low[]')
|
||||
passed_list = request.form.getlist('passed[]')
|
||||
|
||||
# Validate serial numbers
|
||||
serial_pattern = re.compile(r'^[0-9A-F]{16}$')
|
||||
for serial in channel_serials:
|
||||
if not serial_pattern.match(serial):
|
||||
flash(f'Invalid serial number format: {serial}', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
# Get standard calibration dates
|
||||
supabase = get_supabase()
|
||||
standard = supabase.table('standards').select('calibrated_on, calibration_due').eq('id', std_used).execute()
|
||||
if not standard.data:
|
||||
flash('Invalid standard selected', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
std_cal_date = datetime.strptime(standard.data[0]['calibrated_on'], '%Y-%m-%d').date()
|
||||
std_cal_due = datetime.strptime(standard.data[0]['calibration_due'], '%Y-%m-%d').date()
|
||||
|
||||
# Process each calibration
|
||||
for i in range(len(channel_serials)):
|
||||
# Get channel ID from serial
|
||||
channel = supabase.table('channels').select('id').eq('serial_number', channel_serials[i]).execute()
|
||||
if not channel.data:
|
||||
flash(f'Channel not found: {channel_serials[i]}', 'error')
|
||||
continue
|
||||
|
||||
calibration_data = {
|
||||
'channel_id': channel.data[0]['id'],
|
||||
'work_order_id': work_order_id,
|
||||
'calibrated_by': calibrated_by,
|
||||
'std_used': std_used,
|
||||
'std_cal_date': std_cal_date.isoformat(),
|
||||
'std_cal_due': std_cal_due.isoformat(),
|
||||
'date': date.isoformat(),
|
||||
'scale': float(scales[i]),
|
||||
'offset': float(offsets[i]),
|
||||
'deviation_high': float(deviation_highs[i]),
|
||||
'deviation_mid': float(deviation_mids[i]),
|
||||
'deviation_low': float(deviation_lows[i]),
|
||||
'set_high': float(set_highs[i]),
|
||||
'set_mid': float(set_mids[i]),
|
||||
'set_low': float(set_lows[i]),
|
||||
'passed': bool(passed_list[i] if i < len(passed_list) else False)
|
||||
}
|
||||
|
||||
# Insert calibration
|
||||
result = supabase.table('calibrations').insert(calibration_data).execute()
|
||||
if not result.data:
|
||||
flash(f'Failed to create calibration for {channel_serials[i]}', 'error')
|
||||
|
||||
flash('Calibrations processed', 'success')
|
||||
return redirect(url_for('calibrations.index'))
|
||||
|
||||
@calibrations_bp.route('/review/<calibration_id>', methods=['GET', 'POST'])
|
||||
def review_calibration(calibration_id):
|
||||
supabase = get_supabase()
|
||||
|
||||
if request.method == 'POST':
|
||||
# Verify reviewer is logged in
|
||||
reviewer_id = request.form.get('reviewer_id')
|
||||
signature = request.form.get('signature')
|
||||
|
||||
if not reviewer_id or not signature:
|
||||
flash('Reviewer ID and signature are required', 'error')
|
||||
return redirect(request.referrer)
|
||||
|
||||
# Save signature to user profile
|
||||
supabase.table('users').update({
|
||||
'signature_image': signature
|
||||
}).eq('id', reviewer_id).execute()
|
||||
|
||||
# Update calibration with review info
|
||||
result = supabase.table('calibrations').update({
|
||||
'reviewed_by': reviewer_id,
|
||||
'reviewed_at': datetime.now().isoformat()
|
||||
}).eq('id', calibration_id).execute()
|
||||
|
||||
if not result.data:
|
||||
flash('Failed to update calibration review', 'error')
|
||||
else:
|
||||
# Log audit event
|
||||
supabase.table('calibration_audit').insert({
|
||||
'calibration_id': calibration_id,
|
||||
'user_id': reviewer_id,
|
||||
'action': 'review',
|
||||
'new_values': {
|
||||
'reviewed_by': reviewer_id,
|
||||
'reviewed_at': datetime.now().isoformat()
|
||||
},
|
||||
'ip_address': request.remote_addr
|
||||
}).execute()
|
||||
|
||||
flash('Calibration reviewed successfully', 'success')
|
||||
|
||||
return redirect(url_for('calibrations.index'))
|
||||
|
||||
# Get calibration details with related data
|
||||
calibration = supabase.table('calibrations').select('''
|
||||
*,
|
||||
channels:channel_id(serial_number),
|
||||
calibrated_by:calibrated_by(name),
|
||||
work_orders:work_order_id(order_number)
|
||||
''').eq('id', calibration_id).execute()
|
||||
|
||||
if not calibration.data:
|
||||
flash('Calibration not found', 'error')
|
||||
return redirect(url_for('calibrations.index'))
|
||||
|
||||
return render_template('calibration_review.html',
|
||||
calibration=calibration.data[0])
|
||||
|
||||
@calibrations_bp.route('/new')
|
||||
def new_calibration():
|
||||
supabase = get_supabase()
|
||||
|
||||
# Get non-completed work orders with customer names
|
||||
work_orders = supabase.table('work_orders').select('''
|
||||
id,
|
||||
order_number,
|
||||
customers:customer_id(name)
|
||||
''').neq('status', 'Completed').execute()
|
||||
|
||||
# Get all standards
|
||||
standards = supabase.table('standards').select('id, make, model').execute()
|
||||
|
||||
# Get all channels
|
||||
channels = supabase.table('channels').select('serial_number').execute()
|
||||
|
||||
return render_template('calibration_form.html',
|
||||
work_orders=work_orders.data,
|
||||
standards=standards.data,
|
||||
channels=channels.data)
|
||||
|
||||
@calibrations_bp.route('/')
|
||||
def index():
|
||||
supabase = get_supabase()
|
||||
|
||||
# Get summary statistics
|
||||
total = supabase.table('calibrations').select('count', count='exact').execute().count
|
||||
passed = supabase.table('calibrations').select('count', count='exact').eq('passed', True).execute().count
|
||||
failed = total - passed
|
||||
|
||||
# Get recent calibrations (last 10)
|
||||
recent_calibrations = supabase.table('calibrations').select('''
|
||||
*,
|
||||
channels:channel_id(serial_number),
|
||||
standards:std_used(make, model),
|
||||
calibrated_by:calibrated_by(name)
|
||||
''').order('date', desc=True).limit(10).execute()
|
||||
|
||||
return render_template('calibration_dashboard.html',
|
||||
summary={
|
||||
'total_calibrations': total,
|
||||
'passed_calibrations': passed,
|
||||
'failed_calibrations': failed
|
||||
},
|
||||
recent_calibrations=recent_calibrations.data)
|
||||
|
||||
@calibrations_bp.route('/probe/<probe_id>')
|
||||
def calibrations_for_probe(probe_id):
|
||||
"""List all calibrations for a specific probe"""
|
||||
supabase = get_supabase()
|
||||
|
||||
# Get all channels for this probe
|
||||
channels = supabase.table('channels').select('id').eq('probe_id', probe_id).execute()
|
||||
channel_ids = [c['id'] for c in channels.data]
|
||||
|
||||
# Get all calibrations for these channels
|
||||
calibrations = supabase.table('calibrations').select('''
|
||||
*,
|
||||
channels:channel_id(serial_number),
|
||||
standards:std_used(make, model),
|
||||
calibrated_by:calibrated_by(name),
|
||||
reviewed_by:reviewed_by(name)
|
||||
''').in_('channel_id', channel_ids).order('date', desc=True).execute()
|
||||
|
||||
# Get probe details
|
||||
probe = supabase.table('probes').select('*').eq('id', probe_id).execute()
|
||||
|
||||
return render_template('calibration_history.html',
|
||||
calibrations=calibrations.data,
|
||||
probe=probe.data[0] if probe.data else None)
|
||||
|
||||
@calibrations_bp.route('/probe/<probe_id>/trends')
|
||||
def calibration_trends(probe_id):
|
||||
"""Get calibration trend data for a specific probe"""
|
||||
supabase = get_supabase()
|
||||
|
||||
# Get all channels for this probe
|
||||
channels = supabase.table('channels').select('id, serial_number').eq('probe_id', probe_id).execute()
|
||||
channel_ids = [c['id'] for c in channels.data]
|
||||
|
||||
# Get all calibrations for these channels
|
||||
calibrations = supabase.table('calibrations').select('''
|
||||
id,
|
||||
channel_id,
|
||||
date,
|
||||
deviation_high,
|
||||
deviation_mid,
|
||||
deviation_low,
|
||||
channels:channel_id(serial_number)
|
||||
''').in_('channel_id', channel_ids).order('date').execute()
|
||||
|
||||
# Organize data by channel for charting
|
||||
trend_data = {}
|
||||
for cal in calibrations.data:
|
||||
channel_id = cal['channel_id']
|
||||
if channel_id not in trend_data:
|
||||
trend_data[channel_id] = {
|
||||
'serial': cal['channels']['serial_number'],
|
||||
'dates': [],
|
||||
'high': [],
|
||||
'mid': [],
|
||||
'low': []
|
||||
}
|
||||
trend_data[channel_id]['dates'].append(cal['date'])
|
||||
trend_data[channel_id]['high'].append(cal['deviation_high'])
|
||||
trend_data[channel_id]['mid'].append(cal['deviation_mid'])
|
||||
trend_data[channel_id]['low'].append(cal['deviation_low'])
|
||||
|
||||
return {
|
||||
'probe_id': probe_id,
|
||||
'trends': trend_data
|
||||
}
|
||||
|
||||
@calibrations_bp.route('/<calibration_id>')
|
||||
def view_calibration(calibration_id):
|
||||
"""View details of a single calibration"""
|
||||
supabase = get_supabase()
|
||||
|
||||
# Get calibration details with related data
|
||||
calibration = supabase.table('calibrations').select('''
|
||||
*,
|
||||
channels:channel_id(serial_number, probe_id),
|
||||
calibrated_by:calibrated_by(name),
|
||||
reviewed_by:reviewed_by(name),
|
||||
work_orders:work_order_id(order_number),
|
||||
standards:std_used(make, model, description)
|
||||
''').eq('id', calibration_id).execute()
|
||||
|
||||
if not calibration.data:
|
||||
flash('Calibration not found', 'error')
|
||||
return redirect(url_for('calibrations.index'))
|
||||
|
||||
return render_template('calibration_view.html',
|
||||
calibration=calibration.data[0])
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
||||
from app.models import Channel, Calibration
|
||||
from app.routes.auth import role_required
|
||||
|
||||
channels_bp = Blueprint('channels', __name__)
|
||||
|
||||
@channels_bp.route('/<channel_id>')
|
||||
@role_required('review')
|
||||
def view_channel(channel_id):
|
||||
"""View details of a single channel"""
|
||||
channel = Channel.get_by_id(channel_id)
|
||||
if not channel:
|
||||
flash('Channel not found', 'danger')
|
||||
return redirect(url_for('probes.list_probes'))
|
||||
|
||||
# Get calibration history for this channel
|
||||
calibrations = Calibration.get_by_channel(channel_id)
|
||||
|
||||
return render_template('channel_view.html',
|
||||
channel=channel,
|
||||
calibrations=calibrations)
|
||||
@@ -0,0 +1,38 @@
|
||||
from flask import Blueprint, render_template
|
||||
from app.models import ProbeLocation
|
||||
|
||||
locations_bp = Blueprint('locations', __name__)
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
@locations_bp.route('/probe/<probe_id>/locations')
|
||||
def probe_location_history(probe_id):
|
||||
"""Show timeline of location assignments for a probe"""
|
||||
locations = ProbeLocation.get_by_probe(probe_id)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Prepare data for template
|
||||
location_data = []
|
||||
chart_data = {
|
||||
'labels': [],
|
||||
'durations': []
|
||||
}
|
||||
|
||||
for loc in locations:
|
||||
end_date = loc.end_date if loc.end_date else now
|
||||
duration_days = (end_date - loc.start_date).days
|
||||
|
||||
location_data.append({
|
||||
'location': loc,
|
||||
'duration_days': duration_days
|
||||
})
|
||||
|
||||
chart_data['labels'].append(str(loc.location_id))
|
||||
chart_data['durations'].append(duration_days)
|
||||
|
||||
return render_template('location_timeline.html',
|
||||
probe_id=probe_id,
|
||||
location_data=location_data,
|
||||
chart_data_json=json.dumps(chart_data),
|
||||
now=now)
|
||||
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from app.models import Probe, ProbeModel, Channel, Parameter, ProbeLocation
|
||||
from app.routes.auth import role_required
|
||||
|
||||
probes_bp = Blueprint('probes', __name__)
|
||||
|
||||
@probes_bp.route('/')
|
||||
@role_required('review')
|
||||
def list_probes():
|
||||
"""List all probes"""
|
||||
probes = Probe.get_all()
|
||||
return render_template('probe_list.html', probes=probes)
|
||||
|
||||
@probes_bp.route('/new')
|
||||
@role_required('review')
|
||||
def new_probe():
|
||||
"""Display form to create new probe"""
|
||||
probe_models = ProbeModel.get_all()
|
||||
parameters = Parameter.get_all()
|
||||
return render_template('probe_form.html',
|
||||
probe_models=probe_models,
|
||||
parameters=parameters)
|
||||
|
||||
@probes_bp.route('/', methods=['POST'])
|
||||
@role_required('review')
|
||||
def create_probe():
|
||||
"""Handle new probe creation with channels"""
|
||||
try:
|
||||
# First create the probe
|
||||
probe = Probe.create(
|
||||
model_id=request.form['model_id'],
|
||||
serial_number=request.form['serial_number'],
|
||||
description='' # Empty description since field was removed
|
||||
)
|
||||
|
||||
# Then create channels if provided
|
||||
channel_serials = request.form.getlist('channel_serials[]')
|
||||
parameter_ids = request.form.getlist('parameter_ids[]')
|
||||
|
||||
if channel_serials and parameter_ids and len(channel_serials) == len(parameter_ids):
|
||||
for i in range(len(channel_serials)):
|
||||
# Validate channel serial format [0-9A-F]{16}
|
||||
if not re.fullmatch(r'^[0-9A-F]{16}$', channel_serials[i].upper()):
|
||||
raise ValueError(f'Invalid channel serial format: {channel_serials[i]}. Must be 16 hex characters (0-9, A-F)')
|
||||
|
||||
Channel.create(
|
||||
probe_id=probe.id,
|
||||
serial_number=channel_serials[i].upper(), # Store in uppercase
|
||||
parameter_id=parameter_ids[i]
|
||||
)
|
||||
|
||||
flash('Probe and channels created successfully', 'success')
|
||||
return redirect(url_for('probes.list_probes'))
|
||||
except ValueError as ve:
|
||||
flash(f'Validation error: {str(ve)}', 'danger')
|
||||
return redirect(url_for('probes.new_probe'))
|
||||
except Exception as e:
|
||||
flash(f'Error creating probe: {str(e)}', 'danger')
|
||||
return redirect(url_for('probes.new_probe'))
|
||||
|
||||
@probes_bp.route('/<probe_id>')
|
||||
@role_required('review')
|
||||
def view_probe(probe_id):
|
||||
"""View details of a single probe"""
|
||||
probe = Probe.get_by_id(probe_id)
|
||||
if not probe:
|
||||
flash('Probe not found', 'danger')
|
||||
return redirect(url_for('probes.list_probes'))
|
||||
|
||||
# Get associated channels with their parameters
|
||||
channels = Channel.get_by_probe(probe_id)
|
||||
|
||||
# Get location history
|
||||
locations = ProbeLocation.get_by_probe(probe_id)
|
||||
|
||||
return render_template('probe_view.html',
|
||||
probe=probe,
|
||||
channels=channels,
|
||||
locations=locations)
|
||||
@@ -0,0 +1,83 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from app.models import WorkOrder, Customer, User, CalibrationType, Calibration
|
||||
from app.supabase_client import get_supabase
|
||||
from app.routes.auth import role_required
|
||||
|
||||
work_orders_bp = Blueprint('work_orders', __name__)
|
||||
|
||||
@work_orders_bp.route('/')
|
||||
@role_required('review')
|
||||
def list_work_orders():
|
||||
"""List all work orders"""
|
||||
work_orders = WorkOrder.get_all()
|
||||
if not work_orders:
|
||||
flash('No work orders found', 'info')
|
||||
return render_template('work_order_list.html', work_orders=work_orders)
|
||||
|
||||
@work_orders_bp.route('/new')
|
||||
@role_required('review')
|
||||
def new_work_order():
|
||||
"""Display form to create new work order"""
|
||||
customers = Customer.get_all()
|
||||
users = User.get_all()
|
||||
calibration_types = CalibrationType.get_all()
|
||||
return render_template('work_order_form.html',
|
||||
customers=customers,
|
||||
users=users,
|
||||
calibration_types=calibration_types)
|
||||
|
||||
@work_orders_bp.route('/', methods=['POST'])
|
||||
@role_required('review')
|
||||
def create_work_order():
|
||||
"""Handle new work order creation"""
|
||||
try:
|
||||
work_order = WorkOrder.create(
|
||||
order_number=request.form['order_number'],
|
||||
customer_id=request.form['customer_id'],
|
||||
assigned_to=request.form['assigned_to'],
|
||||
due_date=request.form['due_date'],
|
||||
status=request.form['status'],
|
||||
redmine=request.form.get('redmine'),
|
||||
cal_type=request.form['cal_type']
|
||||
)
|
||||
flash('Work order created successfully', 'success')
|
||||
return redirect(url_for('work_orders.list_work_orders'))
|
||||
except Exception as e:
|
||||
flash(f'Error creating work order: {str(e)}', 'danger')
|
||||
return redirect(url_for('work_orders.new_work_order'))
|
||||
|
||||
@work_orders_bp.route('/<work_order_id>')
|
||||
@role_required('review')
|
||||
def view_work_order(work_order_id):
|
||||
"""View details of a single work order"""
|
||||
work_order = WorkOrder.get_by_id(work_order_id)
|
||||
if not work_order:
|
||||
flash('Work order not found', 'danger')
|
||||
return redirect(url_for('work_orders.list_work_orders'))
|
||||
# Get associated calibrations
|
||||
calibrations = Calibration.get_by_work_order(work_order_id)
|
||||
return render_template('work_order_view.html',
|
||||
work_order=work_order,
|
||||
calibrations=calibrations)
|
||||
|
||||
@work_orders_bp.route('/<work_order_id>/status', methods=['POST'])
|
||||
@role_required('review')
|
||||
def update_status(work_order_id):
|
||||
"""Update work order status"""
|
||||
work_order = WorkOrder.get_by_id(work_order_id)
|
||||
if not work_order:
|
||||
flash('Work order not found', 'danger')
|
||||
return redirect(url_for('work_orders.list_work_orders'))
|
||||
|
||||
try:
|
||||
# Update status in database
|
||||
supabase = get_supabase()
|
||||
supabase.table('work_orders').update({
|
||||
'status': request.form['status']
|
||||
}).eq('id', work_order_id).execute()
|
||||
|
||||
flash('Status updated successfully', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Error updating status: {str(e)}', 'danger')
|
||||
|
||||
return redirect(url_for('work_orders.view_work_order', work_order_id=work_order_id))
|
||||
@@ -0,0 +1,127 @@
|
||||
from supabase import create_client, Client
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
url: str = os.getenv('SUPABASE_URL')
|
||||
key: str = os.getenv('SUPABASE_KEY')
|
||||
|
||||
def get_supabase() -> Client:
|
||||
"""Initialize and return Supabase client"""
|
||||
return create_client(url, key)
|
||||
|
||||
def test_connection():
|
||||
"""Test the Supabase connection"""
|
||||
supabase = get_supabase()
|
||||
try:
|
||||
# Simple query to test connection
|
||||
data = supabase.table('probes').select("*").limit(1).execute()
|
||||
return True if data.data else False
|
||||
except Exception as e:
|
||||
print(f"Connection test failed: {e}")
|
||||
return False
|
||||
|
||||
# CRUD Operations
|
||||
def create_probe(probe_data: dict):
|
||||
"""Create a new probe record"""
|
||||
supabase = get_supabase()
|
||||
return supabase.table('probes').insert(probe_data).execute()
|
||||
|
||||
def get_probe(probe_id: str):
|
||||
"""Get a single probe by ID"""
|
||||
supabase = get_supabase()
|
||||
return supabase.table('probes').select("*").eq('id', probe_id).execute()
|
||||
|
||||
def update_probe(probe_id: str, update_data: dict):
|
||||
"""Update a probe record"""
|
||||
supabase = get_supabase()
|
||||
return supabase.table('probes').update(update_data).eq('id', probe_id).execute()
|
||||
|
||||
def delete_probe(probe_id: str):
|
||||
"""Delete a probe record"""
|
||||
supabase = get_supabase()
|
||||
return supabase.table('probes').delete().eq('id', probe_id).execute()
|
||||
|
||||
def list_probes(limit: int = 100):
|
||||
"""List all probes with optional limit"""
|
||||
supabase = get_supabase()
|
||||
return supabase.table('probes').select("*").limit(limit).execute()
|
||||
|
||||
def create_auth_audit_table():
|
||||
"""Create auth_audit table if it doesn't exist"""
|
||||
supabase = get_supabase()
|
||||
supabase.rpc('create_auth_audit_table').execute()
|
||||
|
||||
def execute_sql_file(sql_file_path: str):
|
||||
"""Execute SQL from a file"""
|
||||
supabase = get_supabase()
|
||||
|
||||
# First ensure the execute_sql function exists
|
||||
with open('sql/create_execute_sql_function.sql', 'r') as f:
|
||||
create_func_sql = f.read()
|
||||
supabase.rpc('execute_sql', {'sql_text': create_func_sql}).execute()
|
||||
|
||||
# Now execute the target SQL file
|
||||
with open(sql_file_path, 'r') as f:
|
||||
sql = f.read()
|
||||
supabase.rpc('execute_sql', {'sql_text': sql}).execute()
|
||||
|
||||
return {'data': True} # Return success if no exceptions
|
||||
|
||||
def create_tables():
|
||||
"""Create all required tables"""
|
||||
supabase = get_supabase()
|
||||
# Execute the core tables SQL
|
||||
with open('sql/create_tables.sql', 'r') as f:
|
||||
sql = f.read()
|
||||
supabase.rpc('create_core_tables').execute()
|
||||
# Create audit table
|
||||
create_auth_audit_table()
|
||||
|
||||
def test_user_retrieval():
|
||||
"""Test retrieving users from database"""
|
||||
supabase = get_supabase()
|
||||
try:
|
||||
result = supabase.table('users').select("*").execute()
|
||||
print(f"Found {len(result.data)} users:")
|
||||
for user in result.data:
|
||||
print(f"- {user['name']} ({user['email']})")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error retrieving users: {e}")
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
if test_connection():
|
||||
# Test user retrieval
|
||||
print("\nTesting user retrieval:")
|
||||
test_user_retrieval()
|
||||
print("Supabase connection successful!")
|
||||
|
||||
# Test CRUD operations
|
||||
test_probe = {
|
||||
'model_id': '00000000-0000-0000-0000-000000000000',
|
||||
'serial_number': 'TEST123',
|
||||
'description': 'Test probe'
|
||||
}
|
||||
|
||||
print("\nTesting CRUD operations:")
|
||||
# Create
|
||||
create_result = create_probe(test_probe)
|
||||
print(f"Create: {create_result.data}")
|
||||
|
||||
# Read
|
||||
probe_id = create_result.data[0]['id']
|
||||
read_result = get_probe(probe_id)
|
||||
print(f"Read: {read_result.data}")
|
||||
|
||||
# Update
|
||||
update_result = update_probe(probe_id, {'description': 'Updated test probe'})
|
||||
print(f"Update: {update_result.data}")
|
||||
|
||||
# Delete
|
||||
delete_result = delete_probe(probe_id)
|
||||
print(f"Delete: {delete_result.data}")
|
||||
else:
|
||||
print("Supabase connection failed")
|
||||
@@ -0,0 +1,12 @@
|
||||
from app.supabase_client import execute_sql_file
|
||||
|
||||
def main():
|
||||
print("Creating calibration audit table...")
|
||||
result = execute_sql_file('sql/create_calibration_audit_table.sql')
|
||||
if result.data:
|
||||
print("Calibration audit table created successfully")
|
||||
else:
|
||||
print("Failed to create calibration audit table")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
from app import create_app
|
||||
from flask import render_template, session, redirect
|
||||
|
||||
app = create_app()
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
if 'user_id' not in session:
|
||||
return redirect('/auth/login')
|
||||
return render_template('index.html', user=session)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
````markdown
|
||||
# SmartScan Probe Track - Implementation Plan
|
||||
|
||||
## Technology Stack
|
||||
| Component | Technology |
|
||||
|---------------------|------------------------|
|
||||
| Web Framework | Flask (Python) |
|
||||
| Database | Supabase (PostgreSQL) |
|
||||
| Virtual Environment | uv |
|
||||
| UI Framework | Bootstrap 5 + Vue.js |
|
||||
| Charting | Chart.js |
|
||||
| PDF Generation | ReportLab + PyPDF2 |
|
||||
|
||||
## Database Schema
|
||||
### Tables
|
||||
1. **probes**
|
||||
- id (uuid, PK)
|
||||
- model_id (uuid, FK → probe_models.id)
|
||||
- serial_number (text)
|
||||
- description (text)
|
||||
- created_at (timestamp)
|
||||
- retired_at (timestamp)
|
||||
|
||||
2. **channels**
|
||||
- id (uuid, PK)
|
||||
- probe_id (uuid, FK → probes.id)
|
||||
- serial_number (varchar(16), regex: [0-9A-F]{16})
|
||||
- parameter_id (uuid, FK → parameters.id)
|
||||
- created_at (timestamp)
|
||||
|
||||
3. **calibrations**
|
||||
- id (uuid, PK)
|
||||
- channel_id (uuid, FK → channels.id)
|
||||
- work_order_id (uuid, FK → work_orders.id)
|
||||
- calibrated_by (uuid, FK → users.id)
|
||||
- reviewed_by (uuid, FK → users.id)
|
||||
- std_used (uuid, FK → standards.id)
|
||||
- std_cal_date (date)
|
||||
- std_cal_due (date)
|
||||
- date (date)
|
||||
- scale (float)
|
||||
- offset (float)
|
||||
- deviation_high (float)
|
||||
- deviation_mid (float)
|
||||
- deviation_low (float)
|
||||
- set_high (float)
|
||||
- set_mid (float)
|
||||
- set_low (float)
|
||||
- passed (boolean)
|
||||
|
||||
4. **locations**
|
||||
- id (uuid, PK)
|
||||
- name (text)
|
||||
- address (text)
|
||||
- contact_name (text)
|
||||
- contact_email (text)
|
||||
- contact_phone (text)
|
||||
|
||||
5. **probe_locations**
|
||||
- id (uuid, PK)
|
||||
- probe_id (uuid, FK → probes.id)
|
||||
- location_id (uuid, FK → locations.id)
|
||||
- start_date (date)
|
||||
- end_date (date)
|
||||
|
||||
6. **probe_models**
|
||||
- id (uuid, PK)
|
||||
- model_name (text, unique)
|
||||
- specifications (jsonb)
|
||||
|
||||
7. **work_orders**
|
||||
- id (uuid, PK)
|
||||
- order_number (text, unique)
|
||||
- customer_id (uuid, FK → customers.id)
|
||||
- assigned_to (uuid, FK → users.id)
|
||||
- due_date (date)
|
||||
- status (text)
|
||||
- redmine (integer)
|
||||
- cal_type (uuid, FK → calibration_types.id)
|
||||
|
||||
8. **users**
|
||||
- id (uuid, PK)
|
||||
- name (text)
|
||||
- email (text, unique)
|
||||
- can_calibrate (boolean)
|
||||
- can_review (boolean)
|
||||
- signature_image (text)
|
||||
|
||||
10. **standards**
|
||||
- id (uuid, PK)
|
||||
- make (text)
|
||||
- model (text)
|
||||
- description (text)
|
||||
- uncertainty (text)
|
||||
- calibrated_on (date)
|
||||
- calibration_due (date)
|
||||
- support_name (text)
|
||||
- support_email (text)
|
||||
- support_phone (text)
|
||||
- support_address (text)
|
||||
|
||||
11. **calibration_types**
|
||||
- id (uuid, PK)
|
||||
- type_name (text)
|
||||
|
||||
12. **parameters**
|
||||
- id (uuid, PK)
|
||||
- parameter_name (text)
|
||||
|
||||
### Relationships
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
probes ||--o{ channels : has
|
||||
probes }|--|| probe_models : model
|
||||
channels }|--|| parameters : parameter
|
||||
channels ||--o{ calibrations : has
|
||||
calibrations }|--|| standards : standard
|
||||
work_orders ||--o{ calibrations : contains
|
||||
work_orders }|--|| calibration_types : type
|
||||
work_orders }|--|| users : assigned_to
|
||||
probes ||--o{ probe_locations : assigned
|
||||
locations ||--o{ probe_locations : location
|
||||
customers ||--o{ work_orders : requested
|
||||
users ||--o{ calibrations : calibrated
|
||||
users ||--o{ calibrations : reviewed
|
||||
````
|
||||
|
||||
## Application Structure
|
||||
|
||||
```javascript
|
||||
SmartScanProbeTrack3/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── probes.py
|
||||
│ │ ├── channels.py
|
||||
│ │ ├── calibrations.py
|
||||
│ │ ├── locations.py
|
||||
│ │ └── work_orders.py
|
||||
│ ├── templates/
|
||||
│ │ ├── auth/
|
||||
│ │ │ └── login.html
|
||||
│ │ ├── base.html
|
||||
│ │ ├── index.html
|
||||
│ │ ├── probe_list.html
|
||||
│ │ ├── probe_form.html
|
||||
│ │ ├── probe_view.html
|
||||
│ │ ├── channel_view.html
|
||||
│ │ ├── calibration_form.html
|
||||
│ │ ├── calibration_view.html
|
||||
│ │ ├── calibration_review.html
|
||||
│ │ ├── location_timeline.html
|
||||
│ │ ├── work_order_list.html
|
||||
│ │ ├── work_order_form.html
|
||||
│ │ └── work_order_view.html
|
||||
│ ├── models.py
|
||||
│ ├── supabase_client.py
|
||||
│ └── utils/
|
||||
│ └── pdf_utils.py
|
||||
├── sql/
|
||||
│ ├── create_tables.sql
|
||||
│ ├── create_auth_audit_table.sql
|
||||
│ ├── create_calibration_audit_table.sql
|
||||
│ └── create_execute_sql_function.sql
|
||||
├── tests/
|
||||
├── requirements.txt
|
||||
├── main.py
|
||||
├── create_audit_tables.py
|
||||
├── test_supabase.py
|
||||
└── .env
|
||||
```
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Probe Management
|
||||
|
||||
- **Probe List**: View all probes (probe_list.html)
|
||||
- **Probe Creation**: Form to create new probes with channels (probe_form.html)
|
||||
- **Probe View**: Detailed view showing:
|
||||
- Probe details
|
||||
- Associated channels (linking to channel_view.html)
|
||||
- Location history (linking to location_timeline.html)
|
||||
- **Channel Management**:
|
||||
- Each probe has multiple channels
|
||||
- Channel view shows calibration history (channel_view.html)
|
||||
- **Location Assignment**: Track probe deployment history
|
||||
|
||||
### 2. Calibration Workflow
|
||||
|
||||
1. **Work Order Management**:
|
||||
- List view (work_order_list.html)
|
||||
- Creation form (work_order_form.html)
|
||||
- Detailed view (work_order_view.html)
|
||||
|
||||
2. **Calibration Process**:
|
||||
- **Entry Form**: Multi-channel calibration entry (calibration_form.html)
|
||||
- **Review Interface**: For supervisor approval (calibration_review.html)
|
||||
- **View Details**: Shows full calibration history (calibration_view.html)
|
||||
|
||||
3. **Workflow**:
|
||||
- Create work order → Add calibrations → Submit for review → Approve
|
||||
- Audit trail maintained via SQL audit tables
|
||||
- Electronic signatures captured during review
|
||||
|
||||
4. **Certificate Generation**:
|
||||
- Automatic PDF generation after approval
|
||||
- Includes all calibration metrics and standards data
|
||||
|
||||
### 3. Reporting
|
||||
|
||||
- **Probe history**:
|
||||
- Show location assignment timeline (from `probe_locations`)
|
||||
- Include calibration history with deviation/set point values
|
||||
|
||||
- **Location inventory**:
|
||||
- Current probe assignments with dates
|
||||
- Contact information for each location
|
||||
|
||||
- **Work order tracking**:
|
||||
- Status indicators (pending, in progress, completed)
|
||||
- Filter by calibration type
|
||||
|
||||
### 4. User Management
|
||||
|
||||
- Role-based access control
|
||||
- Electronic signatures
|
||||
- Audit trails
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
Use uv for all relevant virtual environment and package management. For example, use 'uv add' rather than 'uv pip'. Use 'uv run' to run the app.
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
uv venv venv
|
||||
.\venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
uv pip install flask supabase python-dotenv reportlab pypdf2
|
||||
|
||||
# Environment variables (.env)
|
||||
SUPABASE_URL=https://yelyqstoffujrlddboai.supabase.co
|
||||
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InllbHlxc3RvZmZ1anJsZGRib2FpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTM2Mjg3OTIsImV4cCI6MjA2OTIwNDc5Mn0.IAwOaydZXvklT9atJoJix9Abf7pMPr1q1WZMI2JJUCc
|
||||
SECRET_KEY=5e8f8d7e-5e8f-4a3d-9e8f-5e8f8d7e5e8f
|
||||
DATABASE_URL=postgresql://postgres:FRWLUJ5xTpcLJ136Xqob06HNWfaUXlqf@yelyqstoffujrlddboai.supabase.co:5432/postgres
|
||||
```
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
1. Flask application scaffold
|
||||
2. Supabase connection setup
|
||||
3. User authentication system
|
||||
- web app will be hosted internally on a closed network so authentication is only needed to identify users for calibration and review purposes
|
||||
- no passwords are required at this time, though that may change in the future
|
||||
- login page can be as simple as a drop-down to select from a list of existing users
|
||||
4. Basic probe management
|
||||
|
||||
### Phase 2: Core Functionality
|
||||
|
||||
1. Work order management system
|
||||
- Implement calibration type selection
|
||||
- Customer association
|
||||
2. Batch calibration interface
|
||||
- Form fields for new deviation/set point values
|
||||
- Real-time validation for serial numbers
|
||||
3. Review workflow (Completed)
|
||||
- Electronic signature capture (Implemented)
|
||||
- Audit trail implementation (Created calibration_audit table)
|
||||
|
||||
### Phase 3: Reporting & Output
|
||||
|
||||
1. Location history tracking
|
||||
- Timeline visualization for probe locations
|
||||
2. Probe history reports
|
||||
- Tabular display of calibration data
|
||||
- Deviation trend graphs
|
||||
3. PDF certificate generation
|
||||
- Template design with new calibration fields
|
||||
- Automatic inclusion of standard equipment details
|
||||
|
||||
### Phase 4: Analytics (Future)
|
||||
|
||||
1. Probe lifespan prediction
|
||||
2. Calibration trend analysis
|
||||
3. Maintenance scheduling
|
||||
|
||||
## UI/UX Guidelines
|
||||
|
||||
- **Form validation**:
|
||||
- Channel serial numbers: Enforce `[0-9A-F]{16}` pattern
|
||||
- Date fields: Prevent future dates for calibration
|
||||
- Numeric fields: Range validation for deviation/set points
|
||||
- Never allow duplicate channel serial numbers
|
||||
- Never allow duplicate probe serial numbers
|
||||
|
||||
- **Status indicators**:
|
||||
- Color-coded work order status (red/yellow/green)
|
||||
- Pass/fail badges for calibrations
|
||||
|
||||
- **Data visualization**:
|
||||
- Trend graphs for deviation values over time
|
||||
- Location assignment timelines
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
- include clear and industry-standard comments throughout code, compliant with all relevant python PIP regulations
|
||||
- include unit tests throughout
|
||||
- always keep project_plan.md and todo.md in mind when considering what to do next and to maintain an understanding of where in the development process we are
|
||||
@@ -0,0 +1,3 @@
|
||||
# SmartScan Probe Track - Implementation Plan (Backup)
|
||||
|
||||
[Previous content copied exactly from project_plan.md]
|
||||
@@ -0,0 +1,17 @@
|
||||
-- SQL to create auth_audit table in Supabase
|
||||
CREATE OR REPLACE FUNCTION public.create_auth_audit_table()
|
||||
RETURNS void
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
CREATE TABLE IF NOT EXISTS auth_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
details TEXT,
|
||||
ip_address TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_audit_user_id ON auth_audit(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_audit_timestamp ON auth_audit(timestamp);
|
||||
$$;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- SQL to create calibration_audit table in Supabase
|
||||
CREATE TABLE IF NOT EXISTS calibration_audit (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
calibration_id UUID REFERENCES calibrations(id),
|
||||
user_id UUID REFERENCES users(id),
|
||||
action TEXT NOT NULL,
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ip_address TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_audit_calibration_id ON calibration_audit(calibration_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_audit_user_id ON calibration_audit(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calibration_audit_timestamp ON calibration_audit(timestamp);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Create a function in Supabase that can execute arbitrary SQL
|
||||
CREATE OR REPLACE FUNCTION public.execute_sql(sql_text text)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
EXECUTE sql_text;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- SQL to create core tables for SmartScan Probe Track
|
||||
CREATE OR REPLACE FUNCTION public.create_core_tables()
|
||||
RETURNS void
|
||||
LANGUAGE sql
|
||||
AS $$
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
can_calibrate BOOLEAN NOT NULL DEFAULT false,
|
||||
can_review BOOLEAN NOT NULL DEFAULT false,
|
||||
signature_image TEXT
|
||||
);
|
||||
|
||||
-- Probe Models table
|
||||
CREATE TABLE IF NOT EXISTS probe_models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
model_name TEXT UNIQUE NOT NULL,
|
||||
specifications JSONB
|
||||
);
|
||||
|
||||
-- Probes table
|
||||
CREATE TABLE IF NOT EXISTS probes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
model_id UUID REFERENCES probe_models(id),
|
||||
serial_number TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
retired_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_probes_model_id ON probes(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_probes_serial_number ON probes(serial_number);
|
||||
$$;
|
||||
|
||||
-- Execute the function to create tables
|
||||
SELECT public.create_core_tables();
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - SmartScan Probe Track</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">SmartScan Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Select User</label>
|
||||
<select class="form-select" id="email" name="email" required {% if not users %}disabled{% endif %}>
|
||||
<option value="" selected disabled>Choose a user...</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.email }}">{{ user.name }} ({{ user.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if not users %}
|
||||
<div class="alert alert-warning mt-2">
|
||||
Please contact your administrator to create user accounts
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" {% if not users %}disabled{% endif %}>Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}SmartScan Probe Track{% endblock %}</title>
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">SmartScan Probe Track</a>
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/probes/">Probes</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS Bundle with Popper -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Calibration Dashboard</h2>
|
||||
<a href="{{ url_for('calibrations.new_calibration') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> New Calibration Batch
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Total Calibrations</h5>
|
||||
<p class="display-4">{{ summary.total_calibrations }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Passed</h5>
|
||||
<p class="display-4 text-success">{{ summary.passed_calibrations }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Failed</h5>
|
||||
<p class="display-4 text-danger">{{ summary.failed_calibrations }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Recent Calibrations</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Channel</th>
|
||||
<th>Standard</th>
|
||||
<th>Calibrated By</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cal in recent_calibrations %}
|
||||
<tr>
|
||||
<td>{{ cal.date }}</td>
|
||||
<td>{{ cal.channels.serial_number }}</td>
|
||||
<td>{{ cal.standards.make }} {{ cal.standards.model }}</td>
|
||||
<td>{{ cal.calibrated_by.name }}</td>
|
||||
<td>
|
||||
{% if cal.passed %}
|
||||
<span class="badge bg-success">Passed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('calibrations.view_calibration', calibration_id=cal.id) }}"
|
||||
class="btn btn-sm btn-primary">
|
||||
Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Calibration Status</h5>
|
||||
<canvas id="statusChart" height="100"></canvas>
|
||||
<!-- Hidden elements for chart data -->
|
||||
<div id="chartData"
|
||||
data-passed="{{ summary.passed_calibrations }}"
|
||||
data-failed="{{ summary.failed_calibrations }}"
|
||||
style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Chart initialization using data attributes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const dataEl = document.getElementById('chartData');
|
||||
const passed = parseInt(dataEl.dataset.passed);
|
||||
const failed = parseInt(dataEl.dataset.failed);
|
||||
const ctx = document.getElementById('statusChart');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Passed', 'Failed'],
|
||||
datasets: [{
|
||||
data: [passed, failed],
|
||||
backgroundColor: ['#28a745', '#dc3545']
|
||||
}]
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,187 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>New Calibration Batch</h2>
|
||||
|
||||
<form method="POST" action="{{ url_for('calibrations.create_calibrations') }}">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Batch Information</h5>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="work_order_id" class="form-label">Work Order</label>
|
||||
<select class="form-select select2" id="work_order_id" name="work_order_id" required>
|
||||
<option value="">Select Work Order</option>
|
||||
{% for wo in work_orders %}
|
||||
<option value="{{ wo.id }}">{{ wo.order_number }} - {{ wo.customers.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="std_used" class="form-label">Standard Used</label>
|
||||
<select class="form-select select2" id="std_used" name="std_used" required>
|
||||
<option value="">Select Standard</option>
|
||||
{% for std in standards %}
|
||||
<option value="{{ std.id }}">{{ std.make }} {{ std.model }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="date" class="form-label">Calibration Date</label>
|
||||
<input type="date" class="form-control" id="date" name="date" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="calibrated_by" class="form-label">Calibrated By</label>
|
||||
<input type="text" class="form-control" id="calibrated_by" name="calibrated_by" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="card-title mb-0">Channels</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addChannel">
|
||||
<i class="bi bi-plus"></i> Add Channel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="channelRows">
|
||||
<!-- Channel rows will be added here -->
|
||||
<div class="channel-row mb-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Channel</label>
|
||||
<select class="form-select select2" name="channel_serial[]" required>
|
||||
<option value="">Select Channel</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.serial_number }}">{{ channel.serial_number }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Scale</label>
|
||||
<input type="number" step="0.001" class="form-control" name="scale[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Offset</label>
|
||||
<input type="number" step="0.001" class="form-control" name="offset[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Deviation High</label>
|
||||
<input type="number" step="0.001" class="form-control" name="deviation_high[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Deviation Mid</label>
|
||||
<input type="number" step="0.001" class="form-control" name="deviation_mid[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Deviation Low</label>
|
||||
<input type="number" step="0.001" class="form-control" name="deviation_low[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Set High</label>
|
||||
<input type="number" step="0.001" class="form-control" name="set_high[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Set Mid</label>
|
||||
<input type="number" step="0.001" class="form-control" name="set_mid[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Set Low</label>
|
||||
<input type="number" step="0.001" class="form-control" name="set_low[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input class="form-check-input" type="checkbox" name="passed[]" checked>
|
||||
<label class="form-check-label">Passed</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary">Save Calibration Batch</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize Select2
|
||||
$('.select2').select2();
|
||||
|
||||
// Add channel row
|
||||
$('#addChannel').click(function() {
|
||||
const newRow = `
|
||||
<div class="channel-row mb-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Channel</label>
|
||||
<select class="form-select select2" name="channel_serial[]" required>
|
||||
<option value="">Select Channel</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.serial_number }}">{{ channel.serial_number }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Scale</label>
|
||||
<input type="number" step="0.001" class="form-control" name="scale[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Offset</label>
|
||||
<input type="number" step="0.001" class="form-control" name="offset[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Deviation High</label>
|
||||
<input type="number" step="0.001" class="form-control" name="deviation_high[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Deviation Mid</label>
|
||||
<input type="number" step="0.001" class="form-control" name="deviation_mid[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Deviation Low</label>
|
||||
<input type="number" step="0.001" class="form-control" name="deviation_low[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Set High</label>
|
||||
<input type="number" step="0.001" class="form-control" name="set_high[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Set Mid</label>
|
||||
<input type="number" step="0.001" class="form-control" name="set_mid[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Set Low</label>
|
||||
<input type="number" step="0.001" class="form-control" name="set_low[]" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<input class="form-check-input" type="checkbox" name="passed[]" checked>
|
||||
<label class="form-check-label">Passed</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
$('#channelRows').append(newRow);
|
||||
$('.select2').select2();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,150 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Calibration History for {{ probe.serial_number }}</h2>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Probe Details</h5>
|
||||
<p class="card-text">
|
||||
<strong>Description:</strong> {{ probe.description }}<br>
|
||||
<strong>Model ID:</strong> {{ probe.model_id }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Deviation Trends</h5>
|
||||
<canvas id="trendChart" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Channel</th>
|
||||
<th>Standard</th>
|
||||
<th>Calibrated By</th>
|
||||
<th>Deviation (High/Mid/Low)</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cal in calibrations %}
|
||||
<tr>
|
||||
<td>{{ cal.date }}</td>
|
||||
<td>{{ cal.channels.serial_number }}</td>
|
||||
<td>{{ cal.standards.make }} {{ cal.standards.model }}</td>
|
||||
<td>{{ cal.calibrated_by.name }}</td>
|
||||
<td>
|
||||
{{ "%.3f"|format(cal.deviation_high) }} /
|
||||
{{ "%.3f"|format(cal.deviation_mid) }} /
|
||||
{{ "%.3f"|format(cal.deviation_low) }}
|
||||
</td>
|
||||
<td>
|
||||
{% if cal.passed %}
|
||||
<span class="badge bg-success">Passed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('calibrations.view_calibration', calibration_id=cal.id) }}"
|
||||
class="btn btn-sm btn-primary">
|
||||
Details
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetch(`/calibrations/probe/{{ probe.id }}/trends`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const ctx = document.getElementById('trendChart').getContext('2d');
|
||||
const datasets = [];
|
||||
|
||||
// Create a dataset for each channel's deviation metrics
|
||||
for (const [channelId, channelData] of Object.entries(data.trends)) {
|
||||
const color = getRandomColor();
|
||||
|
||||
datasets.push({
|
||||
label: `${channelData.serial} - High`,
|
||||
data: channelData.high,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: 1,
|
||||
tension: 0.1
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: `${channelData.serial} - Mid`,
|
||||
data: channelData.mid,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: 1,
|
||||
tension: 0.1,
|
||||
borderDash: [5, 5]
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: `${channelData.serial} - Low`,
|
||||
data: channelData.low,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: 1,
|
||||
tension: 0.1,
|
||||
borderDash: [2, 2]
|
||||
});
|
||||
}
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: Object.values(data.trends)[0].dates,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Deviation Value'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Calibration Date'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getRandomColor() {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Review Calibration</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Calibration Details</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Channel Serial:</strong> {{ calibration.channels.serial_number }}</p>
|
||||
<p><strong>Date:</strong> {{ calibration.date }}</p>
|
||||
<p><strong>Calibrated By:</strong> {{ calibration.calibrated_by.name }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Scale:</strong> {{ calibration.scale }}</p>
|
||||
<p><strong>Offset:</strong> {{ calibration.offset }}</p>
|
||||
<p><strong>Status:</strong>
|
||||
<span class="badge bg-{{ 'success' if calibration.passed else 'danger' }}">
|
||||
{{ 'Passed' if calibration.passed else 'Failed' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Measurements</h5>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Point</th>
|
||||
<th>Set Value</th>
|
||||
<th>Deviation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>High</td>
|
||||
<td>{{ calibration.set_high }}</td>
|
||||
<td>{{ calibration.deviation_high }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mid</td>
|
||||
<td>{{ calibration.set_mid }}</td>
|
||||
<td>{{ calibration.deviation_mid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Low</td>
|
||||
<td>{{ calibration.set_low }}</td>
|
||||
<td>{{ calibration.deviation_low }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('calibrations.review_calibration', calibration_id=calibration.id) }}">
|
||||
<input type="hidden" name="reviewer_id" value="{{ current_user.id }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Electronic Signature</label>
|
||||
<div id="signaturePad" style="border: 1px solid #ddd; height: 200px;"></div>
|
||||
<input type="hidden" name="signature" id="signatureData">
|
||||
<button type="button" class="btn btn-secondary mt-2" id="clearSignature">Clear</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Submit Review</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.0.0/dist/signature_pad.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = document.getElementById('signaturePad').offsetWidth;
|
||||
canvas.height = 200;
|
||||
document.getElementById('signaturePad').appendChild(canvas);
|
||||
|
||||
const signaturePad = new SignaturePad(canvas);
|
||||
|
||||
document.getElementById('clearSignature').addEventListener('click', function() {
|
||||
signaturePad.clear();
|
||||
});
|
||||
|
||||
document.querySelector('form').addEventListener('submit', function(e) {
|
||||
if (signaturePad.isEmpty()) {
|
||||
e.preventDefault();
|
||||
alert('Please provide your signature');
|
||||
} else {
|
||||
document.getElementById('signatureData').value = signaturePad.toDataURL();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Calibration Details</h2>
|
||||
<a href="{{ url_for('channels.view_channel', channel_id=calibration.channel_id) }}"
|
||||
class="btn btn-outline-secondary">Back to Channel</a>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Basic Information</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Channel Serial:</strong> {{ calibration.channels.serial_number }}</p>
|
||||
<p><strong>Work Order:</strong> {{ calibration.work_orders.order_number }}</p>
|
||||
<p><strong>Date:</strong> {{ calibration.date }}</p>
|
||||
<p><strong>Calibrated By:</strong> {{ calibration.calibrated_by.name }}</p>
|
||||
{% if calibration.reviewed_by %}
|
||||
<p><strong>Reviewed By:</strong> {{ calibration.reviewed_by.name }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Standard Used:</strong> {{ calibration.standards.make }} {{ calibration.standards.model }}</p>
|
||||
<p><strong>Standard Description:</strong> {{ calibration.standards.description }}</p>
|
||||
<p><strong>Status:</strong>
|
||||
<span class="badge bg-{{ 'success' if calibration.passed else 'danger' }}">
|
||||
{{ 'Passed' if calibration.passed else 'Failed' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Calibration Values</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<p><strong>Scale:</strong> {{ calibration.scale }}</p>
|
||||
<p><strong>Offset:</strong> {{ calibration.offset }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Point</th>
|
||||
<th>Set Value</th>
|
||||
<th>Deviation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>High</td>
|
||||
<td>{{ calibration.set_high }}</td>
|
||||
<td>{{ calibration.deviation_high }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mid</td>
|
||||
<td>{{ calibration.set_mid }}</td>
|
||||
<td>{{ calibration.deviation_mid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Low</td>
|
||||
<td>{{ calibration.set_low }}</td>
|
||||
<td>{{ calibration.deviation_low }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Channel: {{ channel.serial_number }}</h2>
|
||||
<a href="{{ url_for('probes.view_probe', probe_id=channel.probe_id) }}"
|
||||
class="btn btn-outline-secondary">Back to Probe</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h5>Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Serial Number</dt>
|
||||
<dd class="col-sm-8">{{ channel.serial_number }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Parameter</dt>
|
||||
<dd class="col-sm-8">{{ channel.parameter.parameter_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Created</dt>
|
||||
<dd class="col-sm-8">{{ channel.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if calibrations %}
|
||||
<div class="mt-4">
|
||||
<h5>Calibration History</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Work Order</th>
|
||||
<th>Status</th>
|
||||
<th>Standard Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cal in calibrations %}
|
||||
<tr>
|
||||
<td>{{ cal.date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ cal.work_order.order_number }}</td>
|
||||
<td>
|
||||
{% if cal.passed %}
|
||||
<span class="badge bg-success">Passed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ cal.standard.make }} {{ cal.standard.model }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('calibrations.view_calibration', calibration_id=cal.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-4">No calibration history found for this channel</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SmartScan Probe Track</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#">SmartScan Probe Track</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<span class="navbar-text me-3">
|
||||
Logged in as: {{ user.user_name }}
|
||||
</span>
|
||||
<a href="/auth/logout" class="btn btn-outline-light">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Navigation
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<a href="/probes" class="list-group-item list-group-item-action">Probes</a>
|
||||
<a href="/calibrations" class="list-group-item list-group-item-action">Calibrations</a>
|
||||
<a href="/work_orders" class="list-group-item list-group-item-action">Work Orders</a>
|
||||
<a href="/locations" class="list-group-item list-group-item-action">Locations</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Dashboard
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Welcome to SmartScan Probe Track</h5>
|
||||
<p class="card-text">You are logged in as {{ user.user_name }}.</p>
|
||||
{% if user.can_calibrate %}
|
||||
<span class="badge bg-success">Calibrator</span>
|
||||
{% endif %}
|
||||
{% if user.can_review %}
|
||||
<span class="badge bg-info">Reviewer</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Location History for Probe {{ probe_id }}</h2>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5>Assignment Timeline</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="timelineChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5>Location Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<th>Start Date</th>
|
||||
<th>End Date</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in location_data %}
|
||||
<tr>
|
||||
<td>{{ item.location.location_id }}</td>
|
||||
<td>{{ item.location.start_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ item.location.end_date.strftime('%Y-%m-%d') if item.location.end_date else 'Current' }}</td>
|
||||
<td>{{ item.duration_days }} days</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('timelineChart').getContext('2d');
|
||||
const chartData = JSON.parse('{{ chart_data_json | safe }}');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: chartData.labels,
|
||||
datasets: [{
|
||||
label: 'Days at Location',
|
||||
data: chartData.durations,
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.5)',
|
||||
borderColor: 'rgba(54, 162, 235, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Days'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.raw.toFixed(1)} days`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Add New Probe</h2>
|
||||
<form method="POST" action="{{ url_for('probes.create_probe') }}">
|
||||
<div class="mb-3">
|
||||
<label for="model_id" class="form-label">Probe Model</label>
|
||||
<select class="form-select" id="model_id" name="model_id" required>
|
||||
{% for model in probe_models %}
|
||||
<option value="{{ model.id }}">{{ model.model_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="serial_number" class="form-label">Serial Number</label>
|
||||
<input type="text" class="form-control" id="serial_number" name="serial_number" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h4>Channels</h4>
|
||||
<div id="channels-container">
|
||||
<div class="channel-entry mb-3">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="parameter_id_0" class="form-label">Parameter</label>
|
||||
<select class="form-select" id="parameter_id_0" name="parameter_ids[]" required>
|
||||
{% for param in parameters %}
|
||||
<option value="{{ param.id }}">{{ param.parameter_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="channel_serial_0" class="form-label">Channel Serial</label>
|
||||
<input type="text" class="form-control" id="channel_serial_0" name="channel_serials[]" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" id="add-channel">Add Another Channel</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create Probe</button>
|
||||
|
||||
<script>
|
||||
document.getElementById('add-channel').addEventListener('click', function() {
|
||||
const container = document.getElementById('channels-container');
|
||||
const count = container.children.length;
|
||||
const newChannel = document.createElement('div');
|
||||
newChannel.className = 'channel-entry mb-3';
|
||||
newChannel.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<label for="parameter_id_${count}" class="form-label">Parameter</label>
|
||||
<select class="form-select" id="parameter_id_${count}" name="parameter_ids[]" required>
|
||||
{% for param in parameters %}
|
||||
<option value="{{ param.id }}">{{ param.parameter_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label for="channel_serial_${count}" class="form-label">Channel Serial</label>
|
||||
<input type="text" class="form-control" id="channel_serial_${count}" name="channel_serials[]" required>
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-end">
|
||||
<button type="button" class="btn btn-danger remove-channel">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(newChannel);
|
||||
|
||||
// Add event listener to new remove button
|
||||
newChannel.querySelector('.remove-channel').addEventListener('click', function() {
|
||||
container.removeChild(newChannel);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Probe Management</h2>
|
||||
<a href="{{ url_for('probes.new_probe') }}" class="btn btn-primary">Add New Probe</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">All Probes</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if probes %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial Number</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for probe in probes %}
|
||||
<tr>
|
||||
<td>{{ probe.serial_number }}</td>
|
||||
<td>{{ probe.description }}</td>
|
||||
<td>{{ probe.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if probe.retired_at %}
|
||||
<span class="badge bg-secondary">Retired</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('probes.view_probe', probe_id=probe.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">No probes found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,109 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Probe: {{ probe.serial_number }}</h2>
|
||||
<span class="badge
|
||||
{% if probe.retired_at %}bg-secondary
|
||||
{% else %}bg-success{% endif %}">
|
||||
{% if probe.retired_at %}Retired{% else %}Active{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h5>Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Serial Number</dt>
|
||||
<dd class="col-sm-8">{{ probe.serial_number }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Created</dt>
|
||||
<dd class="col-sm-8">{{ probe.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
|
||||
{% if probe.retired_at %}
|
||||
<dt class="col-sm-4">Retired</dt>
|
||||
<dd class="col-sm-8">{{ probe.retired_at.strftime('%Y-%m-%d') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if channels %}
|
||||
<div class="mt-4">
|
||||
<h5>Channels</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial Number</th>
|
||||
<th>Parameter</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for channel in channels %}
|
||||
<tr>
|
||||
<td>{{ channel.serial_number }}</td>
|
||||
<td>{{ channel.parameter.parameter_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('channels.view_channel', channel_id=channel.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-4">No channels found for this probe</div>
|
||||
{% endif %}
|
||||
|
||||
{% if locations %}
|
||||
<div class="mt-4">
|
||||
<h5>Location History</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<th>Start Date</th>
|
||||
<th>End Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for location in locations %}
|
||||
<tr>
|
||||
<td>{{ location.location.name }}</td>
|
||||
<td>{{ location.start_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if location.end_date %}
|
||||
{{ location.end_date.strftime('%Y-%m-%d') }}
|
||||
{% else %}
|
||||
Current
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info mt-4">No location history found for this probe</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('probes.list_probes') }}"
|
||||
class="btn btn-outline-secondary">Back to List</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<h2>Create New Work Order</h2>
|
||||
<form method="POST" action="{{ url_for('work_orders.create_work_order') }}">
|
||||
<div class="mb-3">
|
||||
<label for="order_number" class="form-label">Order Number</label>
|
||||
<input type="text" class="form-control" id="order_number" name="order_number" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="customer_id" class="form-label">Customer</label>
|
||||
<select class="form-select" id="customer_id" name="customer_id" required>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="assigned_to" class="form-label">Assigned To</label>
|
||||
<select class="form-select" id="assigned_to" name="assigned_to" required>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}">{{ user.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="due_date" class="form-label">Due Date</label>
|
||||
<input type="date" class="form-control" id="due_date" name="due_date" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select class="form-select" id="status" name="status" required>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="redmine" class="form-label">Redmine ID</label>
|
||||
<input type="number" class="form-control" id="redmine" name="redmine">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cal_type" class="form-label">Calibration Type</label>
|
||||
<select class="form-select" id="cal_type" name="cal_type" required>
|
||||
{% for cal_type in calibration_types %}
|
||||
<option value="{{ cal_type.id }}">{{ cal_type.type_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create Work Order</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Work Order Management</h2>
|
||||
<a href="{{ url_for('work_orders.new_work_order') }}" class="btn btn-primary">Create New Work Order</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">All Work Orders</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if work_orders %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order Number</th>
|
||||
<th>Status</th>
|
||||
<th>Due Date</th>
|
||||
<th>Redmine ID</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for wo in work_orders %}
|
||||
<tr>
|
||||
<td>{{ wo.order_number }}</td>
|
||||
<td>
|
||||
{% if wo.status == 'completed' %}
|
||||
<span class="badge bg-success">Completed</span>
|
||||
{% elif wo.status == 'in_progress' %}
|
||||
<span class="badge bg-warning">In Progress</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ wo.status|title }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if wo.due_date and wo.due_date is not string %}
|
||||
{{ wo.due_date.strftime('%Y-%m-%d') }}
|
||||
{% else %}
|
||||
{{ wo.due_date or '' }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ wo.redmine or '-' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('work_orders.view_work_order', work_order_id=wo.id) }}"
|
||||
class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">No work orders found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h2 class="mb-0">Work Order: {{ work_order.order_number }}</h2>
|
||||
<span class="badge
|
||||
{% if work_order.status == 'completed' %}bg-success
|
||||
{% elif work_order.status == 'in_progress' %}bg-warning
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
{{ work_order.status|title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h5>Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Due Date</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if work_order.due_date and work_order.due_date is not string %}
|
||||
{{ work_order.due_date.strftime('%Y-%m-%d') }}
|
||||
{% else %}
|
||||
{{ work_order.due_date or '' }}
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Redmine ID</dt>
|
||||
<dd class="col-sm-8">{{ work_order.redmine or 'Not specified' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if work_order.calibrations %}
|
||||
<div class="mt-4">
|
||||
<h5>Associated Calibrations</h5>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Channel</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cal in work_order.calibrations %}
|
||||
<tr>
|
||||
<td>{{ cal.channel.serial_number }}</td>
|
||||
<td>{{ cal.date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if cal.passed %}
|
||||
<span class="badge bg-success">Passed</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Failed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<form method="POST" action="{{ url_for('work_orders.update_status', work_order_id=work_order.id) }}">
|
||||
<div class="mb-3">
|
||||
<label for="status" class="form-label">Update Status</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="pending" {% if work_order.status == 'pending' %}selected{% endif %}>Pending</option>
|
||||
<option value="in_progress" {% if work_order.status == 'in_progress' %}selected{% endif %}>In Progress</option>
|
||||
<option value="completed" {% if work_order.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
<a href="{{ url_for('work_orders.list_work_orders') }}"
|
||||
class="btn btn-outline-secondary">Back to List</a>
|
||||
{% if work_order.status == 'completed' %}
|
||||
<a href="#" class="btn btn-success ms-2" id="download-certificate">
|
||||
Download Certificate
|
||||
</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
from supabase import create_client
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
load_dotenv()
|
||||
|
||||
url = os.getenv('SUPABASE_URL')
|
||||
key = os.getenv('SUPABASE_KEY')
|
||||
|
||||
print(f"Testing connection to Supabase URL: {url}")
|
||||
|
||||
try:
|
||||
supabase = create_client(url, key)
|
||||
print("Successfully created Supabase client")
|
||||
|
||||
# Test a simple query
|
||||
result = supabase.table('users').select("*").limit(1).execute()
|
||||
print(f"Found {len(result.data)} users in database")
|
||||
|
||||
# Check if auth_audit table exists
|
||||
try:
|
||||
supabase.table('auth_audit').select("*").limit(1).execute()
|
||||
print("auth_audit table exists")
|
||||
except Exception:
|
||||
print("auth_audit table not found, creating it...")
|
||||
with open('sql/create_auth_audit_table.sql', 'r') as f:
|
||||
sql = f.read()
|
||||
# Extract just the CREATE TABLE statement (skip the function wrapper)
|
||||
create_table_sql = sql.split('$$')[1].strip()
|
||||
supabase.execute(create_table_sql)
|
||||
print("auth_audit table created")
|
||||
except Exception as e:
|
||||
print(f"Error connecting to Supabase: {e}")
|
||||
@@ -0,0 +1,68 @@
|
||||
# SmartScan Probe Track - Implementation Tasks
|
||||
|
||||
## Phase 1: Foundation (Current Priority)
|
||||
|
||||
1. **Flask Application Setup**
|
||||
- [x] Create main.py with basic Flask app
|
||||
- [x] Create app/__init__.py for package initialization
|
||||
- [x] Set up basic configuration in .env (already started)
|
||||
|
||||
2. **Supabase Connection**
|
||||
- [x] Create app/supabase_client.py with connection setup
|
||||
- [x] Implement basic CRUD operations for testing
|
||||
|
||||
3. **User Authentication**
|
||||
- [x] Create User model with role fields and signature (in models.py)
|
||||
- [x] Create auth routes in app/routes/auth.py
|
||||
- [x] Implement login/logout functionality
|
||||
- [x] Set up session management (using Flask sessions)
|
||||
- [x] Implement role-based access control
|
||||
- [ ] Add electronic signature support (future enhancement)
|
||||
- [x] Implement authentication audit trails
|
||||
|
||||
4. **Basic Probe Management**
|
||||
- [x] Create app/models.py with Probe and Channel models
|
||||
- [x] Implement probe listing route in app/routes/probes.py
|
||||
- [x] Create basic probe_list.html template
|
||||
|
||||
## Phase 2: Core Functionality
|
||||
|
||||
1. **Work Order System**
|
||||
- [x] Create WorkOrder model in models.py
|
||||
- [x] Implement work order routes
|
||||
- [x] Create work order form template
|
||||
|
||||
2. **Calibration Interface**
|
||||
- [x] Create Calibration model with new fields
|
||||
- [x] Implement batch calibration form
|
||||
- [x] Add serial number validation
|
||||
|
||||
3. **Review Workflow**
|
||||
- [x] Implement electronic signature system
|
||||
- [x] Create audit trail functionality
|
||||
|
||||
## Phase 3: Reporting & Output
|
||||
|
||||
1. **Location Tracking**
|
||||
- [x] Create Location and ProbeLocation models
|
||||
- [x] Implement timeline visualization
|
||||
|
||||
2. **Probe History**
|
||||
- [x] Create calibration history report
|
||||
- [x] Implement deviation trend graphs
|
||||
|
||||
3. **PDF Generation**
|
||||
- [ ] Create certificate template
|
||||
- [ ] Implement PDF generation with ReportLab
|
||||
|
||||
## Phase 4: Future Analytics
|
||||
|
||||
1. [ ] Probe lifespan prediction
|
||||
2. [ ] Calibration trend analysis
|
||||
3. [ ] Maintenance scheduling
|
||||
|
||||
## Setup Verification
|
||||
|
||||
- [ ] Confirm virtual environment is active
|
||||
- [x] Verify all dependencies are installed (flask, supabase, etc.)
|
||||
- [x] Test basic Flask app runs (accessible at http://127.0.0.1:5000)
|
||||
Reference in New Issue
Block a user