From 2600a5aa61e53c0e6dcd5fc33415f90a561b3bc3 Mon Sep 17 00:00:00 2001 From: Andrew Conlon Date: Tue, 29 Jul 2025 10:51:42 -0400 Subject: [PATCH] Update probe related models, routes and templates --- app/models.py | 49 +++++++++++ app/routes/calibrations.py | 35 ++++++-- app/routes/channels.py | 10 ++- app/routes/probes.py | 169 +++++++++++++++++++++++++++++++++---- templates/probe_form.html | 79 +++++++++++++++-- templates/probe_list.html | 39 +++++---- templates/probe_view.html | 61 +++++++++++++ 7 files changed, 395 insertions(+), 47 deletions(-) diff --git a/app/models.py b/app/models.py index 0ed7e83..c5a0e91 100644 --- a/app/models.py +++ b/app/models.py @@ -93,6 +93,23 @@ class Probe: raise Exception('Failed to create probe') return cls(**result.data[0]) + def retire(self): + """Mark probe as retired with current timestamp""" + supabase = get_supabase() + result = supabase.table('probes').update({ + 'retired_at': datetime.utcnow().isoformat() + }).eq('id', self.id).execute() + if not result.data: + raise Exception('Failed to retire probe') + self.retired_at = datetime.utcnow() + + def delete(self): + """Delete probe from database""" + supabase = get_supabase() + result = supabase.table('probes').delete().eq('id', self.id).execute() + if not result.data: + raise Exception('Failed to delete probe') + @dataclass class Channel: """Channel model representing individual measurement channels""" @@ -131,6 +148,19 @@ class Channel: channels.append(cls(**row)) return channels + @classmethod + def get_by_serial(cls, serial_number: str): + """Get channel by serial number if it exists (case-sensitive exact match)""" + serial_upper = serial_number.upper() + supabase = get_supabase() + result = supabase.table('channels').select("*").eq('serial_number', serial_upper).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 create(cls, probe_id: str, serial_number: str, parameter_id: str): """Create a new channel""" @@ -259,6 +289,25 @@ class Calibration: 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 [] + @classmethod + def get_by_channel(cls, channel_id: str): + """Get all calibrations for a specific channel""" + supabase = get_supabase() + result = supabase.table('calibrations').select("*").eq('channel_id', channel_id).execute() + calibrations = [] + for row in result.data if result.data else []: + try: + # Parse datetime strings + for date_field in ['std_cal_date', 'std_cal_due', 'date']: + if row.get(date_field): + row[date_field] = datetime.fromisoformat(row[date_field]) + + calibrations.append(cls(**row)) + except Exception as e: + print(f"Error parsing calibration data: {e}") + continue + return calibrations + @dataclass class Customer: """Customer model representing clients who request calibrations""" diff --git a/app/routes/calibrations.py b/app/routes/calibrations.py index 0763bcd..e575e5a 100644 --- a/app/routes/calibrations.py +++ b/app/routes/calibrations.py @@ -162,9 +162,14 @@ def review_calibration(calibration_id): flash('Calibration not found', 'error') return redirect(url_for('calibrations.index')) + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('calibration_review.html', calibration=calibration.data[0], - user=session) + user=user) @calibrations_bp.route('/new') def new_calibration(): @@ -189,13 +194,18 @@ def new_calibration(): # Get all parameters (using correct column name) parameters = supabase.table('parameters').select('id, parameter_name').execute() + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('calibration_form.html', work_orders=work_orders.data, standards=standards.data, channels=channels.data, probe_models=probe_models.data, parameters=parameters.data, - user=session) + user=user) @calibrations_bp.route('/filtered-channels') def get_filtered_channels(): @@ -248,6 +258,11 @@ def index(): calibrated_by:calibrated_by(name) ''').order('date', desc=True).limit(10).execute() + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('calibration_dashboard.html', summary={ 'total_calibrations': total, @@ -255,7 +270,7 @@ def index(): 'failed_calibrations': failed }, recent_calibrations=recent_calibrations.data, - user=session) + user=user) @calibrations_bp.route('/probe/') def calibrations_for_probe(probe_id): @@ -278,10 +293,15 @@ def calibrations_for_probe(probe_id): # Get probe details probe = supabase.table('probes').select('*').eq('id', probe_id).execute() + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('calibration_history.html', calibrations=calibrations.data, probe=probe.data[0] if probe.data else None, - user=session) + user=user) @calibrations_bp.route('/probe//trends') def calibration_trends(probe_id): @@ -344,6 +364,11 @@ def view_calibration(calibration_id): flash('Calibration not found', 'error') return redirect(url_for('calibrations.index')) + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('calibration_view.html', calibration=calibration.data[0], - user=session) + user=user) diff --git a/app/routes/channels.py b/app/routes/channels.py index cd2f6c7..688f6bf 100644 --- a/app/routes/channels.py +++ b/app/routes/channels.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, redirect, url_for, flash +from flask import Blueprint, render_template, redirect, url_for, flash, session from app.models import Channel, Calibration from app.routes.auth import role_required @@ -16,6 +16,12 @@ def view_channel(channel_id): # Get calibration history for this channel calibrations = Calibration.get_by_channel(channel_id) + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('channel_view.html', channel=channel, - calibrations=calibrations) + calibrations=calibrations, + user=user) diff --git a/app/routes/probes.py b/app/routes/probes.py index 5402a8e..e63bf14 100644 --- a/app/routes/probes.py +++ b/app/routes/probes.py @@ -1,20 +1,48 @@ import re -from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from flask import Blueprint, render_template, request, redirect, url_for, flash, session, jsonify from app.models import Probe, ProbeModel, Channel, Parameter, ProbeLocation from app.routes.auth import role_required probes_bp = Blueprint('probes', __name__) +@probes_bp.route('/api/check-channel-serials', methods=['POST'], endpoint='check_channel_serials') +@role_required('review') +def check_channel_serials(): + """Check if channel serials already exist in database""" + data = request.get_json() + if not data or 'serials' not in data: + return jsonify({'error': 'Invalid request'}), 400 + + duplicates = [] + for serial in data['serials']: + if Channel.get_by_serial(serial): + duplicates.append(serial) + + return jsonify({'duplicates': duplicates}) + @probes_bp.route('/') @role_required('review') def list_probes(): """List all probes with optional active filter""" active_only = request.args.get('active_only', 'true').lower() == 'true' probes = Probe.get_all(active_only=active_only) + + # Get channels for each probe + probes_with_channels = [] + for probe in probes: + probe_dict = probe.__dict__ + probe_dict['channels'] = Channel.get_by_probe(probe.id) + probes_with_channels.append(probe_dict) + + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('probe_list.html', - probes=probes, + probes=probes_with_channels, active_only=active_only, - user=session) + user=user) @probes_bp.route('/new') @role_required('review') @@ -22,36 +50,51 @@ def new_probe(): """Display form to create new probe""" probe_models = ProbeModel.get_all() parameters = Parameter.get_all() + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('probe_form.html', probe_models=probe_models, parameters=parameters, - user=session) + user=user) @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 + # First validate all channels 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()): + serial_upper = channel_serials[i].upper() + if not re.fullmatch(r'^[0-9A-F]{16}$', serial_upper): raise ValueError(f'Invalid channel serial format: {channel_serials[i]}. Must be 16 hex characters (0-9, A-F)') + # Check for duplicate serial + existing_channel = Channel.get_by_serial(serial_upper) + if existing_channel: + raise ValueError(f'Channel serial already exists: {channel_serials[i]}') + + # Only create probe if all channels are valid + probe = Probe.create( + model_id=request.form['model_id'], + serial_number='', # Default empty string since field was removed from form + description='' # Empty description since field was removed + ) + + # Create channels after successful probe creation + if channel_serials and parameter_ids and len(channel_serials) == len(parameter_ids): + for i in range(len(channel_serials)): + serial_upper = channel_serials[i].upper() Channel.create( probe_id=probe.id, - serial_number=channel_serials[i].upper(), # Store in uppercase + serial_number=serial_upper, parameter_id=parameter_ids[i] ) @@ -79,8 +122,104 @@ def view_probe(probe_id): # Get location history locations = ProbeLocation.get_by_probe(probe_id) + # Get parameters for channel form + parameters = Parameter.get_all() + + user = { + 'user_name': session.get('user_name'), + 'can_calibrate': session.get('can_calibrate'), + 'can_review': session.get('can_review') + } return render_template('probe_view.html', probe=probe, channels=channels, locations=locations, - user=session) + parameters=parameters, + user=user) + +@probes_bp.route('//channels', methods=['POST']) +@role_required('review') +def add_channel(probe_id): + """Add a channel to an existing probe""" + try: + probe = Probe.get_by_id(probe_id) + if not probe: + flash('Probe not found', 'danger') + return redirect(url_for('probes.list_probes')) + + if probe.retired_at: + flash('Cannot add channels to retired probe', 'danger') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + + serial = request.form.get('channel_serial', '').upper() + parameter_id = request.form.get('parameter_id') + + # Validate channel serial format [0-9A-F]{16} + if not re.fullmatch(r'^[0-9A-F]{16}$', serial): + raise ValueError(f'Invalid channel serial format: {serial}. Must be 16 hex characters (0-9, A-F)') + + # Validate parameter_id exists and is valid + if not parameter_id: + raise ValueError('Parameter ID is required') + if not Parameter.get_by_id(parameter_id): + raise ValueError('Invalid parameter ID') + + # Check for duplicate serial + existing_channel = Channel.get_by_serial(serial) + if existing_channel: + raise ValueError(f'Channel serial already exists: {serial}') + + # Create channel + Channel.create( + probe_id=probe_id, + serial_number=serial, + parameter_id=parameter_id + ) + + flash('Channel added successfully', 'success') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + except ValueError as ve: + flash(f'Validation error: {str(ve)}', 'danger') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + except Exception as e: + flash(f'Error adding channel: {str(e)}', 'danger') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + +@probes_bp.route('//retire', methods=['POST']) +@role_required('review') +def retire_probe(probe_id): + """Mark a probe as retired""" + try: + probe = Probe.get_by_id(probe_id) + if not probe: + flash('Probe not found', 'danger') + return redirect(url_for('probes.list_probes')) + + if probe.retired_at: + flash('Probe is already retired', 'warning') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + + probe.retire() + flash('Probe retired successfully', 'success') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + except Exception as e: + flash(f'Error retiring probe: {str(e)}', 'danger') + return redirect(url_for('probes.view_probe', probe_id=probe_id)) + +@probes_bp.route('/', methods=['DELETE']) +@role_required('review') +def delete_probe(probe_id): + """Delete a probe (only if it has no channels)""" + try: + probe = Probe.get_by_id(probe_id) + if not probe: + return jsonify({'error': 'Probe not found'}), 404 + + channels = Channel.get_by_probe(probe_id) + if channels: + return jsonify({'error': 'Cannot delete probe with channels'}), 400 + + probe.delete() + return jsonify({'message': 'Probe deleted successfully'}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/templates/probe_form.html b/templates/probe_form.html index c334421..e0d2ddb 100644 --- a/templates/probe_form.html +++ b/templates/probe_form.html @@ -13,11 +13,6 @@ -
- - -
-

Channels

@@ -41,9 +36,75 @@
- +