Show account names in cashier
[bitcoin:spesmilo.git] / cashier.py
1 # -*- coding: utf-8 -*-
2 # Spesmilo -- Python Bitcoin user interface
3 # Copyright © 2011 Luke Dashjr
4 #
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation, version 3 only.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 import re
18 from time import time
19 from PySide.QtCore import *
20 from PySide.QtGui import *
21 import send
22 from settings import SpesmiloSettings, humanAmount, format_number, icon, style_item, disable_item
23
24 class FocusLineEdit(QLineEdit):
25     def __init__(self, text):
26         super(FocusLineEdit, self).__init__(text)
27         self.setReadOnly(True)
28         self.setMaxLength(40)
29
30     def mousePressEvent(self, event):
31         if event.button() == Qt.LeftButton:
32             self.setCursorPosition(100)
33             self.selectAll()
34             event.accept()
35         else:
36             super(FocusLineEdit, self).mousePressEvent(event)
37
38     def focusOutEvent(self, event):
39         event.accept()
40
41     def sizeHint(self):
42         sizeh = super(FocusLineEdit, self).sizeHint()
43         FM = self.fontMetrics()
44         aw = [FM.width(L) for L in '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz']
45         aw.sort()
46         mw = aw[30]
47         sizeh.setWidth(mw * self.maxLength())
48         return sizeh
49
50 class TransactionItem(QTableWidgetItem):
51     def __init__(self, text, align=Qt.AlignLeft):
52         super(TransactionItem, self).__init__(text)
53         self.setFlags(Qt.ItemIsEnabled)
54         self.setTextAlignment(align|Qt.AlignVCenter)
55
56 class TransactionsTable(QTableWidget):
57     # These are the proportions for the various columns
58     hedprops = (0x80, 0x70, 0x150, 0x68, 0)
59
60     def __init__(self):
61         super(TransactionsTable, self).__init__()
62
63         self.setColumnCount(5)
64         hedlabels = (self.tr('Status'),
65                      self.tr('Date'),
66                      self.tr('Transactions'),
67                      self.tr('Credits'),
68                      self.tr('Balance'))
69         self.setHorizontalHeaderLabels(hedlabels)
70         for i, sz in enumerate(self.hedprops):
71             self.horizontalHeader().resizeSection(i, sz)
72         self.hideColumn(4)
73
74         self.setSelectionBehavior(self.SelectRows)
75         self.setSelectionMode(self.NoSelection)
76         self.setFocusPolicy(Qt.NoFocus)
77         self.setAlternatingRowColors(True)
78         self.verticalHeader().hide()
79         self.setShowGrid(False)
80         self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
81         self.horizontalHeader().setStretchLastSection(True)
82
83     # Resize columns while maintaining proportions
84     def resizeEvent(self, event):
85         self_width = event.size().width()
86         total_prop_width = sum(self.hedprops)
87         newszs = [sz * self_width / total_prop_width for sz in self.hedprops]
88         for i, sz in enumerate(newszs):
89             self.horizontalHeader().resizeSection(i, sz)
90
91     def update_confirmation(self, i, increment, adjustment = True):
92         status_item = self.item(i, 0)
93         hastxt = len(status_item.text())
94         confirms = status_item.confirmations
95         if confirms is None:
96             return
97         category = status_item.category if hasattr(status_item, 'category') else None
98         if adjustment:
99             if self.confirmation_stage(category, confirms) < 0x100:
100                 return
101             confirms = confirms + increment
102         else:
103             if increment == confirms and hastxt:
104                 return
105             confirms = increment
106
107         row_disabled = False
108         stage = self.confirmation_stage(category, confirms)
109         sf = None
110         if stage:
111             if stage == 0x100:
112                 status = self.tr('Confirmed (%s)')
113             else:
114                 status = self.tr('Processing... (%s)')
115             if self.confirmation_stage(category, status_item.confirmations) < stage:
116                 sf = self.enable_table_item
117         else:
118             status = self.tr('Validating... (%s)')
119             if not hastxt:
120                 sf = self.disable_table_item
121         status %= (format_number(confirms),)
122
123         status_item.setText(status)
124         status_item.confirmations = confirms
125
126         if sf:
127             for j in range(0, 5):
128                 sf(self.item(i, j))
129
130     def update_transaction_time(self, row, unixtime):
131         date_formatter = QDateTime()
132         date_formatter.setTime_t(unixtime)
133         # we need to do this in parts to have a month name translation
134         # datetime = date_formatter.toString('hh:mm d ')
135         # datetime += self.tr(date_formatter.toString('MMM '))
136         # datetime += date_formatter.toString('yy')
137         datetime = date_formatter.toString('hh:mm d MMM yy')
138         date_item = self.item(row, 1)
139         date_item.setText(datetime)
140
141     def add_transaction_entry(self, transaction):
142         self.insertRow(0)
143         confirms = None
144         if 'confirmations' in transaction:
145             confirms = transaction['confirmations']
146         unixtime = transaction['time']
147         address = 'N/A'
148         if 'address' in transaction:
149             address = transaction['address']
150         credit =  transaction['amount']
151         balance = 'N/A'
152         category = transaction['category']
153
154         status_item = TransactionItem('')
155         if confirms is None:
156             status_item.setText('N/A')
157         status_item.confirmations = confirms
158         self.setItem(0, 0, status_item)
159
160         date_item = TransactionItem('')
161         self.setItem(0, 1, date_item)
162         self.update_transaction_time(0, unixtime)
163
164         if category == 'send':
165             description = self.tr('Sent to %s')%address
166         elif category == 'receive':
167             fullto = address
168             acct = transaction.get('account', None)
169             if acct:
170                                 fullto = "%s: %s" % (acct, address)
171             description = self.tr('Received to %s') % (fullto,)
172         elif category in ('generate', 'immature'):
173             description = self.tr('Generated')
174             status_item.category = category
175         elif category == 'move':
176             description = self.tr('Moved')
177         else:
178             description = self.tr('Unknown')
179         trans_item = TransactionItem(description)
180         self.setItem(0, 2, trans_item)
181
182         credits_item = TransactionItem(humanAmount(credit), Qt.AlignRight)
183         credits_item.amount = credit
184         self.setItem(0, 3, credits_item)
185
186         balance_item = TransactionItem(humanAmount(balance), Qt.AlignRight)
187         self.setItem(0, 4, balance_item)
188
189         self.update_confirmation(0, confirms, adjustment=False)
190
191         return status_item
192
193     def move_row(self, from_row, to_row):
194         items = []
195         CC = self.columnCount()
196         for col in xrange(CC):
197             items.append(self.takeItem(from_row, col))
198         self.removeRow(from_row)
199         self.insertRow(to_row)
200         for col in xrange(CC):
201             self.setItem(to_row, col, items[col])
202
203     def update_amounts(self):
204         for i in xrange(self.rowCount()):
205             credits_item = self.item(i, 3)
206             credits_item.setText(humanAmount(credits_item.amount))
207
208     def update_counters(self):
209         for i in xrange(self.rowCount()):
210             status_item = self.item(i, 0)
211             status_item.setText('')
212             self.update_confirmation(i, status_item.confirmations, adjustment=False)
213
214     def update_confirmations(self, increment, adjustment = True):
215         if increment == 0 and adjustment:
216             return
217         for i in range(0, self.rowCount()):
218             self.update_confirmation(i, increment, adjustment)
219
220     def enable_table_item(self, item):
221         if not hasattr(self, '_eti'):
222             # Must already be enabled :p
223             return
224         style_item(item, self._eti)
225
226     def disable_table_item(self, item):
227         want_old = not hasattr(self, '_eti')
228         rv = disable_item(item, want_old=want_old)
229         if want_old:
230             self._eti = rv
231
232 class Cashier(QDialog):
233     def __init__(self, core, clipboard, parent=None, tray=None):
234         super(Cashier, self).__init__(parent)
235         self.core = core
236         self.clipboard = clipboard
237         self.tray = tray
238
239         self.create_actions()
240         main_layout = QVBoxLayout(self)
241
242         youraddy = QHBoxLayout()
243         # Balance + Send button
244         self.balance_label = QLabel()
245         self.refresh_balance()
246         sendbtn = QToolButton(self)
247         sendbtn.setDefaultAction(self.send_act)
248         # Address + New button + Copy button
249         uraddtext = QLabel(self.tr('Your address:'))
250         self.addy = FocusLineEdit(self.core.default_address())
251         newaddybtn = QToolButton(self)
252         newaddybtn.setDefaultAction(self.newaddy_act)
253         copyaddybtn = QToolButton(self)
254         copyaddybtn.setDefaultAction(self.copyaddy_act)
255         # Add them to the layout
256         youraddy.addWidget(uraddtext)
257         youraddy.addWidget(self.addy)
258         youraddy.addWidget(newaddybtn)
259         youraddy.addWidget(copyaddybtn)
260         youraddy.addStretch()
261         settingsbtn = QToolButton(self)
262         settingsbtn.setDefaultAction(self.settings_act)
263         youraddy.addWidget(settingsbtn)
264         youraddy.addStretch()
265         youraddy.addWidget(self.balance_label)
266         youraddy.addWidget(sendbtn)
267         main_layout.addLayout(youraddy)
268
269         self.transactions_table = TransactionsTable()
270         main_layout.addWidget(self.transactions_table)
271
272         #webview = QWebView()
273         #webview.load('http://bitcoinwatch.com/')
274         #webview.setFixedSize(880, 300)
275         #mf = webview.page().mainFrame()
276         #mf.setScrollBarPolicy(Qt.Horizontal,
277         #                      Qt.ScrollBarAlwaysOff)
278         #mf.setScrollBarPolicy(Qt.Vertical,
279         #                      Qt.ScrollBarAlwaysOff)
280         #main_layout.addWidget(webview)
281
282         caption = self.tr('Spesmilo')
283         if parent is not None:
284             self.setWindowIcon(parent.bitcoin_icon)
285             if parent.caption:
286                 caption = parent.caption
287         self.setWindowTitle(caption)
288         self.setAttribute(Qt.WA_DeleteOnClose, False)
289
290         self.txload_initial = 0x1000
291         self.txload_poll = 8
292         self.txload_waste = 8
293         self._refresh_transactions_debug = []
294         self.transactions_table.confirmation_stage = self.confirmation_stage
295
296         refresh_info_timer = QTimer(self)
297         refresh_info_timer.timeout.connect(self.refresh_info)
298         refresh_info_timer.start(1000)
299         # Stores last transaction added to the table
300         self.last_tx = None
301         self.last_tx_with_confirmations = None
302         # Used for updating number of confirms
303         self.unconfirmed_tx = []
304         #   key=txid, category  val=row, confirms
305         self.trans_lookup = {}
306         self.refresh_info()
307         #self.transactions_table.add_transaction_entry({'confirmations': 3, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
308         #self.transactions_table.add_transaction_entry({'confirmations': 0, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
309
310         self.resize(640, 420)
311
312     def confirmation_stage(self, category, confirms):
313         sch = self.confirmation_stage.sch
314         if category not in sch:
315             category = None
316         sch = sch[category]
317         if confirms < sch[0]:
318             return 0
319         if sch[1] is None or confirms < sch[1]:
320             return 0x80
321         return 0x100
322     confirmation_stage.sch = {}
323     confirmation_stage.sch[None] = (2, 6)
324     confirmation_stage.sch['generate'] = (100, 120)
325     confirmation_stage.sch['immature'] = (100, None)
326
327     def refresh_info(self):
328         self.refresh_balance()
329         self.refresh_transactions()
330
331     def __etxid(self, t):
332         txid = t['txid']
333         category = t['category']
334         if category == 'immature':
335             category = 'generate'
336         etxid = "%s/%s" % (txid, category)
337         return etxid
338
339     def update_amounts(self):
340         self.transactions_table.update_amounts()
341
342     def update_counters(self):
343         self.refresh_balance_label()
344         self.transactions_table.update_counters()
345
346     def refresh_transactions(self):
347         debuglog = []
348         fetchtx = self.txload_initial
349         utx = {}
350         if not self.last_tx is None:
351             # Figure out just how many fetches are needed to comfortably update new unconfirmed tx
352             fetchtx = 0
353             debuglog += [{'raw_unconfirmed_tx': self.unconfirmed_tx}]
354             for etxid, status_item in self.unconfirmed_tx:
355                 row = self.transactions_table.row(status_item)
356                 utx[etxid] = [status_item, None]
357                 debuglog += ["Present unconfirmed tx %s at row %d" % (etxid, row)]
358                 # Allow up to 5 wasted refetches in between unconfirmed refetches
359                 if row <= fetchtx + self.txload_waste:
360                     fetchtx = row + 1
361             fetchtx += self.txload_poll
362         while True:
363             debuglog += ["Fetching %d transactions" % (fetchtx,)]
364             transactions = self.core.transactions('*', fetchtx)
365             debuglog += [{'raw_txlist': transactions}]
366
367             # Sort through fetched transactions, updating confirmation counts
368             ttf = len(transactions)
369             transactions.reverse()
370             otl = []
371             nltwc = None
372             nomore = False
373             petime = time()
374             if self.last_tx:
375                 petime += .001
376             for i in xrange(ttf):
377                 nowtime = time()
378                 if petime < nowtime and self.parent():
379                     self.parent().app.processEvents()
380                     petime = nowtime + .001
381                 t = transactions[i]
382                 if 'txid' not in t:
383                     continue
384                 txid = t['txid'] if 'txid' in t else False
385                 category = t['category']
386                 if 'confirmations' in t:
387                     confirms = t['confirmations']
388                     if nltwc is None and self.confirmation_stage(category, confirms) == 0x100:
389                         nltwc = t
390                         debuglog += ["New last_tx_with_confirmations = %s" % (txid,)]
391                     if txid == self.last_tx_with_confirmations:
392                         ci = confirms - self.last_tx_with_confirmations_n
393                         debuglog += ["Found last_tx_with_confirmations (%s) with %d confirms (+%d)" % (txid, confirms, ci)]
394                         if ci:
395                             self.transactions_table.update_confirmations(ci)
396                         self.last_tx_with_confirmations_n = confirms
397                     etxid = self.__etxid(t)
398                     if etxid in utx:
399                         utx[etxid][1] = (t,)
400                 if nomore:
401                     continue
402                 if txid == self.last_tx:
403                     debuglog += ["Found last recorded tx (%s)" % (txid,)]
404                     nomore = True
405                     if i >= self.txload_poll:
406                         self.txload_poll = i + 1
407                     continue
408                 if category == 'orphan':
409                     continue
410                 otl.append(t)
411             transactions = otl
412
413             if nomore or fetchtx > ttf: break
414
415             # If we get here, that means we didn't fetch enough to see our last confirmed tx... retry, this time getting more
416             fetchtx *= 2
417
418         if not nltwc is None:
419             self.last_tx_with_confirmations = nltwc['txid']
420             self.last_tx_with_confirmations_n = nltwc['confirmations']
421
422         if transactions:
423             transactions.reverse()
424             debuglog += [{'new_txlist': transactions}]
425
426             # Add any new transactions
427             for t in transactions:
428                 etxid = self.__etxid(t)
429                 if etxid in utx:
430                     # When a transaction is goes from 0 to 1 confirmation, bitcoind seems to reset its time and position in the listtransactions list :(
431                     status_item = utx[etxid][0]
432                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
433                     row = self.transactions_table.row(status_item)
434                     unixtime = t['time']
435
436                     self.transactions_table.move_row(row, 0)
437                     self.transactions_table.update_transaction_time(row, unixtime)
438                     continue
439
440                 status_item = self.transactions_table.add_transaction_entry(t)
441                 if 'confirmations' not in t: continue
442                 category = t['category']
443                 confirms = t['confirmations']
444                 if self.confirmation_stage(category, confirms) < 0x100:
445                     self.unconfirmed_tx.insert(0, (etxid, status_item) )
446                     debuglog += ["New unconfirmed tx: %s" % (etxid,)]
447             self.last_tx = transactions[-1]['txid']
448
449         # Finally, fetch individual tx info for any old unconfirmed tx
450         while len(utx):
451             etxid, data = utx.items()[0]
452             status_item, transactions = data
453             txid = etxid[:etxid.index('/')]
454             if transactions is None:
455                 debuglog += ["Specially fetching unconfirmed tx: %s" % (etxid,)]
456                 transactions = self.core.get_transaction(txid)
457             for t in transactions:
458                 etxid = self.__etxid(t)
459                 if etxid in utx:
460                     category = t['category']
461                     confirms = t['confirmations']
462                     txdone = True
463                     status_item = utx[etxid][0]
464                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
465                     row = self.transactions_table.row(status_item)
466                     if category == 'orphan':
467                         debuglog += ["Tx %s (row %d) has been orphaned" % (etxid, row, confirms)]
468                         self.transactions_table.removeRow(row)
469                     else:
470                         debuglog += ["Tx %s (row %d) has %d confirms" % (etxid, row, confirms)]
471                         if category in ('generate', 'immature'):
472                             status_item.category = category
473                         elif hasattr(status_item, 'category'):
474                             del status_item.category
475                         self.transactions_table.update_confirmation(row, confirms, adjustment=False)
476                         if self.confirmation_stage(category, confirms) < 0x100:
477                             txdone = False
478                     del utx[etxid]
479                     if txdone:
480                         for i in xrange(len(self.unconfirmed_tx)):
481                             if self.unconfirmed_tx[i][0] == etxid:
482                                 self.unconfirmed_tx[i:i+1] = ()
483                                 break
484
485         if SpesmiloSettings.debugMode:
486             self._refresh_transactions_debug += [debuglog]
487
488     def refresh_balance(self):
489         self.balance = self.core.balance()
490         self.refresh_balance_label()
491
492     def refresh_balance_label(self):
493         bltext = self.tr('Balance: %s') % (humanAmount(self.balance, wantTLA=True),)
494         self.balance_label.setText(bltext)
495         if hasattr(self, 'tray') and self.tray:
496             self.tray.setToolTip(bltext)
497
498     def create_actions(self):
499         self.send_act = QAction(icon('go-next'), self.tr('Send'),
500             self, toolTip=self.tr('Send bitcoins to another person'),
501             triggered=self.new_send_dialog)
502         self.newaddy_act = QAction(icon('document-new'),
503             self.tr('New address'), self,
504             toolTip=self.tr('Create new address for accepting bitcoins'),
505             triggered=self.new_address)
506         self.copyaddy_act = QAction(icon('copy-bitcoin-address', 'klipper', 'tool_clipboard', 'edit-copy', 'icons/edit-copy.png'),
507             self.tr('Copy address'),
508             self, toolTip=self.tr('Copy address to clipboard'),
509             triggered=self.copy_address)
510         self.settings_act = QAction(icon('configure', 'icons/preferences-system.png'),
511             self.tr('Settings'),
512             self, toolTip=self.tr('Configure Spesmilo'),
513             triggered=self.open_settings)
514
515     def new_send_dialog(self):
516         if self.parent() is not None:
517             send_dialog = send.SendDialog(self.core, self.parent())
518         else:
519             send_dialog = send.SendDialog(self.core, self)
520
521     def new_address(self):
522         self.addy.setText(self.core.new_address())
523
524     def copy_address(self):
525         self.clipboard.setText(self.addy.text())
526
527     def open_settings(self):
528         if hasattr(self, 'settingsdlg'):
529             self.settingsdlg.show()
530             self.settingsdlg.setFocus()
531         else:
532             import settings
533             self.settingsdlg = settings.SettingsDialog(self)
534
535 if __name__ == '__main__':
536     import os
537     import sys
538     import core_interface
539     from settings import SpesmiloSettings
540     os.system('/home/genjix/src/bitcoin/bitcoind')
541     app = QApplication(sys.argv)
542     SpesmiloSettings.loadTranslator()
543     uri = SpesmiloSettings.getEffectiveURI()
544     core = core_interface.CoreInterface(uri)
545     clipboard = qApp.clipboard()
546     cashier = Cashier(core, clipboard)
547     cashier.show()
548     sys.exit(app.exec_())