history_list.py (36531B)
1 #!/usr/bin/env python 2 # 3 # Electrum - lightweight Bitcoin client 4 # Copyright (C) 2015 Thomas Voegtlin 5 # 6 # Permission is hereby granted, free of charge, to any person 7 # obtaining a copy of this software and associated documentation files 8 # (the "Software"), to deal in the Software without restriction, 9 # including without limitation the rights to use, copy, modify, merge, 10 # publish, distribute, sublicense, and/or sell copies of the Software, 11 # and to permit persons to whom the Software is furnished to do so, 12 # subject to the following conditions: 13 # 14 # The above copyright notice and this permission notice shall be 15 # included in all copies or substantial portions of the Software. 16 # 17 # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 21 # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 22 # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 23 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 # SOFTWARE. 25 26 import os 27 import sys 28 import datetime 29 from datetime import date 30 from typing import TYPE_CHECKING, Tuple, Dict 31 import threading 32 from enum import IntEnum 33 from decimal import Decimal 34 35 from PyQt5.QtGui import QMouseEvent, QFont, QBrush, QColor 36 from PyQt5.QtCore import (Qt, QPersistentModelIndex, QModelIndex, QAbstractItemModel, 37 QSortFilterProxyModel, QVariant, QItemSelectionModel, QDate, QPoint) 38 from PyQt5.QtWidgets import (QMenu, QHeaderView, QLabel, QMessageBox, 39 QPushButton, QComboBox, QVBoxLayout, QCalendarWidget, 40 QGridLayout) 41 42 from electrum.address_synchronizer import TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE 43 from electrum.i18n import _ 44 from electrum.util import (block_explorer_URL, profiler, TxMinedInfo, 45 OrderedDictWithIndex, timestamp_to_datetime, 46 Satoshis, Fiat, format_time) 47 from electrum.logging import get_logger, Logger 48 49 from .custom_model import CustomNode, CustomModel 50 from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton, 51 filename_field, MyTreeView, AcceptFileDragDrop, WindowModalDialog, 52 CloseButton, webopen) 53 54 if TYPE_CHECKING: 55 from electrum.wallet import Abstract_Wallet 56 from .main_window import ElectrumWindow 57 58 59 _logger = get_logger(__name__) 60 61 62 try: 63 from electrum.plot import plot_history, NothingToPlotException 64 except: 65 _logger.info("could not import electrum.plot. This feature needs matplotlib to be installed.") 66 plot_history = None 67 68 # note: this list needs to be kept in sync with another in kivy 69 TX_ICONS = [ 70 "unconfirmed.png", 71 "warning.png", 72 "unconfirmed.png", 73 "offline_tx.png", 74 "clock1.png", 75 "clock2.png", 76 "clock3.png", 77 "clock4.png", 78 "clock5.png", 79 "confirmed.png", 80 ] 81 82 class HistoryColumns(IntEnum): 83 STATUS = 0 84 DESCRIPTION = 1 85 AMOUNT = 2 86 BALANCE = 3 87 FIAT_VALUE = 4 88 FIAT_ACQ_PRICE = 5 89 FIAT_CAP_GAINS = 6 90 TXID = 7 91 92 class HistorySortModel(QSortFilterProxyModel): 93 def lessThan(self, source_left: QModelIndex, source_right: QModelIndex): 94 item1 = self.sourceModel().data(source_left, Qt.UserRole) 95 item2 = self.sourceModel().data(source_right, Qt.UserRole) 96 if item1 is None or item2 is None: 97 raise Exception(f'UserRole not set for column {source_left.column()}') 98 v1 = item1.value() 99 v2 = item2.value() 100 if v1 is None or isinstance(v1, Decimal) and v1.is_nan(): v1 = -float("inf") 101 if v2 is None or isinstance(v2, Decimal) and v2.is_nan(): v2 = -float("inf") 102 try: 103 return v1 < v2 104 except: 105 return False 106 107 def get_item_key(tx_item): 108 return tx_item.get('txid') or tx_item['payment_hash'] 109 110 111 class HistoryNode(CustomNode): 112 113 def get_data_for_role(self, index: QModelIndex, role: Qt.ItemDataRole) -> QVariant: 114 # note: this method is performance-critical. 115 # it is called a lot, and so must run extremely fast. 116 assert index.isValid() 117 col = index.column() 118 window = self.model.parent 119 tx_item = self.get_data() 120 is_lightning = tx_item.get('lightning', False) 121 timestamp = tx_item['timestamp'] 122 if is_lightning: 123 status = 0 124 if timestamp is None: 125 status_str = 'unconfirmed' 126 else: 127 status_str = format_time(int(timestamp)) 128 else: 129 tx_hash = tx_item['txid'] 130 conf = tx_item['confirmations'] 131 try: 132 status, status_str = self.model.tx_status_cache[tx_hash] 133 except KeyError: 134 tx_mined_info = self.model.tx_mined_info_from_tx_item(tx_item) 135 status, status_str = window.wallet.get_tx_status(tx_hash, tx_mined_info) 136 137 if role == Qt.UserRole: 138 # for sorting 139 d = { 140 HistoryColumns.STATUS: 141 # respect sort order of self.transactions (wallet.get_full_history) 142 -index.row(), 143 HistoryColumns.DESCRIPTION: 144 tx_item['label'] if 'label' in tx_item else None, 145 HistoryColumns.AMOUNT: 146 (tx_item['bc_value'].value if 'bc_value' in tx_item else 0)\ 147 + (tx_item['ln_value'].value if 'ln_value' in tx_item else 0), 148 HistoryColumns.BALANCE: 149 (tx_item['balance'].value if 'balance' in tx_item else 0), 150 HistoryColumns.FIAT_VALUE: 151 tx_item['fiat_value'].value if 'fiat_value' in tx_item else None, 152 HistoryColumns.FIAT_ACQ_PRICE: 153 tx_item['acquisition_price'].value if 'acquisition_price' in tx_item else None, 154 HistoryColumns.FIAT_CAP_GAINS: 155 tx_item['capital_gain'].value if 'capital_gain' in tx_item else None, 156 HistoryColumns.TXID: tx_hash if not is_lightning else None, 157 } 158 return QVariant(d[col]) 159 if role not in (Qt.DisplayRole, Qt.EditRole): 160 if col == HistoryColumns.STATUS and role == Qt.DecorationRole: 161 icon = "lightning" if is_lightning else TX_ICONS[status] 162 return QVariant(read_QIcon(icon)) 163 elif col == HistoryColumns.STATUS and role == Qt.ToolTipRole: 164 if is_lightning: 165 msg = 'lightning transaction' 166 else: # on-chain 167 if tx_item['height'] == TX_HEIGHT_LOCAL: 168 # note: should we also explain double-spends? 169 msg = _("This transaction is only available on your local machine.\n" 170 "The currently connected server does not know about it.\n" 171 "You can either broadcast it now, or simply remove it.") 172 else: 173 msg = str(conf) + _(" confirmation" + ("s" if conf != 1 else "")) 174 return QVariant(msg) 175 elif col > HistoryColumns.DESCRIPTION and role == Qt.TextAlignmentRole: 176 return QVariant(int(Qt.AlignRight | Qt.AlignVCenter)) 177 elif col > HistoryColumns.DESCRIPTION and role == Qt.FontRole: 178 monospace_font = QFont(MONOSPACE_FONT) 179 return QVariant(monospace_font) 180 #elif col == HistoryColumns.DESCRIPTION and role == Qt.DecorationRole and not is_lightning\ 181 # and self.parent.wallet.invoices.paid.get(tx_hash): 182 # return QVariant(read_QIcon("seal")) 183 elif col in (HistoryColumns.DESCRIPTION, HistoryColumns.AMOUNT) \ 184 and role == Qt.ForegroundRole and tx_item['value'].value < 0: 185 red_brush = QBrush(QColor("#BC1E1E")) 186 return QVariant(red_brush) 187 elif col == HistoryColumns.FIAT_VALUE and role == Qt.ForegroundRole \ 188 and not tx_item.get('fiat_default') and tx_item.get('fiat_value') is not None: 189 blue_brush = QBrush(QColor("#1E1EFF")) 190 return QVariant(blue_brush) 191 return QVariant() 192 if col == HistoryColumns.STATUS: 193 return QVariant(status_str) 194 elif col == HistoryColumns.DESCRIPTION and 'label' in tx_item: 195 return QVariant(tx_item['label']) 196 elif col == HistoryColumns.AMOUNT: 197 bc_value = tx_item['bc_value'].value if 'bc_value' in tx_item else 0 198 ln_value = tx_item['ln_value'].value if 'ln_value' in tx_item else 0 199 value = bc_value + ln_value 200 v_str = window.format_amount(value, is_diff=True, whitespaces=True) 201 return QVariant(v_str) 202 elif col == HistoryColumns.BALANCE: 203 balance = tx_item['balance'].value 204 balance_str = window.format_amount(balance, whitespaces=True) 205 return QVariant(balance_str) 206 elif col == HistoryColumns.FIAT_VALUE and 'fiat_value' in tx_item: 207 value_str = window.fx.format_fiat(tx_item['fiat_value'].value) 208 return QVariant(value_str) 209 elif col == HistoryColumns.FIAT_ACQ_PRICE and \ 210 tx_item['value'].value < 0 and 'acquisition_price' in tx_item: 211 # fixme: should use is_mine 212 acq = tx_item['acquisition_price'].value 213 return QVariant(window.fx.format_fiat(acq)) 214 elif col == HistoryColumns.FIAT_CAP_GAINS and 'capital_gain' in tx_item: 215 cg = tx_item['capital_gain'].value 216 return QVariant(window.fx.format_fiat(cg)) 217 elif col == HistoryColumns.TXID: 218 return QVariant(tx_hash) if not is_lightning else QVariant('') 219 return QVariant() 220 221 222 class HistoryModel(CustomModel, Logger): 223 224 def __init__(self, parent: 'ElectrumWindow'): 225 CustomModel.__init__(self, parent, len(HistoryColumns)) 226 Logger.__init__(self) 227 self.parent = parent 228 self.view = None # type: HistoryList 229 self.transactions = OrderedDictWithIndex() 230 self.tx_status_cache = {} # type: Dict[str, Tuple[int, str]] 231 232 def set_view(self, history_list: 'HistoryList'): 233 # FIXME HistoryModel and HistoryList mutually depend on each other. 234 # After constructing both, this method needs to be called. 235 self.view = history_list # type: HistoryList 236 self.set_visibility_of_columns() 237 238 def update_label(self, index): 239 tx_item = index.internalPointer().get_data() 240 tx_item['label'] = self.parent.wallet.get_label_for_txid(get_item_key(tx_item)) 241 topLeft = bottomRight = self.createIndex(index.row(), HistoryColumns.DESCRIPTION) 242 self.dataChanged.emit(topLeft, bottomRight, [Qt.DisplayRole]) 243 self.parent.utxo_list.update() 244 245 def get_domain(self): 246 """Overridden in address_dialog.py""" 247 return self.parent.wallet.get_addresses() 248 249 def should_include_lightning_payments(self) -> bool: 250 """Overridden in address_dialog.py""" 251 return True 252 253 @profiler 254 def refresh(self, reason: str): 255 self.logger.info(f"refreshing... reason: {reason}") 256 assert self.parent.gui_thread == threading.current_thread(), 'must be called from GUI thread' 257 assert self.view, 'view not set' 258 if self.view.maybe_defer_update(): 259 return 260 selected = self.view.selectionModel().currentIndex() 261 selected_row = None 262 if selected: 263 selected_row = selected.row() 264 fx = self.parent.fx 265 if fx: fx.history_used_spot = False 266 wallet = self.parent.wallet 267 self.set_visibility_of_columns() 268 transactions = wallet.get_full_history( 269 self.parent.fx, 270 onchain_domain=self.get_domain(), 271 include_lightning=self.should_include_lightning_payments()) 272 if transactions == self.transactions: 273 return 274 old_length = self._root.childCount() 275 if old_length != 0: 276 self.beginRemoveRows(QModelIndex(), 0, old_length) 277 self.transactions.clear() 278 self._root = HistoryNode(self, None) 279 self.endRemoveRows() 280 parents = {} 281 for tx_item in transactions.values(): 282 node = HistoryNode(self, tx_item) 283 group_id = tx_item.get('group_id') 284 if group_id is None: 285 self._root.addChild(node) 286 else: 287 parent = parents.get(group_id) 288 if parent is None: 289 # create parent if it does not exist 290 self._root.addChild(node) 291 parents[group_id] = node 292 else: 293 # if parent has no children, create two children 294 if parent.childCount() == 0: 295 child_data = dict(parent.get_data()) 296 node1 = HistoryNode(self, child_data) 297 parent.addChild(node1) 298 parent._data['label'] = child_data.get('group_label') 299 parent._data['bc_value'] = child_data.get('bc_value', Satoshis(0)) 300 parent._data['ln_value'] = child_data.get('ln_value', Satoshis(0)) 301 # add child to parent 302 parent.addChild(node) 303 # update parent data 304 parent._data['balance'] = tx_item['balance'] 305 parent._data['value'] += tx_item['value'] 306 if 'group_label' in tx_item: 307 parent._data['label'] = tx_item['group_label'] 308 if 'bc_value' in tx_item: 309 parent._data['bc_value'] += tx_item['bc_value'] 310 if 'ln_value' in tx_item: 311 parent._data['ln_value'] += tx_item['ln_value'] 312 if 'fiat_value' in tx_item: 313 parent._data['fiat_value'] += tx_item['fiat_value'] 314 if tx_item.get('txid') == group_id: 315 parent._data['lightning'] = False 316 parent._data['txid'] = tx_item['txid'] 317 parent._data['timestamp'] = tx_item['timestamp'] 318 parent._data['height'] = tx_item['height'] 319 parent._data['confirmations'] = tx_item['confirmations'] 320 321 new_length = self._root.childCount() 322 self.beginInsertRows(QModelIndex(), 0, new_length-1) 323 self.transactions = transactions 324 self.endInsertRows() 325 326 if selected_row: 327 self.view.selectionModel().select(self.createIndex(selected_row, 0), QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent) 328 self.view.filter() 329 # update time filter 330 if not self.view.years and self.transactions: 331 start_date = date.today() 332 end_date = date.today() 333 if len(self.transactions) > 0: 334 start_date = self.transactions.value_from_pos(0).get('date') or start_date 335 end_date = self.transactions.value_from_pos(len(self.transactions) - 1).get('date') or end_date 336 self.view.years = [str(i) for i in range(start_date.year, end_date.year + 1)] 337 self.view.period_combo.insertItems(1, self.view.years) 338 # update tx_status_cache 339 self.tx_status_cache.clear() 340 for txid, tx_item in self.transactions.items(): 341 if not tx_item.get('lightning', False): 342 tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) 343 self.tx_status_cache[txid] = self.parent.wallet.get_tx_status(txid, tx_mined_info) 344 345 def set_visibility_of_columns(self): 346 def set_visible(col: int, b: bool): 347 self.view.showColumn(col) if b else self.view.hideColumn(col) 348 # txid 349 set_visible(HistoryColumns.TXID, False) 350 # fiat 351 history = self.parent.fx.show_history() 352 cap_gains = self.parent.fx.get_history_capital_gains_config() 353 set_visible(HistoryColumns.FIAT_VALUE, history) 354 set_visible(HistoryColumns.FIAT_ACQ_PRICE, history and cap_gains) 355 set_visible(HistoryColumns.FIAT_CAP_GAINS, history and cap_gains) 356 357 def update_fiat(self, idx): 358 tx_item = idx.internalPointer().get_data() 359 txid = tx_item['txid'] 360 fee = tx_item.get('fee') 361 value = tx_item['value'].value 362 fiat_fields = self.parent.wallet.get_tx_item_fiat( 363 tx_hash=txid, amount_sat=value, fx=self.parent.fx, tx_fee=fee.value if fee else None) 364 tx_item.update(fiat_fields) 365 self.dataChanged.emit(idx, idx, [Qt.DisplayRole, Qt.ForegroundRole]) 366 367 def update_tx_mined_status(self, tx_hash: str, tx_mined_info: TxMinedInfo): 368 try: 369 row = self.transactions.pos_from_key(tx_hash) 370 tx_item = self.transactions[tx_hash] 371 except KeyError: 372 return 373 self.tx_status_cache[tx_hash] = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info) 374 tx_item.update({ 375 'confirmations': tx_mined_info.conf, 376 'timestamp': tx_mined_info.timestamp, 377 'txpos_in_block': tx_mined_info.txpos, 378 'date': timestamp_to_datetime(tx_mined_info.timestamp), 379 }) 380 topLeft = self.createIndex(row, 0) 381 bottomRight = self.createIndex(row, len(HistoryColumns) - 1) 382 self.dataChanged.emit(topLeft, bottomRight) 383 384 def on_fee_histogram(self): 385 for tx_hash, tx_item in list(self.transactions.items()): 386 if tx_item.get('lightning'): 387 continue 388 tx_mined_info = self.tx_mined_info_from_tx_item(tx_item) 389 if tx_mined_info.conf > 0: 390 # note: we could actually break here if we wanted to rely on the order of txns in self.transactions 391 continue 392 self.update_tx_mined_status(tx_hash, tx_mined_info) 393 394 def headerData(self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole): 395 assert orientation == Qt.Horizontal 396 if role != Qt.DisplayRole: 397 return None 398 fx = self.parent.fx 399 fiat_title = 'n/a fiat value' 400 fiat_acq_title = 'n/a fiat acquisition price' 401 fiat_cg_title = 'n/a fiat capital gains' 402 if fx and fx.show_history(): 403 fiat_title = '%s '%fx.ccy + _('Value') 404 fiat_acq_title = '%s '%fx.ccy + _('Acquisition price') 405 fiat_cg_title = '%s '%fx.ccy + _('Capital Gains') 406 return { 407 HistoryColumns.STATUS: _('Date'), 408 HistoryColumns.DESCRIPTION: _('Description'), 409 HistoryColumns.AMOUNT: _('Amount'), 410 HistoryColumns.BALANCE: _('Balance'), 411 HistoryColumns.FIAT_VALUE: fiat_title, 412 HistoryColumns.FIAT_ACQ_PRICE: fiat_acq_title, 413 HistoryColumns.FIAT_CAP_GAINS: fiat_cg_title, 414 HistoryColumns.TXID: 'TXID', 415 }[section] 416 417 def flags(self, idx): 418 extra_flags = Qt.NoItemFlags # type: Qt.ItemFlag 419 if idx.column() in self.view.editable_columns: 420 extra_flags |= Qt.ItemIsEditable 421 return super().flags(idx) | int(extra_flags) 422 423 @staticmethod 424 def tx_mined_info_from_tx_item(tx_item): 425 tx_mined_info = TxMinedInfo(height=tx_item['height'], 426 conf=tx_item['confirmations'], 427 timestamp=tx_item['timestamp']) 428 return tx_mined_info 429 430 class HistoryList(MyTreeView, AcceptFileDragDrop): 431 filter_columns = [HistoryColumns.STATUS, 432 HistoryColumns.DESCRIPTION, 433 HistoryColumns.AMOUNT, 434 HistoryColumns.TXID] 435 436 def tx_item_from_proxy_row(self, proxy_row): 437 hm_idx = self.model().mapToSource(self.model().index(proxy_row, 0)) 438 return hm_idx.internalPointer().get_data() 439 440 def should_hide(self, proxy_row): 441 if self.start_timestamp and self.end_timestamp: 442 tx_item = self.tx_item_from_proxy_row(proxy_row) 443 date = tx_item['date'] 444 if date: 445 in_interval = self.start_timestamp <= date <= self.end_timestamp 446 if not in_interval: 447 return True 448 return False 449 450 def __init__(self, parent, model: HistoryModel): 451 super().__init__(parent, self.create_menu, stretch_column=HistoryColumns.DESCRIPTION) 452 self.config = parent.config 453 self.hm = model 454 self.proxy = HistorySortModel(self) 455 self.proxy.setSourceModel(model) 456 self.setModel(self.proxy) 457 AcceptFileDragDrop.__init__(self, ".txn") 458 self.setSortingEnabled(True) 459 self.start_timestamp = None 460 self.end_timestamp = None 461 self.years = [] 462 self.create_toolbar_buttons() 463 self.wallet = self.parent.wallet # type: Abstract_Wallet 464 self.sortByColumn(HistoryColumns.STATUS, Qt.AscendingOrder) 465 self.editable_columns |= {HistoryColumns.FIAT_VALUE} 466 self.setRootIsDecorated(True) 467 self.header().setStretchLastSection(False) 468 for col in HistoryColumns: 469 sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents 470 self.header().setSectionResizeMode(col, sm) 471 472 def update(self): 473 self.hm.refresh('HistoryList.update()') 474 475 def format_date(self, d): 476 return str(datetime.date(d.year, d.month, d.day)) if d else _('None') 477 478 def on_combo(self, x): 479 s = self.period_combo.itemText(x) 480 x = s == _('Custom') 481 self.start_button.setEnabled(x) 482 self.end_button.setEnabled(x) 483 if s == _('All'): 484 self.start_timestamp = None 485 self.end_timestamp = None 486 self.start_button.setText("-") 487 self.end_button.setText("-") 488 else: 489 try: 490 year = int(s) 491 except: 492 return 493 self.start_timestamp = start_date = datetime.datetime(year, 1, 1) 494 self.end_timestamp = end_date = datetime.datetime(year+1, 1, 1) 495 self.start_button.setText(_('From') + ' ' + self.format_date(start_date)) 496 self.end_button.setText(_('To') + ' ' + self.format_date(end_date)) 497 self.hide_rows() 498 499 def create_toolbar_buttons(self): 500 self.period_combo = QComboBox() 501 self.start_button = QPushButton('-') 502 self.start_button.pressed.connect(self.select_start_date) 503 self.start_button.setEnabled(False) 504 self.end_button = QPushButton('-') 505 self.end_button.pressed.connect(self.select_end_date) 506 self.end_button.setEnabled(False) 507 self.period_combo.addItems([_('All'), _('Custom')]) 508 self.period_combo.activated.connect(self.on_combo) 509 510 def get_toolbar_buttons(self): 511 return self.period_combo, self.start_button, self.end_button 512 513 def on_hide_toolbar(self): 514 self.start_timestamp = None 515 self.end_timestamp = None 516 self.hide_rows() 517 518 def save_toolbar_state(self, state, config): 519 config.set_key('show_toolbar_history', state) 520 521 def select_start_date(self): 522 self.start_timestamp = self.select_date(self.start_button) 523 self.hide_rows() 524 525 def select_end_date(self): 526 self.end_timestamp = self.select_date(self.end_button) 527 self.hide_rows() 528 529 def select_date(self, button): 530 d = WindowModalDialog(self, _("Select date")) 531 d.setMinimumSize(600, 150) 532 d.date = None 533 vbox = QVBoxLayout() 534 def on_date(date): 535 d.date = date 536 cal = QCalendarWidget() 537 cal.setGridVisible(True) 538 cal.clicked[QDate].connect(on_date) 539 vbox.addWidget(cal) 540 vbox.addLayout(Buttons(OkButton(d), CancelButton(d))) 541 d.setLayout(vbox) 542 if d.exec_(): 543 if d.date is None: 544 return None 545 date = d.date.toPyDate() 546 button.setText(self.format_date(date)) 547 return datetime.datetime(date.year, date.month, date.day) 548 549 def show_summary(self): 550 h = self.parent.wallet.get_detailed_history()['summary'] 551 if not h: 552 self.parent.show_message(_("Nothing to summarize.")) 553 return 554 start_date = h.get('start_date') 555 end_date = h.get('end_date') 556 format_amount = lambda x: self.parent.format_amount(x.value) + ' ' + self.parent.base_unit() 557 d = WindowModalDialog(self, _("Summary")) 558 d.setMinimumSize(600, 150) 559 vbox = QVBoxLayout() 560 grid = QGridLayout() 561 grid.addWidget(QLabel(_("Start")), 0, 0) 562 grid.addWidget(QLabel(self.format_date(start_date)), 0, 1) 563 grid.addWidget(QLabel(str(h.get('fiat_start_value')) + '/BTC'), 0, 2) 564 grid.addWidget(QLabel(_("Initial balance")), 1, 0) 565 grid.addWidget(QLabel(format_amount(h['start_balance'])), 1, 1) 566 grid.addWidget(QLabel(str(h.get('fiat_start_balance'))), 1, 2) 567 grid.addWidget(QLabel(_("End")), 2, 0) 568 grid.addWidget(QLabel(self.format_date(end_date)), 2, 1) 569 grid.addWidget(QLabel(str(h.get('fiat_end_value')) + '/BTC'), 2, 2) 570 grid.addWidget(QLabel(_("Final balance")), 4, 0) 571 grid.addWidget(QLabel(format_amount(h['end_balance'])), 4, 1) 572 grid.addWidget(QLabel(str(h.get('fiat_end_balance'))), 4, 2) 573 grid.addWidget(QLabel(_("Income")), 5, 0) 574 grid.addWidget(QLabel(format_amount(h.get('incoming'))), 5, 1) 575 grid.addWidget(QLabel(str(h.get('fiat_incoming'))), 5, 2) 576 grid.addWidget(QLabel(_("Expenditures")), 6, 0) 577 grid.addWidget(QLabel(format_amount(h.get('outgoing'))), 6, 1) 578 grid.addWidget(QLabel(str(h.get('fiat_outgoing'))), 6, 2) 579 grid.addWidget(QLabel(_("Capital gains")), 7, 0) 580 grid.addWidget(QLabel(str(h.get('fiat_capital_gains'))), 7, 2) 581 grid.addWidget(QLabel(_("Unrealized gains")), 8, 0) 582 grid.addWidget(QLabel(str(h.get('fiat_unrealized_gains', ''))), 8, 2) 583 vbox.addLayout(grid) 584 vbox.addLayout(Buttons(CloseButton(d))) 585 d.setLayout(vbox) 586 d.exec_() 587 588 def plot_history_dialog(self): 589 if plot_history is None: 590 self.parent.show_message( 591 _("Can't plot history.") + '\n' + 592 _("Perhaps some dependencies are missing...") + " (matplotlib?)") 593 return 594 try: 595 plt = plot_history(list(self.hm.transactions.values())) 596 plt.show() 597 except NothingToPlotException as e: 598 self.parent.show_message(str(e)) 599 600 def on_edited(self, index, user_role, text): 601 index = self.model().mapToSource(index) 602 tx_item = index.internalPointer().get_data() 603 column = index.column() 604 key = get_item_key(tx_item) 605 if column == HistoryColumns.DESCRIPTION: 606 if self.wallet.set_label(key, text): #changed 607 self.hm.update_label(index) 608 self.parent.update_completions() 609 elif column == HistoryColumns.FIAT_VALUE: 610 self.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, tx_item['value'].value) 611 value = tx_item['value'].value 612 if value is not None: 613 self.hm.update_fiat(index) 614 else: 615 assert False 616 617 def mouseDoubleClickEvent(self, event: QMouseEvent): 618 idx = self.indexAt(event.pos()) 619 if not idx.isValid(): 620 return 621 tx_item = self.tx_item_from_proxy_row(idx.row()) 622 if self.hm.flags(self.model().mapToSource(idx)) & Qt.ItemIsEditable: 623 super().mouseDoubleClickEvent(event) 624 else: 625 if tx_item.get('lightning'): 626 if tx_item['type'] == 'payment': 627 self.parent.show_lightning_transaction(tx_item) 628 return 629 tx_hash = tx_item['txid'] 630 tx = self.wallet.db.get_transaction(tx_hash) 631 if not tx: 632 return 633 self.show_transaction(tx_item, tx) 634 635 def show_transaction(self, tx_item, tx): 636 tx_hash = tx_item['txid'] 637 label = self.wallet.get_label_for_txid(tx_hash) or None # prefer 'None' if not defined (force tx dialog to hide Description field if missing) 638 self.parent.show_transaction(tx, tx_desc=label) 639 640 def add_copy_menu(self, menu, idx): 641 cc = menu.addMenu(_("Copy")) 642 for column in HistoryColumns: 643 if self.isColumnHidden(column): 644 continue 645 column_title = self.hm.headerData(column, Qt.Horizontal, Qt.DisplayRole) 646 idx2 = idx.sibling(idx.row(), column) 647 column_data = (self.hm.data(idx2, Qt.DisplayRole).value() or '').strip() 648 cc.addAction( 649 column_title, 650 lambda text=column_data, title=column_title: 651 self.place_text_on_clipboard(text, title=title)) 652 return cc 653 654 def create_menu(self, position: QPoint): 655 org_idx: QModelIndex = self.indexAt(position) 656 idx = self.proxy.mapToSource(org_idx) 657 if not idx.isValid(): 658 # can happen e.g. before list is populated for the first time 659 return 660 tx_item = idx.internalPointer().get_data() 661 if tx_item.get('lightning') and tx_item['type'] == 'payment': 662 menu = QMenu() 663 menu.addAction(_("View Payment"), lambda: self.parent.show_lightning_transaction(tx_item)) 664 cc = self.add_copy_menu(menu, idx) 665 cc.addAction(_("Payment Hash"), lambda: self.place_text_on_clipboard(tx_item['payment_hash'], title="Payment Hash")) 666 cc.addAction(_("Preimage"), lambda: self.place_text_on_clipboard(tx_item['preimage'], title="Preimage")) 667 key = tx_item['payment_hash'] 668 log = self.wallet.lnworker.logs.get(key) 669 if log: 670 menu.addAction(_("View log"), lambda: self.parent.invoice_list.show_log(key, log)) 671 menu.exec_(self.viewport().mapToGlobal(position)) 672 return 673 tx_hash = tx_item['txid'] 674 if tx_item.get('lightning'): 675 tx = self.wallet.lnworker.lnwatcher.db.get_transaction(tx_hash) 676 else: 677 tx = self.wallet.db.get_transaction(tx_hash) 678 if not tx: 679 return 680 tx_URL = block_explorer_URL(self.config, 'tx', tx_hash) 681 tx_details = self.wallet.get_tx_info(tx) 682 is_unconfirmed = tx_details.tx_mined_status.height <= 0 683 menu = QMenu() 684 if tx_details.can_remove: 685 menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash)) 686 cc = self.add_copy_menu(menu, idx) 687 cc.addAction(_("Transaction ID"), lambda: self.place_text_on_clipboard(tx_hash, title="TXID")) 688 for c in self.editable_columns: 689 if self.isColumnHidden(c): continue 690 label = self.hm.headerData(c, Qt.Horizontal, Qt.DisplayRole) 691 # TODO use siblingAtColumn when min Qt version is >=5.11 692 persistent = QPersistentModelIndex(org_idx.sibling(org_idx.row(), c)) 693 menu.addAction(_("Edit {}").format(label), lambda p=persistent: self.edit(QModelIndex(p))) 694 menu.addAction(_("View Transaction"), lambda: self.show_transaction(tx_item, tx)) 695 channel_id = tx_item.get('channel_id') 696 if channel_id: 697 menu.addAction(_("View Channel"), lambda: self.parent.show_channel(bytes.fromhex(channel_id))) 698 if is_unconfirmed and tx: 699 if tx_details.can_bump: 700 menu.addAction(_("Increase fee"), lambda: self.parent.bump_fee_dialog(tx)) 701 else: 702 if tx_details.can_cpfp: 703 menu.addAction(_("Child pays for parent"), lambda: self.parent.cpfp_dialog(tx)) 704 if tx_details.can_dscancel: 705 menu.addAction(_("Cancel (double-spend)"), lambda: self.parent.dscancel_dialog(tx)) 706 invoices = self.wallet.get_relevant_invoices_for_tx(tx) 707 if len(invoices) == 1: 708 menu.addAction(_("View invoice"), lambda inv=invoices[0]: self.parent.show_onchain_invoice(inv)) 709 elif len(invoices) > 1: 710 menu_invs = menu.addMenu(_("Related invoices")) 711 for inv in invoices: 712 menu_invs.addAction(_("View invoice"), lambda inv=inv: self.parent.show_onchain_invoice(inv)) 713 if tx_URL: 714 menu.addAction(_("View on block explorer"), lambda: webopen(tx_URL)) 715 menu.exec_(self.viewport().mapToGlobal(position)) 716 717 def remove_local_tx(self, tx_hash: str): 718 num_child_txs = len(self.wallet.get_depending_transactions(tx_hash)) 719 question = _("Are you sure you want to remove this transaction?") 720 if num_child_txs > 0: 721 question = (_("Are you sure you want to remove this transaction and {} child transactions?") 722 .format(num_child_txs)) 723 if not self.parent.question(msg=question, 724 title=_("Please confirm")): 725 return 726 self.wallet.remove_transaction(tx_hash) 727 self.wallet.save_db() 728 # need to update at least: history_list, utxo_list, address_list 729 self.parent.need_update.set() 730 731 def onFileAdded(self, fn): 732 try: 733 with open(fn) as f: 734 tx = self.parent.tx_from_text(f.read()) 735 except IOError as e: 736 self.parent.show_error(e) 737 return 738 if not tx: 739 return 740 self.parent.save_transaction_into_wallet(tx) 741 742 def export_history_dialog(self): 743 d = WindowModalDialog(self, _('Export History')) 744 d.setMinimumSize(400, 200) 745 vbox = QVBoxLayout(d) 746 defaultname = os.path.expanduser('~/electrum-history.csv') 747 select_msg = _('Select file to export your wallet transactions to') 748 hbox, filename_e, csv_button = filename_field(self, self.config, defaultname, select_msg) 749 vbox.addLayout(hbox) 750 vbox.addStretch(1) 751 hbox = Buttons(CancelButton(d), OkButton(d, _('Export'))) 752 vbox.addLayout(hbox) 753 #run_hook('export_history_dialog', self, hbox) 754 self.update() 755 if not d.exec_(): 756 return 757 filename = filename_e.text() 758 if not filename: 759 return 760 try: 761 self.do_export_history(filename, csv_button.isChecked()) 762 except (IOError, os.error) as reason: 763 export_error_label = _("Electrum was unable to produce a transaction export.") 764 self.parent.show_critical(export_error_label + "\n" + str(reason), title=_("Unable to export history")) 765 return 766 self.parent.show_message(_("Your wallet history has been successfully exported.")) 767 768 def do_export_history(self, file_name, is_csv): 769 hist = self.wallet.get_detailed_history(fx=self.parent.fx) 770 txns = hist['transactions'] 771 lines = [] 772 if is_csv: 773 for item in txns: 774 lines.append([item['txid'], 775 item.get('label', ''), 776 item['confirmations'], 777 item['bc_value'], 778 item.get('fiat_value', ''), 779 item.get('fee', ''), 780 item.get('fiat_fee', ''), 781 item['date']]) 782 with open(file_name, "w+", encoding='utf-8') as f: 783 if is_csv: 784 import csv 785 transaction = csv.writer(f, lineterminator='\n') 786 transaction.writerow(["transaction_hash", 787 "label", 788 "confirmations", 789 "value", 790 "fiat_value", 791 "fee", 792 "fiat_fee", 793 "timestamp"]) 794 for line in lines: 795 transaction.writerow(line) 796 else: 797 from electrum.util import json_encode 798 f.write(json_encode(txns)) 799 800 def get_text_and_userrole_from_coordinate(self, row, col): 801 idx = self.model().mapToSource(self.model().index(row, col)) 802 tx_item = idx.internalPointer().get_data() 803 return self.hm.data(idx, Qt.DisplayRole).value(), get_item_key(tx_item)