update: updated ticket error handling

This commit is contained in:
JOhn 2026-08-28 11:12:57 -04:00
parent 0cc496a40b
commit 8039ab08bb
6 changed files with 490 additions and 55 deletions

View file

@ -775,11 +775,13 @@ class CustomWindow(QMainWindow):
if clear: if clear:
self._set_status_font_size(16) self._set_status_font_size(16)
self.status_label.setText('') self.status_label.setText('')
self.disable_marquee()
return return
text = str(text)
full_text = 'Status: ' + text full_text = 'Status: ' + text
metrics = self.status_label.fontMetrics() metrics = self.status_label.fontMetrics()
available_width = self.status_label.width() - 10 available_width = getattr(self, 'text_width', self.status_label.width() - 10)
if metrics.horizontalAdvance(full_text) > available_width: if metrics.horizontalAdvance(full_text) > available_width:
self.enable_marquee(text) self.enable_marquee(text)
@ -809,10 +811,12 @@ class CustomWindow(QMainWindow):
self.navigator.navigate("menu") self.navigator.navigate("menu")
def enable_marquee(self, text): def enable_marquee(self, text):
self.marquee_text = text + " " self.marquee_text = str(text)
self.marquee_gap = " "
self.marquee_position = 0 self.marquee_position = 0
self.marquee_enabled = True self.marquee_enabled = True
self.marquee_timer.start(500) self.update_marquee()
self.marquee_timer.start(180)
def disable_marquee(self): def disable_marquee(self):
self.marquee_enabled = False self.marquee_enabled = False
@ -823,31 +827,33 @@ class CustomWindow(QMainWindow):
return return
metrics = self.status_label.fontMetrics() metrics = self.status_label.fontMetrics()
text_width = metrics.horizontalAdvance(self.marquee_text) prefix = 'Status: '
content_width = max(
80,
getattr(self, 'text_width', self.status_label.width() - 10)
- metrics.horizontalAdvance(prefix)
)
self.marquee_position += self.scroll_speed marquee_unit = self.marquee_text + getattr(self, 'marquee_gap', " ")
if self.marquee_position >= text_width: if not marquee_unit.strip():
self.disable_marquee()
return
if self.marquee_position >= len(marquee_unit):
self.marquee_position = 0 self.marquee_position = 0
looped_text = self.marquee_text * 2 looped_text = marquee_unit + marquee_unit
visible_text = ''
for char in looped_text[self.marquee_position:]:
next_text = visible_text + char
if metrics.horizontalAdvance(next_text) > content_width:
break
visible_text = next_text
chars_that_fit = metrics.horizontalAdvance( display_text = prefix + visible_text
looped_text[:self.text_width])
visible_text = looped_text[self.marquee_position:
self.marquee_position + chars_that_fit]
while metrics.horizontalAdvance(visible_text) < self.text_width:
visible_text += self.marquee_text
while metrics.horizontalAdvance(visible_text) > self.text_width:
visible_text = visible_text[:-1]
display_text = 'Status: ' + ' ' * \
(self.text_start_x - metrics.horizontalAdvance('Status: '))
display_text += visible_text
self.status_label.setText(display_text) self.status_label.setText(display_text)
self.marquee_position += max(1, int(self.scroll_speed / 7))
def set_scroll_speed(self, speed): def set_scroll_speed(self, speed):
self.scroll_speed = speed self.scroll_speed = speed

View file

@ -0,0 +1,95 @@
from core.models.Result import ResultError
def result_is_valid(result):
if hasattr(result, 'valid'):
return bool(result.valid)
if isinstance(result, dict):
return bool(result.get('valid'))
return False
def result_data(result):
if hasattr(result, 'data'):
return result.data
if isinstance(result, dict):
return result.get('data') or result.get('billing_code')
return None
def result_message(result, default='Operation failed.'):
if hasattr(result, 'message') and result.message:
return str(result.message)
if isinstance(result, dict):
return str(result.get('message') or result.get('error_code') or default)
return default
def result_error_value(result, default=ResultError.UNKNOWN.value):
error_type = None
if hasattr(result, 'error_type'):
error_type = result.error_type
elif isinstance(result, dict):
error_type = result.get('error_type') or result.get('error_code')
if error_type is None:
return default
if hasattr(error_type, 'value'):
return str(error_type.value)
return str(error_type)
def operation_failure_payload(operation, result=None, message=None, exception=None, context=None):
if exception is not None:
error_value = ResultError.UNKNOWN.value
detail = str(exception) or type(exception).__name__
else:
error_value = result_error_value(result)
detail = message or result_message(result)
title = _operation_title(operation, error_value)
return {
'operation': operation,
'title': title,
'message': detail,
'error_type': error_value,
'severity': _severity_for(error_value),
'actions': _actions_for(error_value),
'context': context or {},
}
def _operation_title(operation, error_value):
if error_value == ResultError.CONNECTION.value:
return 'Connection Problem'
if operation == 'use_ticket' and error_value == ResultError.SUBSCRIPTION.value:
return 'Ticket Expired'
if operation == 'use_ticket':
return 'Ticket Use Failed'
if operation == 'enable_profile':
return 'Profile Enable Failed'
if operation == 'disable_profile':
return 'Profile Disable Failed'
return 'Operation Failed'
def _severity_for(error_value):
if error_value in {
ResultError.CONNECTION.value,
ResultError.SUBSCRIPTION.value,
ResultError.MISSING_FILE.value,
ResultError.MISSING_DATA.value,
}:
return 'warning'
return 'error'
def _actions_for(error_value):
if error_value == ResultError.CONNECTION.value:
return [
{'key': 'retry', 'label': 'Try Again', 'role': 'primary'},
{'key': 'cancel', 'label': 'Cancel', 'role': 'secondary'},
]
return [
{'key': 'dismiss', 'label': 'OK', 'role': 'primary'},
]

View file

@ -26,5 +26,5 @@ def create_top_section(parent, css_path):
'marquee_timer': marquee_timer, 'marquee_timer': marquee_timer,
'text_start_x': 15, 'text_start_x': 15,
'text_end_x': 420, 'text_end_x': 420,
'text_width': 405, 'text_width': 500,
} }

View file

@ -29,6 +29,7 @@ from gui.v2.ui.pages.location_verification_page import LocationVerificationPage
from gui.v2.ui.styles.styles import SCROLLBAR_CYAN_QSS from gui.v2.ui.styles.styles import SCROLLBAR_CYAN_QSS
from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup
from gui.v2.ui.popups.endpoint_verification_popup import EndpointVerificationPopup from gui.v2.ui.popups.endpoint_verification_popup import EndpointVerificationPopup
from gui.v2.ui.popups.operation_result_popup import OperationResultPopup
from gui.v2.ui.popups.ticket_data_loss_popup import TicketDataLossPopup from gui.v2.ui.popups.ticket_data_loss_popup import TicketDataLossPopup
from gui.v2.workers.worker import Worker from gui.v2.workers.worker import Worker
from gui.v2.workers.worker_thread import WorkerThread from gui.v2.workers.worker_thread import WorkerThread
@ -1258,10 +1259,50 @@ class MenuPage(Page):
self.worker.update_signal.connect(self.update_gui_main_thread) self.worker.update_signal.connect(self.update_gui_main_thread)
self.worker.change_page.connect(self.change_app_page) self.worker.change_page.connect(self.change_app_page)
self.worker.ticket_data_loss.connect(self.show_ticket_data_loss_popup) self.worker.ticket_data_loss.connect(self.show_ticket_data_loss_popup)
self.worker.operation_failed.connect(self.handle_operation_failure)
thread = threading.Thread(target=self.worker.run) thread = threading.Thread(target=self.worker.run)
thread.start() thread.start()
def handle_operation_failure(self, payload):
if not isinstance(payload, dict):
payload = {
'title': 'Operation Failed',
'message': str(payload),
'actions': [{'key': 'dismiss', 'label': 'OK', 'role': 'primary'}],
'severity': 'error',
'context': {},
}
message = str(payload.get('message') or 'The operation could not be completed.')
self.update_status.update_status(message)
self.boton_just.setEnabled(True)
self.boton_just_session.setEnabled(True)
self.disconnect_button.setEnabled(True)
self.disconnect_system_wide_button.setEnabled(True)
self.popup = OperationResultPopup(
self,
title=payload.get('title', 'Operation Failed'),
message=message,
actions=payload.get('actions'),
severity=payload.get('severity', 'error'),
)
self.popup.action_selected.connect(
lambda action, current_payload=payload: self.handle_operation_popup_action(action, current_payload))
self.popup.show()
def handle_operation_popup_action(self, action, payload):
if action != 'retry':
return
context = payload.get('context') or {}
profile_data = context.get('profile_data')
if not isinstance(profile_data, dict) or 'id' not in profile_data:
self.update_status.update_status('Retry is unavailable for this operation.')
return
self.update_status.update_status('Retrying...')
self.enabling_profile(dict(profile_data))
def show_ticket_data_loss_popup(self, ticket_number, billing_code): def show_ticket_data_loss_popup(self, ticket_number, billing_code):
self.custom_window.navigator.navigate("menu") self.custom_window.navigator.navigate("menu")
self.update_status.update_status('Critical: invalid billing code from ticket.') self.update_status.update_status('Critical: invalid billing code from ticket.')

View file

@ -0,0 +1,223 @@
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QFont, QGuiApplication
from PyQt6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from gui.v2.ui.styles.styles import (
POPUP_BG_QSS,
POPUP_CANCEL_BUTTON_QSS,
)
class OperationResultPopup(QDialog):
action_selected = pyqtSignal(str)
def __init__(self, parent=None, title="", message="", actions=None, severity="error"):
super().__init__(parent)
self.parent_window = parent
self.title_text = title or "Operation Failed"
self.message = message or "The operation could not be completed."
self.actions = actions or [{'key': 'dismiss', 'label': 'OK', 'role': 'primary'}]
self.severity = severity
self._completed = False
self._use_parent_local_geometry = QGuiApplication.platformName().lower().startswith("wayland")
self.initUI()
def initUI(self):
self.setFixedSize(560, 300)
if self._use_parent_local_geometry:
if self.parent_window is not None:
self.setParent(self.parent_window)
self.setWindowFlags(Qt.WindowType.Widget)
else:
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
bg_widget = QWidget(self)
bg_widget.setStyleSheet(POPUP_BG_QSS)
outer.addWidget(bg_widget)
content_layout = QVBoxLayout(bg_widget)
content_layout.setContentsMargins(22, 14, 22, 18)
content_layout.setSpacing(12)
top_row = QHBoxLayout()
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(10)
title_label = QLabel(self.title_text)
title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
title_label.setStyleSheet(f"color: {self._title_color()}; background: transparent; border: none;")
title_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
title_label.setMinimumWidth(0)
top_row.addWidget(title_label, 1)
close_button = QPushButton("X")
close_button.setFixedSize(36, 36)
close_button.setFont(QFont("Arial", 14, QFont.Weight.Bold))
close_button.setStyleSheet("""
QPushButton {
background-color: transparent;
color: #444444;
font-size: 18px;
font-weight: 900;
border: none;
}
QPushButton:hover {
color: #d62828;
}
""")
close_button.clicked.connect(lambda: self._choose('cancel'))
top_row.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
content_layout.addLayout(top_row)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setStyleSheet("background: transparent; border: none;")
content_layout.addWidget(scroll_area, 1)
scroll_content = QWidget()
scroll_content.setStyleSheet("background: transparent; border: none;")
scroll_layout = QVBoxLayout(scroll_content)
scroll_layout.setContentsMargins(0, 0, 0, 0)
message_label = QLabel(self.message)
message_label.setFont(QFont("Arial", 12))
message_label.setStyleSheet("color: #333333; background: transparent; border: none;")
message_label.setWordWrap(True)
message_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
message_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop)
scroll_layout.addWidget(message_label)
scroll_layout.addStretch()
scroll_area.setWidget(scroll_content)
button_row = QHBoxLayout()
button_row.addStretch()
for action in self.actions:
button = QPushButton(action.get('label', 'OK'))
button.setFixedSize(max(110, min(180, 22 + len(action.get('label', 'OK')) * 8)), 42)
button.setFont(QFont("Arial", 11, QFont.Weight.Bold))
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setStyleSheet(self._button_style(action.get('role', 'primary')))
button.clicked.connect(lambda checked=False, key=action.get('key', 'dismiss'): self._choose(key))
button_row.addWidget(button)
content_layout.addLayout(button_row)
if not self._use_parent_local_geometry:
self.setWindowModality(Qt.WindowModality.ApplicationModal)
def _title_color(self):
if self.severity == 'warning':
return '#d97706'
return '#d62828'
def _button_style(self, role):
if role == 'secondary':
return POPUP_CANCEL_BUTTON_QSS
if self.severity == 'warning':
return """
QPushButton {
background-color: #f59e0b;
border: none;
color: white;
border-radius: 5px;
font-weight: bold;
}
QPushButton:hover {
background-color: #d97706;
}
"""
return """
QPushButton {
background-color: #d62828;
border: none;
color: white;
border-radius: 5px;
font-weight: bold;
}
QPushButton:hover {
background-color: #b91c1c;
}
"""
def _choose(self, action):
if self._completed:
return
self._completed = True
self.action_selected.emit(action)
self.accept()
def closeEvent(self, event):
if not self._completed:
self._completed = True
self.action_selected.emit('cancel')
event.accept()
def show(self):
self._center_on_parent()
super().show()
def showEvent(self, event):
super().showEvent(event)
self._center_on_parent()
QTimer.singleShot(0, self._center_on_parent)
def _center_on_parent(self):
parent = self.parent_window
if parent is None:
return
try:
if not hasattr(parent, 'mapToGlobal') or not hasattr(parent, 'rect'):
return
parent_local_rect = parent.rect()
if parent_local_rect.isEmpty():
return
my_size = self.size()
if my_size.isEmpty() or my_size.width() <= 0 or my_size.height() <= 0:
my_size = self.sizeHint()
if self._use_parent_local_geometry:
target_x = parent_local_rect.x() + (parent_local_rect.width() - my_size.width()) // 2
target_y = parent_local_rect.y() + (parent_local_rect.height() - my_size.height()) // 2
target_x = max(parent_local_rect.left(), min(target_x, parent_local_rect.right() - my_size.width() + 1))
target_y = max(parent_local_rect.top(), min(target_y, parent_local_rect.bottom() - my_size.height() + 1))
else:
parent_center = parent.mapToGlobal(parent_local_rect.center())
target_x = parent_center.x() - my_size.width() // 2
target_y = parent_center.y() - my_size.height() // 2
screen = None
if hasattr(parent, 'screen'):
try:
screen = parent.screen()
except Exception:
screen = None
if screen is not None:
avail = screen.availableGeometry()
target_x = max(avail.left(), min(target_x, avail.right() - my_size.width()))
target_y = max(avail.top(), min(target_y, avail.bottom() - my_size.height()))
self.setGeometry(target_x, target_y, my_size.width(), my_size.height())
if self._use_parent_local_geometry:
self.raise_()
self.setFocus(Qt.FocusReason.PopupFocusReason)
except Exception:
pass
def mousePressEvent(self, event):
self.oldPos = event.globalPosition().toPoint()
def mouseMoveEvent(self, event):
delta = event.globalPosition().toPoint() - self.oldPos
self.move(self.x() + delta.x(), self.y() + delta.y())
self.oldPos = event.globalPosition().toPoint()

View file

@ -10,7 +10,7 @@ from core.controllers.tickets.UseTicketController import (
) )
from core.models.session.SessionProfile import SessionProfile from core.models.session.SessionProfile import SessionProfile
from core.models.system.SystemProfile import SystemProfile from core.models.system.SystemProfile import SystemProfile
from core.models.Result import Result, ResultError from core.models.Result import ResultError
from core.errors.exceptions import SudoScript, MissingPreReqs, FirewallError from core.errors.exceptions import SudoScript, MissingPreReqs, FirewallError
from core.Errors import ( from core.Errors import (
CommandNotFoundError, CommandNotFoundError,
@ -25,6 +25,11 @@ from core.Errors import (
) )
from gui.v2.actions.database_health import GuiStorageDatabaseError from gui.v2.actions.database_health import GuiStorageDatabaseError
from gui.v2.actions.operation_results import (
operation_failure_payload,
result_data,
result_is_valid,
)
from gui.v2.infrastructure.screen_size import get_max_screensize from gui.v2.infrastructure.screen_size import get_max_screensize
from gui.v2.infrastructure.setup_observers import ( from gui.v2.infrastructure.setup_observers import (
application_version_observer, application_version_observer,
@ -38,6 +43,7 @@ class Worker(QObject):
update_signal = pyqtSignal(str, bool, int, int, str) update_signal = pyqtSignal(str, bool, int, int, str)
change_page = pyqtSignal(str, bool) change_page = pyqtSignal(str, bool)
ticket_data_loss = pyqtSignal(str, str) ticket_data_loss = pyqtSignal(str, str)
operation_failed = pyqtSignal(object)
def __init__(self, profile_data): def __init__(self, profile_data):
self.profile_data = profile_data self.profile_data = profile_data
@ -82,18 +88,20 @@ class Worker(QObject):
self.profile_data['billing_code'] = ticket_billing_code self.profile_data['billing_code'] = ticket_billing_code
if 'billing_code' in self.profile_data: if 'billing_code' in self.profile_data:
subscription = SubscriptionController.get( try:
self.profile_data['billing_code'], connection_observer=connection_observer) subscription = SubscriptionController.get(
self.profile_data['billing_code'], connection_observer=connection_observer)
except Exception as e:
if self._consumed_ticket is not None:
self._emit_subscription_lookup_failure(exception=e)
return
subscription = None
if subscription is not None: if subscription is not None:
ProfileController.attach_subscription( ProfileController.attach_subscription(
self.profile, subscription) self.profile, subscription)
else: else:
if self._consumed_ticket is not None: if self._consumed_ticket is not None:
self._ticket_error_emitted = True self._emit_subscription_lookup_failure()
self.ticket_data_loss.emit(
self._consumed_ticket,
str(self.profile_data.get('billing_code', '')),
)
return return
self.change_page.emit('The billing code is invalid.', True) self.change_page.emit('The billing code is invalid.', True)
return return
@ -113,8 +121,11 @@ class Worker(QObject):
self.update_signal.emit( self.update_signal.emit(
"ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None) "ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None)
except (InvalidSubscriptionError, MissingSubscriptionError) as e: except (InvalidSubscriptionError, MissingSubscriptionError) as e:
self.change_page.emit( if self._consumed_ticket is not None:
f"Subscription missing or invalid for profile {self.profile_data['id']}", True) self._emit_subscription_lookup_failure(exception=e)
else:
self.change_page.emit(
f"Subscription missing or invalid for profile {self.profile_data['id']}", True)
except ProfileActivationError: except ProfileActivationError:
self.update_signal.emit( self.update_signal.emit(
"The profile could not be enabled", False, None, None, None) "The profile could not be enabled", False, None, None, None)
@ -196,11 +207,18 @@ class Worker(QObject):
try: try:
print("using a random ticket from GUI...") print("using a random ticket from GUI...")
which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer)
except Exception: except Exception as e:
self._emit_ticket_failure(None, exception=e)
return None return None
if error_msg: if error_msg:
self._ticket_error_emitted = True self._emit_ticket_failure(
self.change_page.emit(f'Ticket use failed: {error_msg}', True) None,
result={
'valid': False,
'error_type': ResultError.TICKET,
'message': f'Ticket use failed: {error_msg}',
},
)
return None return None
if which_ticket is None or which_ticket == 'error': if which_ticket is None or which_ticket == 'error':
return None return None
@ -211,16 +229,68 @@ class Worker(QObject):
location_id = getattr(location, 'id', None) location_id = getattr(location, 'id', None)
if location_id is None: if location_id is None:
return None return None
location_id = str(location_id).strip() if isinstance(location_id, str):
if not location_id: location_id = location_id.strip()
if location_id == '':
return None return None
return location_id try:
return int(location_id)
except (TypeError, ValueError):
return None
def _retry_profile_data(self, which_ticket=None):
profile_data = dict(self.profile_data)
profile_data.pop('billing_code', None)
if which_ticket is not None:
profile_data['use_ticket'] = which_ticket
return profile_data
def _emit_ticket_failure(self, which_ticket, result=None, message=None, exception=None):
self._ticket_error_emitted = True
context = {
'profile_data': self._retry_profile_data(which_ticket),
}
if which_ticket is not None:
context['ticket'] = str(which_ticket)
self.operation_failed.emit(
operation_failure_payload(
operation='use_ticket',
result=result,
message=message,
exception=exception,
context=context,
)
)
def _emit_subscription_lookup_failure(self, exception=None):
message = (
"Ticket was accepted, but the subscription details could not be "
"retrieved. Check your connection and try again."
)
if exception is not None:
error_text = str(exception)
if error_text:
message = f"{message} {error_text}"
self._emit_ticket_failure(
self._consumed_ticket,
result={
'valid': False,
'error_type': ResultError.CONNECTION,
'message': message,
},
)
def _consume_ticket(self, which_ticket): def _consume_ticket(self, which_ticket):
which_location = self._ticket_location_id() which_location = self._ticket_location_id()
if which_location is None: if which_location is None:
self._ticket_error_emitted = True self._emit_ticket_failure(
self.change_page.emit('Could not determine profile location for ticket use.', True) which_ticket,
result={
'valid': False,
'error_type': ResultError.MISSING_DATA,
'message': 'Could not determine profile location for ticket use.',
},
)
return None return None
try: try:
@ -232,25 +302,25 @@ class Worker(QObject):
profile=self.profile profile=self.profile
) )
except Exception as e: except Exception as e:
self._ticket_error_emitted = True self._emit_ticket_failure(which_ticket, exception=e)
self.change_page.emit(f'Ticket use failed: {e}', True)
return None return None
if not isinstance(outcome, dict): if result_is_valid(outcome):
self._ticket_error_emitted = True billing_code = result_data(outcome)
self.change_page.emit('Ticket use failed: invalid_response', True) if not billing_code:
return None self._emit_ticket_failure(
which_ticket,
billing_code = outcome.get('billing_code') result={
if outcome.get('valid') and billing_code: 'valid': False,
self._consumed_ticket = str(which_ticket) 'error_type': ResultError.INVALID_API_REPLY,
return billing_code 'message': 'Ticket use succeeded but no billing code was returned.',
if billing_code: },
)
return None
self._consumed_ticket = str(which_ticket) self._consumed_ticket = str(which_ticket)
return billing_code return billing_code
self._ticket_error_emitted = True self._emit_ticket_failure(which_ticket, result=outcome)
self.change_page.emit(f'Ticket use failed: {outcome.get("message", "failed")}', True)
return None return None
def handle_profile_status(self, profile, is_enabled): def handle_profile_status(self, profile, is_enabled):