diff --git a/gui/v2/actions/operation_results.py b/gui/v2/actions/operation_results.py index c9d88dc..e652cf4 100755 --- a/gui/v2/actions/operation_results.py +++ b/gui/v2/actions/operation_results.py @@ -1,95 +1,6 @@ -from core.models.Result import ResultError +from core.models.Result import Result, 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'}, - ] +def result_from_exception(exception: Exception, error_type: ResultError = ResultError.UNKNOWN) -> Result: + message = str(exception) or type(exception).__name__ + return Result(valid=False, error_type=error_type, message=message) diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py index a15bd95..d37e414 100755 --- a/gui/v2/ui/pages/menu_page.py +++ b/gui/v2/ui/pages/menu_page.py @@ -17,11 +17,13 @@ from core.controllers.tickets.UseTicketController import ( do_we_use_a_random_ticket, get_unused_tickets, ) +from core.models.Result import Result, ResultError from core.models.session.SessionProfile import SessionProfile from core.models.system.SystemProfile import SystemProfile from gui.v2.infrastructure.setup_observers import ticket_observer 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_status import is_profile_enabled_for_gui from gui.v2.ui.pages.Page import Page @@ -77,6 +79,8 @@ class MenuPage(Page): self.button_states = {} self.is_system_connected = False 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.create_interface_elements() @@ -1255,6 +1259,8 @@ class MenuPage(Page): self.enabling_profile(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.update_signal.connect(self.update_gui_main_thread) self.worker.change_page.connect(self.change_app_page) @@ -1265,44 +1271,73 @@ class MenuPage(Page): 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': {}, - } + result = self._result_from_payload(payload) + if result.valid: + if result.message: + self.update_status.update_status(result.message) + return - 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.boton_just.setEnabled(True) self.boton_just_session.setEnabled(True) self.disconnect_button.setEnabled(True) self.disconnect_system_wide_button.setEnabled(True) + is_actionable = result.error_type in self._operation_choices() + self.popup = OperationResultPopup( self, - title=payload.get('title', 'Operation Failed'), message=message, - actions=payload.get('actions'), - severity=payload.get('severity', 'error'), + action_button_text="Yes" if is_actionable else "OK", + cancel_button_text="No" if is_actionable else None, + action_result=is_actionable, ) 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() - def handle_operation_popup_action(self, action, payload): - if action != 'retry': + def _result_from_payload(self, payload): + 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 - context = payload.get('context') or {} - profile_data = context.get('profile_data') + self._action_for_result(result)(result) + + 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: 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 _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): self.custom_window.navigator.navigate("menu") self.update_status.update_status('Critical: invalid billing code from ticket.') diff --git a/gui/v2/ui/popups/operation_result_popup.py b/gui/v2/ui/popups/operation_result_popup.py index 9a005c2..c2f92f6 100755 --- a/gui/v2/ui/popups/operation_result_popup.py +++ b/gui/v2/ui/popups/operation_result_popup.py @@ -12,21 +12,23 @@ from PyQt6.QtWidgets import ( ) from gui.v2.ui.styles.styles import ( + POPUP_ACTION_BUTTON_RED_QSS, POPUP_BG_QSS, POPUP_CANCEL_BUTTON_QSS, ) 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) 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.action_button_text = action_button_text + self.cancel_button_text = cancel_button_text + self.action_result = action_result self._completed = False self._use_parent_local_geometry = QGuiApplication.platformName().lower().startswith("wayland") self.initUI() @@ -58,7 +60,7 @@ class OperationResultPopup(QDialog): 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.setStyleSheet("color: #d62828; background: transparent; border: none;") title_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) title_label.setMinimumWidth(0) top_row.addWidget(title_label, 1) @@ -78,7 +80,7 @@ class OperationResultPopup(QDialog): 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) content_layout.addLayout(top_row) @@ -106,64 +108,39 @@ class OperationResultPopup(QDialog): 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) + + if self.cancel_button_text is not None: + cancel_button = QPushButton(self.cancel_button_text) + cancel_button.setFixedSize(max(110, min(180, 22 + len(self.cancel_button_text) * 8)), 42) + cancel_button.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + cancel_button.setCursor(Qt.CursorShape.PointingHandCursor) + cancel_button.setStyleSheet(POPUP_CANCEL_BUTTON_QSS) + 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) 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): + def _choose(self, accepted): if self._completed: return self._completed = True - self.action_selected.emit(action) + self.action_selected.emit(accepted) self.accept() def closeEvent(self, event): if not self._completed: self._completed = True - self.action_selected.emit('cancel') + self.action_selected.emit(False) event.accept() def show(self): diff --git a/gui/v2/workers/worker.py b/gui/v2/workers/worker.py index 64c85f0..d1a5298 100755 --- a/gui/v2/workers/worker.py +++ b/gui/v2/workers/worker.py @@ -10,7 +10,7 @@ from core.controllers.tickets.UseTicketController import ( ) from core.models.session.SessionProfile import SessionProfile 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 import ( CommandNotFoundError, @@ -25,11 +25,7 @@ from core.Errors import ( ) 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.actions.operation_results import result_from_exception from gui.v2.infrastructure.screen_size import get_max_screensize from gui.v2.infrastructure.setup_observers import ( application_version_observer, @@ -114,9 +110,12 @@ class Worker(QObject): ignore_exceptions.append(ProfileStateConflictError) ignore_tuple = tuple(ignore_exceptions) max_resolution = get_max_screensize() - ProfileController.enable(self.profile, ignore=ignore_tuple, profile_observer=profile_observer, - application_version_observer=application_version_observer, - connection_observer=connection_observer, ticket_observer=ticket_observer, max_resolution=max_resolution) + enable_result = ProfileController.enable(self.profile, ignore=ignore_tuple, profile_observer=profile_observer, + application_version_observer=application_version_observer, + 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: self.update_signal.emit( "ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None) @@ -213,11 +212,11 @@ class Worker(QObject): if error_msg: self._emit_ticket_failure( None, - result={ - 'valid': False, - 'error_type': ResultError.TICKET, - 'message': f'Ticket use failed: {error_msg}', - }, + result=Result( + valid=False, + error_type=ResultError.TICKET, + message=f'Ticket use failed: {error_msg}', + ), ) return None if which_ticket is None or which_ticket == 'error': @@ -238,29 +237,17 @@ class Worker(QObject): 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, + if exception is not None: + result = result_from_exception(exception) + elif result is None: + result = Result( + valid=False, + error_type=ResultError.UNKNOWN, + message=message or 'Ticket use failed.', ) - ) + self.operation_failed.emit(result) def _emit_subscription_lookup_failure(self, exception=None): message = ( @@ -273,11 +260,11 @@ class Worker(QObject): message = f"{message} {error_text}" self._emit_ticket_failure( self._consumed_ticket, - result={ - 'valid': False, - 'error_type': ResultError.CONNECTION, - 'message': message, - }, + result=Result( + valid=False, + error_type=ResultError.CONNECTION, + message=message, + ), ) def _consume_ticket(self, which_ticket): @@ -285,11 +272,11 @@ class Worker(QObject): if which_location is None: self._emit_ticket_failure( which_ticket, - result={ - 'valid': False, - 'error_type': ResultError.MISSING_DATA, - 'message': 'Could not determine profile location for ticket use.', - }, + result=Result( + valid=False, + error_type=ResultError.MISSING_DATA, + message='Could not determine profile location for ticket use.', + ), ) return None @@ -305,16 +292,27 @@ class Worker(QObject): self._emit_ticket_failure(which_ticket, exception=e) return None - if result_is_valid(outcome): - billing_code = result_data(outcome) + if not isinstance(outcome, Result): + 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: self._emit_ticket_failure( which_ticket, - result={ - 'valid': False, - 'error_type': ResultError.INVALID_API_REPLY, - 'message': 'Ticket use succeeded but no billing code was returned.', - }, + result=Result( + valid=False, + error_type=ResultError.INVALID_API_REPLY, + message='Ticket use succeeded but no billing code was returned.', + ), ) return None self._consumed_ticket = str(which_ticket)