Initial commit

This commit is contained in:
2025-07-27 21:49:34 -04:00
commit 133a935d90
36 changed files with 3121 additions and 0 deletions
+44
View File
@@ -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
+36
View File
@@ -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
View File
@@ -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()
+63
View File
@@ -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'))
+283
View File
@@ -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])
+21
View File
@@ -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)
+38
View File
@@ -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)
+80
View File
@@ -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)
+83
View File
@@ -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))
+127
View File
@@ -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")
+12
View File
@@ -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()
+13
View File
@@ -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
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# SmartScan Probe Track - Implementation Plan (Backup)
[Previous content copied exactly from project_plan.md]
+17
View File
@@ -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);
$$;
+15
View File
@@ -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);
+9
View File
@@ -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;
$$;
+39
View File
@@ -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();
+45
View File
@@ -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>
+29
View File
@@ -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>
+119
View File
@@ -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 %}
+187
View File
@@ -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 %}
+150
View File
@@ -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 %}
+99
View File
@@ -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 %}
+75
View File
@@ -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 %}
+73
View File
@@ -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 %}
+59
View File
@@ -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>
+90
View File
@@ -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 %}
+81
View File
@@ -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 %}
+55
View File
@@ -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 %}
+109
View File
@@ -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 %}
+61
View File
@@ -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 %}
+63
View File
@@ -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 %}
+90
View File
@@ -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 %}
+33
View File
@@ -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}")
+68
View File
@@ -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)