Compare commits
36 commits
refactor/p
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 766d4e29fe | |||
| 031704471b | |||
| ffea00e1ff | |||
| 4273baebc2 | |||
| 38535aee1c | |||
| d1dfa788db | |||
| 707935f80f | |||
| ea12aecf05 | |||
| 39925ad546 | |||
| 817b797701 | |||
| 8938643338 | |||
| 9a13ae1939 | |||
| 8b651aff0f | |||
| de19732055 | |||
| 9cb3d607b4 | |||
| ac17183d7c | |||
| 2cf3b60540 | |||
| f7d792745a | |||
| 17c6a8e258 | |||
| db9553a0f5 | |||
| f25d2aad37 | |||
| e21905c101 | |||
| 1308b025c2 | |||
| 20f405e878 | |||
| 63778659ee | |||
| e43f7aa4fe | |||
| 307e6800f1 | |||
| e071b0f502 | |||
| daa3815c38 | |||
| e1a3b920c9 | |||
| 41ac3a75ff | |||
| 40ee53197e | |||
| 5b97f6ad1b | |||
| 7d6d03367a | |||
| ac51a01dd9 | |||
| e7e7df5bfa |
|
|
@ -107,8 +107,6 @@ def emergency_sync():
|
|||
# BEGIN PROGRAM
|
||||
# ============================================================================
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ASSETS FOLDER
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -17,10 +17,13 @@ from PyQt6 import QtGui
|
|||
from PyQt6.QtCore import Qt, QTimer, QEvent
|
||||
|
||||
from core.Constants import Constants
|
||||
from core.infrastructure.service_registry import ServiceLocator
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
from core.Errors import UnknownConnectionTypeError
|
||||
from core.errors.logger import logger as core_logger
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.observers.Notifications import Notifications, EventType
|
||||
|
||||
core_logger.propagate = False
|
||||
|
||||
|
|
@ -69,7 +72,6 @@ from gui.v2.actions.ticket_failure import (
|
|||
)
|
||||
from gui.v2.actions.should_be_synchronized import should_be_synchronized
|
||||
|
||||
|
||||
class CustomWindow(QMainWindow):
|
||||
def __init__(self, force_sync=False):
|
||||
super().__init__()
|
||||
|
|
@ -160,8 +162,12 @@ class CustomWindow(QMainWindow):
|
|||
self.sync_button = bottom['sync_button']
|
||||
self.page_stack.raise_()
|
||||
|
||||
# class based notification system:
|
||||
self.observers = setup_observers(self.update_status)
|
||||
|
||||
# New EventBus-style notification system:
|
||||
self.notfication_trigger()
|
||||
|
||||
self.update_image()
|
||||
self.set_scroll_speed(7)
|
||||
|
||||
|
|
@ -175,6 +181,7 @@ class CustomWindow(QMainWindow):
|
|||
self.check_logging()
|
||||
|
||||
self.navigator = Navigator(self)
|
||||
ServiceLocator.register(name="navigator", service=self.navigator)
|
||||
|
||||
self.page_stack.currentChanged.connect(self.page_changed)
|
||||
|
||||
|
|
@ -751,6 +758,11 @@ class CustomWindow(QMainWindow):
|
|||
self.status_label.setStyleSheet(
|
||||
f"color: rgb(0, 255, 255); font-size: {font_size}px;")
|
||||
|
||||
@mainthread
|
||||
def notfication_trigger(self):
|
||||
Notifications.subscribe(EventType.STATUS_UPDATE, self.update_status)
|
||||
|
||||
|
||||
@mainthread # wrapper of ThreadSafetyTool
|
||||
def update_status(self, text, clear=False):
|
||||
"""
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 37 KiB |
0
gui/resources/images/hysteria2.png
Executable file → Normal file
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
BIN
gui/resources/images/hysteria2_mini.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 3 KiB |
0
gui/resources/images/hysteria2_nl_li.png
Executable file → Normal file
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
0
gui/resources/images/hysteria2_us_ny.png
Executable file → Normal file
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
0
gui/resources/images/hysteria2_us_wa.png
Executable file → Normal file
|
Before Width: | Height: | Size: 259 KiB After Width: | Height: | Size: 259 KiB |
0
gui/resources/images/hystria2_button.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
0
gui/resources/images/icon_mini_is_1.png
Executable file → Normal file
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
0
gui/resources/images/icon_mini_is_1.webp
Executable file → Normal file
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
0
gui/resources/images/icon_mini_se_ab.png
Executable file → Normal file
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
0
gui/resources/images/icon_se_ab.png
Executable file → Normal file
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
0
gui/resources/images/vless.png
Executable file → Normal file
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 215 KiB |
0
gui/resources/images/vless_button.png
Executable file → Normal file
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 87 KiB |
BIN
gui/resources/images/vless_mini.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB |
0
gui/resources/images/vless_nl_li.png
Executable file → Normal file
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 220 KiB |
0
gui/resources/images/vless_us_ny.png
Executable file → Normal file
|
Before Width: | Height: | Size: 219 KiB After Width: | Height: | Size: 219 KiB |
0
gui/resources/images/vless_us_wa.png
Executable file → Normal file
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 220 KiB |
0
gui/resources/images/wireguard_button.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
0
gui/resources/images/wireguard_mini.png
Executable file → Normal file
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
0
gui/resources/images/wireguard_mini_transparent.png
Executable file → Normal file
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
|
@ -1,9 +1,9 @@
|
|||
import json
|
||||
import logging
|
||||
from core.utils.basic_operations.write_or_read_from_json import read_entire_json, write_json_to_file
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file):
|
||||
def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file) -> bool:
|
||||
os.makedirs(gui_cache_home, exist_ok=True)
|
||||
os.makedirs(gui_config_home, exist_ok=True)
|
||||
|
||||
|
|
@ -14,24 +14,17 @@ def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file):
|
|||
"log_level": "INFO"
|
||||
}
|
||||
}
|
||||
with open(gui_config_file, 'w') as f:
|
||||
json.dump(default_config, f, indent=4)
|
||||
return write_json_to_file(data=default_config, filepath=gui_config_file)
|
||||
return True
|
||||
|
||||
def load_gui_config(gui_config_file) -> str|None:
|
||||
"""Pass through to core for better error handling, such as UnicodeDecodeError."""
|
||||
return read_entire_json(gui_config_file)
|
||||
|
||||
|
||||
def load_gui_config(gui_config_file):
|
||||
try:
|
||||
with open(gui_config_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
logging.error("Invalid GUI config file format")
|
||||
return None
|
||||
|
||||
|
||||
def save_gui_config(gui_config_file, config):
|
||||
with open(gui_config_file, 'w') as f:
|
||||
json.dump(config, f, indent=4)
|
||||
def save_gui_config(gui_config_file, config) -> bool:
|
||||
"""Pass through to core for better error handling"""
|
||||
return write_json_to_file(data=config, filepath=gui_config_file)
|
||||
|
||||
|
||||
def default_gui_config():
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from core.services.profiles.profile_connections import disable_profile
|
||||
from core.models.session.SessionProfile import SessionProfile
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
|
|
@ -56,8 +57,8 @@ def disable_profile_via_controller(profile: BaseProfile):
|
|||
'ticket_observer': ticket_observer,
|
||||
'connection_observer': connection_observer,
|
||||
}
|
||||
supported = inspect.signature(ProfileController.disable).parameters
|
||||
ProfileController.disable(
|
||||
supported = inspect.signature(disable_profile).parameters
|
||||
disable_profile(
|
||||
profile,
|
||||
**{key: value for key, value in kwargs.items() if key in supported}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
from typing import Any, Callable, Mapping, TypedDict
|
||||
|
||||
from core.controllers.LocationController import LocationController
|
||||
from core.models.BaseProfile import BaseProfile
|
||||
from gui.v2.actions.menu_profiles import profile_is_session, summarize_profile
|
||||
|
||||
|
||||
class EditorState(TypedDict):
|
||||
profile_id: int
|
||||
baseline: dict[str, Any]
|
||||
changes: dict[str, str]
|
||||
locations: dict[str, dict[str, str]]
|
||||
location_choices: list[str]
|
||||
browser_choices: list[str]
|
||||
resolutions: list[str]
|
||||
is_synced: bool
|
||||
|
||||
|
||||
class EditorResult(TypedDict, total=False):
|
||||
status: str
|
||||
message: str
|
||||
state: EditorState
|
||||
view: dict[str, Any]
|
||||
request: dict[str, Any]
|
||||
destination: str
|
||||
refresh_menu: bool
|
||||
adjusted: bool
|
||||
saved_fields: list[str]
|
||||
sync_data: tuple[list, list, bool, bool, list, list]
|
||||
|
||||
|
||||
EditorCallback = Callable[[str, EditorResult], None]
|
||||
SINGBOX_PROTOCOLS = ("hysteria2", "vless")
|
||||
EDITABLE_FIELDS = ("name", "location", "browser", "dimentions", "connection")
|
||||
|
||||
|
||||
def location_record(location: Any) -> dict[str, str]:
|
||||
"""Flatten location and operator models before they reach Qt rendering code."""
|
||||
if location is None or isinstance(location, dict):
|
||||
return {}
|
||||
operator = getattr(location, "operator", None)
|
||||
return {
|
||||
"key": f"{location.country_code}_{location.code}",
|
||||
"country": getattr(location, "country_name", "") or "",
|
||||
"label": f"{getattr(location, 'country_name', '')}, {getattr(location, 'name', '')}",
|
||||
"operator": getattr(operator, "name", "") or "",
|
||||
"nostr": getattr(operator, "nostr_public_key", "") or "",
|
||||
}
|
||||
|
||||
|
||||
def snapshot_profile(profile: BaseProfile) -> dict[str, Any]:
|
||||
"""Reuse menu normalization while keeping the editor baseline independent of mutable core models."""
|
||||
summary = summarize_profile(int(profile.id), profile)[1]
|
||||
browser = summary["browser"]
|
||||
version = summary["browser_version"]
|
||||
return {**summary, "browser": f"{browser}:{version}" if version else browser,
|
||||
"is_session": profile_is_session(profile),
|
||||
"location_metadata": location_record(getattr(profile, "location", None))}
|
||||
|
||||
|
||||
def prepare_editor_data() -> dict[str, dict[str, str]]:
|
||||
"""Preload shared location metadata while leaving the editing baseline to a fresh selected-profile read."""
|
||||
records = [location_record(location) for location in LocationController.get_all()]
|
||||
return {record["key"]: record for record in records if record}
|
||||
|
||||
|
||||
def copy_editor_state(state: EditorState) -> EditorState:
|
||||
"""Capture task inputs so asynchronous operations and confirmations cannot modify the live draft."""
|
||||
return {**state, "baseline": dict(state["baseline"]), "changes": dict(state["changes"]),
|
||||
"locations": dict(state["locations"]), "location_choices": list(state["location_choices"]),
|
||||
"browser_choices": list(state["browser_choices"]), "resolutions": list(state["resolutions"])}
|
||||
|
||||
|
||||
def editor_parameters(data: Mapping[str, Any], state: EditorState) -> dict[str, list[str]]:
|
||||
"""Choose supported editor fields with protocol tables instead of nested rendering branches."""
|
||||
shared = {"location": state["location_choices"], "browser": state["browser_choices"],
|
||||
"dimentions": list(dict.fromkeys([*state["resolutions"], str(data.get("dimentions", "None"))]))}
|
||||
proxy = {"protocol": ["residential", "wireguard", "hidetor"], "connection": ["tor", "just proxy"], **shared}
|
||||
protocol = data["protocol"]
|
||||
rules = {
|
||||
"wireguard": {"protocol": ["wireguard", "residential", "hidetor"], "connection": ["browser-only", "system-wide"], **shared},
|
||||
"hidetor": proxy, "residential": proxy, "open": {"protocol": ["open"]},
|
||||
"hysteria2": {"protocol": ["hysteria2"], "connection": ["system-wide"], "location": state["location_choices"]},
|
||||
"vless": {"protocol": ["vless"], "connection": ["system-wide"], "location": state["location_choices"]},
|
||||
}
|
||||
return rules.get(protocol, {})
|
||||
|
||||
|
||||
def _field_spec(key: str, data: Mapping[str, Any], state: EditorState, choices: list[str]) -> dict[str, Any]:
|
||||
"""Prepare field content and control permissions before Qt creates the widgets."""
|
||||
value = str(data.get(key, ""))
|
||||
kind = {"browser": "browser", "location": "location", "dimentions": "resolution"}.get(key, "image")
|
||||
location = state["locations"].get(value, {})
|
||||
protocol_asset = {"hysteria2": "hystria2"}.get(value, value)
|
||||
hidden = data["connection"] == "system-wide" and key in ("browser", "dimentions")
|
||||
locked = key == "protocol" or (key == "connection" and (data["connection"] == "system-wide" or data["protocol"] in SINGBOX_PROTOCOLS))
|
||||
browser = value.replace(":", " ", 1)
|
||||
return {"key": key, "kind": kind, "value": value, "visible": not hidden,
|
||||
"enabled": not locked, "needs_sync": key in ("browser", "location") and not state["is_synced"],
|
||||
"asset": f"{protocol_asset if key == 'protocol' else value}_button.png",
|
||||
"browser_text": browser, "unknown_browser": not browser or browser.lower().startswith("unknown"),
|
||||
"blank_browser": key == "browser" and hidden,
|
||||
"location_asset": f"button_{value}.png", "location_name": location.get("country") or value,
|
||||
"provider": location.get("operator", ""), "choices": list(choices)}
|
||||
|
||||
|
||||
def _preview(data: Mapping[str, Any], state: EditorState) -> dict[str, Any]:
|
||||
"""Select preview assets and operator information without leaving protocol policy in Qt methods."""
|
||||
protocol, connection, location = data["protocol"], data["connection"], data["location"]
|
||||
location_data = state["locations"].get(location, {})
|
||||
tor_prefix = "icon" if connection == "just proxy" else "hdtor"
|
||||
primary = {"hidetor": f"{tor_prefix}_{location}.png"}.get(protocol, f"{protocol}_{location}.png")
|
||||
operator = location_data.get("operator", "")
|
||||
show_operator = bool(operator) and operator != "Simplified Privacy" and protocol not in ("hidetor", *SINGBOX_PROTOCOLS)
|
||||
background = None
|
||||
if protocol == "wireguard" and connection == "browser-only":
|
||||
background = "browser only.png" if data["browser_supported"] else "unsupported_browser_only.png"
|
||||
if protocol == "residential":
|
||||
background = "browser only.png"
|
||||
return {"asset": primary, "layout": {"hidetor": "tor", "vless": "vless"}.get(protocol, "standard"),
|
||||
"encrypted": protocol in SINGBOX_PROTOCOLS,
|
||||
"visible": protocol != "residential", "background": background,
|
||||
"garage": "brazil garaje.png" if protocol == "residential" and location else None,
|
||||
"operator": operator, "location_text": location_data.get("label", ""),
|
||||
"nostr": location_data.get("nostr", ""), "show_operator": show_operator,
|
||||
"location_asset": f"icon_{location}.png", "text_color": "black" if data["is_session"] else "white"}
|
||||
|
||||
|
||||
def render_editor_state(state: EditorState, status: str = "render") -> EditorResult:
|
||||
"""Rebuild presentation from the baseline and draft without fetching profile data again."""
|
||||
data = {**state["baseline"], **state["changes"]}
|
||||
if "browser" in state["changes"]:
|
||||
data["browser_supported"] = data["browser"] in state["browser_choices"]
|
||||
parameters = editor_parameters(data, state)
|
||||
return {"status": status, "state": state, "view": {
|
||||
"name": data["name"], "preview": _preview(data, state),
|
||||
"fields": [_field_spec(key, data, state, choices) for key, choices in parameters.items()],
|
||||
}}
|
||||
|
||||
|
||||
def set_editor_value(state: EditorState, key: str, value: str) -> EditorResult:
|
||||
"""Remove reverted values from the draft so unsaved-change warnings reflect actual differences."""
|
||||
if key not in EDITABLE_FIELDS:
|
||||
return {"status": "error", "message": f"Editing {key} is not supported.", "state": state}
|
||||
updated = copy_editor_state(state)
|
||||
updated["changes"].pop(key, None)
|
||||
if value != str(updated["baseline"].get(key, "")):
|
||||
updated["changes"][key] = value
|
||||
return render_editor_state(updated, "draft" if key == "name" else "render")
|
||||
|
||||
|
||||
def set_editor_resolution(state: EditorState, width_text: str, height_text: str, host_width: int, host_height: int) -> EditorResult:
|
||||
"""Validate and fit custom dimensions before the dialog records a pending resolution."""
|
||||
try:
|
||||
width, height = int(width_text), int(height_text)
|
||||
except ValueError:
|
||||
return {"status": "error", "message": "Enter whole numbers for width and height."}
|
||||
if min(width, height, host_width, host_height) <= 0:
|
||||
return {"status": "error", "message": "Width, height and host dimensions must be positive."}
|
||||
fitted_width = max(1, host_width - 50) if width > host_width else width
|
||||
fitted_height = max(1, host_height - 50) if height > host_height else height
|
||||
result = set_editor_value(state, "dimentions", f"{fitted_width}x{fitted_height}")
|
||||
result["adjusted"] = (width, height) != (fitted_width, fitted_height)
|
||||
return result
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
from functools import partial
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
from core.controllers.profile_state.update_profile import update_profile
|
||||
from gui.v2.actions.database_health import GuiStorageDatabaseError
|
||||
from gui.v2.actions.editor_data import (
|
||||
EDITABLE_FIELDS, EditorCallback, EditorResult, EditorState,
|
||||
copy_editor_state, location_record, prepare_editor_data, render_editor_state, set_editor_value, snapshot_profile,
|
||||
)
|
||||
from gui.v2.actions.profile_operations import current_connection, synchronize
|
||||
from gui.v2.workers.page_data_worker import PageDataWorker
|
||||
|
||||
|
||||
_active_workers: set[PageDataWorker] = set()
|
||||
|
||||
|
||||
def _release_worker(worker: PageDataWorker) -> None:
|
||||
"""Keep worker references until native completion so page navigation cannot destroy running tasks."""
|
||||
_active_workers.discard(worker)
|
||||
worker.deleteLater()
|
||||
|
||||
|
||||
def _report_task_failure(on_result: EditorCallback, request_name: str, message: str) -> None:
|
||||
"""Restore GUI controls even if the worker cannot format an unexpected task failure."""
|
||||
on_result(request_name, {"status": "error", "message": message})
|
||||
|
||||
|
||||
def _execute(operation: Callable[[], EditorResult], state: EditorState | None) -> EditorResult:
|
||||
"""Turn background exceptions into explicit outcomes that leave pending changes available."""
|
||||
try:
|
||||
return operation()
|
||||
except GuiStorageDatabaseError:
|
||||
message = "Local storage database could not be read. Restart and recover storage.db."
|
||||
except Exception as error:
|
||||
message = str(error) or "The editor operation failed."
|
||||
result: EditorResult = {"status": "error", "message": message}
|
||||
if state is not None:
|
||||
result.update(render_editor_state(state, "error"))
|
||||
return result
|
||||
|
||||
|
||||
def _start_task(request_name: str, operation: Callable[[], EditorResult], on_result: EditorCallback, state: EditorState | None = None) -> PageDataWorker:
|
||||
"""Own worker construction and GUI signal delivery so editor handlers only pass task inputs."""
|
||||
worker = PageDataWorker(request_name, _execute, operation, state)
|
||||
worker.data_ready.connect(on_result, Qt.ConnectionType.QueuedConnection)
|
||||
worker.failed.connect(partial(_report_task_failure, on_result), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(_release_worker, worker))
|
||||
_active_workers.add(worker)
|
||||
worker.start()
|
||||
return worker
|
||||
|
||||
|
||||
def _load_editor(profile_id: int | None, location_choices: list[str], browser_choices: list[str], resolutions: list[str], is_synced: bool,
|
||||
prepared: Mapping[str, dict[str, str]] | None, previous: EditorState | None) -> EditorResult:
|
||||
"""Load one editing baseline and location metadata while preserving an existing unsaved draft."""
|
||||
if profile_id is None:
|
||||
return {"status": "error", "message": "Select a profile before opening the editor."}
|
||||
profile_id = int(profile_id)
|
||||
baseline = previous["baseline"] if previous is not None and previous["changes"] else None
|
||||
profile = ProfileController.get(profile_id) if baseline is None else None
|
||||
if baseline is None and profile is None:
|
||||
return {"status": "error", "message": f"Profile {profile_id} no longer exists."}
|
||||
if baseline is None:
|
||||
baseline = snapshot_profile(profile)
|
||||
location_map = dict(prepared) if prepared is not None else prepare_editor_data()
|
||||
if not set(location_choices).issubset(location_map):
|
||||
location_map = prepare_editor_data()
|
||||
location_metadata = baseline["location_metadata"]
|
||||
if location_metadata:
|
||||
location_map[location_metadata["key"]] = location_metadata
|
||||
state: EditorState = {
|
||||
"profile_id": profile_id, "baseline": dict(baseline),
|
||||
"changes": dict(previous["changes"]) if previous is not None else {},
|
||||
"locations": location_map, "location_choices": list(dict.fromkeys(location_choices)),
|
||||
"browser_choices": list(dict.fromkeys(browser_choices)), "resolutions": list(dict.fromkeys(resolutions)),
|
||||
"is_synced": is_synced,
|
||||
}
|
||||
return render_editor_state(state)
|
||||
|
||||
|
||||
def load_editor(profile_id: int | None, location_choices: Sequence[str], browser_choices: Sequence[str], resolutions: Sequence[str], is_synced: bool,
|
||||
prepared: Mapping[str, dict[str, str]] | None, previous: EditorState | None, request_name: str, on_result: EditorCallback) -> PageDataWorker:
|
||||
"""Prepare editor content asynchronously without letting the page coordinate controller lookups."""
|
||||
captured = copy_editor_state(previous) if previous is not None else None
|
||||
operation = partial(_load_editor, profile_id, list(location_choices), list(browser_choices), list(resolutions), is_synced, prepared, captured)
|
||||
return _start_task(request_name, operation, on_result, captured)
|
||||
|
||||
|
||||
def _sync_editor(state: EditorState) -> EditorResult:
|
||||
"""Replace available choices only after synchronization succeeds while retaining the current draft."""
|
||||
synced = synchronize(current_connection())
|
||||
if synced["status"] == "error":
|
||||
return {**render_editor_state(state, "error"), "message": synced["message"], "sync_data": synced["sync_data"]}
|
||||
locations, browsers, _, _, location_models, _ = synced["sync_data"]
|
||||
records = [location_record(location) for location in location_models]
|
||||
updated: EditorState = {**state, "location_choices": locations, "browser_choices": browsers,
|
||||
"locations": {record["key"]: record for record in records if record}, "is_synced": True}
|
||||
return {**render_editor_state(updated), "sync_data": synced["sync_data"], "message": "Sync complete."}
|
||||
|
||||
|
||||
def cycle_editor_value(state: EditorState, key: str, direction: int, request_name: str, on_result: EditorCallback) -> PageDataWorker | None:
|
||||
"""Share previous and next selection logic and own the synchronization needed to populate choices."""
|
||||
captured = copy_editor_state(state)
|
||||
field = next((field for field in render_editor_state(captured)["view"]["fields"] if field["key"] == key), None)
|
||||
if field is None or not field["enabled"] or not field["visible"] or direction not in (-1, 1):
|
||||
on_result(request_name, {"status": "error", "message": "This editor field cannot be changed."})
|
||||
return None
|
||||
if field["needs_sync"]:
|
||||
return _start_task(request_name, partial(_sync_editor, captured), on_result, captured)
|
||||
choices = field["choices"]
|
||||
if not choices:
|
||||
on_result(request_name, {"status": "error", "message": f"No {key} data available. Sync the database and try again."})
|
||||
return None
|
||||
current = field["value"]
|
||||
index = choices.index(current) if current in choices else (-1 if direction == 1 else 0)
|
||||
on_result(request_name, set_editor_value(captured, key, choices[(index + direction) % len(choices)]))
|
||||
return None
|
||||
|
||||
|
||||
def _confirmation(state: EditorState, task: str, message: str) -> EditorResult:
|
||||
"""Capture the profile and draft together so confirmations cannot apply subsequently edited values."""
|
||||
return {"status": "confirmation", "message": message,
|
||||
"request": {"task": task, "state": copy_editor_state(state)}}
|
||||
|
||||
|
||||
def _apply_warning(state: EditorState) -> str:
|
||||
"""Include every destructive change in one confirmation rather than hiding the browser warning behind location changes."""
|
||||
warnings = {
|
||||
"location": "A new location requires a new subscription code and erases the old one.",
|
||||
"browser": "Changing the browser would delete all data associated with it.",
|
||||
}
|
||||
messages = [message for field, message in warnings.items() if field in state["changes"]]
|
||||
return "\n\n".join([*messages, "Do you want to proceed?"]) if messages else ""
|
||||
|
||||
|
||||
def _save_editor(state: EditorState) -> EditorResult:
|
||||
"""Track each core save so partial failures retain only the changes still needing attention."""
|
||||
pending = state["changes"]
|
||||
unsupported = set(pending).difference(EDITABLE_FIELDS)
|
||||
if unsupported:
|
||||
return {**render_editor_state(state, "error"), "message": "These editor changes are not supported; protocol switching is disabled."}
|
||||
updated = copy_editor_state(state)
|
||||
saved: list[str] = []
|
||||
for key, value in pending.items():
|
||||
try:
|
||||
outcome = update_profile(state["profile_id"], key, value)
|
||||
message = None if outcome.valid else str(outcome.message or outcome.data or f"Could not save {key}.")
|
||||
except Exception as error:
|
||||
message = str(error) or f"Could not save {key}."
|
||||
if message is not None:
|
||||
progress = f"Saved {', '.join(saved)}. " if saved else ""
|
||||
return {**render_editor_state(updated, "error"), "message": f"{progress}Could not save {key}: {message}",
|
||||
"saved_fields": saved, "refresh_menu": bool(saved)}
|
||||
updated["baseline"][key] = value
|
||||
if key == "browser":
|
||||
updated["baseline"]["browser_supported"] = value in updated["browser_choices"]
|
||||
updated["changes"].pop(key, None)
|
||||
saved.append(key)
|
||||
return {"status": "navigate", "destination": "menu", "state": updated, "refresh_menu": True,
|
||||
"saved_fields": saved, "message": f"Updated profile {state['profile_id']}"}
|
||||
|
||||
|
||||
def apply_editor_changes(state: EditorState, request_name: str, on_result: EditorCallback, confirmed: bool = False) -> PageDataWorker | None:
|
||||
"""Own confirmation and saving as one user task so the GUI never chains individual core updates."""
|
||||
captured = copy_editor_state(state)
|
||||
if not captured["changes"]:
|
||||
on_result(request_name, {"status": "navigate", "destination": "menu", "state": captured})
|
||||
return None
|
||||
warning = _apply_warning(captured)
|
||||
if warning and not confirmed:
|
||||
on_result(request_name, _confirmation(captured, "apply", warning))
|
||||
return None
|
||||
return _start_task(request_name, partial(_save_editor, captured), on_result, captured)
|
||||
|
||||
|
||||
def leave_editor(state: EditorState, request_name: str, on_result: EditorCallback, confirmed: bool = False) -> None:
|
||||
"""Discard changes only after the user accepts leaving an editor with an unsaved draft."""
|
||||
captured = copy_editor_state(state)
|
||||
if captured["changes"] and not confirmed:
|
||||
on_result(request_name, _confirmation(captured, "leave", "You have unsaved changes. Do you want to continue?"))
|
||||
return
|
||||
captured["changes"].clear()
|
||||
on_result(request_name, {"status": "navigate", "destination": "menu", "state": captured})
|
||||
|
||||
|
||||
def continue_editor_task(request: Mapping[str, Any], accepted: bool, request_name: str, on_result: EditorCallback) -> PageDataWorker | None:
|
||||
"""Return popup decisions to the original task without rebuilding its profile or draft from the page."""
|
||||
state = request["state"]
|
||||
if not accepted:
|
||||
on_result(request_name, render_editor_state(state))
|
||||
return None
|
||||
handlers = {
|
||||
"apply": partial(apply_editor_changes, state, request_name, on_result, confirmed=True),
|
||||
"leave": partial(leave_editor, state, request_name, on_result, confirmed=True),
|
||||
}
|
||||
return handlers[request["task"]]()
|
||||
19
gui/v2/actions/error_handlers.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from gui.v2.infrastructure.action_registry import register_action
|
||||
from core.infrastructure.global_state_coordinator import coordinate_evaluation
|
||||
from core.models.Result import Result, ResultError
|
||||
|
||||
from gui.v2.infrastructure.setup_observers import connection_observer
|
||||
|
||||
def empty_filler(result: Result) -> Result:
|
||||
"""To have a callback, even when one is not required."""
|
||||
return result
|
||||
|
||||
|
||||
@register_action(ResultError.ZOMBIE_CONNECTION)
|
||||
def evaluate_bad_state() -> Result:
|
||||
"""Start core's evaluation of state with a GUI observer."""
|
||||
return coordinate_evaluation(connection_observer=connection_observer)
|
||||
|
||||
@register_action(ResultError.SUBSCRIPTION)
|
||||
def navigate_to_new_sub_page() -> Result:
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="id")
|
||||
281
gui/v2/actions/menu_coordinator.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# core
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
# from core.services.prepare_tickets.ticket_tracker import get_a_random_ticket
|
||||
from core.services.profiles.profile_connections import enable_profile, disable_profile
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
from core.models.Configuration import Configuration, ConnectionChoice
|
||||
from core.services.subscriptions.subscriptions_by_protocol import get_profile_subscription, start_new_payment_cycle, wipe_subscription, evaluate_repeat_cycle
|
||||
from core.services.payment_phase.pre_existing import check_then_save_billing_code
|
||||
from core.utils.check_if_time_lapsed import check_if_time_lapsed
|
||||
from core.models.enum_models import PaymentCategory
|
||||
from core.utils.type_checker import type_checker
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
from core.errors.translate_error import translate_error, get_ignored_exceptions
|
||||
from core.services.payment_phase.payment_state import OverallPaymentCycle
|
||||
|
||||
# gui
|
||||
# from gui.v2.actions.payment_actions import wipe_expired_subscription
|
||||
from gui.v2.actions.ticket_actions import dispatch_ticketing
|
||||
from gui.v2.infrastructure.action_registry import register_action
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from gui.v2.infrastructure.screen_size import get_max_screensize
|
||||
from gui.v2.infrastructure.setup_observers import (
|
||||
application_version_observer, connection_observer,
|
||||
profile_observer, ticket_observer,
|
||||
)
|
||||
from gui.v2.actions.error_handlers import navigate_to_new_sub_page
|
||||
|
||||
# generic
|
||||
from typing import Callable, Optional
|
||||
from functools import partial
|
||||
|
||||
@type_checker
|
||||
def get_profile(profile_id: int):
|
||||
profile = ProfileController.get(int(profile_id))
|
||||
if not profile:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=f"There is no profile with the id {profile_id}")
|
||||
else:
|
||||
return Result(valid=True, data=profile)
|
||||
|
||||
|
||||
def dispatch_individual(profile_id: int, ui_caller: Callable) -> Result:
|
||||
payment_cycle = OverallPaymentCycle()
|
||||
|
||||
print(f"payment_cycle is {payment_cycle}")
|
||||
|
||||
if payment_cycle.active:
|
||||
same_result = evaluate_repeat_cycle(payment_cycle=payment_cycle, profile_id=profile_id)
|
||||
if not same_result.valid:
|
||||
return same_result
|
||||
else:
|
||||
error_msg = "There's an error in the dispatch_individual function"
|
||||
logger.error(f"This shouldn't be going off. {error_msg}")
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="menu", message=error_msg)
|
||||
else:
|
||||
# Starting fresh. Pick a billing type:
|
||||
return navigate_to_new_sub_page()
|
||||
|
||||
|
||||
def filter_subscription(profile_id: int, passed_ui_caller: Callable) -> Result:
|
||||
"""Evaluate if this profile has a subscription that's ready to connect."""
|
||||
|
||||
logger.info(f"Checking the subscription on profile id {profile_id}")
|
||||
subscription = get_profile_subscription(profile_id=profile_id)
|
||||
|
||||
#-----------------------------
|
||||
# No Subscription
|
||||
#-----------------------------
|
||||
if not subscription or not subscription.billing_code:
|
||||
tickets_setup = ConfigurationController.are_tickets_setup()
|
||||
if tickets_setup:
|
||||
# already opted into ticketing:
|
||||
return dispatch_ticketing(profile_id=profile_id, passed_ui_caller=passed_ui_caller)
|
||||
else:
|
||||
return dispatch_individual(profile_id=profile_id, ui_caller=passed_ui_caller)
|
||||
|
||||
#-----------------------------
|
||||
# Pending Subscription
|
||||
#-----------------------------
|
||||
if subscription.billing_pending:
|
||||
start_result = start_new_payment_cycle(category=PaymentCategory.INDIVIDUAL, profile_id=profile_id)
|
||||
if start_result.valid:
|
||||
logger.info("creating a new payment cycle was valid, it worked.")
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="payment_details", message="Please finish your payment.")
|
||||
else:
|
||||
logger.info(f"Returning the error of start_result {start_result}")
|
||||
return start_result
|
||||
|
||||
#-----------------------------
|
||||
# Expired Subscription
|
||||
#-----------------------------
|
||||
expiration_date = subscription.expires_at
|
||||
if not expiration_date:
|
||||
# auto check tickets would be here.
|
||||
return navigate_to_new_sub_page()
|
||||
|
||||
expired = check_if_time_lapsed(expiration_date)
|
||||
if expired:
|
||||
function_choice = partial(wipe_subscription, profile_id=profile_id)
|
||||
return Result(valid=False, function_choice=function_choice, error_type=ResultError.EXPIRED_SUB, message="This subscription is expired, would you like to wipe it and get a new one?")
|
||||
|
||||
#-----------------------------
|
||||
# Proceed with Valid Sub
|
||||
#-----------------------------
|
||||
logger.info(f"Profile id {profile_id} has a valid subscription ready to go..")
|
||||
return Result(valid=True)
|
||||
|
||||
|
||||
@register_action(ResultError.TOR_NEEDED)
|
||||
def switch_to_tor_connection() -> Result:
|
||||
ConfigurationController.set_connection_enum(ConnectionChoice.TOR)
|
||||
return Result(valid=True)
|
||||
|
||||
@type_checker
|
||||
def NOT_need_tor_warnings(profile_id: int) -> Result:
|
||||
"""
|
||||
Purpose:
|
||||
Filters if we need to warn the user about a potential Tor leak.
|
||||
|
||||
Context:
|
||||
If it's a Tor profile, and the user does NOT have Tor connection enabled, do we need to warn them?
|
||||
|
||||
Returns:
|
||||
If we need it, it emits Result as False, with ResultError.TOR_NEEDED
|
||||
If not, returns Result as True, to move on.
|
||||
"""
|
||||
|
||||
# PROFILE
|
||||
profile_result = get_profile(profile_id)
|
||||
profile = profile_result.data
|
||||
|
||||
# Actual Profile is a Tor Profile:
|
||||
if profile.connection.code != "tor":
|
||||
return Result(valid=True) # no warning needed, it's not even Tor
|
||||
|
||||
# Config shows a non-Tor connection to download apps:
|
||||
config = Configuration.get()
|
||||
if config.connection != ConnectionChoice.TOR:
|
||||
return Result(valid=True) # no warning needed, it's already using Tor to download apps
|
||||
|
||||
# Lacks Application:
|
||||
if profile.application_version:
|
||||
return Result(valid=True) # no warning needed, they have the app already.
|
||||
|
||||
error_msg = "This app is not yet installed. Since it's a Tor profile, do you want to switch to Tor to download it? (Hit 'No' to proceed over clearweb)"
|
||||
return Result(valid=False, error_type=ResultError.TOR_NEEDED, message=error_msg)
|
||||
|
||||
|
||||
|
||||
@register_action(ResultError.ENDPOINT_VERIFICATION)
|
||||
def literally_connect(
|
||||
profile_id: int
|
||||
) -> Result:
|
||||
|
||||
# Step 1: Get the Profile
|
||||
profile_result = get_profile(profile_id)
|
||||
if not profile_result:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This profile does not match one of your configuration files.")
|
||||
|
||||
profile = profile_result.data
|
||||
|
||||
# EXCEPTIONS
|
||||
ignored = get_ignored_exceptions(ignore_state_conflict=True)
|
||||
|
||||
# SCREEN SIZE:
|
||||
max_resolution = get_max_screensize()
|
||||
|
||||
try:
|
||||
final_result = enable_profile(
|
||||
profile=profile,
|
||||
ignore=ignored,
|
||||
profile_observer=profile_observer,
|
||||
application_version_observer=application_version_observer,
|
||||
connection_observer=connection_observer,
|
||||
ticket_observer=ticket_observer,
|
||||
max_resolution=max_resolution
|
||||
)
|
||||
if final_result:
|
||||
return final_result
|
||||
else:
|
||||
return Result(valid=True)
|
||||
except Exception as error:
|
||||
return translate_error(error=error, profile_id=profile.id)
|
||||
|
||||
|
||||
|
||||
@register_action(ResultError.STATE_CONFLICT)
|
||||
def ignore_state_conflict(profile_id: int) -> Result:
|
||||
return literally_connect(
|
||||
profile_id=profile_id
|
||||
)
|
||||
|
||||
|
||||
def _connect_profile(profile_id: int, passed_ui_caller: Callable) -> Result:
|
||||
"""Background Threaded Orchestration to Start a Connection"""
|
||||
|
||||
# Step 1: Subscription filter:
|
||||
sub_result = filter_subscription(profile_id=profile_id, passed_ui_caller=passed_ui_caller)
|
||||
if not sub_result.valid:
|
||||
return sub_result
|
||||
|
||||
# Step 2: Tor filter check:
|
||||
result = NOT_need_tor_warnings(profile_id=profile_id)
|
||||
if not result.valid:
|
||||
return result
|
||||
|
||||
# Step 3: Actual Connection:
|
||||
logger.info(f"Profile id {profile_id} is ready to connect..")
|
||||
return literally_connect(profile_id=profile_id)
|
||||
|
||||
|
||||
@type_checker
|
||||
def connect_profile(profile_id: int, ui_caller: Callable) -> Result:
|
||||
"""Type check before we thread"""
|
||||
take_action(
|
||||
action=_connect_profile,
|
||||
ui_caller=ui_caller,
|
||||
profile_id=profile_id,
|
||||
passed_ui_caller=ui_caller
|
||||
)
|
||||
return Result(valid=True)
|
||||
|
||||
|
||||
|
||||
def connect_with_billing_code(profile_id: int, billing_code: str) -> Result:
|
||||
"""Apply a pre-existing billing code to a profile"""
|
||||
|
||||
print(f"billing_code is {billing_code}")
|
||||
|
||||
paid_result = check_then_save_billing_code(
|
||||
profile_id=profile_id,
|
||||
billing_code=billing_code,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
if not paid_result.valid:
|
||||
return paid_result
|
||||
|
||||
return literally_connect(profile_id=profile_id)
|
||||
|
||||
|
||||
def literally_disconnect(profile_id: int) -> Result:
|
||||
"""Get a profile and use core to disable it."""
|
||||
|
||||
# Step 1: Get the Profile
|
||||
profile_result = get_profile(profile_id)
|
||||
if not profile_result:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This profile does not match one of your configuration files.")
|
||||
|
||||
profile = profile_result.data
|
||||
|
||||
# EXCEPTIONS
|
||||
ignored = get_ignored_exceptions(ignore_state_conflict=False)
|
||||
|
||||
print("Literally disconnecting")
|
||||
try:
|
||||
final_result = disable_profile(
|
||||
profile=profile,
|
||||
ignore=ignored,
|
||||
profile_observer=profile_observer,
|
||||
connection_observer=connection_observer,
|
||||
ticket_observer=ticket_observer
|
||||
)
|
||||
if final_result:
|
||||
return final_result
|
||||
else:
|
||||
return Result(valid=True)
|
||||
except Exception as error:
|
||||
return translate_error(error=error, profile_id=profile_id)
|
||||
|
||||
|
||||
|
||||
def disconnect_profile(profile_id: int, ui_caller: Callable) -> Result:
|
||||
print("GUI is disconnecting a profile")
|
||||
return literally_disconnect(profile_id)
|
||||
|
||||
# take_action(
|
||||
# action=literally_disconnect,
|
||||
# ui_caller=ui_caller,
|
||||
# profile_id=profile_id
|
||||
# )
|
||||
|
|
@ -124,7 +124,7 @@ def profile_is_session(profile: BaseProfile | None) -> bool:
|
|||
return isinstance(profile, SessionProfile)
|
||||
|
||||
|
||||
def summarize_profile(profile_id: int, profile: BaseProfile) -> tuple[str, dict[str, Any]]:
|
||||
def _matched_profile(profile_id: int, profile: BaseProfile) -> tuple[str, dict[str, Any]]:
|
||||
"""Build one normalized menu record so ordering does not contain profile-shape branches."""
|
||||
protocol = connection_code(profile)
|
||||
display_protocol = protocol if protocol in ENCRYPTED_PROTOCOLS else "hidetor"
|
||||
|
|
@ -154,7 +154,7 @@ def match_core_profiles(profiles: Mapping[int, BaseProfile], gui_config_file: st
|
|||
"""Translate core profiles into stable menu data before any Qt widgets are created."""
|
||||
profile_ids = normalize_profile_order(gui_config_file, profiles.keys())
|
||||
matched_profiles = (
|
||||
summarize_profile(profile_id, profiles[profile_id])
|
||||
_matched_profile(profile_id, profiles[profile_id])
|
||||
for profile_id in profile_ids
|
||||
if profile_id in profiles
|
||||
)
|
||||
|
|
@ -231,7 +231,9 @@ def _profile_detail_data(profile: BaseProfile | None, summary: Mapping[str, Any]
|
|||
],
|
||||
"location_text": f"{profile_location.country_name}, {profile_location.name}" if profile_location else "",
|
||||
"background_asset": _detail_background(summary, display_mode, profile_is_session(profile)),
|
||||
"primary_asset": f"{protocol}_{summary['location']}.png",
|
||||
"primary_asset": f"{protocol}.png" if protocol in ("hysteria2", "vless") else f"{protocol}_{summary['location']}.png",
|
||||
"location_overlay": protocol in ("hysteria2", "vless"),
|
||||
"overlay_y": {"hysteria2": 0.35}.get(protocol, 0.30),
|
||||
"raised_primary": protocol == "hidetor" or (protocol == "wireguard" and summary["connection"] == "browser-only"),
|
||||
"operator_name": operator_name,
|
||||
"nostr_public_key": getattr(operator, "nostr_public_key", "") if operator else "",
|
||||
|
|
@ -278,7 +280,7 @@ def get_profile_details(
|
|||
) -> dict[str, Any]:
|
||||
"""Load and prepare profile details as one task so the page never coordinates core model helpers."""
|
||||
profile = load_profile(profile_id, on_error)
|
||||
current_summary = summarize_profile(profile_id, profile)[1] if profile is not None else summary
|
||||
current_summary = _matched_profile(profile_id, profile)[1] if profile is not None else summary
|
||||
return _profile_detail_data(profile, current_summary, is_connected)
|
||||
|
||||
|
||||
|
|
@ -316,6 +318,11 @@ def profile_status_message(profile: BaseProfile, is_enabled: bool, idle: bool =
|
|||
"""Generate status text outside Qt so workers and pages report identical subscription state."""
|
||||
subscription = getattr(profile, "subscription", None)
|
||||
profile_date = getattr(subscription, "expires_at", None)
|
||||
billing_pending = getattr(subscription, "billing_pending", None)
|
||||
|
||||
if billing_pending:
|
||||
return "Click Enable to Resume Billing."
|
||||
|
||||
if profile_date is None:
|
||||
return "Offline. No subscription found."
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from core.services.subscriptions.subscriptions_by_protocol import get_profile_subscription
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
|
||||
|
||||
from functools import partial
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from gui.v2.actions.menu_results import MenuCallback
|
||||
from gui.v2.actions.profile_operations import execute_menu_task
|
||||
from gui.v2.infrastructure.screen_size import get_max_screensize
|
||||
|
|
@ -29,6 +33,14 @@ def _start_task(request: Mapping[str, Any], on_result: MenuCallback) -> Worker:
|
|||
return worker
|
||||
|
||||
|
||||
def pre_connect_clear(profile_id: int) -> Result:
|
||||
subscription = get_profile_subscription(profile_id=profile_id)
|
||||
if not subscription or not subscription.billing_code:
|
||||
return Result(valid=False, error_type=ResultError.SUBSCRIPTION, message="No Subscription")
|
||||
|
||||
return Result(valid=True)
|
||||
|
||||
|
||||
def enable_profile(profile_data: Mapping[str, Any], on_result: MenuCallback, allow_uninstalled_tor: bool = False) -> Worker:
|
||||
"""Start the complete activation workflow with billing and retry data kept in one request."""
|
||||
return _start_task({**profile_data, "task": "enable", "allow_uninstalled_tor": allow_uninstalled_tor}, on_result)
|
||||
|
|
|
|||
401
gui/v2/actions/payment_actions.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
# core
|
||||
from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData
|
||||
from core.services.payment_phase.check_payment_loop import CheckPaidTimer
|
||||
from core.services.subscriptions.subscriptions_by_protocol import (
|
||||
create_new_invoice, save_billing_code,
|
||||
get_profile_subscription, wipe_billing_cycle,
|
||||
start_new_payment_cycle, return_pre_existing_invoice,
|
||||
wipe_subscription,
|
||||
)
|
||||
from core.models.enum_models import PaymentType, PaymentCategory
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
from core.utils.type_checker import type_checker
|
||||
from core.observers.Notifications import Notifications, EventType
|
||||
|
||||
|
||||
# gui
|
||||
from gui.v2.infrastructure.action_registry import register_action
|
||||
from gui.v2.actions.ticket_actions import fetch_ticket_invoice
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from gui.v2.infrastructure.setup_observers import connection_observer
|
||||
from gui.v2.actions.error_handlers import navigate_to_new_sub_page
|
||||
|
||||
# generic
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
|
||||
def _wipe_and_navigate(profile_id: Optional[int] = None):
|
||||
cleared_cycle = wipe_billing_cycle(profile_id)
|
||||
if cleared_cycle:
|
||||
return navigate_to_new_sub_page()
|
||||
else:
|
||||
return cleared_cycle
|
||||
|
||||
|
||||
|
||||
@register_action(ResultError.EXPIRED_INVOICE)
|
||||
def reset_individual_billing_cycle() -> Result:
|
||||
payment_cycle = OverallPaymentCycle()
|
||||
|
||||
if not payment_cycle.category == PaymentCategory.INDIVIDUAL:
|
||||
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This function isn't for tickets.")
|
||||
|
||||
billing_data = payment_cycle.billing_data
|
||||
|
||||
if not billing_data:
|
||||
return _wipe_and_navigate()
|
||||
|
||||
profile_id = billing_data.instance_id
|
||||
|
||||
if profile_id:
|
||||
return wipe_subscription(profile_id)
|
||||
else:
|
||||
return _wipe_and_navigate()
|
||||
|
||||
|
||||
# @register_action(ResultError.EXPIRED_SUB)
|
||||
# def wipe_expired_subscription(profile_id: int) -> Result:
|
||||
# """Call core to wipe a subscription, then navigate to the billing page"""
|
||||
# logger.info(f"Wiping the subscription for {profile_id}")
|
||||
|
||||
# wiped_result = wipe_subscription(profile_id)
|
||||
# if wiped_result.valid:
|
||||
# logger.info("Successfully wiped")
|
||||
|
||||
|
||||
|
||||
# payment_cycle = OverallPaymentCycle()
|
||||
# payment_cycle.wipe_billing_cycle()
|
||||
|
||||
|
||||
# # also clear the whole billing cycle, so it's ready for an individual purchase,
|
||||
# return _wipe_and_navigate(profile_id)
|
||||
# else:
|
||||
# return wiped_result
|
||||
|
||||
|
||||
@type_checker
|
||||
def connect_with_subscription_already(profile_id: int, billing_code: str, ui_caller: Callable) -> Result:
|
||||
logger.info(f"Using a pre-existing billing code for profile {str(profile_id)}")
|
||||
|
||||
|
||||
|
||||
@type_checker
|
||||
def start_individual_payment(profile_id: int, ui_caller: Callable) -> Result:
|
||||
logger.info(f"Attempting to start individual billing state for profile {str(profile_id)}")
|
||||
take_action(
|
||||
action=start_new_payment_cycle,
|
||||
ui_caller=ui_caller,
|
||||
category=PaymentCategory.INDIVIDUAL,
|
||||
profile_id=profile_id
|
||||
)
|
||||
|
||||
def gui_starts_invoice(invoice_ui_caller: Callable, timer_resume_caller: Callable, profile_id: Optional[int] = None) -> str | None:
|
||||
|
||||
logger.info(f"gui_starts_invoice is being called with profile id {profile_id}")
|
||||
|
||||
# Is there currently a process?
|
||||
payment_cycle = OverallPaymentCycle()
|
||||
|
||||
if not payment_cycle:
|
||||
error_msg = "There is no payment cycle yet"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
if not payment_cycle.active:
|
||||
error_msg = "Process is not yet active"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
if payment_cycle.got_invoice:
|
||||
print("Cycle already got an invoice")
|
||||
billing_code = payment_cycle.billing_code
|
||||
|
||||
if not billing_code:
|
||||
error_msg = "Did not save the invoice from last time."
|
||||
logger.error(error_msg)
|
||||
# insert function here to get it via the data inside.
|
||||
return error_msg
|
||||
else:
|
||||
pre_existing = return_pre_existing_invoice(billing_code=billing_code, connection_observer=connection_observer)
|
||||
logger.info(f"Inside GUI's start of invoice, with {pre_existing.error_type}")
|
||||
if not pre_existing.valid and pre_existing.error_type == ResultError.PAID and payment_cycle.category == PaymentCategory.TICKET:
|
||||
logger.info("Navigating to ticket prep page")
|
||||
return Result(valid=True, error_type=NAVIGATE_TO, navigate_to="ticket_prep")
|
||||
else:
|
||||
logger.info("returning normally..")
|
||||
return pre_existing
|
||||
|
||||
notification = "Fetching Invoice.."
|
||||
Notifications.emit(EventType.STATUS_UPDATE, notification)
|
||||
|
||||
take_action(
|
||||
action=create_new_invoice,
|
||||
ui_caller=invoice_ui_caller,
|
||||
# profile_id=profile_id,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
|
||||
def start_polling(billing_code: str, profile_id: int, ui_caller: Callable) -> str | None:
|
||||
"""Kickstart an independent timer on a background thread to check if it's paid."""
|
||||
|
||||
payment_cycle = OverallPaymentCycle()
|
||||
|
||||
if not payment_cycle.active:
|
||||
logger.error("Not yet active to start polling.")
|
||||
|
||||
#----------------------------
|
||||
# FILTER ALREADY POLLING
|
||||
#----------------------------
|
||||
if payment_cycle.already_polling:
|
||||
error_msg = "This cycle is already polling, so we don't need to start another poll"
|
||||
logger.info(error_msg)
|
||||
return None
|
||||
|
||||
#----------------------------
|
||||
# FILTER LACK OF DATA
|
||||
#----------------------------
|
||||
if not billing_code:
|
||||
return "Requires a billing code"
|
||||
|
||||
#----------------------------
|
||||
# CHECK PAID ON PRE-EXISTING INVOICE
|
||||
#----------------------------
|
||||
logger.info("Starting a check paid timer..")
|
||||
|
||||
# Setup PyQt6 GUI as mainthread to bring back to:
|
||||
pyqt_threader = ThreadSafetyTool()
|
||||
|
||||
# update the global singleton:
|
||||
payment_cycle.already_polling = True
|
||||
|
||||
notification = "Checking if Paid.."
|
||||
Notifications.emit(EventType.STATUS_UPDATE, notification)
|
||||
|
||||
# setup the timer with the callback:
|
||||
# it doesn't return result here. It calls the view_callback function.
|
||||
check_paid_timer = CheckPaidTimer(
|
||||
view_callback=ui_caller,
|
||||
billing_code=billing_code,
|
||||
ui_dispatcher=pyqt_threader,
|
||||
payment_type=PaymentType.CRYPTO,
|
||||
connection_observer=connection_observer,
|
||||
profile_id=profile_id
|
||||
)
|
||||
check_paid_timer.start_polling()
|
||||
return None
|
||||
|
||||
@register_action(ResultError.BILLING_CONFLICT)
|
||||
def stop_current() -> Result:
|
||||
wiped_result = wipe_billing_cycle()
|
||||
|
||||
logger.info("Stopping current billing cycle!")
|
||||
|
||||
# if not wiped_result.valid:
|
||||
# return wiped_result
|
||||
|
||||
# new_cycle_result = start_new_payment_cycle(category=PaymentCategory.INDIVIDUAL, profile_id=profile_id)
|
||||
|
||||
# if not new_cycle_result.valid:
|
||||
# return new_cycle_result
|
||||
|
||||
notification = "Stopped Checking if Paid"
|
||||
Notifications.emit(EventType.STATUS_UPDATE, notification)
|
||||
return Result(valid=True, error_type=ResultError.NAVIGATE_TO, navigate_to="menu")
|
||||
|
||||
|
||||
@register_action(ResultError.ACTIVE_BILLING)
|
||||
def stop_payment_timer() -> Result:
|
||||
"""Gets the single instance of the payment checker timer, and stops it."""
|
||||
logger.info("Stopping the check paid timer & wiping the cycle..")
|
||||
try:
|
||||
# Wipe the cycle via core, and stop the timer, so this is not the active poller (blocking others)
|
||||
wiped_result = wipe_billing_cycle()
|
||||
|
||||
# stop the timer:
|
||||
# check_paid_timer = CheckPaidTimer.get_instance()
|
||||
# if check_paid_timer:
|
||||
# check_paid_timer.stop_polling()
|
||||
|
||||
if wiped_result.valid:
|
||||
content_to_display = "Stopped! You can resume a different profile's invoice now"
|
||||
notification = "Stopped Checking if Paid"
|
||||
Notifications.emit(EventType.STATUS_UPDATE, notification)
|
||||
return Result(valid=True, message=content_to_display)
|
||||
else:
|
||||
error_msg = "Unable to stop the payment timer and wipe the profile inside stop_payment_timer. But didn't get an actual error."
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Unable to stop the payment timer: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# def new_individual_billing(profile_id: int):
|
||||
# """Wipes pre-existing global payment cycle. And Calls upon core to setup a new individual profile cycle & data state."""
|
||||
# logger.info("starting a new individual profile state. And wiping the old one")
|
||||
# return start_billing_state(
|
||||
# category=PaymentCategory.INDIVIDUAL,
|
||||
# profile_id=profile_id,
|
||||
# wipe_existing=True
|
||||
# )
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# billing_data = payment_cycle.instance
|
||||
# category = payment_cycle.category
|
||||
|
||||
# if not billing_data:
|
||||
# msg = "There is a payment cycle, but no current billing data in it."
|
||||
# print(msg)
|
||||
# return msg
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# #----------------------------
|
||||
# # INDIVIDUAL
|
||||
# #----------------------------
|
||||
# if category == PaymentCategory.INDIVIDUAL:
|
||||
# # already conflicting one:
|
||||
# if not profile_id:
|
||||
# return "Need a profile id for an individual profile."
|
||||
|
||||
# take_action(
|
||||
# action=create_individual_invoice,
|
||||
# ui_caller=invoice_ui_caller,
|
||||
# profile_id=profile_id,
|
||||
# connection_observer=connection_observer
|
||||
# )
|
||||
# #----------------------------
|
||||
# # TICKETS
|
||||
# #----------------------------
|
||||
# elif category == PaymentCategory.TICKET:
|
||||
# logger.info("Starting ticketing invoicing..")
|
||||
# take_action(
|
||||
# action=create_individual_invoice,
|
||||
# ui_caller=invoice_ui_caller,
|
||||
# connection_observer=connection_observer
|
||||
# )
|
||||
|
||||
# else:
|
||||
# error_msg = "Unsupported Payment Category"
|
||||
# logger.error(error_msg)
|
||||
# return error_msg
|
||||
|
||||
# # Finally:
|
||||
# payment_cycle.got_invoice = True
|
||||
# return None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# return fetch_individual_invoice(invoice_ui_caller=invoice_ui_caller, timer_resume_caller=timer_resume_caller, profile_id=profile_id)
|
||||
|
||||
|
||||
|
||||
# # if profile_id != payment_cycle.profile_id:
|
||||
# # logger.info("The new profile id request is different than the pre-existing running one. So we're wiping it and stopping the timer.")
|
||||
# # worked, message = stop_payment_timer()
|
||||
# # if not worked:
|
||||
# # return message
|
||||
|
||||
# # # start over:
|
||||
# # payment_cycle.wipe()
|
||||
# # new_data = new_individual_billing(profile_id)
|
||||
# # if not new_data.valid:
|
||||
# # return new_data # result object
|
||||
# # category = PaymentCategory.INDIVIDUAL
|
||||
# # # billing_data = new_cycle.data
|
||||
|
||||
|
||||
|
||||
# elif category == PaymentCategory.TICKET:
|
||||
# logger.info("Starting ticketing invoicing..")
|
||||
# return fetch_ticket_invoice(invoice_ui_caller=invoice_ui_caller, timer_resume_caller=timer_resume_caller)
|
||||
|
||||
|
||||
# def fetch_individual_invoice(invoice_ui_caller: Callable, timer_resume_caller: Callable, profile_id: int) -> str | None:
|
||||
# """
|
||||
# Purpose:
|
||||
# Create a new invoice if we don't have one yet, or re-uses the saved data.
|
||||
# And starts polling if it's not already.
|
||||
|
||||
# Context:
|
||||
# For individual profiles.
|
||||
|
||||
# Returns:
|
||||
# Returns any errors as strings for UI to display.
|
||||
# """
|
||||
# # Fetch:
|
||||
# take_action(
|
||||
# action=create_individual_invoice,
|
||||
# ui_caller=invoice_ui_caller,
|
||||
# profile_id=profile_id,
|
||||
# connection_observer=connection_observer
|
||||
# )
|
||||
|
||||
|
||||
# def save_and_begin_timer(
|
||||
# ticketing: bool,
|
||||
# gui_callback: Callable,
|
||||
# billing_code: str,
|
||||
# profile_id: Optional[int] = None,
|
||||
# expires_at: Optional[str] = None,
|
||||
# ) -> bool:
|
||||
# """
|
||||
# First saves the billing code & profile expiration, if it's an individual (non-ticket) crypto-based one.
|
||||
# Then it Starts a loop of checking if the invoice is paid and saves it.
|
||||
# """
|
||||
|
||||
# if ticketing:
|
||||
# payment_type = PaymentType.TICKET
|
||||
# logger.error("Ticketing already saves the billing code on it's own when it recieves it.")
|
||||
|
||||
# # NON-ticketing:
|
||||
# elif profile_id and expires_at:
|
||||
# payment_type = PaymentType.CRYPTO
|
||||
# saved = save_billing_code(profile_id=profile_id, billing_code=billing_code, expires_at=expires_at)
|
||||
# if not saved.valid:
|
||||
# return False
|
||||
# else:
|
||||
# logger.error("You need a profile id and expires_at for individual profiles")
|
||||
# return False
|
||||
|
||||
# # Setup PyQt6 GUI as mainthread to bring back to:
|
||||
# pyqt_threader = ThreadSafetyTool()
|
||||
|
||||
# # setup the timer with the callback:
|
||||
# # it doesn't return result here. It calls the gui_callback function.
|
||||
# check_paid_timer = CheckPaidTimer(
|
||||
# view_callback=gui_callback,
|
||||
# billing_code=billing_code,
|
||||
# ui_dispatcher=pyqt_threader,
|
||||
# payment_type=payment_type,
|
||||
# connection_observer=connection_observer,
|
||||
# profile_id=profile_id
|
||||
# )
|
||||
# logger.info("starting polling from GUI action..")
|
||||
# check_paid_timer.start_polling()
|
||||
# return True # this is just that it started
|
||||
|
|
@ -8,6 +8,9 @@ from core.controllers.ClientController import ClientController
|
|||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.controllers.LocationController import LocationController
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
|
||||
from core.services.profiles.profile_connections import enable_profile, disable_profile, destroy_profile
|
||||
|
||||
from core.controllers.SubscriptionController import SubscriptionController
|
||||
from core.controllers.SyncController import new_sync
|
||||
from core.controllers.tickets.UseTicketController import do_we_use_a_random_ticket, get_unused_tickets, use_ticket
|
||||
|
|
@ -103,6 +106,10 @@ def _consume_ticket(profile: BaseProfile, request: dict[str, Any], ticket: int |
|
|||
|
||||
def _prepare_subscription(profile: BaseProfile, request: dict[str, Any]) -> MenuResult | None:
|
||||
"""Prepare billing once before activation while retaining recovery data across prompts."""
|
||||
|
||||
print(f"request is entering: {request}")
|
||||
print(f"request type is {type(request)}")
|
||||
|
||||
if request.get("subscription_attached"):
|
||||
return None
|
||||
ticket = request.get("use_ticket")
|
||||
|
|
@ -167,6 +174,9 @@ def _enable_error(error: Exception, profile: BaseProfile, request: dict[str, Any
|
|||
|
||||
def _enable(request: dict[str, Any], emit: MenuCallback, max_resolution: str | None) -> MenuResult:
|
||||
"""Own readiness, billing, activation and recovery as one connect task."""
|
||||
|
||||
print(f"request enters _enable. {request}")
|
||||
|
||||
synced = synchronize(current_connection()) if request.pop("sync_before_enable", False) else None
|
||||
if synced is not None:
|
||||
emit({**synced, "busy": True})
|
||||
|
|
@ -182,21 +192,43 @@ def _enable(request: dict[str, Any], emit: MenuCallback, max_resolution: str | N
|
|||
if needs_download and current_connection() != "tor" and not request.get("allow_uninstalled_tor"):
|
||||
return _confirmation(request, "tor_download", "The Tor browser is not installed; switch to Tor to download it or proceed over clearweb.")
|
||||
request["allow_uninstalled_tor"] = True
|
||||
|
||||
# Payment not finished:
|
||||
try:
|
||||
if profile.subscription.billing_pending:
|
||||
print("This is resuming a pre-done payment")
|
||||
return {"status": "navigate", "destination": "payment_details"}
|
||||
except Exception as e:
|
||||
print(f"Skipping billing resume check")
|
||||
|
||||
# print("checking sub")
|
||||
# if not profile.subscription.billing:
|
||||
# raise MissingSubscriptionError()
|
||||
|
||||
|
||||
|
||||
print(f"checking preparation")
|
||||
|
||||
preparation = _prepare_subscription(profile, request)
|
||||
if preparation is not None:
|
||||
return preparation
|
||||
|
||||
print("entering observers")
|
||||
ignored = {"ignore_endpoint_verification": EndpointVerificationError,
|
||||
"ignore_profile_state_conflict": ProfileStateConflictError}
|
||||
observer = ProfileObserver()
|
||||
observer.subscribe("enabled", partial(_profile_event, emit, True))
|
||||
observer.subscribe("disabled", partial(_profile_event, emit, False))
|
||||
|
||||
print("doing the enable")
|
||||
try:
|
||||
ProfileController.enable(profile, ignore=tuple(error for flag, error in ignored.items() if request.get(flag)),
|
||||
enable_profile(profile, ignore=tuple(error for flag, error in ignored.items() if request.get(flag)),
|
||||
profile_observer=observer, application_version_observer=application_version_observer,
|
||||
connection_observer=connection_observer, ticket_observer=ticket_observer,
|
||||
max_resolution=max_resolution)
|
||||
except Exception as error:
|
||||
return _enable_error(error, profile, request)
|
||||
print("returning success..")
|
||||
return {"status": "success", "request": dict(request), "refresh": True}
|
||||
|
||||
|
||||
|
|
@ -248,7 +280,8 @@ def _delete(request: dict[str, Any]) -> MenuResult:
|
|||
if not request.get("confirmed"):
|
||||
return _confirmation(request, "delete", "Are you sure you want to delete this profile?", "Delete")
|
||||
profile = _require_profile(request["id"])
|
||||
ProfileController.destroy(profile, profile_observer, ticket_observer, connection_observer)
|
||||
destroy_profile(profile, profile_observer, ticket_observer, connection_observer)
|
||||
# ProfileController.destroy(profile, profile_observer, ticket_observer, connection_observer)
|
||||
return {"status": "success", "message": f"Profile {profile.id} deleted", "profile_id": profile.id,
|
||||
"refresh": True, "clear_selection": True}
|
||||
|
||||
|
|
@ -280,6 +313,9 @@ def _update() -> MenuResult:
|
|||
|
||||
def execute_menu_task(request: dict[str, Any], emit: MenuCallback, max_resolution: str | None) -> MenuResult:
|
||||
"""Dispatch background operations independently of Qt and normalize their failure results."""
|
||||
|
||||
print(f"request is in execute_menu_task as {request}")
|
||||
|
||||
handlers = {"enable": partial(_enable, request, emit, max_resolution), "disable": partial(_disable, request),
|
||||
"disable_all": lambda: _disable_profiles([_require_profile(profile_id) for profile_id in request["connected_ids"]]),
|
||||
"delete": partial(_delete, request), "create": partial(_navigate, request, emit),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from core.infrastructure.global_state import ProfileStateRepository
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
from core.controllers.SystemStateController import SystemStateController
|
||||
from core.models.system.SystemProfile import SystemProfile
|
||||
|
|
@ -21,7 +22,16 @@ def _system_state_profile_id():
|
|||
return None
|
||||
|
||||
|
||||
def is_profile_enabled_for_gui(profile):
|
||||
if isinstance(profile, SystemProfile) and _connection_code(profile) in SINGBOX_PROTOCOLS:
|
||||
return _system_state_profile_id() == int(profile.id)
|
||||
return ProfileController.is_enabled(profile)
|
||||
|
||||
|
||||
def is_profile_enabled_for_gui(profile) -> bool:
|
||||
"""Checks Profile State Against in-memory Repo"""
|
||||
is_enabled = ProfileStateRepository().is_profile_active(profile.id)
|
||||
# print(f"profile id: {profile.id} | Enabled: {is_enabled}")
|
||||
return is_enabled
|
||||
|
||||
|
||||
|
||||
# if isinstance(profile, SystemProfile) and _connection_code(profile) in SINGBOX_PROTOCOLS:
|
||||
# return _system_state_profile_id() == int(profile.id)
|
||||
# return ProfileController.is_enabled(profile)
|
||||
|
|
|
|||
|
|
@ -102,8 +102,10 @@ def extract_endpoint_ip(profile):
|
|||
wg_conf_path = f'{profile_path}/wg.conf.bak'
|
||||
if not os.path.exists(wg_conf_path):
|
||||
return None
|
||||
|
||||
with open(wg_conf_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
for line in content.split('\n'):
|
||||
if line.strip().startswith('Endpoint = '):
|
||||
endpoint = line.strip().split(' = ')[1]
|
||||
|
|
|
|||
178
gui/v2/actions/ticket_actions.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# core
|
||||
from core.services.using_tickets.use_ticket_orchestrator import use_ticket_orchestrator
|
||||
from core.controllers.tickets.TicketPrepController import prepare_tickets
|
||||
from core.controllers.ConfigurationController import ConfigurationController
|
||||
from core.services.prepare_tickets.ticket_tracker import get_a_random_ticket
|
||||
from core.services.subscriptions.subscriptions_by_protocol import start_new_payment_cycle, get_ticket_invoice
|
||||
from core.services.payment_phase.ticket_cycle import wipe_ticket_cycle
|
||||
from core.services.payment_phase.payment_state import TicketPaymentData
|
||||
from core.models.enum_models import PaymentCategory
|
||||
from core.services.prepare_tickets.ticket_tracker import get_all_unused_tickets
|
||||
from core.utils.type_checker import type_checker
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
from core.observers.Notifications import Notifications, EventType
|
||||
|
||||
# gui
|
||||
from gui.v2.infrastructure.action_registry import register_action
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from gui.v2.infrastructure.setup_observers import connection_observer, ticket_observer
|
||||
from gui.v2.actions.error_handlers import navigate_to_new_sub_page
|
||||
|
||||
# generic:
|
||||
from typing import Callable
|
||||
|
||||
# from core.controllers.tickets.UseTicketController import use_ticket
|
||||
# from core.models.BaseProfile import BaseProfile as Profile
|
||||
# from core.controllers.ProfileController import ProfileController
|
||||
# from core.controllers.tickets.TicketPayController import initiate_payment
|
||||
|
||||
|
||||
@register_action(ResultError.TICKET_EXPIRED)
|
||||
def wipe_ticket_billing() -> Result:
|
||||
"""Wipe existing data and start over."""
|
||||
wiped = wipe_ticket_cycle()
|
||||
if wiped.valid:
|
||||
return Result(valid=True, error_type=ResultError.NAVIGATE_TO, navigate_to="plan_picker")
|
||||
else:
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="plan_picker")
|
||||
|
||||
|
||||
@register_action(ResultError.NO_TICKET)
|
||||
def start_and_navigate_to_ticketing() -> Result:
|
||||
"""Starts a new ticketing cycle. (assumes the user opted in)."""
|
||||
start_result = start_new_payment_cycle(category=PaymentCategory.TICKET)
|
||||
if start_result.valid:
|
||||
logger.info("creating a new payment cycle was valid, it worked.")
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="plan_picker")
|
||||
else:
|
||||
logger.info(f"Returning the error of start_result {start_result}")
|
||||
return start_result
|
||||
|
||||
|
||||
@type_checker
|
||||
def random_ticket_dispatch(profile_id: int, passed_ui_caller: Callable) -> Result:
|
||||
"""
|
||||
User explicitly wants a random ticket.
|
||||
The reason this is in GUI repo, is because use_a_ticket is.
|
||||
"""
|
||||
random_ticket_result = get_a_random_ticket()
|
||||
|
||||
if not random_ticket_result.valid:
|
||||
return random_ticket_result
|
||||
|
||||
which_ticket = random_ticket_result.data
|
||||
|
||||
# this is a redundant sanity check:
|
||||
if not which_ticket:
|
||||
Result(valid=False, error_type=ResultError.NO_TICKET, message="No tickets left. Would you like to get some?")
|
||||
|
||||
# thread work that calls the caller, not return:
|
||||
take_action(
|
||||
action=use_ticket_orchestrator,
|
||||
ui_caller=passed_ui_caller,
|
||||
profile_id=profile_id,
|
||||
which_ticket=which_ticket,
|
||||
connection_observer=connection_observer,
|
||||
ticket_observer=ticket_observer
|
||||
)
|
||||
return Result(valid=True)
|
||||
|
||||
|
||||
@type_checker
|
||||
def dispatch_ticketing(profile_id: int, passed_ui_caller: Callable) -> Result:
|
||||
"""Evaluate if a random ticket will be used."""
|
||||
|
||||
use_random_setting = ConfigurationController.use_random()
|
||||
|
||||
if use_random_setting:
|
||||
return random_ticket_dispatch(profile_id=profile_id, passed_ui_caller=passed_ui_caller)
|
||||
|
||||
# User does NOT want an automated selection, so they can pick from their tickets:
|
||||
do_they_have_tickets = get_ticket_data()
|
||||
|
||||
if do_they_have_tickets.valid:
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="ticket_or_billing_choice")
|
||||
|
||||
else:
|
||||
# user can decide if they want to setup tickets or do an individual profile:
|
||||
return navigate_to_new_sub_page()
|
||||
|
||||
|
||||
def start_ticket(ui_caller: Callable):
|
||||
"""Start the entire billing cycle for tickets"""
|
||||
logger.info("started ticketing billing state")
|
||||
notification = "Started Ticket Billing"
|
||||
Notifications.emit(EventType.STATUS_UPDATE, notification)
|
||||
take_action(
|
||||
action=start_new_payment_cycle,
|
||||
category=PaymentCategory.TICKET,
|
||||
ui_caller=ui_caller
|
||||
)
|
||||
|
||||
|
||||
def fetch_ticket_invoice(ui_caller: Callable):
|
||||
"""Fetch the invoice for tickets"""
|
||||
take_action(
|
||||
action=get_ticket_invoice,
|
||||
ui_caller=ui_caller,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
||||
|
||||
def start_bulk_ticket_prep(ui_caller: Callable) -> Result:
|
||||
"""After paid for, start the actual math"""
|
||||
logger.info("Starting a bulk ticket prep")
|
||||
ticket_cycle = TicketPaymentData()
|
||||
how_many_profiles = ticket_cycle.how_many_profiles
|
||||
|
||||
if not ticket_cycle.paid:
|
||||
logger.error("Not yet paid")
|
||||
return Result(valid=False, error_type=ResultError.NAVIGATE_TO, navigate_to="id", message="Tickets are not yet paid for, yet the prep started.")
|
||||
|
||||
logger.info("Threading to prepare_tickets")
|
||||
take_action(
|
||||
action=prepare_tickets,
|
||||
ui_caller=ui_caller,
|
||||
how_many_profiles=how_many_profiles,
|
||||
connection_observer=connection_observer,
|
||||
ticket_observer=ticket_observer,
|
||||
which_ticket=None,
|
||||
billing_code=None
|
||||
)
|
||||
|
||||
|
||||
|
||||
def get_ticket_data() -> Result:
|
||||
"""This is running in the background ALREADY. Get & format a list of unused tickets from core"""
|
||||
|
||||
ticket_results = get_all_unused_tickets()
|
||||
valid = ticket_results.get('valid')
|
||||
|
||||
if not valid:
|
||||
message = ticket_results.get('message')
|
||||
if not message:
|
||||
message = "Error gathering ticket data"
|
||||
Result(valid=False, error_type=ResultError.MISSING_FILE, message=message)
|
||||
|
||||
raw_tickets = ticket_results.get('data')
|
||||
print(f"raw_tickets is {raw_tickets}")
|
||||
if not raw_tickets:
|
||||
Result(valid=False, error_type=ResultError.NO_TICKET, message="No tickets available. Would you like to get some?")
|
||||
|
||||
formatted_data = [(f"Ticket #{t}", t) for t in raw_tickets]
|
||||
print(f"formatted_data is {formatted_data}")
|
||||
return Result(valid=True, data=formatted_data)
|
||||
|
||||
|
||||
|
||||
def use_a_ticket(profile_id: int, which_ticket: int, ui_caller: Callable):
|
||||
"""Use of a given ticket."""
|
||||
take_action(
|
||||
action=use_ticket_orchestrator,
|
||||
ui_caller=ui_caller,
|
||||
profile_id=profile_id,
|
||||
which_ticket=which_ticket,
|
||||
ticket_observer=ticket_observer,
|
||||
connection_observer=connection_observer
|
||||
)
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# from core.infrastructure.thread_dispatch import MainThreadDispatcher
|
||||
|
||||
import sys
|
||||
import time
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QPushButton, QLabel
|
||||
|
|
@ -13,9 +15,15 @@ class ThreadSafetyTool(QObject):
|
|||
|
||||
def _execute(self, func, args, kwargs):
|
||||
func(*args, **kwargs)
|
||||
|
||||
|
||||
# --- PROTOCOL REQUIRES THIS ---
|
||||
def run_on_main_thread(self, func, *args, **kwargs):
|
||||
self._signal.emit(func, args, kwargs)
|
||||
# -----------------------------------------
|
||||
|
||||
def __call__(self, func):
|
||||
"""Decorator that forces a function to run on the main thread."""
|
||||
def wrapper(*args, **kwargs):
|
||||
self._signal.emit(func, args, kwargs)
|
||||
return wrapper
|
||||
|
||||
|
|
|
|||
49
gui/v2/infrastructure/action_registry.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from core.models.Result import Result, ResultError
|
||||
from core.controllers.SyncController import new_sync
|
||||
from core.services.subscriptions.subscriptions_by_protocol import wipe_billing_cycle
|
||||
# from core.services.payment_phase.check_payment_loop import mark_as_paid
|
||||
# from gui.v2.actions.payment_actions import stop_payment_timer
|
||||
|
||||
ENUM_MAP = {
|
||||
ResultError.UNKNOWN: None,
|
||||
ResultError.NEED_SYNC: (new_sync),
|
||||
ResultError.ACTIVE_BILLING: (wipe_billing_cycle),
|
||||
ResultError.EXPIRED_INVOICE: (wipe_billing_cycle),
|
||||
# ResultError.PAID: (mark_as_paid),
|
||||
ResultError.INVALID_API_REPLY: None,
|
||||
ResultError.ENCRYPTION: None
|
||||
}
|
||||
|
||||
|
||||
def register_action(error_code):
|
||||
def wrapper(func):
|
||||
ENUM_MAP[error_code] = (func)
|
||||
return func
|
||||
return wrapper
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Add later potentially:
|
||||
|
||||
# ResultError.ACTIVE_BILLING: (stop_payment_timer),
|
||||
# ResultError.NO_TICKET: (start_billing),
|
||||
# ResultError.NO_PROFILE: (go_to_new_profile_page),
|
||||
# ResultError.ALREADY_EXISTS: (get_invoice), # bypass_existing: True
|
||||
# ResultError.BILLING_CODE_EXISTS: (get_invoice) # bypass_existing: True
|
||||
|
||||
|
||||
|
||||
|
||||
# Potential future upgrade to multiple error codes per function:
|
||||
|
||||
# def register_action(*error_codes):
|
||||
# def wrapper(func):
|
||||
# for code in error_codes:
|
||||
# ACTION_REGISTRY[code] = func
|
||||
# return func
|
||||
# return wrapper
|
||||
|
|
@ -16,7 +16,8 @@ class ConnectionManager:
|
|||
return self.profile_button_objects.get(profile_id, None)
|
||||
|
||||
def set_profile_button_objects(self, profile_id, profile_button_objects):
|
||||
self.profile_button_objects[profile_id] = profile_button_objects
|
||||
pass
|
||||
# self.profile_button_objects[profile_id] = profile_button_objects
|
||||
|
||||
def get_available_resolutions(self, profile_id):
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import importlib
|
|||
import time
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QObject, QTimer
|
||||
from PyQt6.QtCore import QObject, QTimer, pyqtSignal
|
||||
|
||||
from gui.v2.infrastructure.page_registry import (
|
||||
PAGE_REGISTRY,
|
||||
|
|
@ -17,6 +17,8 @@ from core.errors.logger import logger
|
|||
|
||||
|
||||
class Navigator(QObject):
|
||||
external_navigation = pyqtSignal(str)
|
||||
|
||||
def __init__(self, custom_window):
|
||||
super().__init__()
|
||||
self.custom_window = custom_window
|
||||
|
|
@ -37,6 +39,9 @@ class Navigator(QObject):
|
|||
self._preload_timer.setSingleShot(True)
|
||||
self._preload_timer.timeout.connect(self._preload_next)
|
||||
|
||||
# external navigation:
|
||||
self.external_navigation.connect(self.navigate)
|
||||
|
||||
def get_class(self, name):
|
||||
module_path, class_name = PAGE_REGISTRY[name]
|
||||
module = importlib.import_module(module_path)
|
||||
|
|
@ -80,25 +85,25 @@ class Navigator(QObject):
|
|||
|
||||
def _preload_one(self, name):
|
||||
if name in self._instances:
|
||||
logger.debug(f"[preload] {name:30s} SKIP (already cached)")
|
||||
# logger.debug(f"[preload] {name:30s} SKIP (already cached)")
|
||||
return False
|
||||
if name not in PAGE_REGISTRY:
|
||||
logger.debug(f"[preload] {name:30s} SKIP (not in registry)")
|
||||
# logger.debug(f"[preload] {name:30s} SKIP (not in registry)")
|
||||
return False
|
||||
module_path, class_name = PAGE_REGISTRY[name]
|
||||
logger.debug(f"[preload] {name:30s} LOAD {module_path}.{class_name}")
|
||||
# logger.debug(f"[preload] {name:30s} LOAD {module_path}.{class_name}")
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
page = self._instantiate(name)
|
||||
self.register_instance(name, page)
|
||||
dt_ms = (time.perf_counter() - t0) * 1000.0
|
||||
logger.debug(f"[preload] {name:30s} OK ({dt_ms:6.1f} ms, "
|
||||
f"cached={len(self._instances)}, stack_size={self.page_stack.count()})")
|
||||
# logger.debug(f"[preload] {name:30s} OK ({dt_ms:6.1f} ms, "
|
||||
# f"cached={len(self._instances)}, stack_size={self.page_stack.count()})")
|
||||
return True
|
||||
except Exception as e:
|
||||
dt_ms = (time.perf_counter() - t0) * 1000.0
|
||||
logger.debug(f"[preload] {name:30s} FAIL ({dt_ms:6.1f} ms) "
|
||||
f"{type(e).__name__}: {e}")
|
||||
# logger.debug(f"[preload] {name:30s} FAIL ({dt_ms:6.1f} ms) "
|
||||
# f"{type(e).__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
from core.errors.logger import logger as core_logger
|
||||
core_logger.warning(f"Background preload failed for '{name}': "
|
||||
|
|
@ -121,29 +126,29 @@ class Navigator(QObject):
|
|||
self._preload_inflight = 0
|
||||
self._preload_summary_emitted = False
|
||||
self._preload_t_start = time.perf_counter()
|
||||
logger.debug(f"[preload] ============================================================")
|
||||
logger.debug(f"[preload] starting background preload: {self._preload_total} pages queued")
|
||||
logger.debug(f"[preload] order : {', '.join(self._preload_queue)}")
|
||||
logger.debug(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)")
|
||||
if fast_mode:
|
||||
logger.debug(f"[preload] fastmode: ON -> skipping regular-flow pages: "
|
||||
f"{', '.join(skipped_regular) if skipped_regular else '(none)'}")
|
||||
logger.debug(f"[preload] gap : 250 ms between loads, 700 ms initial delay")
|
||||
logger.debug(f"[preload] ============================================================")
|
||||
# logger.debug(f"[preload] ============================================================")
|
||||
# logger.debug(f"[preload] starting background preload: {self._preload_total} pages queued")
|
||||
# logger.debug(f"[preload] order : {', '.join(self._preload_queue)}")
|
||||
# logger.debug(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)")
|
||||
# if fast_mode:
|
||||
# logger.debug(f"[preload] fastmode: ON -> skipping regular-flow pages: "
|
||||
# f"{', '.join(skipped_regular) if skipped_regular else '(none)'}")
|
||||
# logger.debug(f"[preload] gap : 250 ms between loads, 700 ms initial delay")
|
||||
# logger.debug(f"[preload] ============================================================")
|
||||
self._preload_timer.start(700)
|
||||
|
||||
def _preload_next(self):
|
||||
while self._preload_queue and self._preload_queue[0] in self._instances:
|
||||
skipped = self._preload_queue.pop(0)
|
||||
self._preload_skipped_runtime += 1
|
||||
logger.debug(f"[preload] {skipped:30s} SKIP (user navigated here first)")
|
||||
# logger.debug(f"[preload] {skipped:30s} SKIP (user navigated here first)")
|
||||
if not self._preload_queue:
|
||||
self._maybe_emit_preload_summary()
|
||||
return
|
||||
self._preload_dispatched += 1
|
||||
idx = self._preload_dispatched
|
||||
name = self._preload_queue.pop(0)
|
||||
logger.debug(f"[preload] ---- {idx}/{self._preload_total} ----")
|
||||
# logger.debug(f"[preload] ---- {idx}/{self._preload_total} ----")
|
||||
if name in ASYNC_PREPARE:
|
||||
self._dispatch_async_preload(name)
|
||||
else:
|
||||
|
|
@ -177,29 +182,30 @@ class Navigator(QObject):
|
|||
def _on_preload_data_ready(self, name, payload):
|
||||
self._preload_inflight -= 1
|
||||
if name in self._instances:
|
||||
logger.debug(f"[preload] {name:30s} SKIP (already cached, async result dropped)")
|
||||
# logger.debug(f"[preload] {name:30s} SKIP (already cached, async result dropped)")
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
page = self._instantiate(name, prepared=payload)
|
||||
self.register_instance(name, page)
|
||||
self._preload_done += 1
|
||||
logger.debug(f"[preload] {name:30s} OK (async, cached={len(self._instances)}, "
|
||||
f"stack_size={self.page_stack.count()})")
|
||||
# logger.debug(f"[preload] {name:30s} OK (async, cached={len(self._instances)}, "
|
||||
# f"stack_size={self.page_stack.count()})")
|
||||
except Exception as e:
|
||||
self._preload_failed += 1
|
||||
logger.debug(f"[preload] {name:30s} FAIL (async build) {type(e).__name__}: {e}")
|
||||
# logger.debug(f"[preload] {name:30s} FAIL (async build) {type(e).__name__}: {e}")
|
||||
self._maybe_emit_preload_summary()
|
||||
|
||||
def _on_preload_failed(self, name, error):
|
||||
self._preload_inflight -= 1
|
||||
logger.debug(f"[preload] {name:30s} FAIL (async prepare) {error}")
|
||||
# logger.debug(f"[preload] {name:30s} FAIL (async prepare) {error}")
|
||||
if name not in self._instances:
|
||||
ok = self._preload_one(name)
|
||||
if ok:
|
||||
self._preload_done += 1
|
||||
else:
|
||||
self._preload_failed += 1
|
||||
self._maybe_emit_preload_summary()
|
||||
# self._maybe_emit_preload_summary()
|
||||
|
||||
def _cleanup_preload_worker(self, name):
|
||||
worker = self._preload_workers.pop(name, None)
|
||||
|
|
@ -214,11 +220,12 @@ class Navigator(QObject):
|
|||
self._emit_preload_summary()
|
||||
|
||||
def _emit_preload_summary(self):
|
||||
total_s = time.perf_counter() - self._preload_t_start
|
||||
logger.debug(f"[preload] ============================================================")
|
||||
logger.debug(f"[preload] complete: {self._preload_done} loaded, "
|
||||
f"{self._preload_failed} failed, "
|
||||
f"{self._preload_skipped_runtime} skipped (user beat us to it) "
|
||||
f"in {total_s:.2f}s")
|
||||
logger.debug(f"[preload] cached pages now: {sorted(self._instances.keys())}")
|
||||
logger.debug(f"[preload] ============================================================")
|
||||
pass
|
||||
# total_s = time.perf_counter() - self._preload_t_start
|
||||
# logger.debug(f"[preload] ============================================================")
|
||||
# logger.debug(f"[preload] complete: {self._preload_done} loaded, "
|
||||
# f"{self._preload_failed} failed, "
|
||||
# f"{self._preload_skipped_runtime} skipped (user beat us to it) "
|
||||
# f"in {total_s:.2f}s")
|
||||
# logger.debug(f"[preload] cached pages now: {sorted(self._instances.keys())}")
|
||||
# logger.debug(f"[preload] ============================================================")
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ PAGE_REGISTRY = {
|
|||
"policy_suggestion": ("gui.v2.ui.pages.policy_suggestion_page", "PolicySuggestionPage"),
|
||||
"duration_selection": ("gui.v2.ui.pages.duration_selection_page", "DurationSelectionPage"),
|
||||
"currency_selection": ("gui.v2.ui.pages.currency_selection_page", "CurrencySelectionPage"),
|
||||
"payment_details": ("gui.v2.ui.pages.payment_details_page", "PaymentDetailsPage"),
|
||||
"payment_details": ("gui.v2.ui.pages.payments2", "PaymentDetailsPage"),
|
||||
"plan_picker": ("gui.v2.ui.pages.plan_picker_page", "PlanPickerPage"),
|
||||
"ticket_crypto_picker": ("gui.v2.ui.pages.ticket_crypto_picker_page", "TicketCryptoPickerPage"),
|
||||
"ticket_prep": ("gui.v2.ui.pages.ticket_prep_page", "TicketPrepPage"),
|
||||
|
|
@ -61,12 +61,8 @@ REGULAR_FLOW_ONLY = {
|
|||
PRELOAD_ORDER = [
|
||||
"settings",
|
||||
"editor",
|
||||
"ticket_or_billing_choice",
|
||||
"payment_details",
|
||||
"plan_picker",
|
||||
"ticket_crypto_picker",
|
||||
"duration_selection",
|
||||
"currency_selection",
|
||||
"policy_suggestion",
|
||||
"systemwide_prompt",
|
||||
"fast_mode_prompt",
|
||||
|
|
@ -85,3 +81,8 @@ PRELOAD_ORDER = [
|
|||
"install_system_package",
|
||||
"sync_screen",
|
||||
]
|
||||
|
||||
# "duration_selection",
|
||||
# "payment_details",
|
||||
# "ticket_or_billing_choice",
|
||||
# "currency_selection",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ def setup_observers(update_status):
|
|||
profile_observer.subscribe(
|
||||
'destroyed', lambda event: update_status('Profile destroyed'))
|
||||
|
||||
profile_observer.subscribe(
|
||||
'enabled', lambda event: update_status('Profile Enabled'))
|
||||
profile_observer.subscribe(
|
||||
'disabled', lambda event: update_status('Profile Disabled'))
|
||||
|
||||
|
||||
# client_observer.subscribe(
|
||||
# 'synchronizing', lambda event: update_status('Sync in progress...'))
|
||||
|
||||
|
|
|
|||
28
gui/v2/infrastructure/take_plumbing_action.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from core.infrastructure.plumbing import ThreadPlumbing
|
||||
from core.models.Result import ResultError
|
||||
|
||||
from gui.v2.infrastructure.action_registry import ENUM_MAP
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
from gui.v2.ui.popups.error_popup import ErrorPopup
|
||||
|
||||
from typing import Callable
|
||||
|
||||
AUTO_NAVIGATE_REGISTRY = {
|
||||
ResultError.WRONG_LOCATION: "settings"
|
||||
}
|
||||
|
||||
|
||||
def take_action(action: Callable, ui_caller: Callable, *args, **kwargs):
|
||||
"""PyQt6 GUI implementation of plumbing"""
|
||||
|
||||
controller = ThreadPlumbing(
|
||||
action=action,
|
||||
ui_dispatcher=ThreadSafetyTool(), # PyQt6 as mainthread
|
||||
error_implementation=ErrorPopup, # PyQt6 ErrorPopup
|
||||
ENUM_MAP=ENUM_MAP,
|
||||
auto_navigate_dict=AUTO_NAVIGATE_REGISTRY,
|
||||
ui_caller=ui_caller,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
controller.execute()
|
||||
|
|
@ -1,8 +1,24 @@
|
|||
import os
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.errors.logger import logger
|
||||
|
||||
|
||||
from gui.v2.ui.popups.error_popup import ErrorPopup
|
||||
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
mainthread = ThreadSafetyTool()
|
||||
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from PyQt6.QtWidgets import QWidget, QLabel, QPushButton
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6 import QtCore
|
||||
from enum import Enum
|
||||
|
||||
# For formatting errors graphically:
|
||||
class DataError(Enum):
|
||||
FORMAT: "format"
|
||||
BLANK: "blank"
|
||||
|
||||
|
||||
class Page(QWidget):
|
||||
|
|
@ -101,3 +117,54 @@ class Page(QWidget):
|
|||
except TypeError:
|
||||
pass
|
||||
button.clicked.connect(handler)
|
||||
|
||||
@mainthread
|
||||
def show_a_popup(self, message: str, title: str = "Error"):
|
||||
"""Display a popup with an error"""
|
||||
|
||||
# MISTAKE CATCHER:
|
||||
if isinstance(message, Result):
|
||||
final_message = message.message
|
||||
title = message.error_type.value
|
||||
else:
|
||||
final_message = message
|
||||
|
||||
# FINAL REAL CHECK
|
||||
if isinstance(final_message, str) and isinstance(title, str):
|
||||
error_popup = ErrorPopup(
|
||||
choice=False,
|
||||
title=title,
|
||||
message=final_message,
|
||||
end_app = False,
|
||||
on_answer = None,
|
||||
passed_result = None,
|
||||
)
|
||||
|
||||
error_popup.show()
|
||||
else:
|
||||
logger.error("Show a popup was sent non valid strings to display.")
|
||||
|
||||
|
||||
def display_error(self, whats_wrong: DataError, whats_it_called: str, the_thing: Any):
|
||||
"""Generic re-usable error prompt for various data related issues"""
|
||||
|
||||
# Diagnosis
|
||||
if whats_wrong == DataError.FORMAT:
|
||||
error_msg = f"The {whats_it_called} is in the wrong format"
|
||||
status_update = f'Error: Bad {whats_it_called} Format'
|
||||
elif whats_wrong == DataError.BLANK:
|
||||
error_msg = f"The {whats_it_called} is blank! But you need a {whats_it_called} to continue!"
|
||||
status_update = f'Error: Blank {whats_it_called}'
|
||||
else:
|
||||
error_msg = f"The {whats_it_called} has unknown errors!"
|
||||
status_update = f'Unknown Error with {whats_it_called}'
|
||||
|
||||
# Display
|
||||
if the_thing and isinstance(the_thing, str):
|
||||
self.show_a_popup(f"{error_msg}: {the_thing}")
|
||||
elif the_thing and isinstance(the_thing, dict):
|
||||
message = the_thing.get('message', "!")
|
||||
self.show_a_popup(f"{error_msg} {message}")
|
||||
else:
|
||||
self.show_a_popup(error_msg)
|
||||
self.update_status.update_status(status_update)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,18 @@
|
|||
import os
|
||||
# core
|
||||
from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets, get_current_billing_data
|
||||
from core.controllers.profile_state.data_filters import ALL_CURRENCIES
|
||||
from core.models.enum_models import Currency
|
||||
from core.services.payment_phase.payment_state import PaymentData
|
||||
|
||||
from PyQt6.QtWidgets import QButtonGroup, QLabel, QPushButton
|
||||
# gui
|
||||
from gui.v2.ui.pages.Page import Page, DataError
|
||||
|
||||
import os
|
||||
from PyQt6 import QtCore
|
||||
from PyQt6.QtWidgets import QLabel, QPushButton, QButtonGroup
|
||||
from PyQt6.QtWidgets import QVBoxLayout, QWidget, QSpacerItem, QSizePolicy
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtCore import QSize
|
||||
from PyQt6 import QtCore
|
||||
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
|
||||
|
||||
class CurrencySelectionPage(Page):
|
||||
|
|
@ -13,53 +20,99 @@ class CurrencySelectionPage(Page):
|
|||
super().__init__("Select Currency", page_stack, main_window, parent)
|
||||
self.update_status = main_window
|
||||
self.selected_duration = None
|
||||
self.create_interface_elements()
|
||||
|
||||
# Map to link the button object to its currency string value
|
||||
self.button_to_currency = {}
|
||||
|
||||
self.create_initial_elements()
|
||||
self.button_reverse.setVisible(True)
|
||||
self.button_next.setVisible(False)
|
||||
|
||||
def create_interface_elements(self):
|
||||
|
||||
def create_initial_elements(self):
|
||||
self.title = QLabel("Payment Method", self)
|
||||
self.title.setGeometry(QtCore.QRect(510, 30, 250, 40))
|
||||
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
self.button_reverse.clicked.connect(self.reverse)
|
||||
|
||||
button_info = [
|
||||
("monero", (545, 75)),
|
||||
("bitcoin", (545, 290)),
|
||||
("lightnering", (545, 180)),
|
||||
("litecoin", (545, 395))
|
||||
]
|
||||
# container widget to hold the 'block' of buttons
|
||||
self.button_container = QWidget(self)
|
||||
|
||||
# Position of the entire group, not each individual button
|
||||
self.button_container.setGeometry(545, 75, 185, 400)
|
||||
|
||||
# Vertical Layout
|
||||
self.layout = QVBoxLayout(self.button_container)
|
||||
|
||||
# margins avoid cutting off image:
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Consistent gap between buttons
|
||||
self.layout.setSpacing(20)
|
||||
self.layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.buttonGroup = QButtonGroup(self)
|
||||
self.buttons = []
|
||||
|
||||
for j, (icon_name, position) in enumerate(button_info):
|
||||
# loop through enums and get the relevant image,
|
||||
for currency_val in ALL_CURRENCIES:
|
||||
button = QPushButton(self)
|
||||
button.setGeometry(position[0], position[1], 185, 75)
|
||||
|
||||
# size required for the image
|
||||
button.setFixedSize(185, 75)
|
||||
|
||||
button.setIconSize(QSize(190, 120))
|
||||
button.setCheckable(True)
|
||||
self.buttons.append(button)
|
||||
self.buttonGroup.addButton(button, j)
|
||||
button.setIcon(
|
||||
QIcon(os.path.join(self.btn_path, f"{icon_name}.png")))
|
||||
button.setIcon(QIcon(os.path.join(self.btn_path, f"{currency_val}.png")))
|
||||
button.clicked.connect(self.on_currency_selected)
|
||||
|
||||
self.button_to_currency[button] = currency_val
|
||||
self.buttonGroup.addButton(button)
|
||||
self.layout.addWidget(button)
|
||||
|
||||
|
||||
def on_currency_selected(self):
|
||||
self.custom_window.navigator.navigate("payment_details")
|
||||
payment_details_page = self.custom_window.navigator.get_cached("payment_details")
|
||||
if payment_details_page is not None:
|
||||
payment_details_page.selected_duration = self.selected_duration
|
||||
payment_details_page.selected_currency = self.get_selected_currency()
|
||||
payment_details_page.check_invoice()
|
||||
self.update_status.update_status('Loading payment details...')
|
||||
"""Sets the value selected to the singleton used across pages"""
|
||||
|
||||
def get_selected_currency(self):
|
||||
profile_id = int(self.update_status.current_profile_id)
|
||||
|
||||
# get the singleton (this was created on a previous page)
|
||||
print("doing the new method")
|
||||
data_result = get_current_billing_data(profile_id=profile_id)
|
||||
|
||||
if not data_result.valid:
|
||||
message = data_result.message
|
||||
if not message:
|
||||
message = "Error fetching data"
|
||||
self.show_a_popup(str(message))
|
||||
|
||||
# get the singleton (this was created on a previous page)
|
||||
billing_singleton = data_result.data
|
||||
|
||||
if not billing_singleton:
|
||||
message = "Error, no billing data"
|
||||
self.show_a_popup(str(message))
|
||||
|
||||
# prior direct method:
|
||||
# self.billing_data = PaymentData(instance_id=profile_id)
|
||||
|
||||
# set the value as an Enum to be used in a different page,
|
||||
billing_singleton.currency = self._get_selected_currency()
|
||||
|
||||
self.update_status.update_status('Loading payment details...')
|
||||
self.custom_window.navigator.navigate("payment_details")
|
||||
|
||||
|
||||
def _get_selected_currency(self):
|
||||
selected_button = self.buttonGroup.checkedButton()
|
||||
if selected_button:
|
||||
index = self.buttonGroup.id(selected_button)
|
||||
currencies = ["monero", "bitcoin", "lightning", "litecoin"]
|
||||
return currencies[index]
|
||||
return None
|
||||
|
||||
# Retrieve the value from the map using the button object as the key
|
||||
raw_selection = self.button_to_currency.get(selected_button)
|
||||
|
||||
# convert to enum:
|
||||
return Currency(raw_selection)
|
||||
|
||||
|
||||
def reverse(self):
|
||||
self.custom_window.navigator.navigate("duration_selection")
|
||||
if paying_for_tickets():
|
||||
self.custom_window.navigator.navigate("plan_picker")
|
||||
else:
|
||||
self.custom_window.navigator.navigate("duration_selection")
|
||||
|
|
|
|||
|
|
@ -1,17 +1,31 @@
|
|||
# Core
|
||||
from core.services.payment_phase.payment_state import PaymentData
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from core.controllers.profile_state.data_filters import get_plans_for_profile
|
||||
from core.services.subscriptions.subscriptions_by_protocol import get_current_billing_data
|
||||
from core.models.enum_models import PaymentCategory
|
||||
from core.models.Result import Result
|
||||
|
||||
# GUI
|
||||
# from gui.v2.actions.payment_actions import new_individual_billing
|
||||
from gui.v2.ui.styles.payment_styles import DURATION_SELECTION_STYLE, NEXT_BUTTON_STYLE
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
mainthread = ThreadSafetyTool()
|
||||
|
||||
from PyQt6.QtWidgets import QComboBox, QLabel, QPushButton
|
||||
from PyQt6 import QtCore
|
||||
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
|
||||
|
||||
class DurationSelectionPage(Page):
|
||||
def __init__(self, page_stack, main_window=None, parent=None):
|
||||
super().__init__("Select Duration", page_stack, main_window, parent)
|
||||
self.update_status = main_window
|
||||
self.create_interface_elements()
|
||||
self.create_intitial_elements()
|
||||
self.button_reverse.setVisible(True)
|
||||
|
||||
def create_interface_elements(self):
|
||||
def create_intitial_elements(self):
|
||||
self.title = QLabel("Select Duration", self)
|
||||
self.title.setGeometry(QtCore.QRect(530, 30, 210, 40))
|
||||
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
|
@ -19,74 +33,89 @@ class DurationSelectionPage(Page):
|
|||
|
||||
self.combo_box = QComboBox(self)
|
||||
self.combo_box.setGeometry(200, 200, 400, 60)
|
||||
self.combo_box.addItems(
|
||||
["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"])
|
||||
self.combo_box.setEditable(False)
|
||||
self.combo_box.setMaxVisibleItems(4)
|
||||
self.combo_box.setDuplicatesEnabled(True)
|
||||
self.combo_box.setPlaceholderText("Select options")
|
||||
self.combo_box.setStyleSheet("""
|
||||
QComboBox {
|
||||
font-size: 16px;
|
||||
padding: 10px;
|
||||
background-color: #000000;
|
||||
color: #00ffff;
|
||||
border: 2px solid #00ffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
width: 30px;
|
||||
background-color: #1a1a1a;
|
||||
border-left: 1px solid #00ffff;
|
||||
}
|
||||
QComboBox::down-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #00ffff;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #0a0a0a;
|
||||
border: 1px solid #00ffff;
|
||||
border-radius: 4px;
|
||||
selection-background-color: #003333;
|
||||
selection-color: #00ffff;
|
||||
}
|
||||
QComboBox QAbstractItemView::item {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
color: #00ffff;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
QComboBox QAbstractItemView::item:hover {
|
||||
background-color: #1a1a1a;
|
||||
color: #00ffff;
|
||||
}
|
||||
QComboBox QAbstractItemView::item:selected {
|
||||
background-color: #003333;
|
||||
color: #00ffff;
|
||||
}
|
||||
""")
|
||||
self.combo_box.setStyleSheet(DURATION_SELECTION_STYLE)
|
||||
|
||||
self.next_button = QPushButton("Next", self)
|
||||
self.next_button.setGeometry(350, 300, 100, 40)
|
||||
self.next_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 18px;
|
||||
padding: 10px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 10px;
|
||||
background-color: cyan;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
""")
|
||||
self.next_button.setStyleSheet(NEXT_BUTTON_STYLE)
|
||||
self.next_button.clicked.connect(self.show_next)
|
||||
|
||||
# Background thread launch:
|
||||
self.get_data()
|
||||
|
||||
|
||||
def get_data(self):
|
||||
"""Launches background thread to get data"""
|
||||
|
||||
# get profile id:
|
||||
profile_id = int(self.update_status.current_profile_id)
|
||||
|
||||
# Thread SQL in background:
|
||||
take_action(
|
||||
action=get_plans_for_profile,
|
||||
ui_caller=self.populate_with_data,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
|
||||
@mainthread
|
||||
def populate_with_data(self, result: Result):
|
||||
if not result.valid:
|
||||
self.show_a_popup(result.message)
|
||||
return
|
||||
|
||||
self.choice_to_id = {}
|
||||
self.dict_of_choices = result.data
|
||||
|
||||
if not self.dict_of_choices:
|
||||
self.show_a_popup("There is no plan data. Go sync or remove the database and try again.")
|
||||
return
|
||||
|
||||
if not isinstance(self.dict_of_choices, dict):
|
||||
self.show_a_popup("Plan data is in an invalid format.")
|
||||
return
|
||||
|
||||
for plan_string, plan_id in self.dict_of_choices.items():
|
||||
self.combo_box.addItem(str(plan_string))
|
||||
|
||||
def show_next(self):
|
||||
# get current selection:
|
||||
current_choice = self.combo_box.currentText()
|
||||
|
||||
# which plan id is that:
|
||||
plan_id = self.dict_of_choices[current_choice]
|
||||
|
||||
# save to singleton across pages:
|
||||
profile_id = int(self.update_status.current_profile_id)
|
||||
data_result = get_current_billing_data(category=PaymentCategory.INDIVIDUAL, profile_id=profile_id)
|
||||
|
||||
if not data_result.valid:
|
||||
message = data_result.message
|
||||
if not message:
|
||||
message = "Error fetching data"
|
||||
self.show_a_popup(str(message))
|
||||
|
||||
billing_data = data_result.data
|
||||
|
||||
if billing_data:
|
||||
print(f"we have billing data: {billing_data}")
|
||||
print(f"saving plan_id to the billing data")
|
||||
billing_data.plan_id = plan_id
|
||||
else:
|
||||
message = "No Billing Data"
|
||||
self.show_a_popup(message)
|
||||
|
||||
# go to next page:
|
||||
print("going to the next page")
|
||||
self.custom_window.navigator.navigate("currency_selection")
|
||||
currency_page = self.custom_window.navigator.get_cached("currency_selection")
|
||||
if currency_page is not None:
|
||||
currency_page.selected_duration = self.combo_box.currentText()
|
||||
|
||||
def reverse(self):
|
||||
self.custom_window.navigator.navigate("id")
|
||||
|
||||
|
||||
# currency_page.selected_duration
|
||||
# self.combo_box.addItems(
|
||||
# ["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"])
|
||||
|
|
|
|||
|
|
@ -8,13 +8,21 @@ from PyQt6.QtGui import QIcon, QPixmap
|
|||
from PyQt6.QtCore import Qt
|
||||
from PyQt6 import QtCore
|
||||
|
||||
from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
|
||||
# core
|
||||
# from core.models.enum_models import PaymentCategory
|
||||
from core.services.prepare_tickets.ticket_tracker import delete_ticket_data
|
||||
from core.services.payment_phase.payment_state import TicketPaymentData
|
||||
from core.models.Result import Result
|
||||
# from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
|
||||
|
||||
# gui
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
from gui.v2.ui.popups.message_box import style_message_box, mark_confirm_button
|
||||
from gui.v2.actions.menu_tasks import enable_profile
|
||||
|
||||
from gui.v2.actions.menu_coordinator import connect_with_billing_code
|
||||
from gui.v2.actions.payment_actions import start_individual_payment
|
||||
from gui.v2.actions.ticket_actions import start_ticket, fetch_ticket_invoice
|
||||
from gui.v2.ui.styles.css.id_page_style import COLUMN_BUTTON_CSS
|
||||
|
||||
HOW_MANY_PROFILES_DEFAULT = 6
|
||||
|
||||
|
|
@ -44,20 +52,30 @@ class IdPage(Page):
|
|||
pattern = r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||
if re.match(pattern, text):
|
||||
self.update_status.update_status('Enabling profile in progress...')
|
||||
profile_data = {
|
||||
'id': int(self.update_status.current_profile_id),
|
||||
'billing_code': str(text)
|
||||
}
|
||||
menu_page = self.find_menu_page()
|
||||
if menu_page:
|
||||
self.profile_enable_worker = enable_profile(
|
||||
profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
|
||||
)
|
||||
|
||||
else:
|
||||
billing_code = str(text)
|
||||
if not billing_code:
|
||||
self.update_status.update_status(
|
||||
'Incorrect format for the billing code')
|
||||
|
||||
# profile id:
|
||||
profile_id = int(self.update_status.current_profile_id)
|
||||
|
||||
menu_page = self.custom_window.navigator.get_cached("menu")
|
||||
if not menu_page:
|
||||
return
|
||||
|
||||
# menu page recieves result:
|
||||
take_action(
|
||||
action=connect_with_billing_code,
|
||||
ui_caller=menu_page.post_connect,
|
||||
profile_id=profile_id,
|
||||
billing_code=billing_code
|
||||
)
|
||||
|
||||
# navigate to the main menu to see/recieve result:
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
def find_menu_page(self):
|
||||
return self.custom_window.navigator.get_cached("menu")
|
||||
|
||||
|
|
@ -69,18 +87,7 @@ class IdPage(Page):
|
|||
self.button_go.clicked.connect(self.go_selected)
|
||||
|
||||
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_button_style = COLUMN_BUTTON_CSS
|
||||
column_desc_style = "color: #888888; font-size: 12px; font-style: italic;"
|
||||
|
||||
self.single_title = QLabel("Single Profile", self)
|
||||
|
|
@ -110,7 +117,7 @@ class IdPage(Page):
|
|||
self.multiple_button.clicked.connect(self.go_multiple_profiles)
|
||||
|
||||
self.multiple_desc = QLabel(
|
||||
f"Buy a bundle of {HOW_MANY_PROFILES_DEFAULT} tickets at once, which are cryptographically seperated from each other.", self)
|
||||
f"Buy a bundle of {HOW_MANY_PROFILES_DEFAULT} tickets at once, which are cryptographically separated from each other.", self)
|
||||
self.multiple_desc.setGeometry(285, 335, 240, 80)
|
||||
self.multiple_desc.setStyleSheet(column_desc_style)
|
||||
self.multiple_desc.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
|
@ -152,14 +159,59 @@ class IdPage(Page):
|
|||
self.separator_right.setGeometry(540, 60, 2, 420)
|
||||
self.separator_right.setStyleSheet(separator_style)
|
||||
|
||||
|
||||
def go_single_profile(self):
|
||||
self.text_edit.clear()
|
||||
self.custom_window.navigator.navigate("duration_selection")
|
||||
print("starting a single profile payment cycle")
|
||||
start_individual_payment(
|
||||
profile_id=int(self.update_status.current_profile_id),
|
||||
ui_caller=self.single_profile_callback
|
||||
)
|
||||
|
||||
|
||||
def single_profile_callback(self, result: Result):
|
||||
if not result:
|
||||
print("no result.")
|
||||
return
|
||||
|
||||
if result.valid:
|
||||
self.custom_window.navigator.navigate("duration_selection")
|
||||
else:
|
||||
if result.message:
|
||||
self.show_a_popup(str(result.message))
|
||||
|
||||
|
||||
def go_multiple_profiles(self):
|
||||
billing_id = do_we_have_billing_id()
|
||||
if billing_id:
|
||||
self._prompt_existing_tickets(billing_id)
|
||||
start_ticket(ui_caller=self.multi_ticket_callback)
|
||||
|
||||
def multi_ticket_callback(self, result: Result):
|
||||
if not result.valid:
|
||||
if result.message:
|
||||
self.show_a_popup(str(result.message))
|
||||
return
|
||||
|
||||
payment_cycle = result.data
|
||||
if not payment_cycle:
|
||||
return
|
||||
|
||||
billing_data = payment_cycle.billing_data
|
||||
if not billing_data:
|
||||
return
|
||||
|
||||
billing_code = payment_cycle.billing_code
|
||||
paid = TicketPaymentData().paid
|
||||
|
||||
print(f"paid {paid}")
|
||||
|
||||
if paid:
|
||||
print("paid going to prep..")
|
||||
self.custom_window.navigator.navigate("ticket_prep")
|
||||
# prep_page = self.custom_window.navigator.get_cached("ticket_prep")
|
||||
# if prep_page is not None:
|
||||
# prep_page.start_prep()
|
||||
|
||||
elif billing_code:
|
||||
self._prompt_existing_tickets(billing_code)
|
||||
else:
|
||||
self._continue_multiple_flow()
|
||||
|
||||
|
|
@ -198,11 +250,17 @@ class IdPage(Page):
|
|||
failed_delete.exec()
|
||||
|
||||
def _resume_existing_ticket(self, billing_id):
|
||||
print("resuming existing ticket in GUI")
|
||||
self.update_status.update_status("Checking existing ticket payment...")
|
||||
self.custom_window.navigator.navigate("payment_details")
|
||||
payment_page = self.custom_window.navigator.get_cached("payment_details")
|
||||
if payment_page is not None:
|
||||
payment_page.resume_ticket_billing(billing_id)
|
||||
print("calling fetch_ticket_invoice")
|
||||
fetch_ticket_invoice(ui_caller=payment_page.receive_invoice)
|
||||
print("navigating to payment details page..")
|
||||
self.custom_window.navigator.navigate("payment_details")
|
||||
|
||||
|
||||
# payment_page.resume_ticket_billing(billing_id)
|
||||
|
||||
def toggle_button_state(self):
|
||||
text = self.text_edit.toPlainText()
|
||||
|
|
@ -237,3 +295,4 @@ class IdPage(Page):
|
|||
|
||||
def reverse(self):
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
from core.infrastructure.global_state_coordinator import connected_session_profile_ids, connected_system_profile_id, is_systemwide_on, is_connected, evaluate_state_on_startup
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from gui.v2.actions.error_handlers import empty_filler
|
||||
from core.controllers.ProfileController import ProfileController
|
||||
from core.models.BaseProfile import ProfileType
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.observers.Notifications import Notifications, EventType
|
||||
|
||||
import functools
|
||||
import os
|
||||
from itertools import chain
|
||||
|
|
@ -10,19 +18,31 @@ from PyQt6.QtWidgets import (
|
|||
QMessageBox, QPushButton, QScrollArea, QTextEdit, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from gui.v2.actions.menu_coordinator import connect_profile, disconnect_profile
|
||||
|
||||
from gui.v2.actions.menu_profiles import find_verification_icon, get_profile_details, load_menu_profiles, menu_icons
|
||||
from gui.v2.actions.menu_tasks import (
|
||||
continue_menu_task, create_profile, delete_profile, disable_profile,
|
||||
download_update, edit_profile, enable_profile,
|
||||
download_update, edit_profile, enable_profile
|
||||
)
|
||||
from gui.v2.actions.menu_results import MenuResult
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
from gui.v2.ui.pages.location_verification_page import LocationVerificationPage
|
||||
from gui.v2.ui.styles.styles import SCROLLBAR_CYAN_QSS
|
||||
from gui.v2.ui.styles.css.menu_css import CREATE_BUTTON_STYLE
|
||||
from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup
|
||||
from gui.v2.ui.popups.endpoint_verification_popup import EndpointVerificationPopup
|
||||
from gui.v2.ui.popups.ticket_data_loss_popup import TicketDataLossPopup
|
||||
|
||||
from gui.v2.infrastructure.setup_observers import (
|
||||
application_version_observer, connection_observer,
|
||||
profile_observer
|
||||
)
|
||||
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
mainthread = ThreadSafetyTool()
|
||||
|
||||
from typing import Optional
|
||||
|
||||
class MenuPage(Page):
|
||||
def __init__(self, page_stack=None, main_window=None, parent=None):
|
||||
|
|
@ -66,10 +86,12 @@ class MenuPage(Page):
|
|||
self.font_style = f"font-family: '{main_window.open_sans_family}';" if main_window.open_sans_family else ""
|
||||
|
||||
self.create_interface_elements()
|
||||
self.observers_subscribe()
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._set_verification_icons_enabled()
|
||||
self.state_refresh()
|
||||
|
||||
def _set_verification_icons_enabled(self):
|
||||
parents = filter(None, (button.parent() for button in getattr(self, 'buttons', [])))
|
||||
|
|
@ -94,41 +116,98 @@ class MenuPage(Page):
|
|||
button_definitions = {
|
||||
"create_profile": (self.create_prof, (410, 440, 110, 60), True, True),
|
||||
"edit_profile": (self.edit_prof, (540, 440, 110, 60), False, True),
|
||||
"launch": (self.connect, (0, 440, 185, 63), False, False),
|
||||
"disconnect": (self.disconnect, (200, 433, 185, 73), True, False),
|
||||
"disconnect_system_wide": (self.disconnect, (200, 433, 185, 73), True, False),
|
||||
"just": (self.connect, (200, 433, 185, 73), False, False),
|
||||
"just_session": (self.connect, (200, 433, 185, 73), False, True),
|
||||
"settings": (self.settings_gui, (670, 440, 110, 60), True, True),
|
||||
"action_button": (self.handle_action_click, (200, 433, 185, 73), True, True),
|
||||
}
|
||||
|
||||
buttons = {}
|
||||
for icon_name, (function, position, enabled, visible) in button_definitions.items():
|
||||
boton = QPushButton(self)
|
||||
boton.setGeometry(QtCore.QRect(*position))
|
||||
boton.setObjectName("create_edit_settings_buttons")
|
||||
icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))
|
||||
boton.setIcon(icon)
|
||||
boton.setIconSize(boton.size())
|
||||
# Note: action_button icon is handled dynamically in update_action_button()
|
||||
if icon_name != "action_button":
|
||||
icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))
|
||||
boton.setIcon(icon)
|
||||
boton.setIconSize(boton.size())
|
||||
|
||||
boton.clicked.connect(functools.partial(function))
|
||||
boton.setEnabled(enabled)
|
||||
boton.setVisible(visible)
|
||||
buttons[icon_name] = boton
|
||||
|
||||
button_attributes = {
|
||||
"disconnect_button": "disconnect",
|
||||
"boton_launch": "launch",
|
||||
"boton_create": "create_profile",
|
||||
"boton_edit": "edit_profile",
|
||||
"boton_just": "just",
|
||||
"boton_just_session": "just_session",
|
||||
"connect_button": "just_session",
|
||||
"disconnect_system_wide_button": "disconnect_system_wide",
|
||||
"settings_button": "settings",
|
||||
}
|
||||
self.__dict__.update({attribute: buttons[name] for attribute, name in button_attributes.items()})
|
||||
# Map the specific buttons back to the class
|
||||
self.boton_create = buttons["create_profile"]
|
||||
self.boton_edit = buttons["edit_profile"]
|
||||
self.settings_button = buttons["settings"]
|
||||
self.action_button = buttons["action_button"]
|
||||
self.action_button.setIconSize(self.action_button.size())
|
||||
|
||||
self.refresh_menu_buttons()
|
||||
|
||||
def trigger_action_button_visuals(self, desired_status: bool):
|
||||
"""Backend uses direct Notifications to trigger the action button status, without a profile_id"""
|
||||
if desired_status:
|
||||
self.action_button.setEnabled(True)
|
||||
self.action_button.setVisible(True)
|
||||
|
||||
|
||||
def update_action_button_visuals(self, triggered_profile_id: Optional[int] = None):
|
||||
"""Syncs the button art with enabled state with the current external truth."""
|
||||
|
||||
# Which profile is selected:
|
||||
profile_id = getattr(self.update_status, 'current_profile_id', None)
|
||||
if profile_id is None:
|
||||
self.action_button.setVisible(False)
|
||||
return
|
||||
|
||||
# triggered_profile_id is coming via a Notification.
|
||||
# Don't update the button via from a notification, if the user clicked to a different profile,
|
||||
if triggered_profile_id and profile_id != triggered_profile_id:
|
||||
return
|
||||
|
||||
self.action_button.setVisible(True)
|
||||
self.action_button.setEnabled(True) # Re-enable after the Event has fired
|
||||
|
||||
connected = is_connected(profile_id)
|
||||
p_type = ProfileController.what_type_is_profile(profile_id)
|
||||
|
||||
# if it's connected, then it can't be edited.
|
||||
self.boton_edit.setEnabled(not connected)
|
||||
|
||||
# Logic Matrix for Art
|
||||
if p_type == ProfileType.SESSION:
|
||||
icon_name = "disconnect" if connected else "just_session"
|
||||
else: # SYSTEM
|
||||
icon_name = "disconnect_system_wide" if connected else "just"
|
||||
|
||||
print(f"Setting icon_name to {icon_name}")
|
||||
icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))
|
||||
self.action_button.setIcon(icon)
|
||||
|
||||
|
||||
def handle_action_click(self):
|
||||
"""Route the action button to connect or disconnect, depending on state"""
|
||||
|
||||
# 1. Relevant profile ID:
|
||||
profile_id = getattr(self.update_status, 'current_profile_id', None)
|
||||
if profile_id is None:
|
||||
return
|
||||
|
||||
# 2. Immediately disable to prevent double-clicks during async transition
|
||||
self.action_button.setEnabled(False)
|
||||
|
||||
# 3. Connection Status
|
||||
connected = is_connected(profile_id)
|
||||
print(f"is {profile_id} connected? {connected}")
|
||||
|
||||
# 4. Route:
|
||||
if connected:
|
||||
self.disconnect()
|
||||
else:
|
||||
self.connect()
|
||||
|
||||
|
||||
def _make_confirmation_popup(self, message, button_text='Proceed'):
|
||||
popup = ConfirmationPopup(
|
||||
self, message=message, action_button_text=button_text, cancel_button_text="Cancel")
|
||||
|
|
@ -154,7 +233,8 @@ class MenuPage(Page):
|
|||
icon_paths = {"session_tor": self.connected_tor_profile,
|
||||
"session": self.connected_profile, "system": self.system_wide_connected_profile}
|
||||
self.button_states = {profile_id: icon_paths[kind] for profile_id, kind in menu_data["enabled_kinds"].items()}
|
||||
self.update_status.update_image(appearance_value=menu_data["background"])
|
||||
|
||||
|
||||
for button in getattr(self, 'buttons', []):
|
||||
button.parent().deleteLater()
|
||||
if hasattr(self, 'button_group'):
|
||||
|
|
@ -189,12 +269,8 @@ class MenuPage(Page):
|
|||
self.update_status.current_profile_id = None
|
||||
self.current_profile_location = None
|
||||
self.reverse_id = None
|
||||
|
||||
self.boton_edit.setEnabled(False)
|
||||
self.boton_launch.setEnabled(False)
|
||||
self.boton_just.setVisible(False)
|
||||
self.boton_just_session.setVisible(False)
|
||||
self.disconnect_button.setVisible(False)
|
||||
self.disconnect_system_wide_button.setVisible(False)
|
||||
if not hasattr(self, 'button_group'):
|
||||
return
|
||||
self.button_group.setExclusive(False)
|
||||
|
|
@ -223,25 +299,13 @@ class MenuPage(Page):
|
|||
parent_label.setVisible(True)
|
||||
return parent_label
|
||||
|
||||
|
||||
def create_profile_button(self, parent_label, profile_id, key):
|
||||
button = QPushButton(parent_label)
|
||||
button.setGeometry(0, 0, 175, 60)
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: rgba(200, 200, 200, 50);
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: rgba(200, 200, 200, 80);
|
||||
}
|
||||
""")
|
||||
button.setStyleSheet(CREATE_BUTTON_STYLE)
|
||||
|
||||
icon = QIcon(self.button_states.get(
|
||||
profile_id, self.not_connected_profile))
|
||||
button.setIcon(icon)
|
||||
button.setIcon(QIcon(self.not_connected_profile))
|
||||
button.setIconSize(QtCore.QSize(175, 60))
|
||||
|
||||
button.show()
|
||||
|
|
@ -392,19 +456,22 @@ class MenuPage(Page):
|
|||
self._render_profile_details(details)
|
||||
|
||||
def _render_profile_details(self, details):
|
||||
|
||||
# print(f"details entering _render_profile_details is {details}")
|
||||
|
||||
self.reverse_id = details["id"]
|
||||
|
||||
# THIS IS UPDATING THE PROFILE ID
|
||||
self.update_status.current_profile_id = self.reverse_id
|
||||
|
||||
self.current_profile_location = details["location"]
|
||||
target_button = self.profile_button_map.get(self.reverse_id)
|
||||
if target_button is not None:
|
||||
target_button.setChecked(True)
|
||||
self.connection_manager.set_profile_button_objects(self.reverse_id, target_button)
|
||||
self.boton_edit.setEnabled(not details["is_connected"] and not self._task_busy)
|
||||
self.boton_launch.setEnabled(not self._task_busy)
|
||||
self.boton_just.setEnabled(not self._task_busy)
|
||||
self.boton_just_session.setEnabled(not self._task_busy)
|
||||
|
||||
# Update buttons:
|
||||
self.update_action_button_visuals(self.reverse_id)
|
||||
|
||||
# verification icons:
|
||||
self._set_verification_icons_enabled()
|
||||
self.update_button_visibility(details["is_connected"], details["is_session"])
|
||||
|
||||
self.update_status_message(details)
|
||||
for label in self.additional_labels:
|
||||
label.deleteLater()
|
||||
|
|
@ -419,10 +486,10 @@ class MenuPage(Page):
|
|||
self._detail_image("verified_profile.png", (20, 50, 100, 80))
|
||||
self.add_selected_profile(details["selection"])
|
||||
|
||||
def _detail_image(self, asset, geometry, lower=False):
|
||||
def _detail_image(self, asset, geometry, lower=False, pixmap=None):
|
||||
label = QLabel(self)
|
||||
label.setGeometry(*geometry)
|
||||
label.setPixmap(QPixmap(os.path.join(self.btn_path, asset)))
|
||||
label.setPixmap(pixmap if pixmap is not None else QPixmap(os.path.join(self.btn_path, asset)))
|
||||
label.setScaledContents(True)
|
||||
label.show()
|
||||
if lower:
|
||||
|
|
@ -434,7 +501,10 @@ class MenuPage(Page):
|
|||
self.additional_labels.append(self.create_verification_widget(details["verification_values"]))
|
||||
|
||||
def _render_protocol_image(self, details):
|
||||
self._detail_image(details["primary_asset"], (0, 80 if details["raised_primary"] else 90, 400, 300))
|
||||
pixmap = QPixmap(os.path.join(self.btn_path, details["primary_asset"]))
|
||||
if details["location_overlay"]:
|
||||
pixmap = self.build_encrypted_proxy_detail_pixmap(pixmap, details["location"], details["overlay_y"])
|
||||
self._detail_image(details["primary_asset"], (0, 80 if details["raised_primary"] else 90, 400, 300), pixmap=pixmap)
|
||||
|
||||
def _render_operator_details(self, details):
|
||||
color = "black" if details["is_session"] else "white"
|
||||
|
|
@ -457,6 +527,34 @@ class MenuPage(Page):
|
|||
widget.show()
|
||||
self.additional_labels.append(widget)
|
||||
|
||||
def build_encrypted_proxy_detail_pixmap(self, pixmap, location, overlay_y):
|
||||
if pixmap.isNull():
|
||||
return pixmap
|
||||
|
||||
location_pixmap = QPixmap(os.path.join(
|
||||
self.btn_path, f"icon_mini_{location}.png"))
|
||||
if location_pixmap.isNull():
|
||||
location_pixmap = QPixmap(os.path.join(
|
||||
self.btn_path, "default_location_mini.png"))
|
||||
if location_pixmap.isNull():
|
||||
return pixmap
|
||||
|
||||
icon_size = max(42, min(72, int(min(pixmap.width(), pixmap.height()) * 0.22)))
|
||||
location_pixmap = location_pixmap.scaled(
|
||||
icon_size,
|
||||
icon_size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation)
|
||||
x = int(pixmap.width() * 0.58)
|
||||
y = int(pixmap.height() * overlay_y)
|
||||
x = min(max(0, x), max(0, pixmap.width() - location_pixmap.width()))
|
||||
y = min(max(0, y), max(0, pixmap.height() - location_pixmap.height()))
|
||||
|
||||
painter = QPainter(pixmap)
|
||||
painter.drawPixmap(x, y, location_pixmap)
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
def show_profile_verification(self, profile_id):
|
||||
location_icon_name = find_verification_icon(
|
||||
profile_id,
|
||||
|
|
@ -473,40 +571,102 @@ class MenuPage(Page):
|
|||
self.page_stack.indexOf(verification_page))
|
||||
|
||||
def _set_task_busy(self, busy):
|
||||
self._task_busy = busy
|
||||
for button in (self.boton_just, self.boton_just_session, self.disconnect_button,
|
||||
self.disconnect_system_wide_button, self.boton_create, self.boton_edit):
|
||||
button.setEnabled(not busy)
|
||||
self.scroll_area.setEnabled(not busy)
|
||||
self.boton_just.setEnabled(not busy and self.reverse_id is not None)
|
||||
self.boton_just_session.setEnabled(not busy and self.reverse_id is not None)
|
||||
self.boton_edit.setEnabled(not busy and self.reverse_id is not None
|
||||
and not self.connection_manager.is_profile_connected(self.reverse_id))
|
||||
pass
|
||||
# self._task_busy = busy
|
||||
# for button in (self.boton_just, self.boton_just_session, self.disconnect_button,
|
||||
# self.disconnect_system_wide_button, self.boton_create, self.boton_edit):
|
||||
# button.setEnabled(not busy)
|
||||
# self.scroll_area.setEnabled(not busy)
|
||||
# self.boton_just.setEnabled(not busy and self.reverse_id is not None)
|
||||
# self.boton_just_session.setEnabled(not busy and self.reverse_id is not None)
|
||||
# self.boton_edit.setEnabled(not busy and self.reverse_id is not None
|
||||
# and not self.connection_manager.is_profile_connected(self.reverse_id))
|
||||
|
||||
|
||||
def post_disconnect(self, result: Result):
|
||||
"""Recieve the callback after disconnection"""
|
||||
print(f"result is {result}")
|
||||
|
||||
# --------------------
|
||||
# BOTTOM LEFT STATUS BAR
|
||||
# --------------------
|
||||
# if result.message:
|
||||
# if len(result.message) > 20:
|
||||
# self.update_status.disable_marquee
|
||||
# self.update_status.update_status(result.message)
|
||||
|
||||
# --------------------
|
||||
# WORKED
|
||||
# --------------------
|
||||
if result.valid:
|
||||
self.boton_edit.setEnabled(True)
|
||||
return
|
||||
|
||||
self.deal_with_errors(result)
|
||||
|
||||
def post_connect(self, result: Result):
|
||||
"""Recieve the callback after a connection attempt."""
|
||||
|
||||
# --------------------
|
||||
# BOTTOM LEFT STATUS BAR
|
||||
# --------------------
|
||||
if result.message:
|
||||
if len(result.message) > 20:
|
||||
self.update_status.enable_marquee
|
||||
self.update_status.update_status(result.message)
|
||||
|
||||
# --------------------
|
||||
# CONNECTION WORKED
|
||||
# --------------------
|
||||
if result.valid:
|
||||
return
|
||||
|
||||
self.deal_with_errors(result)
|
||||
|
||||
def deal_with_errors(self, result: Result):
|
||||
if result.error_type == ResultError.NAVIGATE_TO:
|
||||
print(f"We are auto-navigating to page {result.navigate_to}")
|
||||
self.custom_window.navigator.navigate(result.navigate_to)
|
||||
|
||||
elif result.error_type == ResultError.SUBSCRIPTION:
|
||||
print("We are navigating to the subscription page")
|
||||
self.update_status.update_status("Select Subscription Choice")
|
||||
self.custom_window.navigator.navigate("id")
|
||||
else:
|
||||
error_type = result.error_type.value
|
||||
print(f"We do not currently have a pre-made solution for the unknown situation of {error_type}")
|
||||
self.update_status.update_status(error_type)
|
||||
|
||||
def connect(self):
|
||||
self._set_task_busy(True)
|
||||
self.update_status.update_status("Attempting to connect...")
|
||||
self.worker = enable_profile({"id": self.reverse_id}, self.handle_task_result)
|
||||
"""Enable the profile id tied to reverse_id"""
|
||||
print("Starting to connect..")
|
||||
|
||||
# # Step 1. Disable edit button.
|
||||
self.boton_edit.setEnabled(False)
|
||||
|
||||
# Step 2. Connect with post_connect setup to recieve the result.
|
||||
immediate_error = connect_profile(profile_id=self.reverse_id, ui_caller=self.post_connect)
|
||||
|
||||
# Step 3. Evaluate immediate errors (if it never even gets to the callback)
|
||||
if not immediate_error.valid:
|
||||
self.update_status.update_status(immediate_error.message)
|
||||
return
|
||||
|
||||
def disconnect(self):
|
||||
self._set_task_busy(True)
|
||||
self.update_status.update_status("Disconnecting...")
|
||||
self.worker = disable_profile(self.reverse_id, self.connection_manager.get_connected_profiles(),
|
||||
self.handle_task_result)
|
||||
print(f"Disconnecting profile {self.reverse_id}")
|
||||
result = disconnect_profile(profile_id=self.reverse_id, ui_caller=self.post_disconnect)
|
||||
print("Disconnected")
|
||||
|
||||
# self._set_task_busy(True)
|
||||
# self.worker = disable_profile(self.reverse_id, self.connection_manager.get_connected_profiles(),
|
||||
# self.handle_task_result)
|
||||
|
||||
def create_prof(self):
|
||||
self._set_task_busy(True)
|
||||
self.worker = create_profile(getattr(self.update_status, "gui_config_file", None),
|
||||
self.connection_manager.is_synced(), self.handle_task_result)
|
||||
|
||||
def update_button_visibility(self, is_connected, is_session_profile):
|
||||
self.boton_just_session.setVisible(
|
||||
not is_connected and is_session_profile)
|
||||
self.boton_just.setVisible(not is_connected and not is_session_profile)
|
||||
self.disconnect_button.setVisible(is_connected and is_session_profile)
|
||||
self.disconnect_system_wide_button.setVisible(
|
||||
is_connected and not is_session_profile)
|
||||
|
||||
def update_status_message(self, state):
|
||||
if not state["profile_loaded"]:
|
||||
self.update_status.update_status("Profile data could not be loaded. Sync or restart and try again.")
|
||||
|
|
@ -545,19 +705,27 @@ class MenuPage(Page):
|
|||
|
||||
@pyqtSlot(object)
|
||||
def handle_task_result(self, result: MenuResult):
|
||||
|
||||
print(f"result in handle_task_result is {result}")
|
||||
|
||||
if "sync_data" in result:
|
||||
self.update_status.update_values(*result["sync_data"])
|
||||
if result.get("clear_selection") and result.get("profile_id") == self.reverse_id:
|
||||
self.clear_profile_details()
|
||||
|
||||
if result.get("refresh"):
|
||||
self.refresh_menu_buttons(result)
|
||||
|
||||
self._set_task_busy(result.get("busy", False))
|
||||
|
||||
if result.get("message"):
|
||||
handlers = {True: self.update_status.enable_marquee, False: self.update_status.update_status}
|
||||
handlers[bool(result.get("is_enabled"))](result["message"])
|
||||
|
||||
handlers = {"confirmation": self._show_confirmation, "verification": self._show_verification,
|
||||
"navigate": self._navigate_result, "install": self._show_install_result,
|
||||
"ticket_data_loss": self._show_ticket_loss_result}
|
||||
|
||||
handler = handlers.get(result["status"])
|
||||
if handler is not None:
|
||||
handler(result)
|
||||
|
|
@ -596,3 +764,128 @@ class MenuPage(Page):
|
|||
|
||||
def _show_ticket_loss_result(self, result):
|
||||
self.show_ticket_data_loss_popup(result["ticket_number"], result["billing_code"])
|
||||
|
||||
|
||||
def _update_profile_icon(self, profile_id: int, image_path: str, is_active: bool = False):
|
||||
"""Helper to update profile icons and the 'checked' state for the border/highlight."""
|
||||
button = self.profile_button_map.get(profile_id)
|
||||
if button:
|
||||
button.setIcon(QIcon(image_path))
|
||||
# This ensures the UI 'checked' state matches the connection state
|
||||
button.setChecked(is_active)
|
||||
|
||||
|
||||
def profile_is_currently_selected(self, profile_id: int) -> bool:
|
||||
"""Checks if the profile that's currently selected by the user, matches profile_id"""
|
||||
return self.update_status.current_profile_id == profile_id
|
||||
|
||||
@mainthread
|
||||
def turn_on_session_visuals(self, profile_id: int):
|
||||
"""Flip off the Visual buttons for a given profile."""
|
||||
print(f"Turning ON Session {profile_id}!")
|
||||
|
||||
# Update the specific profile button
|
||||
self._update_profile_icon(profile_id, self.connected_profile, is_active=True)
|
||||
|
||||
# Update disconnect/action button:
|
||||
self.update_action_button_visuals(profile_id)
|
||||
|
||||
@mainthread
|
||||
def turn_off_session_visuals(self, profile_id: int, surpress_output: Optional[bool] = False):
|
||||
"""
|
||||
Flip off the Visual buttons for a given profile.
|
||||
And re-enable the edit button if its currently selected.
|
||||
"""
|
||||
if not surpress_output:
|
||||
print(f"Stopping Session {profile_id}!")
|
||||
# If you want the border to stay when off, set is_active=True
|
||||
# If you want the border to disappear when off, set is_active=False
|
||||
self._update_profile_icon(profile_id, self.not_connected_profile, is_active=True)
|
||||
|
||||
# Update disconnect/action button:
|
||||
self.update_action_button_visuals(profile_id)
|
||||
|
||||
# Re-enable edit button:
|
||||
if self.profile_is_currently_selected(profile_id):
|
||||
self.boton_edit.setEnabled(True)
|
||||
|
||||
|
||||
@mainthread
|
||||
def turn_on_system_visuals(self, profile_id: int):
|
||||
print(f"Turning ON Systemwide on {profile_id}!")
|
||||
|
||||
# Update the specific profile button
|
||||
self._update_profile_icon(profile_id, self.system_wide_connected_profile, is_active=True)
|
||||
|
||||
# Update the general background status
|
||||
self.update_status.update_image(appearance_value="background_connected")
|
||||
|
||||
# Update disconnect/action button:
|
||||
self.update_action_button_visuals(profile_id)
|
||||
|
||||
@mainthread
|
||||
def turn_off_system_visuals(self, profile_id: int):
|
||||
"""
|
||||
Flip off the Visual buttons for a given System-wide.
|
||||
Change the entire app's background for connected.
|
||||
And re-enable the edit button if its currently selected.
|
||||
"""
|
||||
print(f"Turning OFF Systemwide on {profile_id}!")
|
||||
|
||||
# Update the specific profile button
|
||||
self._update_profile_icon(profile_id, self.not_connected_profile, is_active=True)
|
||||
|
||||
# Update the general background status
|
||||
self.update_status.update_image(appearance_value="original")
|
||||
|
||||
# Update connect/action button:
|
||||
self.update_action_button_visuals(profile_id)
|
||||
|
||||
# Re-enable edit button:
|
||||
if self.profile_is_currently_selected(profile_id):
|
||||
self.boton_edit.setEnabled(True)
|
||||
|
||||
def observers_subscribe(self):
|
||||
Notifications.subscribe(EventType.SESSION_PROFILE_ENABLE, self.turn_on_session_visuals)
|
||||
Notifications.subscribe(EventType.SESSION_PROFILE_DISABLE, self.turn_off_session_visuals)
|
||||
Notifications.subscribe(EventType.SYSTEM_ENABLE, self.turn_on_system_visuals)
|
||||
Notifications.subscribe(EventType.SYSTEM_DISABLE, self.turn_off_system_visuals)
|
||||
Notifications.subscribe(EventType.RESUME_ACTIONS, self.trigger_action_button_visuals)
|
||||
Notifications.subscribe(EventType.SHOW_POPUP, self.show_a_popup)
|
||||
|
||||
take_action(
|
||||
action=evaluate_state_on_startup,
|
||||
ui_caller=empty_filler # do nothing on success.
|
||||
)
|
||||
|
||||
def state_refresh(self):
|
||||
"""Update the UI with the current Global State Repo"""
|
||||
|
||||
# 1. Collect current state
|
||||
sys_on = is_systemwide_on()
|
||||
sys_id = connected_system_profile_id()
|
||||
sess_ids = connected_session_profile_ids() or []
|
||||
|
||||
# 2. Set the global background
|
||||
if sys_on:
|
||||
self.update_status.update_image(appearance_value="background_connected")
|
||||
else:
|
||||
self.update_status.update_image(appearance_value="original")
|
||||
|
||||
# 3. Sync every button
|
||||
for pid in self.profile_button_map.keys():
|
||||
# We check for the "On" states first.
|
||||
# If none of these match, it falls through to the default 'off' state.
|
||||
|
||||
if sys_on and pid == sys_id:
|
||||
# Priority 1: System-wide active
|
||||
self.turn_on_system_visuals(pid)
|
||||
|
||||
elif pid in sess_ids:
|
||||
# Priority 2: Session active
|
||||
self.turn_on_session_visuals(pid)
|
||||
|
||||
else:
|
||||
# DEFAULT STARTING PLACE:
|
||||
# If it's not system-wide and not a session, it must be off.
|
||||
self.turn_off_session_visuals(profile_id=pid, surpress_output=True)
|
||||
|
|
|
|||
505
gui/v2/ui/pages/payments2.py
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
# core
|
||||
from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets, get_current_billing_data
|
||||
from core.services.payment_phase.payment_state import PaymentData
|
||||
from core.models.pydantic_models.ServerReply import SubscriptionInvoice
|
||||
from core.models.invoice.TicketInvoice import TicketInvoice
|
||||
from core.models.enum_models import Currency
|
||||
from core.models.Result import Result, ResultError
|
||||
from core.observers.Notifications import Notifications, EventType
|
||||
|
||||
# GUI
|
||||
from gui.v2.actions.payment_actions import (
|
||||
start_polling,
|
||||
gui_starts_invoice,
|
||||
stop_payment_timer,
|
||||
)
|
||||
from gui.v2.ui.pages.Page import Page, DataError
|
||||
from gui.v2.ui.popups.qrcode_dialog import QRCodeDialog
|
||||
from gui.v2.ui.styles.css.payment_styles import PAYMENT_STYLE_SHEET
|
||||
|
||||
# Bring background threads to the main thread UI:
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
|
||||
mainthread = ThreadSafetyTool()
|
||||
|
||||
# generic
|
||||
from typing import Optional
|
||||
import os
|
||||
from PyQt6 import QtCore, QtWidgets
|
||||
from PyQt6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QSpacerItem,
|
||||
QSizePolicy,
|
||||
QStackedWidget,
|
||||
QMainWindow,
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtGui import QIcon
|
||||
from enum import Enum
|
||||
|
||||
# For formatting errors graphically:
|
||||
class DataError(Enum):
|
||||
FORMAT: "format"
|
||||
BLANK: "blank"
|
||||
|
||||
|
||||
class PaymentDetailsPage(Page):
|
||||
"""
|
||||
Display Payment Details, fetch an invoice, and launch a repeat timer to check payment status.
|
||||
|
||||
From Page: Inherits display_error, show_a_popup
|
||||
From Navigator: gets the functions navigate, get_cached, page_stack object
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
page_stack: QStackedWidget,
|
||||
main_window: Optional[QMainWindow] = None,
|
||||
parent: Optional[QWidget] = None,
|
||||
):
|
||||
super().__init__("Payment Details", page_stack, main_window, parent)
|
||||
self.update_status = main_window
|
||||
|
||||
self.fields = {}
|
||||
self.ticketing = paying_for_tickets()
|
||||
self.polling = False
|
||||
|
||||
self.setStyleSheet(PAYMENT_STYLE_SHEET)
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
self.create_initial_elements()
|
||||
self.button_reverse.setVisible(True)
|
||||
|
||||
# self.begin_invoice()
|
||||
|
||||
def showEvent(self, event):
|
||||
"""Refresh data. This runs every time the widget is displayed"""
|
||||
|
||||
# Call the parent class showEvent first
|
||||
super().showEvent(event)
|
||||
|
||||
self.ticketing = paying_for_tickets()
|
||||
self.begin_invoice()
|
||||
|
||||
|
||||
def create_initial_elements(self):
|
||||
"""Setup Payment UI Buttons as part of a dict by field key."""
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# put the entire group (card + buttons) in the middle:
|
||||
main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
main_layout.setSpacing(15)
|
||||
main_layout.setContentsMargins(10, 10, 10, 10)
|
||||
|
||||
# --- THE INFO CARD ---
|
||||
card = QFrame()
|
||||
card.setObjectName("InfoCard")
|
||||
card_layout = QVBoxLayout(card)
|
||||
card_layout.setSpacing(8)
|
||||
card_layout.setContentsMargins(12, 12, 12, 12)
|
||||
|
||||
def add_info_row(label_text, field_key=None, show_copy=False):
|
||||
row_layout = QHBoxLayout()
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
lbl = QLabel(label_text)
|
||||
lbl.setStyleSheet("font-size: 12px;")
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
row_layout.addWidget(lbl)
|
||||
|
||||
if field_key is not None:
|
||||
edit = QLineEdit()
|
||||
edit.setReadOnly(True)
|
||||
edit.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.fields[field_key] = edit
|
||||
row_layout.addWidget(edit)
|
||||
|
||||
if show_copy:
|
||||
btn = QPushButton()
|
||||
btn.setFixedSize(28, 28)
|
||||
btn.setIcon(QIcon(os.path.join(self.btn_path, "paste_button.png")))
|
||||
btn.clicked.connect(
|
||||
lambda checked, key=field_key: self.copy_text(key)
|
||||
)
|
||||
row_layout.addWidget(btn)
|
||||
|
||||
card_layout.addLayout(row_layout)
|
||||
|
||||
add_info_row("Billing ID:", field_key="billing_code", show_copy=True)
|
||||
add_info_row("Subscription End-date", field_key="duration", show_copy=False)
|
||||
add_info_row("Currency:", field_key="currency", show_copy=False)
|
||||
add_info_row("Amount:", field_key="amount", show_copy=True)
|
||||
|
||||
addr_sep = QLabel("Wallet Address")
|
||||
addr_sep.setStyleSheet(
|
||||
"font-size: 11px; font-weight: bold; color: #89b4fa; margin-top: 2px;"
|
||||
)
|
||||
card_layout.addWidget(addr_sep)
|
||||
add_info_row("", field_key="address", show_copy=True)
|
||||
|
||||
main_layout.addWidget(card)
|
||||
|
||||
action_bar = QHBoxLayout()
|
||||
action_bar.setSpacing(10)
|
||||
action_bar.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self.qr_code_button = QPushButton(" Scan QR Code")
|
||||
self.qr_code_button.setIcon(QIcon(os.path.join(self.btn_path, "qr-code.png")))
|
||||
self.qr_code_button.setMinimumHeight(40)
|
||||
self.qr_code_button.clicked.connect(self.show_qr_code)
|
||||
self.qr_code_button.setDisabled(True)
|
||||
|
||||
self.stop_button = QPushButton(" Stop Checking")
|
||||
self.stop_button.setObjectName("StopButton")
|
||||
self.stop_button.setMinimumHeight(40)
|
||||
self.stop_button.clicked.connect(self.stop_or_start_check)
|
||||
self.stop_button.setDisabled(True)
|
||||
|
||||
self.switch_to_background_button = QPushButton(" Check in Background")
|
||||
self.switch_to_background_button.setMinimumHeight(40)
|
||||
self.switch_to_background_button.clicked.connect(self.background_fetch_mode)
|
||||
self.switch_to_background_button.setDisabled(True)
|
||||
|
||||
action_bar.addWidget(self.qr_code_button)
|
||||
action_bar.addWidget(self.stop_button)
|
||||
action_bar.addWidget(self.switch_to_background_button)
|
||||
|
||||
main_layout.addLayout(action_bar)
|
||||
self.button_reverse.clicked.connect(self.reverse)
|
||||
|
||||
def clear_pre_existing_invoice(self):
|
||||
text = "connecting.."
|
||||
self.fields["billing_code"].setText(text)
|
||||
self.fields["duration"].setText(text)
|
||||
self.fields["amount"].setText(text)
|
||||
self.fields["address"].setText(text)
|
||||
self.fields["currency"].setText(text)
|
||||
|
||||
def begin_invoice(self):
|
||||
""" "Fetches an Invoice from GUI's actions modules"""
|
||||
self.update_status.update_status("Getting Invoice...")
|
||||
self.profile_id = int(self.update_status.current_profile_id)
|
||||
|
||||
print(f"profile_id is {self.profile_id}")
|
||||
|
||||
if not self.ticketing and not self.profile_id:
|
||||
self.show_a_popup(f"Missing a profile id for an individual profile.")
|
||||
return
|
||||
|
||||
# this is not used here, but this is the right place to put it. Since we should have it by now:
|
||||
self.currency = self._get_currency()
|
||||
if not self.currency:
|
||||
self.custom_window.navigator.navigate("currency_selection")
|
||||
return # error is already displayed in subfunction.
|
||||
|
||||
self.clear_pre_existing_invoice()
|
||||
|
||||
# launches background thread tasks:
|
||||
error_msg = gui_starts_invoice(
|
||||
invoice_ui_caller=self.receive_invoice,
|
||||
timer_resume_caller=self.timer_updates_gui,
|
||||
profile_id=self.profile_id,
|
||||
)
|
||||
|
||||
if error_msg:
|
||||
self.show_a_popup(error_msg)
|
||||
else:
|
||||
self.polling = True
|
||||
# And make sure it's allowed to be stopped:
|
||||
self.stop_button.setDisabled(False)
|
||||
self.switch_to_background_button.setDisabled(False)
|
||||
|
||||
def stop_or_start_check(self):
|
||||
"""Toggle On/Off Polling"""
|
||||
# If it's on:
|
||||
if self.polling:
|
||||
timer_result = stop_payment_timer()
|
||||
|
||||
message = timer_result.message
|
||||
|
||||
if not timer_result.valid:
|
||||
self.update_status.update_status("Stopped checking!")
|
||||
self.stop_button.setDisabled(True)
|
||||
self.switch_to_background_button.setDisabled(True)
|
||||
|
||||
# regardless of if it worked or not:
|
||||
if message:
|
||||
self.show_a_popup(str(message))
|
||||
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
# # Turn it back on if off:
|
||||
# else:
|
||||
# self.begin_invoice()
|
||||
|
||||
def background_fetch_mode(self):
|
||||
"""Continue checking if paid, but navigate back to the menu page"""
|
||||
if self.polling:
|
||||
notification = "Checking if Paid in Background.."
|
||||
Notifications.emit(EventType.STATUS_UPDATE, notification)
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
else:
|
||||
message = "It was not polling to begin with."
|
||||
self.show_a_popup(message)
|
||||
|
||||
def copy_text(self, key: str):
|
||||
"""Copy/Paste fields and update the bottom status"""
|
||||
try:
|
||||
# Use the key to determine if we use elided text or the full raw address
|
||||
text = self.full_address if key == "address" else self.fields[key].text()
|
||||
QApplication.clipboard().setText(text)
|
||||
|
||||
# messages based on the key
|
||||
msg_map = {
|
||||
"billing_code": "Billing code copied!",
|
||||
"address": "Address copied!",
|
||||
"amount": "Pay amount copied!",
|
||||
}
|
||||
self.update_status.update_status(msg_map.get(key, "Copied!"))
|
||||
except Exception as e:
|
||||
self.show_a_popup(str(e))
|
||||
self.update_status.update_status("No content available for copying")
|
||||
|
||||
@mainthread
|
||||
def receive_invoice(self, invoice_result: Result):
|
||||
"""Callback for the background API thread to update the UI"""
|
||||
|
||||
print("receive_invoice is recieving the invoice result")
|
||||
|
||||
if not invoice_result:
|
||||
self.display_error(
|
||||
whats_wrong=DataError.BLANK,
|
||||
whats_it_called="Backend Invoice Result",
|
||||
the_thing=invoice_result,
|
||||
)
|
||||
return
|
||||
|
||||
if not isinstance(invoice_result, Result):
|
||||
self.display_error(
|
||||
whats_wrong=DataError.FORMAT,
|
||||
whats_it_called="Backend Invoice Result",
|
||||
the_thing=invoice_result,
|
||||
)
|
||||
return
|
||||
|
||||
if not invoice_result.valid:
|
||||
# Paid already, then navigate away:
|
||||
if invoice_result.error_type == ResultError.PAID:
|
||||
print("already paid")
|
||||
print("already paid")
|
||||
print("already paid")
|
||||
print("already paid")
|
||||
if paying_for_tickets():
|
||||
print("triggering ticketing confirmed")
|
||||
self.ticket_payment_confirmed()
|
||||
else:
|
||||
self.update_status.update_status('Paid. Please proceed.')
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
return
|
||||
|
||||
# otherwise display the error:
|
||||
self.update_status.update_status(str(invoice_result.error_type.value))
|
||||
self.show_a_popup(str(invoice_result.message))
|
||||
return
|
||||
|
||||
# pull the actual invoice out of the result wrapper,
|
||||
invoice = invoice_result.data
|
||||
|
||||
if not invoice:
|
||||
self.show_a_popup("Missing Invoice from API call's data")
|
||||
self.custom_window.navigator.navigate("id")
|
||||
return
|
||||
|
||||
# Fill in UI:
|
||||
self.fill_in_invoice_data(invoice)
|
||||
|
||||
# Start polling:
|
||||
error_msg = start_polling(
|
||||
billing_code=invoice.billing_code,
|
||||
profile_id=int(self.update_status.current_profile_id),
|
||||
ui_caller=self.timer_updates_gui
|
||||
)
|
||||
if error_msg:
|
||||
self.show_a_popup(error_msg)
|
||||
else:
|
||||
self.update_status.update_status("Invoice generated. Awaiting payment...")
|
||||
|
||||
def fill_in_invoice_data(self, invoice: SubscriptionInvoice):
|
||||
"""Extract and populate invoice data into the labels"""
|
||||
|
||||
print(f"filling in {invoice}")
|
||||
|
||||
# Extract from the already validated object:
|
||||
billing_code = str(invoice.billing_code)
|
||||
duration = str(invoice.expires_at)
|
||||
amount_text = str(invoice.due_amount)
|
||||
self.full_address = str(invoice.address)
|
||||
|
||||
if billing_code:
|
||||
self.fields["billing_code"].setText(billing_code)
|
||||
|
||||
if duration:
|
||||
self.fields["duration"].setText(duration)
|
||||
self.fields["duration"].setStyleSheet("font-size: 13px;")
|
||||
|
||||
if amount_text:
|
||||
self.fields["amount"].setText(amount_text)
|
||||
|
||||
# Dynamic font sizing for amount
|
||||
font_size = 14
|
||||
if len(amount_text) > 9:
|
||||
font_size = 15
|
||||
if len(amount_text) > 13:
|
||||
font_size = 10
|
||||
self.fields["amount"].setStyleSheet(
|
||||
f"font-size: {font_size}px; font-weight: bold;"
|
||||
)
|
||||
|
||||
if self.full_address:
|
||||
metrics = self.fields["address"].fontMetrics()
|
||||
elided_text = metrics.elidedText(
|
||||
self.full_address,
|
||||
Qt.TextElideMode.ElideMiddle,
|
||||
self.fields["address"].width(),
|
||||
)
|
||||
self.fields["address"].setText(elided_text)
|
||||
|
||||
if self.currency:
|
||||
self.fields["currency"].setText(self.currency.value)
|
||||
|
||||
# Now that we have the data populated, show the QR code:
|
||||
self.qr_code_button.setDisabled(False)
|
||||
|
||||
# And make sure it's allowed to be stopped:
|
||||
self.stop_button.setDisabled(False)
|
||||
self.switch_to_background_button.setDisabled(False)
|
||||
|
||||
@mainthread
|
||||
def timer_updates_gui(self, paid: bool, stopped: bool, message: str, billing_code: str):
|
||||
"""Callback for the background thread timer to repeatedly update the UI"""
|
||||
|
||||
# CONTINUED UPDATES
|
||||
if message and isinstance(message, str):
|
||||
self.update_status.update_status(message)
|
||||
|
||||
# PAID
|
||||
if paid:
|
||||
self.update_status.update_status("Payment received.")
|
||||
if self.ticketing:
|
||||
self.ticket_payment_confirmed()
|
||||
else:
|
||||
self.regular_payment_confirmed(billing_code=billing_code)
|
||||
|
||||
# NOT PAID BUT ENDED
|
||||
elif not paid and stopped:
|
||||
self._display_it_stopped(message)
|
||||
|
||||
|
||||
def _display_it_stopped(self, message: str):
|
||||
"""Notify the user if it's NOT paid, but stopped"""
|
||||
if not message:
|
||||
error_msg = "It stopped checking if it's paid. Could be a connection timeout or other issue"
|
||||
else:
|
||||
error_msg = f"It stopped checking if it's paid. {message}"
|
||||
self.update_status.update_status("Timer Stopped")
|
||||
self.show_a_popup(error_msg)
|
||||
|
||||
def show_qr_code(self):
|
||||
"""Launch an external QR scanning popup screen"""
|
||||
if self.full_address and self.currency:
|
||||
qr_dialog = QRCodeDialog(
|
||||
self.full_address,
|
||||
self.fields["amount"].text(),
|
||||
self.currency.value,
|
||||
self,
|
||||
)
|
||||
qr_dialog.exec()
|
||||
else:
|
||||
self.update_status.update_status("No address available for QR code")
|
||||
|
||||
def reverse(self):
|
||||
"""Navigate away from the page, depending on which payment flow."""
|
||||
if self.ticketing:
|
||||
self.ticketing = False
|
||||
self.custom_window.navigator.navigate("ticket_crypto_picker")
|
||||
else:
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
def _get_currency(self):
|
||||
"""Get the currency from prior cached Payment Data"""
|
||||
|
||||
if not self.profile_id or not isinstance(self.profile_id, int):
|
||||
self.display_error(
|
||||
whats_wrong=DataError.FORMAT,
|
||||
whats_it_called="profile_id",
|
||||
the_thing=self.profile_id,
|
||||
)
|
||||
|
||||
# get the singleton (this was created on a previous page)
|
||||
data_result = get_current_billing_data(profile_id=self.profile_id)
|
||||
|
||||
if not data_result.valid:
|
||||
message = data_result.message
|
||||
if not message:
|
||||
message = "Error fetching data"
|
||||
self.show_a_popup(str(message))
|
||||
|
||||
# get the singleton (this was created on a previous page)
|
||||
billing_singleton = data_result.data
|
||||
|
||||
print(f"Inside payments screen GUI of _get_currency, the billing_singleton is {billing_singleton}")
|
||||
|
||||
if not billing_singleton:
|
||||
message = "Error, no billing data"
|
||||
self.show_a_popup(str(message))
|
||||
|
||||
currency = billing_singleton.currency
|
||||
|
||||
if not currency:
|
||||
return None
|
||||
# self.show_a_popup('Missing Currency')
|
||||
|
||||
if not isinstance(currency, Currency):
|
||||
self.display_error(
|
||||
whats_wrong=DataError.FORMAT,
|
||||
whats_it_called="currency",
|
||||
the_thing=currency,
|
||||
)
|
||||
|
||||
return currency
|
||||
|
||||
|
||||
def regular_payment_confirmed(self, billing_code: str):
|
||||
"""Conclude a regular payment by putting the billing code on the final confirmation window."""
|
||||
print("calling regular_payment_confirmed")
|
||||
payment_page = self.custom_window.navigator.get_cached("payment_confirmed")
|
||||
if payment_page is not None:
|
||||
for line in self.text_fields:
|
||||
line.setText('')
|
||||
payment_page.set_billing_code(billing_code)
|
||||
self.custom_window.navigator.navigate("payment_confirmed")
|
||||
else:
|
||||
print("PaymentConfirmed page not found")
|
||||
self.update_status.update_status('Paid. Please proceed.')
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
def ticket_payment_confirmed(self):
|
||||
"""Conclude a ticket payment by forwarding to the prep page."""
|
||||
self.update_status.update_status('Payment received.')
|
||||
self.custom_window.navigator.navigate("ticket_prep")
|
||||
# prep_page = self.custom_window.navigator.get_cached("ticket_prep")
|
||||
# if prep_page is not None:
|
||||
# prep_page.start_prep()
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,16 +1,30 @@
|
|||
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem
|
||||
from PyQt6 import QtCore
|
||||
# core
|
||||
from core.models.enum_models import PaymentCategory
|
||||
from core.models.Result import Result
|
||||
from core.services.payment_phase.payment_state import TicketPaymentData
|
||||
|
||||
# gui
|
||||
from gui.v2.actions.ticket_actions import start_ticket
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
from gui.v2.ui.styles.styles import TERMINAL_LIST_QSS
|
||||
from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
|
||||
|
||||
# generic
|
||||
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem
|
||||
from PyQt6 import QtCore
|
||||
|
||||
|
||||
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
|
||||
|
||||
# this page only, immediate pricing display:
|
||||
self.pricing_data = None
|
||||
|
||||
# cross page cache overall data:
|
||||
self.billing_data = None
|
||||
|
||||
self.worker = None
|
||||
|
||||
self.title.setText("Pick a Plan")
|
||||
|
|
@ -39,6 +53,10 @@ class PlanPickerPage(Page):
|
|||
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
def start_sync(self):
|
||||
# STATE
|
||||
result = start_ticket(ui_caller=self.recieve_initial_billing_data)
|
||||
|
||||
# Begin Tickets:
|
||||
self.plan_list.clear()
|
||||
self.status_label.setText("Syncing prices...")
|
||||
self.update_status.update_status("Syncing ticket prices...")
|
||||
|
|
@ -47,27 +65,18 @@ class PlanPickerPage(Page):
|
|||
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}")
|
||||
def on_sync_done(self, pricing_data: Result):
|
||||
if not pricing_data.valid:
|
||||
self.status_label.setText("Pricing data fetch failed.")
|
||||
self.show_a_popup(pricing_data.message)
|
||||
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.pricing_data = pricing_data.data
|
||||
self.populate_plans(self.pricing_data)
|
||||
self.status_label.setText("Pick a plan above.")
|
||||
self.update_status.update_status("Plans loaded.")
|
||||
|
||||
def populate_plans(self, pricing_data):
|
||||
def populate_plans(self, pricing_data: dict):
|
||||
self.plan_list.clear()
|
||||
if not isinstance(pricing_data, dict):
|
||||
self.status_label.setText("Unexpected pricing data.")
|
||||
|
|
@ -84,14 +93,57 @@ class PlanPickerPage(Page):
|
|||
item.setData(QtCore.Qt.ItemDataRole.UserRole, plan_key)
|
||||
self.plan_list.addItem(item)
|
||||
|
||||
def recieve_initial_billing_data(self, result: Result):
|
||||
"""Callback to recieve initial billing data instance"""
|
||||
|
||||
if not isinstance(result, Result):
|
||||
self.show_a_popup("Developer Error: recieve_initial_billing_data should not have gotten a non-result object.")
|
||||
|
||||
if not result.valid:
|
||||
error_msg = result.message
|
||||
if error_msg:
|
||||
self.show_a_popup(str(error_msg))
|
||||
else:
|
||||
self.show_a_popup("Error with Starting Billing")
|
||||
|
||||
# cache data to save the choice to it:
|
||||
self.billing_data = result.data
|
||||
if self.billing_data:
|
||||
print("got pre-existing data")
|
||||
|
||||
def on_plan_clicked(self, item):
|
||||
print(f"on_plan_clicked is {item}")
|
||||
plan_key = item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if not plan_key:
|
||||
self.show_a_popup("Developer Error where the selected item is not showing up in on_plan_clicked.")
|
||||
return
|
||||
self.custom_window.navigator.navigate("ticket_crypto_picker")
|
||||
crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker")
|
||||
if crypto_page is not None:
|
||||
crypto_page.set_selected_plan(plan_key)
|
||||
|
||||
print(f"plan_key is {plan_key}")
|
||||
|
||||
# in temp cache save choice:
|
||||
if not self.billing_data:
|
||||
self.show_a_popup("Developer Error that ticket flow didn't produce a billing_data object in plan picker page.")
|
||||
|
||||
# self.billing_data.which_key = plan_key
|
||||
|
||||
print("Getting TicketPaymentData()")
|
||||
billing_data = TicketPaymentData()
|
||||
print(f"billing_data is {billing_data}")
|
||||
billing_data.which_key = plan_key
|
||||
print("saved the which_key. navigating to currency selection..")
|
||||
|
||||
print(f"FINALLY billing_data is {billing_data}")
|
||||
|
||||
# go to next page:
|
||||
# currency_page = self.custom_window.navigator.get_cached("currency_selection")
|
||||
# if currency_page is not None:
|
||||
self.custom_window.navigator.navigate("currency_selection")
|
||||
|
||||
|
||||
# self.custom_window.navigator.navigate("ticket_crypto_picker")
|
||||
# crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker")
|
||||
# if crypto_page is not None:
|
||||
# crypto_page.set_selected_plan(plan_key)
|
||||
|
||||
def on_error(self, msg):
|
||||
self.status_label.setText(f"Error: {msg}")
|
||||
|
|
@ -99,3 +151,17 @@ class PlanPickerPage(Page):
|
|||
|
||||
def reverse(self):
|
||||
self.custom_window.navigator.navigate("id")
|
||||
|
||||
|
||||
# LEGACY:
|
||||
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@ class TicketCryptoPickerPage(Page):
|
|||
currency = selected_button.property('currency')
|
||||
self.start_initiate_payment(currency)
|
||||
|
||||
def check_if_paid_for_existing(self, temp_billing_code):
|
||||
def check_if_paid_for_existing(self, billing_code):
|
||||
self.update_status.update_status("Checking if paid...")
|
||||
result = check_if_paid(temp_billing_code, ticket_observer, connection_observer)
|
||||
result = check_if_paid(billing_code, ticket_observer, connection_observer)
|
||||
if isinstance(result, dict) and result.get('valid') and result.get('payment_status') == 'paid':
|
||||
self.update_status.update_status("Already Paid!")
|
||||
self.custom_window.navigator.navigate("ticket_prep")
|
||||
|
|
@ -78,8 +78,8 @@ class TicketCryptoPickerPage(Page):
|
|||
else:
|
||||
self.update_status.update_status("Not yet paid.")
|
||||
clipboard = QApplication.clipboard()
|
||||
clipboard.setText(temp_billing_code)
|
||||
not_paid_msg = f"The billing code is not yet showing paid for {temp_billing_code}. Right now, your clipboard has the billing code, to paste it in any text editor. If you did pay, either wait longer for blockchain confirmation, or contact customer support with the code in your clipboard now."
|
||||
clipboard.setText(billing_code)
|
||||
not_paid_msg = f"The billing code is not yet showing paid for {billing_code}. Right now, your clipboard has the billing code, to paste it in any text editor. If you did pay, either wait longer for blockchain confirmation, or contact customer support with the code in your clipboard now."
|
||||
info = QMessageBox(self)
|
||||
info.setWindowTitle("Not Paid")
|
||||
info.setText(not_paid_msg)
|
||||
|
|
@ -120,10 +120,10 @@ class TicketCryptoPickerPage(Page):
|
|||
self._prompt_wipe_existing(invoice)
|
||||
return
|
||||
elif error_code == ResultError.BILLING_CODE_EXISTS and not self.bypass_existing:
|
||||
temp_billing_code = getattr(invoice, 'temp_billing_code', None)
|
||||
print(f"temp_billing_code is {temp_billing_code}")
|
||||
if temp_billing_code:
|
||||
self._prompt_wipe_billingcode(temp_billing_code)
|
||||
billing_code = getattr(invoice, 'billing_code', None)
|
||||
print(f"billing_code is {billing_code}")
|
||||
if billing_code:
|
||||
self._prompt_wipe_billingcode(billing_code)
|
||||
return
|
||||
else:
|
||||
self._prompt_wipe_billingcode("NONE")
|
||||
|
|
@ -151,7 +151,7 @@ class TicketCryptoPickerPage(Page):
|
|||
else:
|
||||
self.update_status.update_status("Cancelled.")
|
||||
|
||||
def _prompt_wipe_billingcode(self, temp_billing_code):
|
||||
def _prompt_wipe_billingcode(self, billing_code):
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle("Existing billing code found")
|
||||
msg.setText("You already have a ticket billing code. Do you want to use it? Only hit YES if you ALREADY paid.")
|
||||
|
|
@ -161,7 +161,7 @@ class TicketCryptoPickerPage(Page):
|
|||
result = msg.exec()
|
||||
if result == QMessageBox.StandardButton.Yes:
|
||||
self.update_status.update_status("Reusing same code")
|
||||
self.check_if_paid_for_existing(temp_billing_code)
|
||||
self.check_if_paid_for_existing(billing_code)
|
||||
else:
|
||||
self.bypass_existing = True
|
||||
delete_ticket_data()
|
||||
|
|
|
|||
|
|
@ -1,18 +1,31 @@
|
|||
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
|
||||
# core
|
||||
# from core.controllers.tickets.UseTicketController import get_unused_tickets
|
||||
from core.models.Result import Result, ResultError
|
||||
|
||||
# gui
|
||||
from gui.v2.ui.styles.css.ticket_billing_styles import TICKET_LIST_STYLE_ONE, TICKET_BUTTON_A, USE_BILLING_BUTTON_STYLE
|
||||
from gui.v2.actions.locations import location_candidates
|
||||
from gui.v2.actions.menu_tasks import enable_profile
|
||||
from gui.v2.infrastructure.setup_observers import ticket_observer
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
from gui.v2.infrastructure.take_plumbing_action import take_action
|
||||
from gui.v2.actions.ticket_actions import get_ticket_data, use_a_ticket
|
||||
|
||||
# Bring background threads to the main thread UI:
|
||||
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
|
||||
mainthread = ThreadSafetyTool()
|
||||
|
||||
|
||||
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton
|
||||
from PyQt6 import QtCore
|
||||
|
||||
|
||||
|
||||
class TicketOrBillingChoicePage(Page):
|
||||
def __init__(self, page_stack, main_window=None, parent=None):
|
||||
super().__init__("TicketOrBilling", page_stack, main_window, parent)
|
||||
|
||||
print("loading TicketOrBillingChoicePage")
|
||||
self.update_status = main_window
|
||||
|
||||
self.title.setText("Pick a Ticket or Use Billing")
|
||||
|
|
@ -32,86 +45,103 @@ class TicketOrBillingChoicePage(Page):
|
|||
|
||||
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.ticket_list.setStyleSheet(TICKET_LIST_STYLE_ONE)
|
||||
|
||||
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.setStyleSheet(TICKET_BUTTON_A)
|
||||
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.setStyleSheet(USE_BILLING_BUTTON_STYLE)
|
||||
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)
|
||||
self.get_tickets()
|
||||
|
||||
def populate(self):
|
||||
def get_tickets(self):
|
||||
"""Gets tickets in the background"""
|
||||
print("doing get_tickets in background")
|
||||
take_action(
|
||||
action=get_ticket_data,
|
||||
ui_caller=self.populate
|
||||
)
|
||||
|
||||
@mainthread
|
||||
def populate(self, result: Result):
|
||||
"""Callback to Populate tickets."""
|
||||
print(f"populate recieved {result}")
|
||||
self.ticket_list.clear()
|
||||
try:
|
||||
unused = get_unused_tickets(ticket_observer)
|
||||
except Exception as e:
|
||||
self.status_label.setText(f"Error reading tickets: {e}")
|
||||
|
||||
message = result.message
|
||||
|
||||
if not result.valid:
|
||||
self.handle_error(result)
|
||||
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.'))
|
||||
|
||||
data = result.data
|
||||
if not data:
|
||||
if message:
|
||||
self.status_label.setText(message) # this is valid state of none
|
||||
return
|
||||
|
||||
# POPULATE
|
||||
for display_text, ticket_id in result.data:
|
||||
item = QListWidgetItem(display_text)
|
||||
item.setData(QtCore.Qt.ItemDataRole.UserRole, ticket_id)
|
||||
self.ticket_list.addItem(item)
|
||||
|
||||
|
||||
def handle_error(result: Result):
|
||||
self.status_label.setText("Error")
|
||||
|
||||
if message:
|
||||
self.show_a_popup(message) # this is an error
|
||||
|
||||
if result.error_type == ResultError.NAVIGATE_TO:
|
||||
self.custom_window.navigator.navigate(result.navigate_to)
|
||||
|
||||
|
||||
def on_use_ticket(self):
|
||||
# 1. Get the visual item the user clicked on
|
||||
item = self.ticket_list.currentItem()
|
||||
|
||||
if not item:
|
||||
self.status_label.setText("Pick a ticket from the list first.")
|
||||
return
|
||||
|
||||
# 2. This returns the actual ID (e.g., 1), NOT the string "Ticket #1"
|
||||
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.")
|
||||
|
||||
print(f"which_ticket is {which_ticket}")
|
||||
profile_id = int(self.update_status.current_profile_id)
|
||||
print(f"profile_id is {profile_id}")
|
||||
|
||||
if not profile_id:
|
||||
self.update_status.update_status("Can not find the correct profile id")
|
||||
return
|
||||
profile_data = {
|
||||
'id': int(profile_id),
|
||||
'use_ticket': which_ticket,
|
||||
'ticket_location': candidates[0]
|
||||
}
|
||||
|
||||
use_a_ticket(profile_id=profile_id, which_ticket=which_ticket, ui_caller=self.recieve_use_result)
|
||||
self.update_status.update_status(f"Using ticket #{which_ticket}...")
|
||||
|
||||
|
||||
def recieve_use_result(self, result: Result):
|
||||
"""Recieves the callback from gui_uses_ticket"""
|
||||
|
||||
if not result or not isinstance(result, Result):
|
||||
self.show_a_popup("This is a developer error, the use of the ticket failed to produce a result object")
|
||||
|
||||
if not result.valid:
|
||||
self.update_status.update_status(str(result.message))
|
||||
return
|
||||
|
||||
menu_page = self.custom_window.navigator.get_cached("menu")
|
||||
if menu_page:
|
||||
self.update_status.update_status(f"Using ticket #{which_ticket}...")
|
||||
self.profile_enable_worker = enable_profile(
|
||||
profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
|
||||
)
|
||||
self.update_status.update_status("Ticket Use Worked!")
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
def on_use_billing(self):
|
||||
|
|
@ -119,3 +149,31 @@ class TicketOrBillingChoicePage(Page):
|
|||
|
||||
def reverse(self):
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
# 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.")
|
||||
# return
|
||||
# profile_data = {
|
||||
# 'id': int(profile_id),
|
||||
# 'use_ticket': which_ticket,
|
||||
# 'ticket_location': candidates[0]
|
||||
# }
|
||||
|
||||
# self.profile_enable_worker = enable_profile(
|
||||
# profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
|
||||
# )
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
from PyQt6.QtWidgets import QLabel, QPushButton
|
||||
from PyQt6 import QtCore
|
||||
from core.models.Result import Result, ResultError
|
||||
|
||||
from gui.v2.actions.ticket_actions import start_bulk_ticket_prep
|
||||
from gui.v2.ui.styles.css.ticket_billing_styles import TICKET_TERMINAL_STYLE, TICKET_TERMINAL_STYLE_SHEET
|
||||
from gui.v2.infrastructure.setup_observers import ticket_observer
|
||||
from gui.v2.actions.menu_tasks import enable_profile
|
||||
from gui.v2.ui.pages.Page import Page
|
||||
from gui.v2.ui.widgets.terminal_widget import TerminalWidget
|
||||
from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
|
||||
# from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
|
||||
|
||||
from PyQt6.QtWidgets import QLabel, QPushButton
|
||||
from PyQt6 import QtCore
|
||||
|
||||
|
||||
HOW_MANY_PROFILES_DEFAULT = 6
|
||||
|
|
@ -18,7 +22,11 @@ class TicketPrepPage(Page):
|
|||
self.worker = None
|
||||
self._terminal_bound = False
|
||||
self._tickets_ready = False
|
||||
self.setup_initial_layout()
|
||||
self.active_prep = False
|
||||
self.start_prep()
|
||||
|
||||
def setup_initial_layout(self):
|
||||
self.title.setText("Preparing Tickets")
|
||||
self.title.setGeometry(QtCore.QRect(280, 20, 240, 40))
|
||||
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||
|
|
@ -28,18 +36,12 @@ class TicketPrepPage(Page):
|
|||
|
||||
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.setStyleSheet(TICKET_TERMINAL_STYLE_SHEET)
|
||||
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.setStyleSheet(TICKET_TERMINAL_STYLE)
|
||||
self.continue_button.setEnabled(True)
|
||||
self.continue_button.clicked.connect(self.on_continue)
|
||||
|
||||
|
|
@ -61,60 +63,82 @@ class TicketPrepPage(Page):
|
|||
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()
|
||||
if not self.active_prep:
|
||||
# Actually Start it:
|
||||
start_bulk_ticket_prep(ui_caller=self.on_prep_done)
|
||||
self.active_prep = True
|
||||
|
||||
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._tickets_ready = True
|
||||
self.update_status.clear_ticket_verification_failure()
|
||||
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}")
|
||||
if self.update_status.save_ticket_verification_failure(result):
|
||||
self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.")
|
||||
self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.")
|
||||
self.update_status.update_status("Ticket verification failed")
|
||||
self.continue_button.setEnabled(True)
|
||||
return
|
||||
self.status_label.setText(f"Preparation failed: {msg}")
|
||||
self.update_status.update_status(f"An error occurred")
|
||||
self.continue_button.setEnabled(True)
|
||||
|
||||
def on_error(self, msg):
|
||||
self.terminal.append(f"EXCEPTION: {msg}")
|
||||
self.status_label.setText(f"An unkown error occured")
|
||||
def on_prep_done(self, result: Result):
|
||||
print(f"on_prep_done called with {result}")
|
||||
self.continue_button.setEnabled(True)
|
||||
|
||||
if not result or not isinstance(result, Result):
|
||||
self.terminal.append("Results returned to the graphical display in an invalid format.")
|
||||
return
|
||||
|
||||
if result.valid:
|
||||
self.terminal.append("=== Ticket preparation complete ===")
|
||||
self.status_label.setText("Tickets ready. Click Continue to continue to the main page.")
|
||||
self.update_status.update_status("Tickets ready.")
|
||||
return
|
||||
|
||||
self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.")
|
||||
self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.")
|
||||
self.update_status.update_status("Ticket verification failed")
|
||||
|
||||
def on_continue(self):
|
||||
menu_page = self.custom_window.navigator.get_cached("menu")
|
||||
profile_id = getattr(self.update_status, 'current_profile_id', None)
|
||||
if menu_page:
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
if not self._tickets_ready:
|
||||
self.update_status.update_status("Ticket preparation was not completed.")
|
||||
return
|
||||
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...")
|
||||
self.profile_enable_worker = enable_profile(
|
||||
{'id': int(profile_id)}, menu_page.handle_task_result, allow_uninstalled_tor=True,
|
||||
)
|
||||
self.custom_window.navigator.navigate("menu")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# self.status_label.setText("Preparation failed.")
|
||||
# if not isinstance(result, dict):
|
||||
# self.terminal.append("ERROR: invalid result format.")
|
||||
# return
|
||||
|
||||
# if result.get('valid') is True:
|
||||
# self._tickets_ready = True
|
||||
# self.update_status.clear_ticket_verification_failure()
|
||||
# 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}")
|
||||
# if self.update_status.save_ticket_verification_failure(result):
|
||||
|
||||
# self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.")
|
||||
# self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.")
|
||||
# self.update_status.update_status("Ticket verification failed")
|
||||
# self.continue_button.setEnabled(True)
|
||||
# return
|
||||
# self.status_label.setText(f"Preparation failed: {msg}")
|
||||
# self.update_status.update_status(f"An error occurred")
|
||||
# self.continue_button.setEnabled(True)
|
||||
|
||||
# def on_error(self, msg):
|
||||
# self.terminal.append(f"EXCEPTION: {msg}")
|
||||
# self.status_label.setText(f"An unknown error occured")
|
||||
# self.continue_button.setEnabled(True)
|
||||
|
||||
# def on_continue(self):
|
||||
# menu_page = self.custom_window.navigator.get_cached("menu")
|
||||
# profile_id = getattr(self.update_status, 'current_profile_id', None)
|
||||
# if menu_page:
|
||||
# self.custom_window.navigator.navigate("menu")
|
||||
# if not self._tickets_ready:
|
||||
# self.update_status.update_status("Ticket preparation was not completed.")
|
||||
# return
|
||||
# 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...")
|
||||
# self.profile_enable_worker = enable_profile(
|
||||
# {'id': int(profile_id)}, menu_page.handle_task_result, allow_uninstalled_tor=True,
|
||||
# )
|
||||
|
|
|
|||
96
gui/v2/ui/popups/error_popup.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
from typing import Optional, Callable, Any
|
||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
class ErrorPopup(QDialog):
|
||||
def __init__(
|
||||
self,
|
||||
choice: bool,
|
||||
title: str,
|
||||
message: str,
|
||||
end_app: Optional[bool] = False,
|
||||
on_answer: Optional[Callable] = None,
|
||||
passed_result: Optional[Any] = None,
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Protocol state
|
||||
self.title_text = title
|
||||
self.on_answer = on_answer
|
||||
self.passed_result = passed_result
|
||||
self.end_app = end_app
|
||||
|
||||
# Window Configuration
|
||||
self.setWindowTitle(title)
|
||||
self.setFixedSize(400, 300)
|
||||
self.setModal(True)
|
||||
|
||||
# Dark Theme Styling (Replacing Kivy Canvas/RoundedRectangle)
|
||||
self.setStyleSheet("""
|
||||
QDialog {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 20px;
|
||||
}
|
||||
QLabel {
|
||||
color: #33ff33;
|
||||
font-size: 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
QPushButton {
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
min-height: 50px;
|
||||
}
|
||||
""")
|
||||
|
||||
# Layout setup
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||||
main_layout.setSpacing(20)
|
||||
|
||||
# MESSAGE LABEL
|
||||
self.message_label = QLabel(message)
|
||||
self.message_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.message_label.setWordWrap(True)
|
||||
main_layout.addWidget(self.message_label)
|
||||
|
||||
if choice:
|
||||
# BUTTON LAYOUT for Yes/No
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(20)
|
||||
|
||||
yes_btn = QPushButton("Yes")
|
||||
yes_btn.setStyleSheet("background-color: #1b4d1b;")
|
||||
yes_btn.clicked.connect(lambda: self._choice_response(True))
|
||||
|
||||
no_btn = QPushButton("No")
|
||||
no_btn.setStyleSheet("background-color: #801a1a;")
|
||||
no_btn.clicked.connect(lambda: self._choice_response(False))
|
||||
|
||||
button_layout.addWidget(yes_btn)
|
||||
button_layout.addWidget(no_btn)
|
||||
main_layout.addLayout(button_layout)
|
||||
else:
|
||||
# OK button
|
||||
ok_btn = QPushButton("OK")
|
||||
ok_btn.setStyleSheet("background-color: #1b4d1b;")
|
||||
ok_btn.clicked.connect(self.accept) # Closes the dialog
|
||||
main_layout.addWidget(ok_btn)
|
||||
|
||||
self.setLayout(main_layout)
|
||||
|
||||
def _choice_response(self, user_choice: bool):
|
||||
if self.on_answer:
|
||||
self.on_answer(user_choice, self.passed_result)
|
||||
self.accept()
|
||||
|
||||
def _terminate_app(self):
|
||||
"""Desktop equivalent of App.get_running_app().stop()"""
|
||||
QApplication.quit()
|
||||
|
||||
def show(self):
|
||||
self.exec()
|
||||
14
gui/v2/ui/styles/css/id_page_style.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
|
||||
COLUMN_BUTTON_CSS = """
|
||||
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; }
|
||||
"""
|
||||
14
gui/v2/ui/styles/css/menu_css.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
|
||||
|
||||
CREATE_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: rgba(200, 200, 200, 50);
|
||||
}
|
||||
QPushButton:checked {
|
||||
background-color: rgba(200, 200, 200, 80);
|
||||
}
|
||||
"""
|
||||
57
gui/v2/ui/styles/css/payment_styles.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
|
||||
PAYMENT_STYLE_SHEET = """
|
||||
QWidget {
|
||||
color: #cdd6f4;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
background-color: transparent; /* Force the main container to be invisible */
|
||||
}
|
||||
|
||||
QLabel {
|
||||
color: #bac2de;
|
||||
background-color: transparent; /* Ensure labels don't bring back the color */
|
||||
}
|
||||
|
||||
/* The 'Card' still keeps its color because it's a specific ID */
|
||||
#InfoCard {
|
||||
background-color: #313244;
|
||||
border-radius: 15px;
|
||||
border: 1px solid #45475a;
|
||||
}
|
||||
|
||||
QLineEdit {
|
||||
background-color: #181825;
|
||||
border: 2px solid #45475a;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
color: #f5e0dc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QLineEdit:read-only {
|
||||
color: #a6adc8;
|
||||
border: 1px solid #585b70;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #89b4fa;
|
||||
color: #11111b;
|
||||
border-radius: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #b4befe;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #585b70;
|
||||
color: #7f849c;
|
||||
}
|
||||
|
||||
#StopButton {
|
||||
background-color: #f38ba8;
|
||||
}
|
||||
#StopButton:hover {
|
||||
background-color: #eba0ac;
|
||||
}
|
||||
"""
|
||||
41
gui/v2/ui/styles/css/ticket_billing_styles.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
|
||||
|
||||
|
||||
TICKET_LIST_STYLE_ONE = """
|
||||
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; }
|
||||
"""
|
||||
|
||||
TICKET_BUTTON_A = """
|
||||
QPushButton {
|
||||
background-color: #00aaff; color: white; border: none;
|
||||
border-radius: 5px; font-size: 14px; font-weight: bold;
|
||||
}
|
||||
QPushButton:disabled { background-color: #555555; }
|
||||
"""
|
||||
|
||||
USE_BILLING_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background-color: #000000; color: #00ffff;
|
||||
border: 2px solid #00ffff; border-radius: 10px;
|
||||
font-size: 14px; font-weight: bold;
|
||||
}
|
||||
QPushButton:hover { background-color: #003333; }
|
||||
"""
|
||||
|
||||
|
||||
TICKET_TERMINAL_STYLE = """
|
||||
QPushButton {
|
||||
background-color: #2ecc71; color: white; border: none;
|
||||
border-radius: 5px; font-size: 14px; font-weight: bold;
|
||||
}
|
||||
QPushButton:disabled { background-color: #555555; }
|
||||
"""
|
||||
|
||||
TICKET_TERMINAL_STYLE_SHEET = "color: #00ffff; font-size: 14px;"
|
||||
56
gui/v2/ui/styles/payment_styles.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
|
||||
DURATION_SELECTION_STYLE = """
|
||||
QComboBox {
|
||||
font-size: 16px;
|
||||
padding: 10px;
|
||||
background-color: #000000;
|
||||
color: #00ffff;
|
||||
border: 2px solid #00ffff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
width: 30px;
|
||||
background-color: #1a1a1a;
|
||||
border-left: 1px solid #00ffff;
|
||||
}
|
||||
QComboBox::down-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: #00ffff;
|
||||
}
|
||||
QComboBox QAbstractItemView {
|
||||
background-color: #0a0a0a;
|
||||
border: 1px solid #00ffff;
|
||||
border-radius: 4px;
|
||||
selection-background-color: #003333;
|
||||
selection-color: #00ffff;
|
||||
}
|
||||
QComboBox QAbstractItemView::item {
|
||||
padding: 8px;
|
||||
border: none;
|
||||
color: #00ffff;
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
QComboBox QAbstractItemView::item:hover {
|
||||
background-color: #1a1a1a;
|
||||
color: #00ffff;
|
||||
}
|
||||
QComboBox QAbstractItemView::item:selected {
|
||||
background-color: #003333;
|
||||
color: #00ffff;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
NEXT_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
font-size: 18px;
|
||||
padding: 10px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 10px;
|
||||
background-color: cyan;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
"""
|
||||
|
|
@ -48,7 +48,7 @@ class TicketingWorkerThread(QThread):
|
|||
)
|
||||
self.invoice_ready.emit(result)
|
||||
elif self.action == 'CHECK_PAID':
|
||||
result = check_if_paid(self.params['temp_billing_code'], ticket_observer, connection_observer)
|
||||
result = check_if_paid(self.params['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'):
|
||||
|
|
|
|||