electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

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)