thistory_list.py - electrum - Electrum Bitcoin wallet
HTML git clone https://git.parazyd.org/electrum
DIR Log
DIR Files
DIR Refs
DIR Submodules
---
thistory_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)