update: updated tickets enum handling

This commit is contained in:
JOhn 2026-08-28 17:23:42 -04:00
parent 8039ab08bb
commit f13bc8d61e
4 changed files with 133 additions and 212 deletions

View file

@ -1,95 +1,6 @@
from core.models.Result import ResultError from core.models.Result import Result, ResultError
def result_is_valid(result): def result_from_exception(exception: Exception, error_type: ResultError = ResultError.UNKNOWN) -> Result:
if hasattr(result, 'valid'): message = str(exception) or type(exception).__name__
return bool(result.valid) return Result(valid=False, error_type=error_type, message=message)
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

@ -17,11 +17,13 @@ from core.controllers.tickets.UseTicketController import (
do_we_use_a_random_ticket, do_we_use_a_random_ticket,
get_unused_tickets, get_unused_tickets,
) )
from core.models.Result import Result, ResultError
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 gui.v2.infrastructure.setup_observers import ticket_observer from gui.v2.infrastructure.setup_observers import ticket_observer
from gui.v2.actions.database_health import GuiStorageDatabaseError from gui.v2.actions.database_health import GuiStorageDatabaseError
from gui.v2.actions.operation_results import result_from_exception
from gui.v2.actions.profile_order import normalize_profile_order from gui.v2.actions.profile_order import normalize_profile_order
from gui.v2.actions.profile_status import is_profile_enabled_for_gui from gui.v2.actions.profile_status import is_profile_enabled_for_gui
from gui.v2.ui.pages.Page import Page from gui.v2.ui.pages.Page import Page
@ -77,6 +79,8 @@ class MenuPage(Page):
self.button_states = {} self.button_states = {}
self.is_system_connected = False self.is_system_connected = False
self.profile_button_map = {} self.profile_button_map = {}
self._pending_operation_name = None
self._pending_operation_profile_data = None
self.font_style = f"font-family: '{main_window.open_sans_family}';" if main_window.open_sans_family else "" self.font_style = f"font-family: '{main_window.open_sans_family}';" if main_window.open_sans_family else ""
self.create_interface_elements() self.create_interface_elements()
@ -1255,6 +1259,8 @@ class MenuPage(Page):
self.enabling_profile(profile_data) self.enabling_profile(profile_data)
def enabling_profile(self, profile_data): def enabling_profile(self, profile_data):
self._pending_operation_name = 'enable_profile'
self._pending_operation_profile_data = dict(profile_data)
self.worker = Worker(profile_data) self.worker = Worker(profile_data)
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)
@ -1265,44 +1271,73 @@ class MenuPage(Page):
thread.start() thread.start()
def handle_operation_failure(self, payload): def handle_operation_failure(self, payload):
if not isinstance(payload, dict): result = self._result_from_payload(payload)
payload = { if result.valid:
'title': 'Operation Failed', if result.message:
'message': str(payload), self.update_status.update_status(result.message)
'actions': [{'key': 'dismiss', 'label': 'OK', 'role': 'primary'}], return
'severity': 'error',
'context': {},
}
message = str(payload.get('message') or 'The operation could not be completed.') message = result.message or result.user_message()
self.update_status.update_status(message) self.update_status.update_status(message)
self.boton_just.setEnabled(True) self.boton_just.setEnabled(True)
self.boton_just_session.setEnabled(True) self.boton_just_session.setEnabled(True)
self.disconnect_button.setEnabled(True) self.disconnect_button.setEnabled(True)
self.disconnect_system_wide_button.setEnabled(True) self.disconnect_system_wide_button.setEnabled(True)
is_actionable = result.error_type in self._operation_choices()
self.popup = OperationResultPopup( self.popup = OperationResultPopup(
self, self,
title=payload.get('title', 'Operation Failed'),
message=message, message=message,
actions=payload.get('actions'), action_button_text="Yes" if is_actionable else "OK",
severity=payload.get('severity', 'error'), cancel_button_text="No" if is_actionable else None,
action_result=is_actionable,
) )
self.popup.action_selected.connect( self.popup.action_selected.connect(
lambda action, current_payload=payload: self.handle_operation_popup_action(action, current_payload)) lambda accepted, current_result=result: self.handle_operation_popup_action(accepted, current_result))
self.popup.show() self.popup.show()
def handle_operation_popup_action(self, action, payload): def _result_from_payload(self, payload):
if action != 'retry': if isinstance(payload, Result):
return payload
if isinstance(payload, Exception):
return result_from_exception(payload)
return Result(
valid=False,
error_type=ResultError.UNKNOWN,
message=str(payload) or 'The operation could not be completed.',
)
def handle_operation_popup_action(self, accepted, result):
if not accepted:
return return
context = payload.get('context') or {} self._action_for_result(result)(result)
profile_data = context.get('profile_data')
def _action_for_result(self, result):
return self._operation_choices().get(result.error_type, self._dismiss_operation_result)
def _operation_choices(self):
CHOICES = {
ResultError.CONNECTION: self._retry_pending_operation,
ResultError.SUBSCRIPTION: self._handle_expired_ticket_result,
}
return CHOICES
def _retry_pending_operation(self, result):
profile_data = self._pending_operation_profile_data
if not isinstance(profile_data, dict) or 'id' not in 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.') self.update_status.update_status('Retry is unavailable for this operation.')
return return
self.update_status.update_status('Retrying...') self.update_status.update_status('Retrying...')
self.enabling_profile(dict(profile_data)) self.enabling_profile(dict(profile_data))
def _handle_expired_ticket_result(self, result):
self.update_status.update_status(result.message or 'Ticket expired.')
self._route_to_billing_entry()
def _dismiss_operation_result(self, result):
self.update_status.update_status(result.message or 'The operation could not be completed.')
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

@ -12,21 +12,23 @@ from PyQt6.QtWidgets import (
) )
from gui.v2.ui.styles.styles import ( from gui.v2.ui.styles.styles import (
POPUP_ACTION_BUTTON_RED_QSS,
POPUP_BG_QSS, POPUP_BG_QSS,
POPUP_CANCEL_BUTTON_QSS, POPUP_CANCEL_BUTTON_QSS,
) )
class OperationResultPopup(QDialog): class OperationResultPopup(QDialog):
action_selected = pyqtSignal(str) action_selected = pyqtSignal(bool)
def __init__(self, parent=None, title="", message="", actions=None, severity="error"): def __init__(self, parent=None, message="", title="Operation Failed", action_button_text="Yes", cancel_button_text="No", action_result=True):
super().__init__(parent) super().__init__(parent)
self.parent_window = parent self.parent_window = parent
self.title_text = title or "Operation Failed" self.title_text = title or "Operation Failed"
self.message = message or "The operation could not be completed." self.message = message or "The operation could not be completed."
self.actions = actions or [{'key': 'dismiss', 'label': 'OK', 'role': 'primary'}] self.action_button_text = action_button_text
self.severity = severity self.cancel_button_text = cancel_button_text
self.action_result = action_result
self._completed = False self._completed = False
self._use_parent_local_geometry = QGuiApplication.platformName().lower().startswith("wayland") self._use_parent_local_geometry = QGuiApplication.platformName().lower().startswith("wayland")
self.initUI() self.initUI()
@ -58,7 +60,7 @@ class OperationResultPopup(QDialog):
title_label = QLabel(self.title_text) title_label = QLabel(self.title_text)
title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold)) title_label.setFont(QFont("Arial", 18, QFont.Weight.Bold))
title_label.setStyleSheet(f"color: {self._title_color()}; background: transparent; border: none;") title_label.setStyleSheet("color: #d62828; background: transparent; border: none;")
title_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) title_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
title_label.setMinimumWidth(0) title_label.setMinimumWidth(0)
top_row.addWidget(title_label, 1) top_row.addWidget(title_label, 1)
@ -78,7 +80,7 @@ class OperationResultPopup(QDialog):
color: #d62828; color: #d62828;
} }
""") """)
close_button.clicked.connect(lambda: self._choose('cancel')) close_button.clicked.connect(lambda: self._choose(False))
top_row.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop) top_row.addWidget(close_button, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
content_layout.addLayout(top_row) content_layout.addLayout(top_row)
@ -106,64 +108,39 @@ class OperationResultPopup(QDialog):
button_row = QHBoxLayout() button_row = QHBoxLayout()
button_row.addStretch() button_row.addStretch()
for action in self.actions:
button = QPushButton(action.get('label', 'OK')) if self.cancel_button_text is not None:
button.setFixedSize(max(110, min(180, 22 + len(action.get('label', 'OK')) * 8)), 42) cancel_button = QPushButton(self.cancel_button_text)
button.setFont(QFont("Arial", 11, QFont.Weight.Bold)) cancel_button.setFixedSize(max(110, min(180, 22 + len(self.cancel_button_text) * 8)), 42)
button.setCursor(Qt.CursorShape.PointingHandCursor) cancel_button.setFont(QFont("Arial", 11, QFont.Weight.Bold))
button.setStyleSheet(self._button_style(action.get('role', 'primary'))) cancel_button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda checked=False, key=action.get('key', 'dismiss'): self._choose(key)) cancel_button.setStyleSheet(POPUP_CANCEL_BUTTON_QSS)
button_row.addWidget(button) cancel_button.clicked.connect(lambda: self._choose(False))
button_row.addWidget(cancel_button)
action_button = QPushButton(self.action_button_text)
action_button.setFixedSize(max(110, min(180, 22 + len(self.action_button_text) * 8)), 42)
action_button.setFont(QFont("Arial", 11, QFont.Weight.Bold))
action_button.setCursor(Qt.CursorShape.PointingHandCursor)
action_button.setStyleSheet(POPUP_ACTION_BUTTON_RED_QSS)
action_button.clicked.connect(lambda: self._choose(self.action_result))
button_row.addWidget(action_button)
content_layout.addLayout(button_row) content_layout.addLayout(button_row)
if not self._use_parent_local_geometry: if not self._use_parent_local_geometry:
self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setWindowModality(Qt.WindowModality.ApplicationModal)
def _title_color(self): def _choose(self, accepted):
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: if self._completed:
return return
self._completed = True self._completed = True
self.action_selected.emit(action) self.action_selected.emit(accepted)
self.accept() self.accept()
def closeEvent(self, event): def closeEvent(self, event):
if not self._completed: if not self._completed:
self._completed = True self._completed = True
self.action_selected.emit('cancel') self.action_selected.emit(False)
event.accept() event.accept()
def show(self): def show(self):

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 ResultError from core.models.Result import Result, 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,11 +25,7 @@ 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 ( from gui.v2.actions.operation_results import result_from_exception
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,
@ -114,9 +110,12 @@ class Worker(QObject):
ignore_exceptions.append(ProfileStateConflictError) ignore_exceptions.append(ProfileStateConflictError)
ignore_tuple = tuple(ignore_exceptions) ignore_tuple = tuple(ignore_exceptions)
max_resolution = get_max_screensize() max_resolution = get_max_screensize()
ProfileController.enable(self.profile, ignore=ignore_tuple, profile_observer=profile_observer, enable_result = ProfileController.enable(self.profile, ignore=ignore_tuple, profile_observer=profile_observer,
application_version_observer=application_version_observer, application_version_observer=application_version_observer,
connection_observer=connection_observer, ticket_observer=ticket_observer, max_resolution=max_resolution) connection_observer=connection_observer, ticket_observer=ticket_observer, max_resolution=max_resolution)
if isinstance(enable_result, Result) and not enable_result.valid:
self.operation_failed.emit(enable_result)
return
except EndpointVerificationError: except EndpointVerificationError:
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)
@ -213,11 +212,11 @@ class Worker(QObject):
if error_msg: if error_msg:
self._emit_ticket_failure( self._emit_ticket_failure(
None, None,
result={ result=Result(
'valid': False, valid=False,
'error_type': ResultError.TICKET, error_type=ResultError.TICKET,
'message': f'Ticket use failed: {error_msg}', 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':
@ -238,29 +237,17 @@ class Worker(QObject):
except (TypeError, ValueError): except (TypeError, ValueError):
return None 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): def _emit_ticket_failure(self, which_ticket, result=None, message=None, exception=None):
self._ticket_error_emitted = True self._ticket_error_emitted = True
context = { if exception is not None:
'profile_data': self._retry_profile_data(which_ticket), result = result_from_exception(exception)
} elif result is None:
if which_ticket is not None: result = Result(
context['ticket'] = str(which_ticket) valid=False,
self.operation_failed.emit( error_type=ResultError.UNKNOWN,
operation_failure_payload( message=message or 'Ticket use failed.',
operation='use_ticket',
result=result,
message=message,
exception=exception,
context=context,
) )
) self.operation_failed.emit(result)
def _emit_subscription_lookup_failure(self, exception=None): def _emit_subscription_lookup_failure(self, exception=None):
message = ( message = (
@ -273,11 +260,11 @@ class Worker(QObject):
message = f"{message} {error_text}" message = f"{message} {error_text}"
self._emit_ticket_failure( self._emit_ticket_failure(
self._consumed_ticket, self._consumed_ticket,
result={ result=Result(
'valid': False, valid=False,
'error_type': ResultError.CONNECTION, error_type=ResultError.CONNECTION,
'message': message, message=message,
}, ),
) )
def _consume_ticket(self, which_ticket): def _consume_ticket(self, which_ticket):
@ -285,11 +272,11 @@ class Worker(QObject):
if which_location is None: if which_location is None:
self._emit_ticket_failure( self._emit_ticket_failure(
which_ticket, which_ticket,
result={ result=Result(
'valid': False, valid=False,
'error_type': ResultError.MISSING_DATA, error_type=ResultError.MISSING_DATA,
'message': 'Could not determine profile location for ticket use.', message='Could not determine profile location for ticket use.',
}, ),
) )
return None return None
@ -305,16 +292,27 @@ class Worker(QObject):
self._emit_ticket_failure(which_ticket, exception=e) self._emit_ticket_failure(which_ticket, exception=e)
return None return None
if result_is_valid(outcome): if not isinstance(outcome, Result):
billing_code = result_data(outcome) self._emit_ticket_failure(
which_ticket,
result=Result(
valid=False,
error_type=ResultError.INVALID_API_REPLY,
message=f'use_ticket returned {type(outcome).__name__}, expected Result.',
),
)
return None
if outcome.valid:
billing_code = outcome.data
if not billing_code: if not billing_code:
self._emit_ticket_failure( self._emit_ticket_failure(
which_ticket, which_ticket,
result={ result=Result(
'valid': False, valid=False,
'error_type': ResultError.INVALID_API_REPLY, error_type=ResultError.INVALID_API_REPLY,
'message': 'Ticket use succeeded but no billing code was returned.', message='Ticket use succeeded but no billing code was returned.',
}, ),
) )
return None return None
self._consumed_ticket = str(which_ticket) self._consumed_ticket = str(which_ticket)