Spaces:
Sleeping
Sleeping
Commit
·
5515ec2
1
Parent(s):
5078a4a
Almost Done
Browse files
server.py
CHANGED
|
@@ -8,6 +8,7 @@ import os
|
|
| 8 |
import base64
|
| 9 |
import json
|
| 10 |
import re
|
|
|
|
| 11 |
from datetime import datetime
|
| 12 |
from flask import Flask, Response, request, jsonify
|
| 13 |
from flask_socketio import SocketIO, emit
|
|
@@ -21,6 +22,10 @@ from dotenv import load_dotenv
|
|
| 21 |
|
| 22 |
load_dotenv()
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
app = Flask(__name__)
|
| 25 |
app.config['SECRET_KEY'] = 'secret-key-for-hillside-automation'
|
| 26 |
FRONTEND_ORIGIN = os.getenv('FRONTEND_URL', 'https://quantbot.netlify.app')
|
|
@@ -30,27 +35,24 @@ socketio = SocketIO(app, cors_allowed_origins=[FRONTEND_ORIGIN, "http://localhos
|
|
| 30 |
bot_instance = None
|
| 31 |
session_data = {}
|
| 32 |
|
| 33 |
-
def log(message):
|
| 34 |
-
print(f"[Server Log] {datetime.now().strftime('%H:%M:%S')} - {message}")
|
| 35 |
-
|
| 36 |
class GmailApiService:
|
| 37 |
def __init__(self):
|
| 38 |
self.sender_email = os.getenv('EMAIL_SENDER'); self.service = None
|
| 39 |
try:
|
| 40 |
from google.oauth2 import service_account; from googleapiclient.discovery import build
|
| 41 |
base64_creds = os.getenv('GDRIVE_SA_KEY_BASE64')
|
| 42 |
-
if not base64_creds:
|
| 43 |
creds_json = base64.b64decode(base64_creds).decode('utf-8'); creds_dict = json.loads(creds_json)
|
| 44 |
credentials = service_account.Credentials.from_service_account_info(creds_dict, scopes=['https://www.googleapis.com/auth/gmail.send'])
|
| 45 |
if self.sender_email: credentials = credentials.with_subject(self.sender_email)
|
| 46 |
self.service = build('gmail', 'v1', credentials=credentials)
|
| 47 |
-
|
| 48 |
-
except Exception
|
| 49 |
|
| 50 |
def create_professional_email_template(self, subject, status_text, stats, custom_name):
|
| 51 |
status_color = "#28a745" if "completed" in status_text else "#ffc107" if "terminated" in status_text else "#dc3545"
|
| 52 |
current_date = datetime.now().strftime("%B %d, %Y at %I:%M %p")
|
| 53 |
-
|
| 54 |
<!DOCTYPE html>
|
| 55 |
<html><head><title>{subject}</title><style>
|
| 56 |
body{{font-family: 'Segoe UI', sans-serif; background-color: #f8f8f8; color: #2c2c2c;}}
|
|
@@ -81,6 +83,7 @@ class GmailApiService:
|
|
| 81 |
<li><b>{custom_name}_Skipped.csv:</b> A filtered list of patients that were skipped due to having no PRN.</li></ul></div></div>
|
| 82 |
<div class="footer"><p>© {datetime.now().year} Hillside Automation. This is an automated report.</p></div></div></body></html>
|
| 83 |
"""
|
|
|
|
| 84 |
|
| 85 |
def send_report(self, recipients, subject, body, attachments=None):
|
| 86 |
if not self.service or not recipients: return False
|
|
@@ -95,8 +98,9 @@ class GmailApiService:
|
|
| 95 |
message.attach(part)
|
| 96 |
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
|
| 97 |
sent_message = self.service.users().messages().send(userId='me', body={'raw': raw_message}).execute()
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 100 |
|
| 101 |
class GoogleDriveService:
|
| 102 |
def __init__(self):
|
|
@@ -108,8 +112,8 @@ class GoogleDriveService:
|
|
| 108 |
creds_json = base64.b64decode(base64_creds).decode('utf-8'); creds_dict = json.loads(creds_json)
|
| 109 |
self.creds = service_account.Credentials.from_service_account_info(creds_dict, scopes=['https://www.googleapis.com/auth/drive'])
|
| 110 |
self.service = build('drive', 'v3', credentials=self.creds)
|
| 111 |
-
|
| 112 |
-
except Exception
|
| 113 |
|
| 114 |
def upload_file(self, filename, file_content):
|
| 115 |
if not self.service: return False
|
|
@@ -118,8 +122,8 @@ class GoogleDriveService:
|
|
| 118 |
file_metadata = {'name': filename, 'parents': [self.folder_id]}
|
| 119 |
media = MediaIoBaseUpload(io.BytesIO(file_content.encode('utf-8')), mimetype='text/csv', resumable=True)
|
| 120 |
self.service.files().create(body=file_metadata, media_body=media, fields='id').execute()
|
| 121 |
-
|
| 122 |
-
except Exception
|
| 123 |
|
| 124 |
email_service = GmailApiService()
|
| 125 |
drive_service = GoogleDriveService()
|
|
@@ -133,26 +137,30 @@ def run_automation_process(session_id):
|
|
| 133 |
global bot_instance
|
| 134 |
results = []; is_terminated = False; is_crash = False
|
| 135 |
try:
|
|
|
|
| 136 |
data = session_data.get(session_id, {}); patient_data = data.get('patient_data'); workflow = data.get('workflow')
|
| 137 |
if not patient_data: raise ValueError("No patient data prepared.")
|
| 138 |
socketio.emit('initial_stats', {'total': len(patient_data)})
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
| 142 |
is_terminated = bot_instance.termination_event.is_set()
|
| 143 |
except Exception as e:
|
| 144 |
-
|
|
|
|
| 145 |
socketio.emit('error', {'message': f'A fatal error occurred: {e}'})
|
| 146 |
finally:
|
| 147 |
-
|
| 148 |
generate_and_send_reports(session_id, results, is_crash_report=is_crash, is_terminated=is_terminated)
|
| 149 |
if bot_instance: bot_instance.shutdown(); bot_instance = None
|
| 150 |
if session_id in session_data: del session_data[session_id]
|
| 151 |
|
| 152 |
def generate_and_send_reports(session_id, results, is_crash_report=False, is_terminated=False):
|
| 153 |
-
|
| 154 |
data = session_data.get(session_id, {})
|
| 155 |
-
if not data:
|
| 156 |
|
| 157 |
full_df = pd.DataFrame(data.get('patient_data_for_report'))
|
| 158 |
if results:
|
|
@@ -179,14 +187,14 @@ def generate_and_send_reports(session_id, results, is_crash_report=False, is_ter
|
|
| 179 |
bad_report_name: bad_df.to_csv(index=False),
|
| 180 |
skipped_report_name: skipped_df.to_csv(index=False)
|
| 181 |
}
|
| 182 |
-
status_text = "terminated by user" if is_terminated else "crashed" if is_crash_report else "completed successfully"
|
| 183 |
stats = {
|
| 184 |
'total': len(full_df), 'processed': len(results),
|
| 185 |
'successful': len(full_df[full_df['Status'] == 'Done']),
|
| 186 |
'bad': len(bad_df), 'skipped': len(skipped_df)
|
| 187 |
}
|
| 188 |
subject = f"Automation Report [{status_text.upper()}]: {custom_name}"
|
| 189 |
-
professional_body = email_service.create_professional_email_template(subject, status_text
|
| 190 |
email_service.send_report(data.get('emails'), subject, professional_body, attachments)
|
| 191 |
socketio.emit('process_complete', {'message': f'Process {status_text}. Report sent.'})
|
| 192 |
|
|
@@ -200,9 +208,39 @@ def extract_patient_name(raw_name):
|
|
| 200 |
name_only = raw_name.split('DOB')[0].strip()
|
| 201 |
return re.sub(r'[:\d\-\s]+$', '', name_only).strip()
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
@socketio.on('connect')
|
| 204 |
def handle_connect():
|
| 205 |
-
|
| 206 |
emit('email_list', {'emails': get_email_list()})
|
| 207 |
|
| 208 |
@socketio.on('get_email_list')
|
|
@@ -212,7 +250,6 @@ def handle_get_email_list():
|
|
| 212 |
@socketio.on('initialize_session')
|
| 213 |
def handle_init(data):
|
| 214 |
session_id = 'user_session'
|
| 215 |
-
log("Received initialize_session event.")
|
| 216 |
session_data.setdefault(session_id, {})
|
| 217 |
session_data[session_id].update({
|
| 218 |
'emails': data.get('emails', []), 'filename': data.get('filename'),
|
|
@@ -251,17 +288,16 @@ def handle_init(data):
|
|
| 251 |
session_data[session_id]['patient_data'] = master_df.to_dict('records')
|
| 252 |
|
| 253 |
socketio.emit('data_processed')
|
| 254 |
-
|
| 255 |
except Exception as e:
|
| 256 |
-
|
| 257 |
emit('error', {'message': f'Error preparing data: {e}'})
|
| 258 |
|
| 259 |
@socketio.on('start_login')
|
| 260 |
def handle_login(credentials):
|
| 261 |
-
log("Login process started.")
|
| 262 |
global bot_instance
|
| 263 |
if bot_instance: bot_instance.shutdown()
|
| 264 |
-
bot_instance = QuantumBot(socketio, app)
|
| 265 |
is_success, error_message = bot_instance.initialize_driver()
|
| 266 |
if is_success:
|
| 267 |
is_login_success, login_error = bot_instance.login(credentials['username'], credentials['password'])
|
|
@@ -272,7 +308,6 @@ def handle_login(credentials):
|
|
| 272 |
|
| 273 |
@socketio.on('submit_otp')
|
| 274 |
def handle_otp(data):
|
| 275 |
-
log("OTP submission received.")
|
| 276 |
if not bot_instance: return emit('error', {'message': 'Bot not initialized.'})
|
| 277 |
is_success, error_message = bot_instance.submit_otp(data['otp'])
|
| 278 |
if is_success:
|
|
@@ -283,12 +318,12 @@ def handle_otp(data):
|
|
| 283 |
|
| 284 |
@socketio.on('terminate_process')
|
| 285 |
def handle_terminate():
|
| 286 |
-
if bot_instance:
|
| 287 |
|
| 288 |
if __name__ == '__main__':
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
socketio.run(app, host='0.0.0.0', port=int(os.getenv('PORT', 7860)))
|
|
|
|
| 8 |
import base64
|
| 9 |
import json
|
| 10 |
import re
|
| 11 |
+
import logging
|
| 12 |
from datetime import datetime
|
| 13 |
from flask import Flask, Response, request, jsonify
|
| 14 |
from flask_socketio import SocketIO, emit
|
|
|
|
| 22 |
|
| 23 |
load_dotenv()
|
| 24 |
|
| 25 |
+
# --- Definitive Logging Setup ---
|
| 26 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
app = Flask(__name__)
|
| 30 |
app.config['SECRET_KEY'] = 'secret-key-for-hillside-automation'
|
| 31 |
FRONTEND_ORIGIN = os.getenv('FRONTEND_URL', 'https://quantbot.netlify.app')
|
|
|
|
| 35 |
bot_instance = None
|
| 36 |
session_data = {}
|
| 37 |
|
|
|
|
|
|
|
|
|
|
| 38 |
class GmailApiService:
|
| 39 |
def __init__(self):
|
| 40 |
self.sender_email = os.getenv('EMAIL_SENDER'); self.service = None
|
| 41 |
try:
|
| 42 |
from google.oauth2 import service_account; from googleapiclient.discovery import build
|
| 43 |
base64_creds = os.getenv('GDRIVE_SA_KEY_BASE64')
|
| 44 |
+
if not base64_creds: logger.warning("GDRIVE_SA_KEY_BASE64 not found in secrets."); return
|
| 45 |
creds_json = base64.b64decode(base64_creds).decode('utf-8'); creds_dict = json.loads(creds_json)
|
| 46 |
credentials = service_account.Credentials.from_service_account_info(creds_dict, scopes=['https://www.googleapis.com/auth/gmail.send'])
|
| 47 |
if self.sender_email: credentials = credentials.with_subject(self.sender_email)
|
| 48 |
self.service = build('gmail', 'v1', credentials=credentials)
|
| 49 |
+
logger.info("Gmail API Service initialized successfully")
|
| 50 |
+
except Exception: logger.exception("Gmail API Service initialization failed")
|
| 51 |
|
| 52 |
def create_professional_email_template(self, subject, status_text, stats, custom_name):
|
| 53 |
status_color = "#28a745" if "completed" in status_text else "#ffc107" if "terminated" in status_text else "#dc3545"
|
| 54 |
current_date = datetime.now().strftime("%B %d, %Y at %I:%M %p")
|
| 55 |
+
html_template = f"""
|
| 56 |
<!DOCTYPE html>
|
| 57 |
<html><head><title>{subject}</title><style>
|
| 58 |
body{{font-family: 'Segoe UI', sans-serif; background-color: #f8f8f8; color: #2c2c2c;}}
|
|
|
|
| 83 |
<li><b>{custom_name}_Skipped.csv:</b> A filtered list of patients that were skipped due to having no PRN.</li></ul></div></div>
|
| 84 |
<div class="footer"><p>© {datetime.now().year} Hillside Automation. This is an automated report.</p></div></div></body></html>
|
| 85 |
"""
|
| 86 |
+
return html_template
|
| 87 |
|
| 88 |
def send_report(self, recipients, subject, body, attachments=None):
|
| 89 |
if not self.service or not recipients: return False
|
|
|
|
| 98 |
message.attach(part)
|
| 99 |
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
|
| 100 |
sent_message = self.service.users().messages().send(userId='me', body={'raw': raw_message}).execute()
|
| 101 |
+
logger.info(f"Email sent successfully! Message ID: {sent_message['id']}")
|
| 102 |
+
return True
|
| 103 |
+
except Exception: logger.exception("Failed to send email"); return False
|
| 104 |
|
| 105 |
class GoogleDriveService:
|
| 106 |
def __init__(self):
|
|
|
|
| 112 |
creds_json = base64.b64decode(base64_creds).decode('utf-8'); creds_dict = json.loads(creds_json)
|
| 113 |
self.creds = service_account.Credentials.from_service_account_info(creds_dict, scopes=['https://www.googleapis.com/auth/drive'])
|
| 114 |
self.service = build('drive', 'v3', credentials=self.creds)
|
| 115 |
+
logger.info("Google Drive Service initialized successfully.")
|
| 116 |
+
except Exception: logger.exception("G-Drive ERROR: Could not initialize service")
|
| 117 |
|
| 118 |
def upload_file(self, filename, file_content):
|
| 119 |
if not self.service: return False
|
|
|
|
| 122 |
file_metadata = {'name': filename, 'parents': [self.folder_id]}
|
| 123 |
media = MediaIoBaseUpload(io.BytesIO(file_content.encode('utf-8')), mimetype='text/csv', resumable=True)
|
| 124 |
self.service.files().create(body=file_metadata, media_body=media, fields='id').execute()
|
| 125 |
+
logger.info(f"File '{filename}' uploaded to Google Drive."); return True
|
| 126 |
+
except Exception: logger.exception("G-Drive ERROR: File upload failed"); return False
|
| 127 |
|
| 128 |
email_service = GmailApiService()
|
| 129 |
drive_service = GoogleDriveService()
|
|
|
|
| 137 |
global bot_instance
|
| 138 |
results = []; is_terminated = False; is_crash = False
|
| 139 |
try:
|
| 140 |
+
logger.info(f"Starting automation thread for session: {session_id}")
|
| 141 |
data = session_data.get(session_id, {}); patient_data = data.get('patient_data'); workflow = data.get('workflow')
|
| 142 |
if not patient_data: raise ValueError("No patient data prepared.")
|
| 143 |
socketio.emit('initial_stats', {'total': len(patient_data)})
|
| 144 |
+
if workflow == 'void':
|
| 145 |
+
results = bot_instance.process_patient_list(patient_data, 'void')
|
| 146 |
+
elif workflow == 'refund':
|
| 147 |
+
date_range = {'start_date': data.get('start_date'), 'end_date': data.get('end_date')}
|
| 148 |
+
results = bot_instance.process_patient_list(patient_data, 'refund', date_range)
|
| 149 |
is_terminated = bot_instance.termination_event.is_set()
|
| 150 |
except Exception as e:
|
| 151 |
+
logger.exception("Fatal error in automation thread")
|
| 152 |
+
is_crash = True
|
| 153 |
socketio.emit('error', {'message': f'A fatal error occurred: {e}'})
|
| 154 |
finally:
|
| 155 |
+
logger.info("Automation thread finished. Generating final reports...")
|
| 156 |
generate_and_send_reports(session_id, results, is_crash_report=is_crash, is_terminated=is_terminated)
|
| 157 |
if bot_instance: bot_instance.shutdown(); bot_instance = None
|
| 158 |
if session_id in session_data: del session_data[session_id]
|
| 159 |
|
| 160 |
def generate_and_send_reports(session_id, results, is_crash_report=False, is_terminated=False):
|
| 161 |
+
logger.info(f"Preparing final reports for session {session_id}. Terminated: {is_terminated}, Crashed: {is_crash}")
|
| 162 |
data = session_data.get(session_id, {})
|
| 163 |
+
if not data: logger.error("Session data not found, cannot generate report."); return
|
| 164 |
|
| 165 |
full_df = pd.DataFrame(data.get('patient_data_for_report'))
|
| 166 |
if results:
|
|
|
|
| 187 |
bad_report_name: bad_df.to_csv(index=False),
|
| 188 |
skipped_report_name: skipped_df.to_csv(index=False)
|
| 189 |
}
|
| 190 |
+
status_text = "terminated by user" if is_terminated else "crashed due to an error" if is_crash_report else "completed successfully"
|
| 191 |
stats = {
|
| 192 |
'total': len(full_df), 'processed': len(results),
|
| 193 |
'successful': len(full_df[full_df['Status'] == 'Done']),
|
| 194 |
'bad': len(bad_df), 'skipped': len(skipped_df)
|
| 195 |
}
|
| 196 |
subject = f"Automation Report [{status_text.upper()}]: {custom_name}"
|
| 197 |
+
professional_body = email_service.create_professional_email_template(subject, status_text, stats, custom_name)
|
| 198 |
email_service.send_report(data.get('emails'), subject, professional_body, attachments)
|
| 199 |
socketio.emit('process_complete', {'message': f'Process {status_text}. Report sent.'})
|
| 200 |
|
|
|
|
| 208 |
name_only = raw_name.split('DOB')[0].strip()
|
| 209 |
return re.sub(r'[:\d\-\s]+$', '', name_only).strip()
|
| 210 |
|
| 211 |
+
@app.route('/process_files_for_automation', methods=['POST'])
|
| 212 |
+
def handle_file_processing():
|
| 213 |
+
session_id = 'user_session'
|
| 214 |
+
try:
|
| 215 |
+
if 'app_data' not in request.files or 'quantum_data' not in request.files:
|
| 216 |
+
return jsonify({"error": "Both files are required."}), 400
|
| 217 |
+
|
| 218 |
+
df_app = pd.read_excel(request.files['app_data']) if request.files['app_data'].filename.endswith('.xlsx') else pd.read_csv(request.files['app_data'])
|
| 219 |
+
df_quantum = pd.read_excel(request.files['quantum_data']) if request.files['quantum_data'].filename.endswith('.xlsx') else pd.read_csv(request.files['quantum_data'])
|
| 220 |
+
|
| 221 |
+
if 'Patient Name' not in df_app.columns or 'PRN' not in df_app.columns: raise ValueError("App Data must contain 'Patient Name' and 'PRN' columns.")
|
| 222 |
+
if 'Name' not in df_quantum.columns: raise ValueError("Quantum Data must contain a 'Name' column.")
|
| 223 |
+
|
| 224 |
+
df_app_filtered = df_app.dropna(subset=['PRN']); df_app_filtered = df_app_filtered[df_app_filtered['PRN'].astype(str).str.strip() != '']
|
| 225 |
+
prn_lookup_dict = {extract_patient_name(row['Patient Name']): row['PRN'] for _, row in df_app_filtered.iterrows()}
|
| 226 |
+
|
| 227 |
+
master_df = df_quantum.copy()
|
| 228 |
+
master_df['PRN'] = master_df['Name'].apply(lambda name: prn_lookup_dict.get(name, ""))
|
| 229 |
+
master_df['Status'] = ''
|
| 230 |
+
|
| 231 |
+
session_data[session_id]['patient_data_for_report'] = master_df
|
| 232 |
+
session_data[session_id]['patient_data'] = master_df.to_dict('records')
|
| 233 |
+
|
| 234 |
+
socketio.emit('data_processed')
|
| 235 |
+
logger.info(f"Data prepared for session {session_id}. Total records: {len(master_df)}")
|
| 236 |
+
return jsonify({"message": "Data processed successfully."})
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.exception("Error during file processing")
|
| 239 |
+
return jsonify({"error": str(e)}), 500
|
| 240 |
+
|
| 241 |
@socketio.on('connect')
|
| 242 |
def handle_connect():
|
| 243 |
+
logger.info('Frontend connected.')
|
| 244 |
emit('email_list', {'emails': get_email_list()})
|
| 245 |
|
| 246 |
@socketio.on('get_email_list')
|
|
|
|
| 250 |
@socketio.on('initialize_session')
|
| 251 |
def handle_init(data):
|
| 252 |
session_id = 'user_session'
|
|
|
|
| 253 |
session_data.setdefault(session_id, {})
|
| 254 |
session_data[session_id].update({
|
| 255 |
'emails': data.get('emails', []), 'filename': data.get('filename'),
|
|
|
|
| 288 |
session_data[session_id]['patient_data'] = master_df.to_dict('records')
|
| 289 |
|
| 290 |
socketio.emit('data_processed')
|
| 291 |
+
logger.info(f"Data prepared for session {session_id}. Total records: {len(master_df)}")
|
| 292 |
except Exception as e:
|
| 293 |
+
logger.exception("Error preparing data")
|
| 294 |
emit('error', {'message': f'Error preparing data: {e}'})
|
| 295 |
|
| 296 |
@socketio.on('start_login')
|
| 297 |
def handle_login(credentials):
|
|
|
|
| 298 |
global bot_instance
|
| 299 |
if bot_instance: bot_instance.shutdown()
|
| 300 |
+
bot_instance = QuantumBot(socketio, app, logger)
|
| 301 |
is_success, error_message = bot_instance.initialize_driver()
|
| 302 |
if is_success:
|
| 303 |
is_login_success, login_error = bot_instance.login(credentials['username'], credentials['password'])
|
|
|
|
| 308 |
|
| 309 |
@socketio.on('submit_otp')
|
| 310 |
def handle_otp(data):
|
|
|
|
| 311 |
if not bot_instance: return emit('error', {'message': 'Bot not initialized.'})
|
| 312 |
is_success, error_message = bot_instance.submit_otp(data['otp'])
|
| 313 |
if is_success:
|
|
|
|
| 318 |
|
| 319 |
@socketio.on('terminate_process')
|
| 320 |
def handle_terminate():
|
| 321 |
+
if bot_instance: logger.info("Termination signal received from frontend."); bot_instance.stop()
|
| 322 |
|
| 323 |
if __name__ == '__main__':
|
| 324 |
+
logger.info("====================================================================")
|
| 325 |
+
logger.info(" 🤗 Hillside Automation - Definitive Dual-Workflow Version")
|
| 326 |
+
logger.info(f" Frontend URL: {FRONTEND_ORIGIN}")
|
| 327 |
+
logger.info(f" Port: {os.getenv('PORT', 7860)}")
|
| 328 |
+
logger.info("====================================================================")
|
| 329 |
socketio.run(app, host='0.0.0.0', port=int(os.getenv('PORT', 7860)))
|
worker.py
CHANGED
|
@@ -14,26 +14,19 @@ from selenium.webdriver.support import expected_conditions as EC
|
|
| 14 |
from selenium.common.exceptions import TimeoutException
|
| 15 |
|
| 16 |
class QuantumBot:
|
| 17 |
-
def __init__(self, socketio, app):
|
| 18 |
self.socketio = socketio; self.app = app; self.driver = None
|
| 19 |
self.DEFAULT_TIMEOUT = 30; self.termination_event = threading.Event()
|
| 20 |
-
|
| 21 |
-
def _log(self, message):
|
| 22 |
-
print(f"[Bot Log] {datetime.now().strftime('%H:%M:%S')} - {message}")
|
| 23 |
-
|
| 24 |
-
def micro_status(self, message):
|
| 25 |
-
self._log(message)
|
| 26 |
-
with self.app.app_context():
|
| 27 |
-
self.socketio.emit('micro_status_update', {'message': message})
|
| 28 |
|
| 29 |
def _kill_chrome_processes(self):
|
| 30 |
try:
|
| 31 |
-
self.
|
| 32 |
subprocess.run(['pkill', '-f', 'chromium'], check=True, timeout=5)
|
| 33 |
time.sleep(1)
|
| 34 |
-
self.
|
| 35 |
except Exception as e:
|
| 36 |
-
self.
|
| 37 |
|
| 38 |
def initialize_driver(self):
|
| 39 |
try:
|
|
@@ -47,11 +40,19 @@ class QuantumBot:
|
|
| 47 |
options.add_argument(f"--user-agent={user_agent}")
|
| 48 |
service = ChromeService(executable_path="/usr/bin/chromedriver")
|
| 49 |
self.driver = webdriver.Chrome(service=service, options=options)
|
| 50 |
-
self.driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
|
|
|
|
|
|
|
|
|
|
| 51 |
return True, None
|
| 52 |
-
except Exception
|
| 53 |
-
|
| 54 |
-
return False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
def stop(self):
|
| 57 |
self.micro_status("Termination signal received..."); self.termination_event.set()
|
|
@@ -65,11 +66,11 @@ class QuantumBot:
|
|
| 65 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
|
| 66 |
self.micro_status("Waiting for OTP screen...")
|
| 67 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "code1")))
|
| 68 |
-
self.
|
| 69 |
return True, None
|
| 70 |
-
except Exception
|
| 71 |
-
|
| 72 |
-
return False,
|
| 73 |
|
| 74 |
def submit_otp(self, otp):
|
| 75 |
try:
|
|
@@ -78,18 +79,19 @@ class QuantumBot:
|
|
| 78 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
|
| 79 |
self.micro_status("Verifying login success...")
|
| 80 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//span[text()='Payments']")))
|
| 81 |
-
self.
|
| 82 |
return True, None
|
| 83 |
-
except Exception
|
| 84 |
-
|
| 85 |
-
return False,
|
| 86 |
-
|
| 87 |
def _get_calendar_months(self):
|
| 88 |
try:
|
| 89 |
titles = self.driver.find_elements(By.XPATH, "//div[contains(@class, 'vc-title')]")
|
| 90 |
if not titles: return []
|
| 91 |
return [datetime.strptime(title.text, "%B %Y") for title in titles]
|
| 92 |
-
except Exception
|
|
|
|
| 93 |
|
| 94 |
def _select_date_in_calendar(self, target_date):
|
| 95 |
target_month_str = target_date.strftime("%B %Y")
|
|
@@ -98,8 +100,7 @@ class QuantumBot:
|
|
| 98 |
visible_months = self._get_calendar_months()
|
| 99 |
if not visible_months: raise Exception("Calendar months not visible.")
|
| 100 |
if any(d.strftime("%B %Y") == target_month_str for d in visible_months):
|
| 101 |
-
|
| 102 |
-
day_format = "%-d"
|
| 103 |
day_xpath = f"//span[@aria-label='{target_date.strftime(f'%A, %B {day_format}, %Y')}']"
|
| 104 |
day_element = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.XPATH, day_xpath)))
|
| 105 |
self.driver.execute_script("arguments[0].click();", day_element); return
|
|
@@ -114,28 +115,21 @@ class QuantumBot:
|
|
| 114 |
self._select_date_in_calendar(start_date); time.sleep(1)
|
| 115 |
self._select_date_in_calendar(end_date); time.sleep(1)
|
| 116 |
self.driver.find_element(By.TAG_NAME, "body").click(); time.sleep(3)
|
| 117 |
-
self._log("...Date range set successfully.")
|
| 118 |
|
| 119 |
def process_patient_list(self, patient_data, workflow, date_range=None):
|
|
|
|
| 120 |
results = []
|
| 121 |
-
records_to_process = list(patient_data)
|
| 122 |
if workflow == 'refund':
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
self._log(f"FATAL ERROR during Refund setup: {e}. Aborting process.")
|
| 129 |
-
return results # Return empty results if setup fails
|
| 130 |
-
|
| 131 |
-
for index, record in enumerate(records_to_process):
|
| 132 |
-
if self.termination_event.is_set(): self._log("Termination detected."); break
|
| 133 |
patient_name = record['Name']; patient_prn = record.get('PRN', '')
|
| 134 |
if not patient_prn or not str(patient_prn).strip():
|
| 135 |
-
status = 'Skipped - No PRN'
|
| 136 |
-
self.micro_status(f"Skipping '{patient_name}' (No PRN)."); time.sleep(0.1)
|
| 137 |
else:
|
| 138 |
-
self.micro_status(f"Processing '{patient_name}' ({index + 1}/{len(
|
| 139 |
if workflow == 'void':
|
| 140 |
status = self._process_single_void(patient_name, patient_prn)
|
| 141 |
elif workflow == 'refund':
|
|
@@ -143,59 +137,58 @@ class QuantumBot:
|
|
| 143 |
results.append({'Name': patient_name, 'PRN': patient_prn, 'Status': status})
|
| 144 |
with self.app.app_context():
|
| 145 |
self.socketio.emit('log_update', {'name': patient_name, 'prn': patient_prn, 'status': status})
|
| 146 |
-
self.socketio.emit('stats_update', {'processed': len(results), 'remaining': len(
|
|
|
|
| 147 |
return results
|
| 148 |
|
| 149 |
def _perform_core_patient_processing(self, patient_name, patient_prn):
|
| 150 |
try:
|
| 151 |
-
self.micro_status(f"Searching for '{patient_name}'...")
|
| 152 |
-
search_box
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
self.micro_status("Opening transaction details...")
|
| 156 |
-
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.
|
| 157 |
-
|
| 158 |
-
self.micro_status("Adding to Vault...")
|
| 159 |
-
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//
|
| 160 |
-
|
| 161 |
try:
|
| 162 |
-
self.micro_status("Verifying success and
|
| 163 |
company_input = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.NAME, "company_name")))
|
| 164 |
-
company_input.
|
| 165 |
-
self._log("...Company Name entered.")
|
| 166 |
-
|
| 167 |
contact_input = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((By.NAME, "company_contact")))
|
| 168 |
contact_input.click(); contact_input.send_keys(Keys.CONTROL + "a"); contact_input.send_keys(Keys.BACK_SPACE)
|
| 169 |
contact_input.send_keys(str(patient_prn))
|
| 170 |
-
self._log("...Contact Name (PRN) entered.")
|
| 171 |
-
|
| 172 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Save Changes']"))).click()
|
| 173 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Confirm']]"))).click()
|
| 174 |
-
time.sleep(5);
|
| 175 |
except TimeoutException:
|
| 176 |
self.micro_status(f"'{patient_name}' is in a bad state, cancelling.")
|
| 177 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Cancel']]"))).click()
|
| 178 |
return 'Bad'
|
| 179 |
except Exception as e:
|
| 180 |
-
self.
|
|
|
|
| 181 |
|
| 182 |
def _process_single_void(self, patient_name, patient_prn):
|
| 183 |
try:
|
| 184 |
-
self.
|
| 185 |
-
self.driver.get("https://gateway.quantumepay.com/credit-card/void")
|
| 186 |
return self._perform_core_patient_processing(patient_name, patient_prn)
|
| 187 |
except Exception as e:
|
| 188 |
-
self.
|
|
|
|
| 189 |
|
| 190 |
def _process_single_refund(self, patient_name, patient_prn):
|
| 191 |
try:
|
| 192 |
return self._perform_core_patient_processing(patient_name, patient_prn)
|
| 193 |
except Exception as e:
|
| 194 |
-
self.
|
|
|
|
| 195 |
|
| 196 |
def shutdown(self):
|
| 197 |
try:
|
| 198 |
if self.driver: self.driver.quit()
|
| 199 |
self._kill_chrome_processes()
|
| 200 |
-
self.
|
| 201 |
-
except Exception as e: self.
|
|
|
|
| 14 |
from selenium.common.exceptions import TimeoutException
|
| 15 |
|
| 16 |
class QuantumBot:
|
| 17 |
+
def __init__(self, socketio, app, logger):
|
| 18 |
self.socketio = socketio; self.app = app; self.driver = None
|
| 19 |
self.DEFAULT_TIMEOUT = 30; self.termination_event = threading.Event()
|
| 20 |
+
self.logger = logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def _kill_chrome_processes(self):
|
| 23 |
try:
|
| 24 |
+
self.logger.info("Attempting to kill any lingering chromium processes...")
|
| 25 |
subprocess.run(['pkill', '-f', 'chromium'], check=True, timeout=5)
|
| 26 |
time.sleep(1)
|
| 27 |
+
self.logger.info("Lingering processes terminated.")
|
| 28 |
except Exception as e:
|
| 29 |
+
self.logger.warning(f"Could not kill chrome processes (this is often normal): {e}")
|
| 30 |
|
| 31 |
def initialize_driver(self):
|
| 32 |
try:
|
|
|
|
| 40 |
options.add_argument(f"--user-agent={user_agent}")
|
| 41 |
service = ChromeService(executable_path="/usr/bin/chromedriver")
|
| 42 |
self.driver = webdriver.Chrome(service=service, options=options)
|
| 43 |
+
self.driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
|
| 44 |
+
'source': "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
| 45 |
+
})
|
| 46 |
+
self.logger.info("WebDriver initialized successfully.")
|
| 47 |
return True, None
|
| 48 |
+
except Exception:
|
| 49 |
+
self.logger.exception("CRITICAL ERROR in WebDriver Initialization")
|
| 50 |
+
return False, "WebDriver initialization failed. See backend logs for details."
|
| 51 |
+
|
| 52 |
+
def micro_status(self, message):
|
| 53 |
+
self.logger.info(message)
|
| 54 |
+
with self.app.app_context():
|
| 55 |
+
self.socketio.emit('micro_status_update', {'message': message})
|
| 56 |
|
| 57 |
def stop(self):
|
| 58 |
self.micro_status("Termination signal received..."); self.termination_event.set()
|
|
|
|
| 66 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
|
| 67 |
self.micro_status("Waiting for OTP screen...")
|
| 68 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.presence_of_element_located((By.ID, "code1")))
|
| 69 |
+
self.logger.info("Login successful, OTP screen is visible.")
|
| 70 |
return True, None
|
| 71 |
+
except Exception:
|
| 72 |
+
self.logger.exception("ERROR during login process")
|
| 73 |
+
return False, "An error occurred during login. See backend logs."
|
| 74 |
|
| 75 |
def submit_otp(self, otp):
|
| 76 |
try:
|
|
|
|
| 79 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.ID, "login"))).click()
|
| 80 |
self.micro_status("Verifying login success...")
|
| 81 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//span[text()='Payments']")))
|
| 82 |
+
self.logger.info("OTP submission successful, dashboard is visible.")
|
| 83 |
return True, None
|
| 84 |
+
except Exception:
|
| 85 |
+
self.logger.exception("ERROR during OTP submission")
|
| 86 |
+
return False, "OTP submission failed. See backend logs."
|
| 87 |
+
|
| 88 |
def _get_calendar_months(self):
|
| 89 |
try:
|
| 90 |
titles = self.driver.find_elements(By.XPATH, "//div[contains(@class, 'vc-title')]")
|
| 91 |
if not titles: return []
|
| 92 |
return [datetime.strptime(title.text, "%B %Y") for title in titles]
|
| 93 |
+
except Exception as e:
|
| 94 |
+
self.logger.warning(f"Could not get calendar months: {e}"); return []
|
| 95 |
|
| 96 |
def _select_date_in_calendar(self, target_date):
|
| 97 |
target_month_str = target_date.strftime("%B %Y")
|
|
|
|
| 100 |
visible_months = self._get_calendar_months()
|
| 101 |
if not visible_months: raise Exception("Calendar months not visible.")
|
| 102 |
if any(d.strftime("%B %Y") == target_month_str for d in visible_months):
|
| 103 |
+
day_format = "%#d" if os.name == 'nt' else "%-d"
|
|
|
|
| 104 |
day_xpath = f"//span[@aria-label='{target_date.strftime(f'%A, %B {day_format}, %Y')}']"
|
| 105 |
day_element = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.XPATH, day_xpath)))
|
| 106 |
self.driver.execute_script("arguments[0].click();", day_element); return
|
|
|
|
| 115 |
self._select_date_in_calendar(start_date); time.sleep(1)
|
| 116 |
self._select_date_in_calendar(end_date); time.sleep(1)
|
| 117 |
self.driver.find_element(By.TAG_NAME, "body").click(); time.sleep(3)
|
|
|
|
| 118 |
|
| 119 |
def process_patient_list(self, patient_data, workflow, date_range=None):
|
| 120 |
+
self.logger.info(f"Starting patient processing for '{workflow}' workflow. Total patients: {len(patient_data)}")
|
| 121 |
results = []
|
|
|
|
| 122 |
if workflow == 'refund':
|
| 123 |
+
self._navigate_and_verify("https://gateway.quantumepay.com/credit-card/refund", "//button[.//span[contains(text(), '-')]]")
|
| 124 |
+
self._set_date_range(date_range['start_date'], date_range['end_date'])
|
| 125 |
+
|
| 126 |
+
for index, record in enumerate(patient_data):
|
| 127 |
+
if self.termination_event.is_set(): self.logger.info("Termination detected. Ending patient processing loop."); break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
patient_name = record['Name']; patient_prn = record.get('PRN', '')
|
| 129 |
if not patient_prn or not str(patient_prn).strip():
|
| 130 |
+
status = 'Skipped - No PRN'; self.micro_status(f"Skipping '{patient_name}' (No PRN)."); time.sleep(0.1)
|
|
|
|
| 131 |
else:
|
| 132 |
+
self.micro_status(f"Processing '{patient_name}' ({index + 1}/{len(patient_data)})...")
|
| 133 |
if workflow == 'void':
|
| 134 |
status = self._process_single_void(patient_name, patient_prn)
|
| 135 |
elif workflow == 'refund':
|
|
|
|
| 137 |
results.append({'Name': patient_name, 'PRN': patient_prn, 'Status': status})
|
| 138 |
with self.app.app_context():
|
| 139 |
self.socketio.emit('log_update', {'name': patient_name, 'prn': patient_prn, 'status': status})
|
| 140 |
+
self.socketio.emit('stats_update', {'processed': len(results), 'remaining': len(patient_data) - len(results), 'status': status})
|
| 141 |
+
self.logger.info("Finished processing patient list.")
|
| 142 |
return results
|
| 143 |
|
| 144 |
def _perform_core_patient_processing(self, patient_name, patient_prn):
|
| 145 |
try:
|
| 146 |
+
self.micro_status(f"Searching for '{patient_name}'...")
|
| 147 |
+
search_box = WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//input[@placeholder='Search']")))
|
| 148 |
+
search_box.click(); time.sleep(0.5); search_box.clear(); time.sleep(0.5)
|
| 149 |
+
search_box.send_keys(patient_name); time.sleep(3)
|
| 150 |
+
self.micro_status("Opening transaction details...")
|
| 151 |
+
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, f"//tr[contains(., \"{patient_name}\")]//button[@data-v-b6b33fa0]"))).click()
|
| 152 |
+
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.LINK_TEXT, "Transaction Detail"))).click()
|
| 153 |
+
self.micro_status("Adding to Vault...")
|
| 154 |
+
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Add to Vault']"))).click()
|
| 155 |
+
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//div[@class='modal-footer']//button/span[normalize-space()='Confirm']"))).click()
|
| 156 |
try:
|
| 157 |
+
self.micro_status("Verifying success and saving data...")
|
| 158 |
company_input = WebDriverWait(self.driver, 10).until(EC.element_to_be_clickable((By.NAME, "company_name")))
|
| 159 |
+
company_input.clear(); company_input.send_keys(patient_name)
|
|
|
|
|
|
|
| 160 |
contact_input = WebDriverWait(self.driver, 5).until(EC.element_to_be_clickable((By.NAME, "company_contact")))
|
| 161 |
contact_input.click(); contact_input.send_keys(Keys.CONTROL + "a"); contact_input.send_keys(Keys.BACK_SPACE)
|
| 162 |
contact_input.send_keys(str(patient_prn))
|
|
|
|
|
|
|
| 163 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button/span[normalize-space()='Save Changes']"))).click()
|
| 164 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Confirm']]"))).click()
|
| 165 |
+
time.sleep(5); return 'Done'
|
| 166 |
except TimeoutException:
|
| 167 |
self.micro_status(f"'{patient_name}' is in a bad state, cancelling.")
|
| 168 |
WebDriverWait(self.driver, self.DEFAULT_TIMEOUT).until(EC.element_to_be_clickable((By.XPATH, "//button[.//span[normalize-space()='Cancel']]"))).click()
|
| 169 |
return 'Bad'
|
| 170 |
except Exception as e:
|
| 171 |
+
self.logger.exception(f"An error occurred while processing {patient_name} (core)")
|
| 172 |
+
return 'Error'
|
| 173 |
|
| 174 |
def _process_single_void(self, patient_name, patient_prn):
|
| 175 |
try:
|
| 176 |
+
self._navigate_and_verify("https://gateway.quantumepay.com/credit-card/void", "//input[@placeholder='Search']")
|
|
|
|
| 177 |
return self._perform_core_patient_processing(patient_name, patient_prn)
|
| 178 |
except Exception as e:
|
| 179 |
+
self.logger.exception(f"Error in Void workflow for {patient_name}")
|
| 180 |
+
return 'Error'
|
| 181 |
|
| 182 |
def _process_single_refund(self, patient_name, patient_prn):
|
| 183 |
try:
|
| 184 |
return self._perform_core_patient_processing(patient_name, patient_prn)
|
| 185 |
except Exception as e:
|
| 186 |
+
self.logger.exception(f"Error in Refund workflow for {patient_name}")
|
| 187 |
+
return 'Error'
|
| 188 |
|
| 189 |
def shutdown(self):
|
| 190 |
try:
|
| 191 |
if self.driver: self.driver.quit()
|
| 192 |
self._kill_chrome_processes()
|
| 193 |
+
self.logger.info("Chrome session closed and cleaned up successfully.")
|
| 194 |
+
except Exception as e: self.logger.error(f"Error during shutdown: {e}")
|