From 5d2b6f2eac345edcb60fcabc01a73e0226a15ec5 Mon Sep 17 00:00:00 2001 From: JOhn Date: Tue, 15 Sep 2026 08:23:12 -0400 Subject: [PATCH] update: fixed location page buttons display --- gui/v2/actions/disable_profiles.py | 0 gui/v2/actions/locations.py | 35 --- gui/v2/actions/operation_result_dispatch.py | 74 +++++++ gui/v2/actions/operation_results.py | 18 ++ gui/v2/ui/builders/top_section.py | 2 +- gui/v2/ui/pages/location_page.py | 68 +++++- gui/v2/ui/pages/menu_page.py | 36 +++ .../ui/pages/ticket_or_billing_choice_page.py | 18 +- gui/v2/ui/popups/operation_result_popup.py | 200 +++++++++++++++++ gui/v2/workers/ticketing_worker_thread.py | 11 - gui/v2/workers/worker.py | 206 +++++++++++++----- 11 files changed, 547 insertions(+), 121 deletions(-) mode change 100644 => 100755 gui/v2/actions/disable_profiles.py delete mode 100755 gui/v2/actions/locations.py create mode 100755 gui/v2/actions/operation_result_dispatch.py create mode 100755 gui/v2/actions/operation_results.py create mode 100755 gui/v2/ui/popups/operation_result_popup.py diff --git a/gui/v2/actions/disable_profiles.py b/gui/v2/actions/disable_profiles.py old mode 100644 new mode 100755 diff --git a/gui/v2/actions/locations.py b/gui/v2/actions/locations.py deleted file mode 100755 index c773f78..0000000 --- a/gui/v2/actions/locations.py +++ /dev/null @@ -1,35 +0,0 @@ -def location_candidates(profile, preferred=None): - candidates = [] - seen = set() - - def add(val): - if val is None: - return - s = str(val).strip().lower().replace(' ', '_') - if s and s not in seen: - seen.add(s) - candidates.append(s) - - if preferred: - add(preferred) - - sources = [] - try: - if profile and profile.connection and profile.connection.location: - sources.append(profile.connection.location) - except Exception: - pass - try: - if profile and profile.location: - sources.append(profile.location) - except Exception: - pass - - for source in sources: - add(getattr(source, 'id', None)) - add(getattr(source, 'country_name', None)) - add(getattr(source, 'name', None)) - add(getattr(source, 'code', None)) - add(getattr(source, 'country_code', None)) - - return candidates diff --git a/gui/v2/actions/operation_result_dispatch.py b/gui/v2/actions/operation_result_dispatch.py new file mode 100755 index 0000000..52cb518 --- /dev/null +++ b/gui/v2/actions/operation_result_dispatch.py @@ -0,0 +1,74 @@ +import inspect +from enum import Enum +from typing import Any + +from core.controllers.ConfigurationController import ConfigurationController +from core.models.Configuration import ConnectionChoice +from core.models.Result import ResultError +from core.services.prepare_tickets import ticket_tracker + + +DEFAULT_ENUM_MAP = { + ResultError.SUBSCRIPTION: ( + ticket_tracker.wipe_one_ticket_sub, + {}, + ), + ResultError.CONNECTION: ( + ConfigurationController.set_connection_enum, + {'connection_enum': ConnectionChoice.SYSTEM}, + ), + ResultError.INVALID_API_REPLY: None, +} + + +def has_function(enum_map: dict[Enum, Any], enum_key: Enum) -> bool: + action = enum_map.get(enum_key) + if action is None: + return False + function, _ = _normalize_action(action) + return callable(function) + + +def error_dispatch(enum_map: dict[Enum, Any], enum_key: Enum, **kwargs: Any) -> Any: + if enum_key not in enum_map: + return None + action = enum_map[enum_key] + if action is None: + return None + function, default_kwargs = _normalize_action(action) + call_kwargs = dict(default_kwargs) + call_kwargs.update(kwargs) + return function(**_filter_kwargs(function, call_kwargs)) + + +def has_result_action(result, enum_map: dict[Enum, Any] | None = None) -> bool: + return has_function(enum_map or DEFAULT_ENUM_MAP, result.error_type) + + +def dispatch_result_action(result, enum_map: dict[Enum, Any] | None = None, **kwargs: Any) -> Any: + return error_dispatch(enum_map or DEFAULT_ENUM_MAP, result.error_type, **kwargs) + + +def _normalize_action(action: Any) -> tuple[Any, dict[str, Any]]: + if isinstance(action, tuple): + function = action[0] + if len(action) > 1 and isinstance(action[1], dict): + return function, action[1] + return function, {} + return action, {} + + +def _filter_kwargs(function: Any, kwargs: dict[str, Any]) -> dict[str, Any]: + signature = inspect.signature(function) + parameters = signature.parameters + if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in parameters.values()): + return kwargs + accepted = { + name + for name, param in parameters.items() + if param.kind in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + } + return {key: value for key, value in kwargs.items() if key in accepted} diff --git a/gui/v2/actions/operation_results.py b/gui/v2/actions/operation_results.py new file mode 100755 index 0000000..a9a976b --- /dev/null +++ b/gui/v2/actions/operation_results.py @@ -0,0 +1,18 @@ +from core.models.Result import Result, ResultError + + +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) + + +def result_from_payload(payload) -> Result: + 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.', + ) diff --git a/gui/v2/ui/builders/top_section.py b/gui/v2/ui/builders/top_section.py index 0aa54a0..edd88cd 100755 --- a/gui/v2/ui/builders/top_section.py +++ b/gui/v2/ui/builders/top_section.py @@ -26,5 +26,5 @@ def create_top_section(parent, css_path): 'marquee_timer': marquee_timer, 'text_start_x': 15, 'text_end_x': 420, - 'text_width': 405, + 'text_width': 500, } diff --git a/gui/v2/ui/pages/location_page.py b/gui/v2/ui/pages/location_page.py index 977c33c..84d7364 100755 --- a/gui/v2/ui/pages/location_page.py +++ b/gui/v2/ui/pages/location_page.py @@ -1,6 +1,6 @@ import os -from PyQt6.QtWidgets import QApplication, QLabel, QPushButton, QButtonGroup +from PyQt6.QtWidgets import QApplication, QLabel, QPushButton, QButtonGroup, QScrollArea, QWidget from PyQt6.QtGui import QPixmap, QIcon, QPainter, QColor, QFont, QTransform from PyQt6.QtCore import Qt from PyQt6 import QtCore @@ -20,6 +20,7 @@ class LocationPage(Page): self.display.setGeometry(QtCore.QRect(5, 10, 390, 520)) self.title.setGeometry(395, 40, 380, 40) self.title.setText("Pick a location") + self._setup_scroll_area() self.initial_display = QLabel(self) self.initial_display.setGeometry(5, 22, 355, 485) @@ -70,13 +71,24 @@ class LocationPage(Page): self.refresh_protocol_location_visibility() def create_interface_elements(self, available_locations): + old_button_widget = self.scroll_area.takeWidget() + if old_button_widget is not None: + old_button_widget.deleteLater() + button_widget = QWidget() + button_widget.setStyleSheet("background: transparent;") + self._button_widget = button_widget self.buttonGroup = QButtonGroup(self) self.buttons = [] for j, (object_type, icon_name, geometry) in enumerate(available_locations): - boton = object_type(self) - boton.setGeometry(*geometry) + boton = object_type(button_widget) + boton.setGeometry( + 0, + 0, + geometry[2], + geometry[3], + ) boton.setIconSize(boton.size()) boton.setCheckable(True) @@ -123,11 +135,61 @@ class LocationPage(Page): boton.clicked.connect( lambda checked, loc=icon_name: self.show_location(loc)) + content_height = max( + (geometry[3] for _, _, geometry in available_locations), + default=0, + ) + button_widget.setFixedSize(380, max(1, content_height)) + self.scroll_area.setWidget(button_widget) + self._layout_visible_buttons() + + def _setup_scroll_area(self): + self.scroll_area = QScrollArea(self) + self.scroll_area.setGeometry(405, 90, 385, 400) + self.scroll_area.setWidgetResizable(False) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setStyleSheet(""" + QScrollArea { + background: transparent; + border: none; + } + QScrollBar:vertical { + background: transparent; + border: none; + width: 10px; + margin: 0px 0px 0px 4px; + } + QScrollBar::handle:vertical { + background: #00ffff; + border-radius: 5px; + min-height: 20px; + margin: 1px 0px; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + } + """) + def refresh_protocol_location_visibility(self): for button in self.buttons: location_key = getattr(button, 'location_icon_name', None) locations = self.connection_manager.get_location_info(location_key) button.setVisible(not locations or self._location_supports_selected_protocol(locations)) + self._layout_visible_buttons() + + def _layout_visible_buttons(self): + visible_buttons = [button for button in self.buttons if not button.isHidden()] + for index, button in enumerate(visible_buttons): + button.move((index % 2) * 195, (index // 2) * 105) + + if hasattr(self, '_button_widget'): + button_height = self.buttons[0].height() if self.buttons else 0 + row_count = (len(visible_buttons) + 1) // 2 + self._button_widget.setFixedSize( + 380, + max(1, row_count * 105 - 30 + button_height), + ) def _current_protocol(self): profile_data = self.update_status.read_data() diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py index a3a614e..37e1df4 100755 --- a/gui/v2/ui/pages/menu_page.py +++ b/gui/v2/ui/pages/menu_page.py @@ -22,6 +22,8 @@ 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_result_dispatch import has_result_action +from gui.v2.actions.operation_results import result_from_payload 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 @@ -29,6 +31,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.popups.confirmation_popup import ConfirmationPopup 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.workers.worker import Worker from gui.v2.workers.worker_thread import WorkerThread @@ -1257,10 +1260,43 @@ class MenuPage(Page): self.worker.update_signal.connect(self.update_gui_main_thread) self.worker.change_page.connect(self.change_app_page) 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.start() + def handle_operation_failure(self, payload): + result = result_from_payload(payload) + if result.valid: + if result.message: + self.update_status.update_status(result.message) + return + + 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 = has_result_action(result) + + self.popup = OperationResultPopup( + self, + message=message, + 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 accepted: self.handle_operation_popup_action(accepted)) + self.popup.show() + + def handle_operation_popup_action(self, accepted): + if self.worker is None: + return + self.worker.handle_operation_popup_choice(accepted) + 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/pages/ticket_or_billing_choice_page.py b/gui/v2/ui/pages/ticket_or_billing_choice_page.py index d32a648..8ae4914 100755 --- a/gui/v2/ui/pages/ticket_or_billing_choice_page.py +++ b/gui/v2/ui/pages/ticket_or_billing_choice_page.py @@ -1,10 +1,8 @@ from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton from PyQt6 import QtCore -from core.controllers.ProfileController import ProfileController from core.controllers.tickets.UseTicketController import get_unused_tickets -from gui.v2.actions.locations import location_candidates from gui.v2.infrastructure.setup_observers import ticket_observer from gui.v2.ui.pages.Page import Page @@ -91,20 +89,14 @@ class TicketOrBillingChoicePage(Page): self.status_label.setText("Pick a ticket from the list first.") return which_ticket = item.data(QtCore.Qt.ItemDataRole.UserRole) - profile_id = self.update_status.current_profile_id try: - profile = ProfileController.get(int(profile_id)) - except Exception: - profile = None - candidates = location_candidates(profile) - if not candidates: - self.status_label.setText("Could not determine profile location.") + profile_id = int(self.update_status.current_profile_id) + except (TypeError, ValueError): + self.status_label.setText("Could not determine profile.") return profile_data = { - 'id': int(profile_id), - 'use_ticket': which_ticket, - 'ticket_location': candidates[0], - 'profile': profile + 'id': profile_id, + 'use_ticket': which_ticket } menu_page = self.custom_window.navigator.get_cached("menu") if menu_page: diff --git a/gui/v2/ui/popups/operation_result_popup.py b/gui/v2/ui/popups/operation_result_popup.py new file mode 100755 index 0000000..c2f92f6 --- /dev/null +++ b/gui/v2/ui/popups/operation_result_popup.py @@ -0,0 +1,200 @@ +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_ACTION_BUTTON_RED_QSS, + POPUP_BG_QSS, + POPUP_CANCEL_BUTTON_QSS, +) + + +class OperationResultPopup(QDialog): + action_selected = pyqtSignal(bool) + + 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.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() + + 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("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) + + 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(False)) + 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() + + 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 _choose(self, accepted): + if self._completed: + return + self._completed = True + self.action_selected.emit(accepted) + self.accept() + + def closeEvent(self, event): + if not self._completed: + self._completed = True + self.action_selected.emit(False) + 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() diff --git a/gui/v2/workers/ticketing_worker_thread.py b/gui/v2/workers/ticketing_worker_thread.py index 1ea0d74..a8ac19d 100755 --- a/gui/v2/workers/ticketing_worker_thread.py +++ b/gui/v2/workers/ticketing_worker_thread.py @@ -7,7 +7,6 @@ from core.controllers.tickets.FailedVerificationController import ( evaluate_if_its_the_key, prepare_tickets_with_saved_blind_sigs, ) -from core.controllers.tickets.UseTicketController import use_ticket from gui.v2.infrastructure.setup_observers import ( connection_observer, @@ -22,7 +21,6 @@ class TicketingWorkerThread(QThread): not_paid = pyqtSignal() paid_check_failed = pyqtSignal(str) prep_done = pyqtSignal(object) - use_done = pyqtSignal(object) failed_verification_evaluated = pyqtSignal(object) saved_blind_prep_done = pyqtSignal(object) error = pyqtSignal(str) @@ -71,14 +69,5 @@ class TicketingWorkerThread(QThread): elif self.action == 'PREPARE_SAVED_BLIND_SIGS': result = prepare_tickets_with_saved_blind_sigs(ticket_observer, connection_observer) self.saved_blind_prep_done.emit(result) - elif self.action == 'USE_TICKET': - result = use_ticket( - which_ticket=self.params['which_ticket'], - which_location=self.params['which_location'], - ticket_observer=ticket_observer, - connection_observer=connection_observer, - profile=self.params['profile'] - ) - self.use_done.emit(result) except Exception as e: self.error.emit(str(e)) diff --git a/gui/v2/workers/worker.py b/gui/v2/workers/worker.py index 736107f..81a0cfe 100755 --- a/gui/v2/workers/worker.py +++ b/gui/v2/workers/worker.py @@ -24,8 +24,9 @@ from core.Errors import ( UnsupportedApplicationVersionError, ) -from gui.v2.actions.locations import location_candidates from gui.v2.actions.database_health import GuiStorageDatabaseError +from gui.v2.actions.operation_result_dispatch import dispatch_result_action +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, @@ -39,6 +40,7 @@ class Worker(QObject): update_signal = pyqtSignal(str, bool, int, int, str) change_page = pyqtSignal(str, bool) ticket_data_loss = pyqtSignal(str, str) + operation_failed = pyqtSignal(object) def __init__(self, profile_data): self.profile_data = profile_data @@ -50,6 +52,8 @@ class Worker(QObject): self.profile_type = None self._ticket_error_emitted = False self._consumed_ticket = None + self._pending_operation_result = None + self._pending_operation_kwargs = {} def run(self): try: @@ -70,8 +74,7 @@ class Worker(QObject): if 'use_ticket' in self.profile_data: ticket_billing_code = self._consume_ticket( - self.profile_data['use_ticket'], - self.profile_data.get('ticket_location')) + self.profile_data['use_ticket']) if ticket_billing_code is None: return self.profile_data['billing_code'] = ticket_billing_code @@ -84,18 +87,20 @@ class Worker(QObject): self.profile_data['billing_code'] = ticket_billing_code if 'billing_code' in self.profile_data: - subscription = SubscriptionController.get( - self.profile_data['billing_code'], connection_observer=connection_observer) + try: + 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: ProfileController.attach_subscription( self.profile, subscription) else: if self._consumed_ticket is not None: - self._ticket_error_emitted = True - self.ticket_data_loss.emit( - self._consumed_ticket, - str(self.profile_data.get('billing_code', '')), - ) + self._emit_subscription_lookup_failure() return self.change_page.emit('The billing code is invalid.', True) return @@ -108,15 +113,21 @@ 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._emit_operation_failure(enable_result) + return except EndpointVerificationError: self.update_signal.emit( "ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None) except (InvalidSubscriptionError, MissingSubscriptionError) as e: - self.change_page.emit( - f"Subscription missing or invalid for profile {self.profile_data['id']}", True) + if self._consumed_ticket is not None: + 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: self.update_signal.emit( "The profile could not be enabled", False, None, None, None) @@ -170,9 +181,6 @@ class Worker(QObject): return None - def _location_candidates(self, preferred=None): - return location_candidates(self.profile, preferred) - def _profile_has_valid_subscription(self): try: sub = getattr(self.profile, 'subscription', None) @@ -201,55 +209,137 @@ class Worker(QObject): try: print("using a random ticket from GUI...") 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 if error_msg: - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {error_msg}', True) + self._emit_ticket_failure( + None, + 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': return None - return self._consume_ticket(which_ticket, None) + return self._consume_ticket(which_ticket) - def _consume_ticket(self, which_ticket, which_location): - candidates = self._location_candidates(preferred=which_location) - if not candidates: - self._ticket_error_emitted = True - self.change_page.emit('Could not determine profile location for ticket use.', True) + def _ticket_location_id(self): + location = getattr(self.profile, 'location', None) + location_id = getattr(location, 'id', None) + if location_id is None: + return None + if isinstance(location_id, str): + location_id = location_id.strip() + if location_id == '': + return None + try: + return int(location_id) + except (TypeError, ValueError): return None - last_msg = None - for cand in candidates: - try: - outcome = use_ticket( - which_ticket=which_ticket, - which_location=cand, - ticket_observer=ticket_observer, - connection_observer=connection_observer, - profile=self.profile - ) - except Exception as e: - last_msg = str(e) - continue - if not isinstance(outcome, dict): - last_msg = 'invalid_response' - continue - billing_code = outcome.get('billing_code') - if outcome.get('valid') and billing_code: - self._consumed_ticket = str(which_ticket) - return billing_code - if billing_code: - self._consumed_ticket = str(which_ticket) - return billing_code - msg = outcome.get('message', 'failed') - last_msg = msg - if msg != 'invalid_location': - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {msg}', True) - return None - + def _emit_ticket_failure(self, which_ticket, result=None, message=None, exception=None): self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {last_msg or "no valid location"}', True) + 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._emit_operation_failure(result, which_ticket=which_ticket) + + def _emit_operation_failure(self, result, **kwargs): + self._pending_operation_result = result + self._pending_operation_kwargs = { + key: value for key, value in kwargs.items() + if value is not None + } + self.operation_failed.emit(result) + + def handle_operation_popup_choice(self, accepted): + if not accepted: + return None + if not isinstance(self._pending_operation_result, Result): + return None + return dispatch_result_action( + self._pending_operation_result, + **self._pending_operation_kwargs, + ) + + 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=Result( + valid=False, + error_type=ResultError.CONNECTION, + message=message, + ), + ) + + def _consume_ticket(self, which_ticket): + which_location = self._ticket_location_id() + if which_location is None: + self._emit_ticket_failure( + which_ticket, + result=Result( + valid=False, + error_type=ResultError.MISSING_DATA, + message='Could not determine profile location for ticket use.', + ), + ) + return None + + try: + outcome = use_ticket( + which_ticket=which_ticket, + which_location=which_location, + ticket_observer=ticket_observer, + connection_observer=connection_observer, + profile=self.profile + ) + except Exception as e: + self._emit_ticket_failure(which_ticket, exception=e) + return None + + 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=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) + return billing_code + + self._emit_ticket_failure(which_ticket, result=outcome) return None def handle_profile_status(self, profile, is_enabled):