From d5eff5b7ace76500680097306f67fc1b9272d51a Mon Sep 17 00:00:00 2001 From: JOhn Date: Sun, 10 May 2026 19:07:04 -0400 Subject: [PATCH] update: new ui for ticket system --- gui/__main__.py | 979 ++++++++++++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- 2 files changed, 941 insertions(+), 40 deletions(-) diff --git a/gui/__main__.py b/gui/__main__.py index 8794bc8..74158e2 100755 --- a/gui/__main__.py +++ b/gui/__main__.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QComboBox, QButtonGroup, QLineEdit, QMainWindow, QLabel, QWidget, QStackedWidget, QApplication, QPushButton, QTextEdit, QFrame, QHBoxLayout, QVBoxLayout, QScrollArea, QSystemTrayIcon, QMessageBox, QGraphicsDropShadowEffect, QGridLayout, QCheckBox, QStackedLayout, QGroupBox, QDialog +from PyQt6.QtWidgets import QComboBox, QButtonGroup, QLineEdit, QMainWindow, QLabel, QWidget, QStackedWidget, QApplication, QPushButton, QTextEdit, QFrame, QHBoxLayout, QVBoxLayout, QScrollArea, QSystemTrayIcon, QMessageBox, QGraphicsDropShadowEffect, QGridLayout, QCheckBox, QStackedLayout, QGroupBox, QDialog, QPlainTextEdit, QListWidget, QListWidgetItem from PyQt6.QtGui import QIcon, QPixmap, QTransform, QPainter, QColor, QFont, QFontDatabase from PyQt6 import QtGui from PyQt6 import QtCore @@ -34,6 +34,17 @@ from core.observers.ClientObserver import ClientObserver from core.observers.ConnectionObserver import ConnectionObserver from core.observers.InvoiceObserver import InvoiceObserver from core.observers.ProfileObserver import ProfileObserver +from core.observers.TicketObserver import TicketObserver +from core.controllers.tickets.TicketSyncController import sync_ticket_prices +from core.controllers.tickets.TicketPayController import initiate_payment, check_if_paid +from core.controllers.tickets.TicketPrepController import prepare_tickets +from core.controllers.tickets.UseTicketController import ( + use_ticket, + modify_random_tickets_setting, + get_unused_tickets, + pick_a_random_ticket, + do_we_use_a_random_ticket, +) from datetime import datetime, timezone, timedelta from core.Constants import Constants import json @@ -45,6 +56,56 @@ client_observer = ClientObserver() connection_observer = ConnectionObserver() invoice_observer = InvoiceObserver() profile_observer = ProfileObserver() +ticket_observer = TicketObserver() + + +class TerminalWidget(QPlainTextEdit): + line_appended = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + font = QFont() + font.setFamily("Courier New") + font.setPointSize(11) + font.setWeight(QFont.Weight.Bold) + self.setFont(font) + self.setStyleSheet(""" + QPlainTextEdit { + background-color: #0a0e27; + color: #0fff50; + border: 3px solid #0fff50; + border-radius: 8px; + padding: 10px; + font-family: 'Courier New', 'Consolas', monospace; + font-size: 11pt; + font-weight: bold; + } + """) + self.line_appended.connect(self._append_on_main_thread) + self._observer = None + self._topics = [] + self._callbacks = [] + + def _append_on_main_thread(self, text): + self.appendPlainText(text) + + def bind_observer(self, observer, topics): + self._observer = observer + self._topics = list(topics) + for topic in self._topics: + cb = lambda event, t=topic: self.line_appended.emit(self._format_event(t, event)) + observer.subscribe(topic, cb) + self._callbacks.append((topic, cb)) + + def _format_event(self, topic, event): + subject = getattr(event, 'subject', None) + if subject is None: + return f"[{topic}]" + return f"[{topic}] {subject}" + + def append(self, text): + self.line_appended.emit(str(text)) class WorkerThread(QThread): @@ -419,6 +480,18 @@ class CustomWindow(QMainWindow): connection_observer.subscribe( 'tor_bootstrapped', lambda event: self.update_status('Tor connection established.')) + ticket_observer.subscribe('connecting', lambda event: self.update_status('Connecting to ticket server...')) + ticket_observer.subscribe('sync_done', lambda event: self.update_status('Ticket prices synced.')) + ticket_observer.subscribe('waiting', lambda event: self.update_status('Waiting for payment...')) + ticket_observer.subscribe('paid', lambda event: self.update_status('Payment received.')) + ticket_observer.subscribe('ticket_ready', lambda event: self.update_status('Ticket ready.')) + ticket_observer.subscribe('used', lambda event: self.update_status('Ticket used.')) + ticket_observer.subscribe('connection_error', lambda event: self.update_status('Ticket server connection error.')) + ticket_observer.subscribe('failed_output', lambda event: self.update_status(f'Ticket server error: {event.subject if event.subject else ""}')) + ticket_observer.subscribe('failed_input', lambda event: self.update_status(f'Invalid ticket input: {event.subject if event.subject else ""}')) + ticket_observer.subscribe('unknown_error', lambda event: self.update_status('Unknown ticket error.')) + ticket_observer.subscribe('error', lambda event: self.update_status(f'Ticket error: {event.subject if event.subject else ""}')) + self.setFixedSize(800, 570) self.central_widget = QWidget(self) self.central_widget.setMaximumSize(800, 600) @@ -986,7 +1059,11 @@ class CustomWindow(QMainWindow): FastRegistrationPage(self.page_stack, self), Settings(self.page_stack, self), ConnectionPage(self.page_stack, self), - SyncScreen(self.page_stack, self)] + SyncScreen(self.page_stack, self), + PlanPickerPage(self.page_stack, self), + TicketCryptoPickerPage(self.page_stack, self), + TicketPrepPage(self.page_stack, self), + TicketOrBillingChoicePage(self.page_stack, self)] for page in self.pages: self.page_stack.addWidget(page) # Conectar la señal currentChanged al método page_changed @@ -1305,6 +1382,17 @@ class Worker(QObject): def run(self): self.profile = ProfileController.get(int(self.profile_data['id'])) + if 'use_ticket' in self.profile_data: + ticket_billing_code = self._consume_ticket( + self.profile_data['use_ticket'], + self.profile_data.get('ticket_location')) + if ticket_billing_code is None: + return + self.profile_data['billing_code'] = ticket_billing_code + elif 'billing_code' not in self.profile_data and self.profile is not None: + ticket_billing_code = self._maybe_auto_use_ticket() + if ticket_billing_code: + self.profile_data['billing_code'] = ticket_billing_code if 'billing_code' in self.profile_data: subscription = SubscriptionController.get( @@ -1358,6 +1446,80 @@ class Worker(QObject): self.update_signal.emit( f"No profile found with ID: {self.profile_data['id']}", False, None, None, None) + def _location_candidates(self, 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 self.profile and self.profile.connection and self.profile.connection.location: + sources.append(self.profile.connection.location) + except Exception: + pass + try: + if self.profile and self.profile.location: + sources.append(self.profile.location) + except Exception: + pass + + for source in sources: + add(getattr(source, 'country_name', None)) + add(getattr(source, 'name', None)) + add(getattr(source, 'code', None)) + add(getattr(source, 'country_code', None)) + + return candidates + + def _maybe_auto_use_ticket(self): + try: + which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) + except Exception: + return None + if which_ticket is None or which_ticket == 'error' or error_msg: + return None + return self._consume_ticket(which_ticket, None) + + def _consume_ticket(self, which_ticket, which_location): + candidates = self._location_candidates(preferred=which_location) + if not candidates: + self.change_page.emit('Could not determine profile location for ticket use.', True) + return None + + last_msg = None + for cand in candidates: + try: + outcome = use_ticket(which_ticket, cand, ticket_observer, connection_observer) + 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: + return billing_code + if billing_code: + return billing_code + msg = outcome.get('message', 'failed') + last_msg = msg + if msg != 'invalid_location': + self.change_page.emit(f'Ticket use failed: {msg}', True) + return None + + self.change_page.emit(f'Ticket use failed: {last_msg or "no valid location"}', True) + return None + def handle_profile_status(self, profile, is_enabled): profile_id = profile.id profile_connection = str(profile.connection.code) @@ -2277,13 +2439,34 @@ class MenuPage(Page): def _handle_popup_result(self, result, change_screen=False): if result: if change_screen: - self.page_stack.setCurrentIndex( - self.page_stack.indexOf(self.page_stack.findChild(IdPage))) + self._route_to_billing_entry() else: self.get_billing_code_by_id() else: self.popup.close() + def _route_to_billing_entry(self): + try: + unused = get_unused_tickets(ticket_observer) + random_setting, _ = do_we_use_a_random_ticket(ticket_observer) + except Exception: + unused = {'valid': False} + random_setting = None + + has_tickets = isinstance(unused, dict) and unused.get('valid') and len(unused.get('data', [])) > 0 + random_off = random_setting is None + + if has_tickets and random_off: + chooser = self.page_stack.findChild(TicketOrBillingChoicePage) + if chooser: + chooser.populate() + self.page_stack.setCurrentWidget(chooser) + return + + id_page = self.page_stack.findChild(IdPage) + if id_page: + self.page_stack.setCurrentWidget(id_page) + def disconnect(self): self.disconnect_button.setEnabled(False) self.disconnect_system_wide_button.setEnabled(False) @@ -2534,8 +2717,7 @@ class MenuPage(Page): lambda result: self._handle_popup_result(result, True)) self.popup.show() else: - self.page_stack.setCurrentIndex( - self.page_stack.indexOf(self.page_stack.findChild(IdPage))) + self._route_to_billing_entry() self.boton_just.setEnabled(True) self.boton_just_session.setEnabled(True) @@ -4807,7 +4989,6 @@ class ResumePage(Page): self.labels_creados.append(nostr_txt) elif connection_exists: - print('inside connection_exists') if profile_1.get("connection", "") == "system-wide": image_path = os.path.join( self.btn_path, f"wireguard_{profile_1.get('location', '')}.png") @@ -6157,6 +6338,7 @@ class Settings(Page): menu_items = [ ("Overview", self.show_account_page), ("Subscriptions", self.show_subscription_page), + ("Tickets", self.show_tickets_page), ("Create/Edit", self.show_registrations_page), ("Verification", self.show_verification_page), ("Legacy-Version", self.show_systemwide_page), @@ -7411,6 +7593,10 @@ class Settings(Page): self.subscription_page = self.create_subscription_page() self.content_layout.addWidget(self.subscription_page) + self.content_layout.removeWidget(self.tickets_page) + self.tickets_page = self.create_tickets_page() + self.content_layout.addWidget(self.tickets_page) + self.content_layout.removeWidget(self.registrations_page) self.registrations_page = self.create_registrations_page() self.content_layout.addWidget(self.registrations_page) @@ -7635,6 +7821,7 @@ class Settings(Page): def setup_pages(self): self.account_page = self.create_account_page() self.subscription_page = self.create_subscription_page() + self.tickets_page = self.create_tickets_page() self.registrations_page = self.create_registrations_page() self.verification_page = self.create_verification_page() self.systemwide_page = self.create_systemwide_page() @@ -7645,6 +7832,7 @@ class Settings(Page): self.content_layout.addWidget(self.account_page) self.content_layout.addWidget(self.subscription_page) + self.content_layout.addWidget(self.tickets_page) self.content_layout.addWidget(self.registrations_page) self.content_layout.addWidget(self.verification_page) self.content_layout.addWidget(self.systemwide_page) @@ -7664,6 +7852,11 @@ class Settings(Page): self.content_layout.setCurrentWidget(self.subscription_page) self._select_menu_button("Subscriptions") + def show_tickets_page(self): + self.content_layout.setCurrentWidget(self.tickets_page) + self._select_menu_button("Tickets") + self._refresh_tickets_inventory() + def show_registrations_page(self): self.content_layout.setCurrentWidget(self.registrations_page) self._select_menu_button("Create/Edit") @@ -7699,6 +7892,89 @@ class Settings(Page): else: btn.setChecked(False) + def create_tickets_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("TICKETS") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + random_group = QGroupBox("Random Ticket Use") + random_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + random_layout = QVBoxLayout(random_group) + + self.random_tickets_checkbox = QCheckBox("Use a random ticket automatically when enabling a profile") + self.random_tickets_checkbox.setStyleSheet(f"color: white; {self.font_style}") + try: + which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) + self.random_tickets_checkbox.setChecked( + which_ticket is not None and which_ticket != 'error') + except Exception: + self.random_tickets_checkbox.setChecked(False) + self.random_tickets_checkbox.toggled.connect(self._on_random_toggle) + random_layout.addWidget(self.random_tickets_checkbox) + layout.addWidget(random_group) + + inventory_group = QGroupBox("Unused Tickets") + inventory_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + inventory_layout = QVBoxLayout(inventory_group) + + self.tickets_inventory_label = QLabel("Loading tickets...") + self.tickets_inventory_label.setStyleSheet( + f"color: white; font-size: 13px; {self.font_style}") + self.tickets_inventory_label.setWordWrap(True) + inventory_layout.addWidget(self.tickets_inventory_label) + + self.refresh_tickets_button = QPushButton("Refresh") + self.refresh_tickets_button.setFixedSize(120, 32) + self.refresh_tickets_button.setStyleSheet(f""" + QPushButton {{ + background: #00aaff; color: white; border: none; + border-radius: 5px; font-weight: bold; {self.font_style} + }} + QPushButton:hover {{ background: #0088cc; }} + """) + self.refresh_tickets_button.clicked.connect(self._refresh_tickets_inventory) + inventory_layout.addWidget(self.refresh_tickets_button) + layout.addWidget(inventory_group) + + layout.addStretch() + return page + + def _on_random_toggle(self, checked): + try: + result = modify_random_tickets_setting('on' if checked else 'off', ticket_observer) + if not (isinstance(result, dict) and result.get('valid')): + msg = result.get('message', 'failed') if isinstance(result, dict) else 'failed' + self.update_status.update_status(f'Could not change setting: {msg}') + except Exception as e: + self.update_status.update_status(f'Could not change setting: {e}') + + def _refresh_tickets_inventory(self): + if not hasattr(self, 'tickets_inventory_label'): + return + try: + unused = get_unused_tickets(ticket_observer) + except Exception as e: + self.tickets_inventory_label.setText(f"Error: {e}") + return + if isinstance(unused, dict) and unused.get('valid'): + data = unused.get('data', []) + if data: + self.tickets_inventory_label.setText( + f"You have {len(data)} unused tickets: " + ", ".join(f"#{t}" for t in data)) + else: + self.tickets_inventory_label.setText("No unused tickets.") + else: + msg = unused.get('message', 'No tickets found.') if isinstance(unused, dict) else 'No tickets found.' + self.tickets_inventory_label.setText(msg) + def create_logs_page(self): page = QWidget() layout = QVBoxLayout(page) @@ -8166,50 +8442,107 @@ class IdPage(Page): def create_interface_elements(self): self.object_selected = None - self.display.setGeometry(QtCore.QRect(5, 50, 550, 460)) - self.title = QLabel("Entry Id", self) - self.title.setGeometry(QtCore.QRect(560, 50, 220, 40)) - self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.display.setGeometry(QtCore.QRect(0, 0, 0, 0)) self.button_reverse.clicked.connect(self.reverse) self.button_go.clicked.connect(self.go_selected) - objects_info = [ - (QPushButton, os.path.join(self.btn_path, "new_id.png"), - self.show_next, DurationSelectionPage, (575, 100, 185, 75)), - (QLabel, os.path.join(self.btn_path, "button230x220.png"), - None, None, (550, 220, 250, 220)), - (QTextEdit, None, self.validate_password, - DurationSelectionPage, (550, 230, 230, 190)) - ] + column_title_style = "color: #00ffff; font-size: 18px; font-weight: bold;" + column_button_style = """ + QPushButton { + background-color: #000000; + color: #00ffff; + border: 2px solid #00ffff; + border-radius: 10px; + font-size: 15px; + font-weight: bold; + padding: 10px; + } + QPushButton:hover { background-color: #003333; } + """ + column_desc_style = "color: #888888; font-size: 12px; font-style: italic;" - for obj_type, icon_name, function, page_class, geometry in objects_info: - obj = obj_type(self) - obj.setGeometry(*geometry) - if isinstance(obj, QPushButton): - obj.setIconSize(QtCore.QSize(190, 120)) - obj.setIcon(QtGui.QIcon(icon_name)) - obj.clicked.connect( - lambda _, func=function, page=page_class: self.show_object_selected(func, page)) + self.single_title = QLabel("Single Profile", self) + self.single_title.setGeometry(15, 50, 240, 40) + self.single_title.setStyleSheet(column_title_style) + self.single_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) - elif isinstance(obj, QLabel): - obj.setPixmap(QPixmap(icon_name).scaled( - obj.size(), Qt.AspectRatioMode.KeepAspectRatio)) + self.single_button = QPushButton("New Billing ID", self) + self.single_button.setGeometry(40, 220, 200, 100) + self.single_button.setStyleSheet(column_button_style) + self.single_button.clicked.connect(self.go_single_profile) - elif isinstance(obj, QTextEdit): - obj.setPlaceholderText( - "Or use an existing billing Id, enter it here") - obj.textChanged.connect(self.toggle_button_state) - self.text_edit = obj + self.single_desc = QLabel("Buy a single billing for one profile.", self) + self.single_desc.setGeometry(15, 335, 240, 40) + self.single_desc.setStyleSheet(column_desc_style) + self.single_desc.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.single_desc.setWordWrap(True) + + self.multiple_title = QLabel("Multiple Profiles", self) + self.multiple_title.setGeometry(285, 50, 240, 40) + self.multiple_title.setStyleSheet(column_title_style) + self.multiple_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.multiple_button = QPushButton("Multiple Profiles\nat Once", self) + self.multiple_button.setGeometry(310, 220, 200, 100) + self.multiple_button.setStyleSheet(column_button_style) + self.multiple_button.clicked.connect(self.go_multiple_profiles) + + self.multiple_desc = QLabel( + f"Buy a bundle of {HOW_MANY_PROFILES_DEFAULT} tickets upfront.", self) + self.multiple_desc.setGeometry(285, 335, 240, 40) + self.multiple_desc.setStyleSheet(column_desc_style) + self.multiple_desc.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.multiple_desc.setWordWrap(True) + + self.title = QLabel("Entry Id", self) + self.title.setGeometry(QtCore.QRect(555, 50, 230, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.title.setStyleSheet(column_title_style) self.note_label = QLabel( - "Note: Billing IDs are tied to a single location", self) - self.note_label.setGeometry(1, 100, 500, 20) - self.note_label.setStyleSheet( - "color: white; font-size: 17px; font-style: italic; font-weight: bold;") + "Billing IDs are tied to a single location", self) + self.note_label.setGeometry(555, 90, 230, 50) + self.note_label.setStyleSheet(column_desc_style) self.note_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.note_label.setWordWrap(True) self.note_label.show() + self.id_bg_label = QLabel(self) + self.id_bg_label.setGeometry(550, 220, 250, 220) + self.id_bg_label.setPixmap(QPixmap(os.path.join(self.btn_path, "button230x220.png")).scaled( + self.id_bg_label.size(), Qt.AspectRatioMode.KeepAspectRatio)) + + self.text_edit = QTextEdit(self) + self.text_edit.setGeometry(550, 230, 230, 190) + self.text_edit.setPlaceholderText("Enter your existing billing Id here") + self.text_edit.textChanged.connect(self.toggle_button_state) + + separator_style = "color: #00ffff; background-color: #00ffff;" + self.separator_left = QFrame(self) + self.separator_left.setFrameShape(QFrame.Shape.VLine) + self.separator_left.setFrameShadow(QFrame.Shadow.Plain) + self.separator_left.setGeometry(270, 60, 2, 420) + self.separator_left.setStyleSheet(separator_style) + + self.separator_right = QFrame(self) + self.separator_right.setFrameShape(QFrame.Shape.VLine) + self.separator_right.setFrameShadow(QFrame.Shadow.Plain) + self.separator_right.setGeometry(540, 60, 2, 420) + self.separator_right.setStyleSheet(separator_style) + + def go_single_profile(self): + self.text_edit.clear() + duration_page = self.page_stack.findChild(DurationSelectionPage) + if duration_page: + self.page_stack.setCurrentWidget(duration_page) + + def go_multiple_profiles(self): + plan_page = self.page_stack.findChild(PlanPickerPage) + if plan_page: + plan_page.start_sync() + self.page_stack.setCurrentWidget(plan_page) + def toggle_button_state(self): text = self.text_edit.toPlainText() if text.strip(): @@ -9467,6 +9800,13 @@ class PaymentDetailsPage(Page): self.selected_duration = None self.selected_currency = None + self.ticketing = False + self.temp_billing_code = None + self._ticket_check_running = False + self._ticket_worker = None + self.ticket_poll_timer = QTimer(self) + self.ticket_poll_timer.timeout.connect(self._tick_check_paid) + self.create_interface_elements() self.button_reverse.setVisible(True) @@ -9768,10 +10108,94 @@ class PaymentDetailsPage(Page): f'An error occurred when copying the text') def reverse(self): + if self.ticket_poll_timer.isActive(): + self.ticket_poll_timer.stop() + if self.ticketing: + self.ticketing = False + crypto_page = self.page_stack.findChild(TicketCryptoPickerPage) + if crypto_page: + self.page_stack.setCurrentWidget(crypto_page) + return currency_page = self.page_stack.findChild(CurrencySelectionPage) if currency_page: self.page_stack.setCurrentWidget(currency_page) + def set_ticket_invoice(self, invoice, plan_key): + self.ticketing = True + self.temp_billing_code = getattr(invoice, 'temp_billing_code', None) + raw_currency = getattr(invoice, 'selected_currency', None) or '' + self.selected_currency = self._normalize_currency(raw_currency) + self.selected_duration = f"Plan {plan_key}" if plan_key else "Plan" + self._populate_ticket_fields(invoice) + if self.ticket_poll_timer.isActive(): + self.ticket_poll_timer.stop() + self.ticket_poll_timer.start(3000) + self.update_status.update_status('Awaiting ticket payment...') + + def _populate_ticket_fields(self, invoice): + self.text_fields[0].setText(str(getattr(invoice, 'temp_billing_code', '') or '')) + self.text_fields[1].setText(self.selected_duration) + self.text_fields[1].setStyleSheet('font-size: 13px;') + amount = getattr(invoice, 'due_amount', '') or '' + self.text_fields[2].setText(str(amount)) + addr = str(getattr(invoice, 'address', '') or '') + self.full_address = addr + metrics = self.text_fields[3].fontMetrics() + width = self.text_fields[3].width() + elided = metrics.elidedText(addr, QtCore.Qt.TextElideMode.ElideMiddle, width) + self.text_fields[3].setText(elided) + currency_index_map = {'monero': 0, 'bitcoin': 1, 'lightning': 2, 'litecoin': 3} + idx = currency_index_map.get(self.selected_currency, -1) + if idx >= 0: + for i, btn in enumerate(self.currency_display_buttons): + btn.setChecked(i == idx) + btn.setEnabled(i == idx) + self.qr_code_button.setDisabled(False) + + def _normalize_currency(self, raw): + if not raw: + return None + raw = str(raw).lower() + m = { + 'monero': 'monero', 'xmr': 'monero', + 'bitcoin': 'bitcoin', 'btc': 'bitcoin', + 'lightning': 'lightning', 'btc-ln': 'lightning', 'ln': 'lightning', + 'litecoin': 'litecoin', 'ltc': 'litecoin', + } + return m.get(raw, raw) + + def _tick_check_paid(self): + if not self.temp_billing_code or self._ticket_check_running: + return + self._ticket_check_running = True + self._ticket_worker = TicketingWorkerThread('CHECK_PAID', params={ + 'temp_billing_code': self.temp_billing_code + }) + self._ticket_worker.paid.connect(self._on_ticket_paid) + self._ticket_worker.not_paid.connect(self._on_ticket_not_paid) + self._ticket_worker.paid_check_failed.connect(self._on_ticket_check_failed) + self._ticket_worker.error.connect(self._on_ticket_check_failed) + self._ticket_worker.finished.connect(self._on_ticket_check_done) + self._ticket_worker.start() + + def _on_ticket_check_done(self): + self._ticket_check_running = False + + def _on_ticket_paid(self): + self.ticket_poll_timer.stop() + self.ticketing = False + self.update_status.update_status('Payment received.') + prep_page = self.page_stack.findChild(TicketPrepPage) + if prep_page: + prep_page.start_prep() + self.page_stack.setCurrentWidget(prep_page) + + def _on_ticket_not_paid(self): + self.update_status.update_status('Awaiting ticket payment...') + + def _on_ticket_check_failed(self, msg): + self.update_status.update_status(f'Payment check failed: {msg}') + def show_qr_code(self): full_amount = self.text_fields[2].text() if hasattr(self, 'full_address') and self.full_address: @@ -9792,6 +10216,483 @@ class PaymentDetailsPage(Page): return total_hours +HOW_MANY_PROFILES_DEFAULT = 6 + + +class TicketingWorkerThread(QThread): + sync_done = pyqtSignal(object) + invoice_ready = pyqtSignal(object) + paid = pyqtSignal() + not_paid = pyqtSignal() + paid_check_failed = pyqtSignal(str) + prep_done = pyqtSignal(object) + use_done = pyqtSignal(object) + error = pyqtSignal(str) + + def __init__(self, action, params=None): + super().__init__() + self.action = action + self.params = params or {} + + def run(self): + try: + if self.action == 'SYNC_PRICES': + result = sync_ticket_prices(ticket_observer, connection_observer) + self.sync_done.emit(result) + elif self.action == 'INITIATE_PAYMENT': + result = initiate_payment( + self.params['how_many_profiles'], + self.params['which_key'], + self.params['which_cryptocurrency'], + ticket_observer, + connection_observer, + self.params.get('bypass_existing', False), + ) + self.invoice_ready.emit(result) + elif self.action == 'CHECK_PAID': + result = check_if_paid(self.params['temp_billing_code'], ticket_observer, connection_observer) + if isinstance(result, dict) and result.get('valid') and result.get('payment_status') == 'paid': + self.paid.emit() + elif isinstance(result, dict) and result.get('valid'): + self.not_paid.emit() + else: + err = 'unknown' + if isinstance(result, dict): + err = str(result.get('error_code') or result.get('message') or 'unknown') + self.paid_check_failed.emit(err) + elif self.action == 'PREPARE_TICKETS': + result = prepare_tickets(self.params['how_many_profiles'], ticket_observer, connection_observer) + self.prep_done.emit(result) + elif self.action == 'USE_TICKET': + result = use_ticket( + self.params['which_ticket'], + self.params['which_location'], + ticket_observer, + connection_observer, + ) + self.use_done.emit(result) + except Exception as e: + self.error.emit(str(e)) + + +class PlanPickerPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("PickPlan", page_stack, main_window, parent) + self.update_status = main_window + self.pricing_data = None + self.worker = None + + self.title.setText("Pick a Plan") + self.title.setGeometry(QtCore.QRect(290, 30, 220, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + + self.intro_label = QLabel( + "All members of the group expire at the same time. How much you pay depends on when you join.", + self) + self.intro_label.setGeometry(40, 90, 720, 40) + self.intro_label.setStyleSheet("color: white; font-size: 13px;") + self.intro_label.setWordWrap(True) + self.intro_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.plan_list = QListWidget(self) + self.plan_list.setGeometry(60, 140, 680, 320) + self.plan_list.setStyleSheet(""" + QListWidget { + background-color: #000000; + color: #00ffff; + border: 2px solid #00ffff; + border-radius: 8px; + font-size: 14px; + } + QListWidget::item { + padding: 12px; + border-bottom: 1px solid #003333; + } + QListWidget::item:hover { background-color: #003333; } + QListWidget::item:selected { background-color: #006666; color: white; } + """) + self.plan_list.itemClicked.connect(self.on_plan_clicked) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(60, 470, 680, 30) + self.status_label.setStyleSheet("color: #cccccc; font-size: 13px;") + self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + def start_sync(self): + self.plan_list.clear() + self.status_label.setText("Syncing prices...") + self.update_status.update_status("Syncing ticket prices...") + self.worker = TicketingWorkerThread('SYNC_PRICES') + self.worker.sync_done.connect(self.on_sync_done) + self.worker.error.connect(self.on_error) + self.worker.start() + + def on_sync_done(self, pricing_data): + if isinstance(pricing_data, dict) and pricing_data.get('valid') is False: + err = pricing_data.get('error_code', 'unknown') + self.status_label.setText(f"Sync failed: {err}") + self.update_status.update_status(f"Sync failed: {err}") + return + + if isinstance(pricing_data, dict) and isinstance(pricing_data.get('data'), dict): + plans = pricing_data['data'] + elif isinstance(pricing_data, dict): + plans = pricing_data + else: + self.status_label.setText("Unexpected pricing data.") + return + + self.pricing_data = plans + self.populate_plans(plans) + self.status_label.setText("Pick a plan above.") + self.update_status.update_status("Plans loaded.") + + def populate_plans(self, pricing_data): + self.plan_list.clear() + if not isinstance(pricing_data, dict): + self.status_label.setText("Unexpected pricing data.") + return + for plan_key, plan_data in pricing_data.items(): + if not isinstance(plan_data, dict): + continue + cost = plan_data.get('cost', '?') + months = plan_data.get('months', '?') + days = plan_data.get('days', '?') + profiles = plan_data.get('profiles', '?') + label = f" Plan {plan_key} | €{cost} | {months}m {days}d left | {profiles} profiles" + item = QListWidgetItem(label) + item.setData(QtCore.Qt.ItemDataRole.UserRole, plan_key) + self.plan_list.addItem(item) + + def on_plan_clicked(self, item): + plan_key = item.data(QtCore.Qt.ItemDataRole.UserRole) + if not plan_key: + return + crypto_page = self.page_stack.findChild(TicketCryptoPickerPage) + if crypto_page: + crypto_page.set_selected_plan(plan_key) + self.page_stack.setCurrentWidget(crypto_page) + + def on_error(self, msg): + self.status_label.setText(f"Error: {msg}") + self.update_status.update_status(f"Sync error: {msg}") + + def reverse(self): + id_page = self.page_stack.findChild(IdPage) + if id_page: + self.page_stack.setCurrentWidget(id_page) + + +class TicketCryptoPickerPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("TicketCrypto", page_stack, main_window, parent) + self.update_status = main_window + self.selected_plan = None + self.bypass_existing = False + self.worker = None + + self.title.setText("Payment Method") + self.title.setGeometry(QtCore.QRect(510, 30, 250, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + + + button_info = [ + ("monero", "monero", (545, 75)), + ("bitcoin", "bitcoin", (545, 290)), + ("lightning", "lightnering", (545, 180)), + ("litecoin", "litecoin", (545, 395)), + ] + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + for j, (currency, icon_name, position) in enumerate(button_info): + button = QPushButton(self) + button.setGeometry(position[0], position[1], 185, 75) + button.setIconSize(QSize(190, 120)) + button.setCheckable(True) + button.setIcon(QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + button.setProperty('currency', currency) + self.buttons.append(button) + self.buttonGroup.addButton(button, j) + button.clicked.connect(self.on_currency_selected) + + + + def set_selected_plan(self, plan_key): + self.selected_plan = plan_key + self.bypass_existing = False + self.update_status.update_status(f"Selected plan: {plan_key}") + for btn in self.buttons: + btn.setChecked(False) + + def on_currency_selected(self): + selected_button = self.buttonGroup.checkedButton() + if not selected_button or not self.selected_plan: + return + currency = selected_button.property('currency') + self.start_initiate_payment(currency) + + def start_initiate_payment(self, currency): + self.update_status.update_status("Initiating payment...") + self.worker = TicketingWorkerThread('INITIATE_PAYMENT', params={ + 'how_many_profiles': HOW_MANY_PROFILES_DEFAULT, + 'which_key': self.selected_plan, + 'which_cryptocurrency': currency, + 'bypass_existing': self.bypass_existing, + }) + self.worker.invoice_ready.connect(self.on_invoice_ready) + self.worker.error.connect(self.on_error) + self.worker.start() + + def on_invoice_ready(self, invoice): + if invoice is None or invoice is False: + self.update_status.update_status("Could not initiate payment.") + return + + error_code = getattr(invoice, 'error_code', None) + if error_code == 'already_exists' and not self.bypass_existing: + self._prompt_wipe_existing(invoice) + return + if error_code: + msg = getattr(invoice, 'final_error_msg', None) or error_code + self.update_status.update_status(f"Payment error: {msg}") + return + + payment_page = self.page_stack.findChild(PaymentDetailsPage) + if payment_page: + payment_page.set_ticket_invoice(invoice, self.selected_plan) + self.page_stack.setCurrentWidget(payment_page) + + def _prompt_wipe_existing(self, invoice): + msg = QMessageBox(self) + msg.setWindowTitle("Existing tickets found") + msg.setText("You already have ticket data. Wipe it and start over?") + msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + result = msg.exec() + if result == QMessageBox.StandardButton.Yes: + self.bypass_existing = True + currency_btn = self.buttonGroup.checkedButton() + if currency_btn: + self.start_initiate_payment(currency_btn.property('currency')) + else: + self.update_status.update_status("Cancelled.") + + def on_error(self, msg): + self.update_status.update_status(f"Payment error: {msg}") + + def reverse(self): + plan_page = self.page_stack.findChild(PlanPickerPage) + if plan_page: + self.page_stack.setCurrentWidget(plan_page) + + +class TicketPrepPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("TicketPrep", page_stack, main_window, parent) + self.update_status = main_window + self.worker = None + self._terminal_bound = False + + self.title.setText("Preparing Tickets") + self.title.setGeometry(QtCore.QRect(280, 20, 240, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.terminal = TerminalWidget(self) + self.terminal.setGeometry(20, 70, 760, 380) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(20, 460, 760, 30) + self.status_label.setStyleSheet("color: #00ffff; font-size: 14px;") + self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.continue_button = QPushButton("Continue", self) + self.continue_button.setGeometry(340, 490, 120, 35) + self.continue_button.setStyleSheet(""" + QPushButton { + background-color: #2ecc71; color: white; border: none; + border-radius: 5px; font-size: 14px; font-weight: bold; + } + QPushButton:disabled { background-color: #555555; } + """) + self.continue_button.setEnabled(False) + self.continue_button.clicked.connect(self.on_continue) + + def _bind_terminal_once(self): + if self._terminal_bound: + return + self.terminal.bind_observer(ticket_observer, [ + 'preparing', 'connecting', 'sync_done', 'waiting', 'paid', + 'ticket_ready', 'used', 'failed_input', 'failed_output', + 'connection_error', 'unknown_error', 'error', + ]) + self._terminal_bound = True + + def start_prep(self): + self._bind_terminal_once() + self.terminal.append("=== Starting ticket preparation ===") + self.continue_button.setEnabled(False) + self.status_label.setText("Preparing tickets...") + self.update_status.update_status("Preparing tickets...") + + self.worker = TicketingWorkerThread('PREPARE_TICKETS', params={ + 'how_many_profiles': HOW_MANY_PROFILES_DEFAULT, + }) + self.worker.prep_done.connect(self.on_prep_done) + self.worker.error.connect(self.on_error) + self.worker.start() + + def on_prep_done(self, result): + if not isinstance(result, dict): + self.terminal.append("ERROR: invalid result format.") + self.status_label.setText("Preparation failed.") + return + if result.get('valid') is True: + self.terminal.append("=== Ticket preparation complete ===") + self.status_label.setText("Tickets ready. Click Continue to apply one to your profile.") + self.update_status.update_status("Tickets ready.") + self.continue_button.setEnabled(True) + else: + msg = result.get('message', 'failed') + self.terminal.append(f"ERROR: {msg}") + if msg == 'verification_failed': + how_many_failed = result.get('how_many_failed', '?') + failed_validations = result.get('failed_validations', []) + self.terminal.append(f" failed count: {how_many_failed}") + self.terminal.append(f" failed indices: {failed_validations}") + self.status_label.setText(f"Preparation failed: {msg}") + self.update_status.update_status(f"An error occurred") + + def on_error(self, msg): + self.terminal.append(f"EXCEPTION: {msg}") + self.status_label.setText(f"Error: {msg}") + + def on_continue(self): + menu_page = self.page_stack.findChild(MenuPage) + profile_id = getattr(self.update_status, 'current_profile_id', None) + if menu_page: + self.page_stack.setCurrentWidget(menu_page) + if profile_id is None or menu_page is None: + self.update_status.update_status("Tickets ready. Random ticket use is ON.") + return + self.update_status.update_status("Applying random ticket to profile...") + menu_page.enabling_profile({'id': int(profile_id)}) + + +class TicketOrBillingChoicePage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("TicketOrBilling", page_stack, main_window, parent) + self.update_status = main_window + + self.title.setText("Pick a Ticket or Use Billing") + self.title.setGeometry(QtCore.QRect(220, 30, 360, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + + self.intro = QLabel( + "Random ticket use is OFF. Pick a ticket to use, or fall back to a billing code.", + self) + self.intro.setGeometry(40, 90, 720, 40) + self.intro.setStyleSheet("color: white; font-size: 13px;") + self.intro.setWordWrap(True) + self.intro.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.ticket_list = QListWidget(self) + self.ticket_list.setGeometry(60, 140, 380, 320) + self.ticket_list.setStyleSheet(""" + QListWidget { + background-color: #000000; color: #00ffff; + border: 2px solid #00ffff; border-radius: 8px; + font-size: 14px; + } + QListWidget::item { padding: 10px; } + QListWidget::item:hover { background-color: #003333; } + QListWidget::item:selected { background-color: #006666; color: white; } + """) + + self.use_ticket_button = QPushButton("Use Selected Ticket", self) + self.use_ticket_button.setGeometry(60, 470, 380, 40) + self.use_ticket_button.setStyleSheet(""" + QPushButton { + background-color: #00aaff; color: white; border: none; + border-radius: 5px; font-size: 14px; font-weight: bold; + } + QPushButton:disabled { background-color: #555555; } + """) + self.use_ticket_button.clicked.connect(self.on_use_ticket) + + self.use_billing_button = QPushButton("Use a Billing Code Instead", self) + self.use_billing_button.setGeometry(470, 200, 280, 60) + self.use_billing_button.setStyleSheet(""" + QPushButton { + background-color: #000000; color: #00ffff; + border: 2px solid #00ffff; border-radius: 10px; + font-size: 14px; font-weight: bold; + } + QPushButton:hover { background-color: #003333; } + """) + self.use_billing_button.clicked.connect(self.on_use_billing) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(20, 520, 760, 20) + self.status_label.setStyleSheet("color: #cccccc; font-size: 12px;") + self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + def populate(self): + self.ticket_list.clear() + try: + unused = get_unused_tickets(ticket_observer) + except Exception as e: + self.status_label.setText(f"Error reading tickets: {e}") + return + if unused.get('valid'): + for t in unused.get('data', []): + item = QListWidgetItem(f"Ticket #{t}") + item.setData(QtCore.Qt.ItemDataRole.UserRole, t) + self.ticket_list.addItem(item) + else: + self.status_label.setText(unused.get('message', 'No tickets available.')) + + def on_use_ticket(self): + item = self.ticket_list.currentItem() + if not item: + 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 + location = '' + try: + profile = ProfileController.get(int(profile_id)) + if profile and profile.connection and profile.connection.location: + location = str(profile.connection.location.code) + except Exception: + pass + if not location: + self.status_label.setText("Could not determine profile location.") + return + profile_data = {'id': int(profile_id), 'use_ticket': which_ticket, 'ticket_location': location} + menu_page = self.page_stack.findChild(MenuPage) + if menu_page: + self.update_status.update_status(f"Using ticket #{which_ticket}...") + menu_page.enabling_profile(profile_data) + + def on_use_billing(self): + id_page = self.page_stack.findChild(IdPage) + if id_page: + self.page_stack.setCurrentWidget(id_page) + + def reverse(self): + menu_page = self.page_stack.findChild(MenuPage) + if menu_page: + self.page_stack.setCurrentWidget(menu_page) + + class FastRegistrationPage(Page): def __init__(self, page_stack, main_window): super().__init__("FastRegistration", page_stack, main_window) diff --git a/pyproject.toml b/pyproject.toml index 8cec662..d969837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ classifiers = [ "Operating System :: POSIX :: Linux", ] dependencies = [ - "sp-hydra-veil-core == 2.2.1", + "sp-hydra-veil-core == 2.3.1", "pyperclip ~= 1.9.0", "pyqt6 ~= 6.7.1", "qrcode[pil] ~= 8.2"