From 133a935d90b0a81b5268ed3b497fa918e2207621 Mon Sep 17 00:00:00 2001 From: Andy Date: Sun, 27 Jul 2025 21:49:34 -0400 Subject: [PATCH] Initial commit --- .gitignore | 44 +++ app/__init__.py | 36 ++ app/models.py | 440 +++++++++++++++++++++++++ app/routes/auth.py | 63 ++++ app/routes/calibrations.py | 283 ++++++++++++++++ app/routes/channels.py | 21 ++ app/routes/locations.py | 38 +++ app/routes/probes.py | 80 +++++ app/routes/work_orders.py | 83 +++++ app/supabase_client.py | 127 +++++++ create_audit_tables.py | 12 + main.py | 13 + project_plan.md | 312 ++++++++++++++++++ project_plan_backup.md | 3 + sql/create_auth_audit_table.sql | 17 + sql/create_calibration_audit_table.sql | 15 + sql/create_execute_sql_function.sql | 9 + sql/create_tables.sql | 39 +++ templates/auth/login.html | 45 +++ templates/base.html | 29 ++ templates/calibration_dashboard.html | 119 +++++++ templates/calibration_form.html | 187 +++++++++++ templates/calibration_history.html | 150 +++++++++ templates/calibration_review.html | 99 ++++++ templates/calibration_view.html | 75 +++++ templates/channel_view.html | 73 ++++ templates/index.html | 59 ++++ templates/location_timeline.html | 90 +++++ templates/probe_form.html | 81 +++++ templates/probe_list.html | 55 ++++ templates/probe_view.html | 109 ++++++ templates/work_order_form.html | 61 ++++ templates/work_order_list.html | 63 ++++ templates/work_order_view.html | 90 +++++ test_supabase.py | 33 ++ todo.md | 68 ++++ 36 files changed, 3121 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/calibrations.py create mode 100644 app/routes/channels.py create mode 100644 app/routes/locations.py create mode 100644 app/routes/probes.py create mode 100644 app/routes/work_orders.py create mode 100644 app/supabase_client.py create mode 100644 create_audit_tables.py create mode 100644 main.py create mode 100644 project_plan.md create mode 100644 project_plan_backup.md create mode 100644 sql/create_auth_audit_table.sql create mode 100644 sql/create_calibration_audit_table.sql create mode 100644 sql/create_execute_sql_function.sql create mode 100644 sql/create_tables.sql create mode 100644 templates/auth/login.html create mode 100644 templates/base.html create mode 100644 templates/calibration_dashboard.html create mode 100644 templates/calibration_form.html create mode 100644 templates/calibration_history.html create mode 100644 templates/calibration_review.html create mode 100644 templates/calibration_view.html create mode 100644 templates/channel_view.html create mode 100644 templates/index.html create mode 100644 templates/location_timeline.html create mode 100644 templates/probe_form.html create mode 100644 templates/probe_list.html create mode 100644 templates/probe_view.html create mode 100644 templates/work_order_form.html create mode 100644 templates/work_order_list.html create mode 100644 templates/work_order_view.html create mode 100644 test_supabase.py create mode 100644 todo.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbfe12e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..17faa32 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..9570b25 --- /dev/null +++ b/app/models.py @@ -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() diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..bf44a6b --- /dev/null +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/calibrations.py b/app/routes/calibrations.py new file mode 100644 index 0000000..1412ba2 --- /dev/null +++ b/app/routes/calibrations.py @@ -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/', 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/') +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//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('/') +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]) diff --git a/app/routes/channels.py b/app/routes/channels.py new file mode 100644 index 0000000..cd2f6c7 --- /dev/null +++ b/app/routes/channels.py @@ -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('/') +@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) diff --git a/app/routes/locations.py b/app/routes/locations.py new file mode 100644 index 0000000..758a775 --- /dev/null +++ b/app/routes/locations.py @@ -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//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) diff --git a/app/routes/probes.py b/app/routes/probes.py new file mode 100644 index 0000000..ab3eaeb --- /dev/null +++ b/app/routes/probes.py @@ -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('/') +@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) diff --git a/app/routes/work_orders.py b/app/routes/work_orders.py new file mode 100644 index 0000000..1ed0872 --- /dev/null +++ b/app/routes/work_orders.py @@ -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('/') +@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('//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)) diff --git a/app/supabase_client.py b/app/supabase_client.py new file mode 100644 index 0000000..c24861e --- /dev/null +++ b/app/supabase_client.py @@ -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") diff --git a/create_audit_tables.py b/create_audit_tables.py new file mode 100644 index 0000000..b434d8b --- /dev/null +++ b/create_audit_tables.py @@ -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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..1bcf39b --- /dev/null +++ b/main.py @@ -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) diff --git a/project_plan.md b/project_plan.md new file mode 100644 index 0000000..b690f72 --- /dev/null +++ b/project_plan.md @@ -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 diff --git a/project_plan_backup.md b/project_plan_backup.md new file mode 100644 index 0000000..bc3381a --- /dev/null +++ b/project_plan_backup.md @@ -0,0 +1,3 @@ +# SmartScan Probe Track - Implementation Plan (Backup) + +[Previous content copied exactly from project_plan.md] diff --git a/sql/create_auth_audit_table.sql b/sql/create_auth_audit_table.sql new file mode 100644 index 0000000..0abddec --- /dev/null +++ b/sql/create_auth_audit_table.sql @@ -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); +$$; diff --git a/sql/create_calibration_audit_table.sql b/sql/create_calibration_audit_table.sql new file mode 100644 index 0000000..bf80d97 --- /dev/null +++ b/sql/create_calibration_audit_table.sql @@ -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); diff --git a/sql/create_execute_sql_function.sql b/sql/create_execute_sql_function.sql new file mode 100644 index 0000000..a607fc5 --- /dev/null +++ b/sql/create_execute_sql_function.sql @@ -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; +$$; diff --git a/sql/create_tables.sql b/sql/create_tables.sql new file mode 100644 index 0000000..359cf0f --- /dev/null +++ b/sql/create_tables.sql @@ -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(); diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..a262ccf --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,45 @@ + + + + + + Login - SmartScan Probe Track + + + +
+
+
+
+
+

SmartScan Login

+
+
+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + + {% if not users %} +
+ Please contact your administrator to create user accounts +
+ {% endif %} +
+ +
+
+
+
+
+
+ + + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..582f4ce --- /dev/null +++ b/templates/base.html @@ -0,0 +1,29 @@ + + + + + + {% block title %}SmartScan Probe Track{% endblock %} + + + {% block head %}{% endblock %} + + + + +
+ {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + + diff --git a/templates/calibration_dashboard.html b/templates/calibration_dashboard.html new file mode 100644 index 0000000..58719cf --- /dev/null +++ b/templates/calibration_dashboard.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

Calibration Dashboard

+ + New Calibration Batch + +
+ +
+
+
+
+
Total Calibrations
+

{{ summary.total_calibrations }}

+
+
+
+
+
+
+
Passed
+

{{ summary.passed_calibrations }}

+
+
+
+
+
+
+
Failed
+

{{ summary.failed_calibrations }}

+
+
+
+
+ +
+
+
Recent Calibrations
+
+ + + + + + + + + + + + + {% for cal in recent_calibrations %} + + + + + + + + + {% endfor %} + +
DateChannelStandardCalibrated ByStatusActions
{{ cal.date }}{{ cal.channels.serial_number }}{{ cal.standards.make }} {{ cal.standards.model }}{{ cal.calibrated_by.name }} + {% if cal.passed %} + Passed + {% else %} + Failed + {% endif %} + + + Details + +
+
+
+
+ +
+
+
Calibration Status
+ + + +
+
+
+ + +{% endblock %} diff --git a/templates/calibration_form.html b/templates/calibration_form.html new file mode 100644 index 0000000..070b894 --- /dev/null +++ b/templates/calibration_form.html @@ -0,0 +1,187 @@ +{% extends "base.html" %} + +{% block head %} + + +{% endblock %} + +{% block content %} +
+

New Calibration Batch

+ +
+
+
+
Batch Information
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+
+
Channels
+ +
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
+
+
+ +
+ +
+
+
+ + +{% endblock %} diff --git a/templates/calibration_history.html b/templates/calibration_history.html new file mode 100644 index 0000000..591eb55 --- /dev/null +++ b/templates/calibration_history.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+

Calibration History for {{ probe.serial_number }}

+
+
+
Probe Details
+

+ Description: {{ probe.description }}
+ Model ID: {{ probe.model_id } +

+
+
+ +
+
+
Deviation Trends
+ +
+
+ +
+ + + + + + + + + + + + + + {% for cal in calibrations %} + + + + + + + + + + {% endfor %} + +
DateChannelStandardCalibrated ByDeviation (High/Mid/Low)StatusActions
{{ cal.date }}{{ cal.channels.serial_number }}{{ cal.standards.make }} {{ cal.standards.model }}{{ cal.calibrated_by.name }} + {{ "%.3f"|format(cal.deviation_high) }} / + {{ "%.3f"|format(cal.deviation_mid) }} / + {{ "%.3f"|format(cal.deviation_low) }} + + {% if cal.passed %} + Passed + {% else %} + Failed + {% endif %} + + + Details + +
+
+
+ + +{% endblock %} diff --git a/templates/calibration_review.html b/templates/calibration_review.html new file mode 100644 index 0000000..2d9d70f --- /dev/null +++ b/templates/calibration_review.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block content %} +
+

Review Calibration

+ +
+
+
Calibration Details
+
+
+

Channel Serial: {{ calibration.channels.serial_number }}

+

Date: {{ calibration.date }}

+

Calibrated By: {{ calibration.calibrated_by.name }}

+
+
+

Scale: {{ calibration.scale }}

+

Offset: {{ calibration.offset }}

+

Status: + + {{ 'Passed' if calibration.passed else 'Failed' }} + +

+
+
+
+
+ +
+
+
Measurements
+ + + + + + + + + + + + + + + + + + + + + + + + + +
PointSet ValueDeviation
High{{ calibration.set_high }}{{ calibration.deviation_high }}
Mid{{ calibration.set_mid }}{{ calibration.deviation_mid }}
Low{{ calibration.set_low }}{{ calibration.deviation_low }}
+
+
+ +
+ + +
+ +
+ + +
+ + +
+
+ + + +{% endblock %} diff --git a/templates/calibration_view.html b/templates/calibration_view.html new file mode 100644 index 0000000..c8874b9 --- /dev/null +++ b/templates/calibration_view.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Calibration Details

+ Back to Channel +
+ +
+
+
Basic Information
+
+
+

Channel Serial: {{ calibration.channels.serial_number }}

+

Work Order: {{ calibration.work_orders.order_number }}

+

Date: {{ calibration.date }}

+

Calibrated By: {{ calibration.calibrated_by.name }}

+ {% if calibration.reviewed_by %} +

Reviewed By: {{ calibration.reviewed_by.name }}

+ {% endif %} +
+
+

Standard Used: {{ calibration.standards.make }} {{ calibration.standards.model }}

+

Standard Description: {{ calibration.standards.description }}

+

Status: + + {{ 'Passed' if calibration.passed else 'Failed' }} + +

+
+
+
+
+ +
+
+
Calibration Values
+
+
+

Scale: {{ calibration.scale }}

+

Offset: {{ calibration.offset }}

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
PointSet ValueDeviation
High{{ calibration.set_high }}{{ calibration.deviation_high }}
Mid{{ calibration.set_mid }}{{ calibration.deviation_mid }}
Low{{ calibration.set_low }}{{ calibration.deviation_low }}
+
+
+
+{% endblock %} diff --git a/templates/channel_view.html b/templates/channel_view.html new file mode 100644 index 0000000..861cbd5 --- /dev/null +++ b/templates/channel_view.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Channel: {{ channel.serial_number }}

+ Back to Probe +
+
+
+
+
+
Details
+
+
Serial Number
+
{{ channel.serial_number }}
+ +
Parameter
+
{{ channel.parameter.parameter_name }}
+ +
Created
+
{{ channel.created_at.strftime('%Y-%m-%d') }}
+
+
+
+ + {% if calibrations %} +
+
Calibration History
+
+ + + + + + + + + + + + {% for cal in calibrations %} + + + + + + + + {% endfor %} + +
DateWork OrderStatusStandard UsedActions
{{ cal.date.strftime('%Y-%m-%d') }}{{ cal.work_order.order_number }} + {% if cal.passed %} + Passed + {% else %} + Failed + {% endif %} + {{ cal.standard.make }} {{ cal.standard.model }} + View +
+
+
+ {% else %} +
No calibration history found for this channel
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..bbf9603 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,59 @@ + + + + + + SmartScan Probe Track + + + + + +
+
+
+
+
+ Navigation +
+ +
+
+
+
+
+ Dashboard +
+
+
Welcome to SmartScan Probe Track
+

You are logged in as {{ user.user_name }}.

+ {% if user.can_calibrate %} + Calibrator + {% endif %} + {% if user.can_review %} + Reviewer + {% endif %} +
+
+
+
+
+ + + + diff --git a/templates/location_timeline.html b/templates/location_timeline.html new file mode 100644 index 0000000..907d603 --- /dev/null +++ b/templates/location_timeline.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block content %} +
+

Location History for Probe {{ probe_id }}

+ +
+
+
Assignment Timeline
+
+
+ +
+
+ +
+
+
Location Details
+
+
+ + + + + + + + + + + {% for item in location_data %} + + + + + + + {% endfor %} + +
LocationStart DateEnd DateDuration
{{ item.location.location_id }}{{ item.location.start_date.strftime('%Y-%m-%d') }}{{ item.location.end_date.strftime('%Y-%m-%d') if item.location.end_date else 'Current' }}{{ item.duration_days }} days
+
+
+
+ +{% block scripts %} +{{ super() }} + + +{% endblock %} +{% endblock %} diff --git a/templates/probe_form.html b/templates/probe_form.html new file mode 100644 index 0000000..c334421 --- /dev/null +++ b/templates/probe_form.html @@ -0,0 +1,81 @@ +{% extends "base.html" %} + +{% block content %} +
+

Add New Probe

+
+
+ + +
+ +
+ + +
+ +
+

Channels

+
+
+
+
+ + +
+
+ + +
+
+
+
+ +
+ + + + +
+
+{% endblock %} diff --git a/templates/probe_list.html b/templates/probe_list.html new file mode 100644 index 0000000..f491cce --- /dev/null +++ b/templates/probe_list.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Probe Management

+ Add New Probe +
+ +
+
+
All Probes
+
+
+ {% if probes %} +
+ + + + + + + + + + + + {% for probe in probes %} + + + + + + + + {% endfor %} + +
Serial NumberDescriptionCreatedStatusActions
{{ probe.serial_number }}{{ probe.description }}{{ probe.created_at.strftime('%Y-%m-%d') }} + {% if probe.retired_at %} + Retired + {% else %} + Active + {% endif %} + + View +
+
+ {% else %} +
No probes found
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/probe_view.html b/templates/probe_view.html new file mode 100644 index 0000000..4b3ad32 --- /dev/null +++ b/templates/probe_view.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Probe: {{ probe.serial_number }}

+ + {% if probe.retired_at %}Retired{% else %}Active{% endif %} + +
+
+
+
+
+
Details
+
+
Serial Number
+
{{ probe.serial_number }}
+ +
Created
+
{{ probe.created_at.strftime('%Y-%m-%d') }}
+ + {% if probe.retired_at %} +
Retired
+
{{ probe.retired_at.strftime('%Y-%m-%d') }}
+ {% endif %} +
+
+
+ +
+ + {% if channels %} +
+
Channels
+
+ + + + + + + + + + {% for channel in channels %} + + + + + + {% endfor %} + +
Serial NumberParameterActions
{{ channel.serial_number }}{{ channel.parameter.parameter_name }} + View +
+
+
+ {% else %} +
No channels found for this probe
+ {% endif %} + + {% if locations %} +
+
Location History
+
+ + + + + + + + + + {% for location in locations %} + + + + + + {% endfor %} + +
LocationStart DateEnd Date
{{ location.location.name }}{{ location.start_date.strftime('%Y-%m-%d') }} + {% if location.end_date %} + {{ location.end_date.strftime('%Y-%m-%d') }} + {% else %} + Current + {% endif %} +
+
+
+ {% else %} +
No location history found for this probe
+ {% endif %} + + +
+
+ +{% endblock %} diff --git a/templates/work_order_form.html b/templates/work_order_form.html new file mode 100644 index 0000000..160faa3 --- /dev/null +++ b/templates/work_order_form.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block content %} +
+

Create New Work Order

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+{% endblock %} diff --git a/templates/work_order_list.html b/templates/work_order_list.html new file mode 100644 index 0000000..d0086d7 --- /dev/null +++ b/templates/work_order_list.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Work Order Management

+ Create New Work Order +
+ +
+
+
All Work Orders
+
+
+ {% if work_orders %} +
+ + + + + + + + + + + + {% for wo in work_orders %} + + + + + + + + {% endfor %} + +
Order NumberStatusDue DateRedmine IDActions
{{ wo.order_number }} + {% if wo.status == 'completed' %} + Completed + {% elif wo.status == 'in_progress' %} + In Progress + {% else %} + {{ wo.status|title }} + {% endif %} + + {% if wo.due_date and wo.due_date is not string %} + {{ wo.due_date.strftime('%Y-%m-%d') }} + {% else %} + {{ wo.due_date or '' }} + {% endif %} + {{ wo.redmine or '-' }} + View +
+
+ {% else %} +
No work orders found
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/work_order_view.html b/templates/work_order_view.html new file mode 100644 index 0000000..addf611 --- /dev/null +++ b/templates/work_order_view.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Work Order: {{ work_order.order_number }}

+ + {{ work_order.status|title }} + +
+
+
+
+
+
Details
+
+
Due Date
+
+ {% 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 %} +
+ +
Redmine ID
+
{{ work_order.redmine or 'Not specified' }}
+
+
+
+ + {% if work_order.calibrations %} +
+
Associated Calibrations
+ + + + + + + + + + {% for cal in work_order.calibrations %} + + + + + + {% endfor %} + +
ChannelDateStatus
{{ cal.channel.serial_number }}{{ cal.date.strftime('%Y-%m-%d') }} + {% if cal.passed %} + Passed + {% else %} + Failed + {% endif %} +
+
+ {% endif %} + +
+
+
+ + +
+ + Back to List + {% if work_order.status == 'completed' %} + + Download Certificate + + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/test_supabase.py b/test_supabase.py new file mode 100644 index 0000000..665fab1 --- /dev/null +++ b/test_supabase.py @@ -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}") diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..323a9e5 --- /dev/null +++ b/todo.md @@ -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)