use bitcoinrpc for "make local"
[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             description = self.tr('Received to %s')%address
168         elif category in ('generate', 'immature'):
169             description = self.tr('Generated')
170             status_item.category = category
171         elif category == 'move':
172             description = self.tr('Moved')
173         else:
174             description = self.tr('Unknown')
175         trans_item = TransactionItem(description)
176         self.setItem(0, 2, trans_item)
177
178         credits_item = TransactionItem(humanAmount(credit), Qt.AlignRight)
179         credits_item.amount = credit
180         self.setItem(0, 3, credits_item)
181
182         balance_item = TransactionItem(humanAmount(balance), Qt.AlignRight)
183         self.setItem(0, 4, balance_item)
184
185         self.update_confirmation(0, confirms, adjustment=False)
186
187         return status_item
188
189     def move_row(self, from_row, to_row):
190         items = []
191         CC = self.columnCount()
192         for col in xrange(CC):
193             items.append(self.takeItem(from_row, col))
194         self.removeRow(from_row)
195         self.insertRow(to_row)
196         for col in xrange(CC):
197             self.setItem(to_row, col, items[col])
198
199     def update_amounts(self):
200         for i in xrange(self.rowCount()):
201             credits_item = self.item(i, 3)
202             credits_item.setText(humanAmount(credits_item.amount))
203
204     def update_counters(self):
205         for i in xrange(self.rowCount()):
206             status_item = self.item(i, 0)
207             status_item.setText('')
208             self.update_confirmation(i, status_item.confirmations, adjustment=False)
209
210     def update_confirmations(self, increment, adjustment = True):
211         if increment == 0 and adjustment:
212             return
213         for i in range(0, self.rowCount()):
214             self.update_confirmation(i, increment, adjustment)
215
216     def enable_table_item(self, item):
217         if not hasattr(self, '_eti'):
218             # Must already be enabled :p
219             return
220         style_item(item, self._eti)
221
222     def disable_table_item(self, item):
223         want_old = not hasattr(self, '_eti')
224         rv = disable_item(item, want_old=want_old)
225         if want_old:
226             self._eti = rv
227
228 class Cashier(QDialog):
229     def __init__(self, core, clipboard, parent=None, tray=None):
230         super(Cashier, self).__init__(parent)
231         self.core = core
232         self.clipboard = clipboard
233         self.tray = tray
234
235         self.create_actions()
236         main_layout = QVBoxLayout(self)
237
238         youraddy = QHBoxLayout()
239         # Balance + Send button
240         self.balance_label = QLabel()
241         self.refresh_balance()
242         sendbtn = QToolButton(self)
243         sendbtn.setDefaultAction(self.send_act)
244         # Address + New button + Copy button
245         uraddtext = QLabel(self.tr('Your address:'))
246         self.addy = FocusLineEdit(self.core.default_address())
247         newaddybtn = QToolButton(self)
248         newaddybtn.setDefaultAction(self.newaddy_act)
249         copyaddybtn = QToolButton(self)
250         copyaddybtn.setDefaultAction(self.copyaddy_act)
251         # Add them to the layout
252         youraddy.addWidget(uraddtext)
253         youraddy.addWidget(self.addy)
254         youraddy.addWidget(newaddybtn)
255         youraddy.addWidget(copyaddybtn)
256         youraddy.addStretch()
257         settingsbtn = QToolButton(self)
258         settingsbtn.setDefaultAction(self.settings_act)
259         youraddy.addWidget(settingsbtn)
260         youraddy.addStretch()
261         youraddy.addWidget(self.balance_label)
262         youraddy.addWidget(sendbtn)
263         main_layout.addLayout(youraddy)
264
265         self.transactions_table = TransactionsTable()
266         main_layout.addWidget(self.transactions_table)
267
268         #webview = QWebView()
269         #webview.load('http://bitcoinwatch.com/')
270         #webview.setFixedSize(880, 300)
271         #mf = webview.page().mainFrame()
272         #mf.setScrollBarPolicy(Qt.Horizontal,
273         #                      Qt.ScrollBarAlwaysOff)
274         #mf.setScrollBarPolicy(Qt.Vertical,
275         #                      Qt.ScrollBarAlwaysOff)
276         #main_layout.addWidget(webview)
277
278         caption = self.tr('Spesmilo')
279         if parent is not None:
280             self.setWindowIcon(parent.bitcoin_icon)
281             if parent.caption:
282                 caption = parent.caption
283         self.setWindowTitle(caption)
284         self.setAttribute(Qt.WA_DeleteOnClose, False)
285
286         self.txload_initial = 0x1000
287         self.txload_poll = 8
288         self.txload_waste = 8
289         self._refresh_transactions_debug = []
290         self.transactions_table.confirmation_stage = self.confirmation_stage
291
292         refresh_info_timer = QTimer(self)
293         refresh_info_timer.timeout.connect(self.refresh_info)
294         refresh_info_timer.start(1000)
295         # Stores last transaction added to the table
296         self.last_tx = None
297         self.last_tx_with_confirmations = None
298         # Used for updating number of confirms
299         self.unconfirmed_tx = []
300         #   key=txid, category  val=row, confirms
301         self.trans_lookup = {}
302         self.refresh_info()
303         #self.transactions_table.add_transaction_entry({'confirmations': 3, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
304         #self.transactions_table.add_transaction_entry({'confirmations': 0, 'time': 1223332, 'address': 'fake', 'amount': 111, 'category': 'send'})
305
306         self.resize(640, 420)
307
308     def confirmation_stage(self, category, confirms):
309         sch = self.confirmation_stage.sch
310         if category not in sch:
311             category = None
312         sch = sch[category]
313         if confirms < sch[0]:
314             return 0
315         if sch[1] is None or confirms < sch[1]:
316             return 0x80
317         return 0x100
318     confirmation_stage.sch = {}
319     confirmation_stage.sch[None] = (2, 6)
320     confirmation_stage.sch['generate'] = (100, 120)
321     confirmation_stage.sch['immature'] = (100, None)
322
323     def refresh_info(self):
324         self.refresh_balance()
325         self.refresh_transactions()
326
327     def __etxid(self, t):
328         txid = t['txid']
329         category = t['category']
330         if category == 'immature':
331             category = 'generate'
332         etxid = "%s/%s" % (txid, category)
333         return etxid
334
335     def update_amounts(self):
336         self.transactions_table.update_amounts()
337
338     def update_counters(self):
339         self.refresh_balance_label()
340         self.transactions_table.update_counters()
341
342     def refresh_transactions(self):
343         debuglog = []
344         fetchtx = self.txload_initial
345         utx = {}
346         if not self.last_tx is None:
347             # Figure out just how many fetches are needed to comfortably update new unconfirmed tx
348             fetchtx = 0
349             debuglog += [{'raw_unconfirmed_tx': self.unconfirmed_tx}]
350             for etxid, status_item in self.unconfirmed_tx:
351                 row = self.transactions_table.row(status_item)
352                 utx[etxid] = [status_item, None]
353                 debuglog += ["Present unconfirmed tx %s at row %d" % (etxid, row)]
354                 # Allow up to 5 wasted refetches in between unconfirmed refetches
355                 if row <= fetchtx + self.txload_waste:
356                     fetchtx = row + 1
357             fetchtx += self.txload_poll
358         while True:
359             debuglog += ["Fetching %d transactions" % (fetchtx,)]
360             transactions = self.core.transactions('*', fetchtx)
361             debuglog += [{'raw_txlist': transactions}]
362
363             # Sort through fetched transactions, updating confirmation counts
364             ttf = len(transactions)
365             transactions.reverse()
366             otl = []
367             nltwc = None
368             nomore = False
369             petime = time()
370             if self.last_tx:
371                 petime += .001
372             for i in xrange(ttf):
373                 nowtime = time()
374                 if petime < nowtime and self.parent():
375                     self.parent().app.processEvents()
376                     petime = nowtime + .001
377                 t = transactions[i]
378                 if 'txid' not in t:
379                     continue
380                 txid = t['txid'] if 'txid' in t else False
381                 category = t['category']
382                 if 'confirmations' in t:
383                     confirms = t['confirmations']
384                     if nltwc is None and self.confirmation_stage(category, confirms) == 0x100:
385                         nltwc = t
386                         debuglog += ["New last_tx_with_confirmations = %s" % (txid,)]
387                     if txid == self.last_tx_with_confirmations:
388                         ci = confirms - self.last_tx_with_confirmations_n
389                         debuglog += ["Found last_tx_with_confirmations (%s) with %d confirms (+%d)" % (txid, confirms, ci)]
390                         if ci:
391                             self.transactions_table.update_confirmations(ci)
392                         self.last_tx_with_confirmations_n = confirms
393                     etxid = self.__etxid(t)
394                     if etxid in utx:
395                         utx[etxid][1] = (t,)
396                 if nomore:
397                     continue
398                 if txid == self.last_tx:
399                     debuglog += ["Found last recorded tx (%s)" % (txid,)]
400                     nomore = True
401                     if i >= self.txload_poll:
402                         self.txload_poll = i + 1
403                     continue
404                 if category == 'orphan':
405                     continue
406                 otl.append(t)
407             transactions = otl
408
409             if nomore or fetchtx > ttf: break
410
411             # If we get here, that means we didn't fetch enough to see our last confirmed tx... retry, this time getting more
412             fetchtx *= 2
413
414         if not nltwc is None:
415             self.last_tx_with_confirmations = nltwc['txid']
416             self.last_tx_with_confirmations_n = nltwc['confirmations']
417
418         if transactions:
419             transactions.reverse()
420             debuglog += [{'new_txlist': transactions}]
421
422             # Add any new transactions
423             for t in transactions:
424                 etxid = self.__etxid(t)
425                 if etxid in utx:
426                     # When a transaction is goes from 0 to 1 confirmation, bitcoind seems to reset its time and position in the listtransactions list :(
427                     status_item = utx[etxid][0]
428                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
429                     row = self.transactions_table.row(status_item)
430                     unixtime = t['time']
431
432                     self.transactions_table.move_row(row, 0)
433                     self.transactions_table.update_transaction_time(row, unixtime)
434                     continue
435
436                 status_item = self.transactions_table.add_transaction_entry(t)
437                 if 'confirmations' not in t: continue
438                 category = t['category']
439                 confirms = t['confirmations']
440                 if self.confirmation_stage(category, confirms) < 0x100:
441                     self.unconfirmed_tx.insert(0, (etxid, status_item) )
442                     debuglog += ["New unconfirmed tx: %s" % (etxid,)]
443             self.last_tx = transactions[-1]['txid']
444
445         # Finally, fetch individual tx info for any old unconfirmed tx
446         while len(utx):
447             etxid, data = utx.items()[0]
448             status_item, transactions = data
449             txid = etxid[:etxid.index('/')]
450             if transactions is None:
451                 debuglog += ["Specially fetching unconfirmed tx: %s" % (etxid,)]
452                 transactions = self.core.get_transaction(txid)
453             for t in transactions:
454                 etxid = self.__etxid(t)
455                 if etxid in utx:
456                     category = t['category']
457                     confirms = t['confirmations']
458                     txdone = True
459                     status_item = utx[etxid][0]
460                     # NOTE: the row may have changed since the start of the function, so don't try to cache it from above
461                     row = self.transactions_table.row(status_item)
462                     if category == 'orphan':
463                         debuglog += ["Tx %s (row %d) has been orphaned" % (etxid, row, confirms)]
464                         self.transactions_table.removeRow(row)
465                     else:
466                         debuglog += ["Tx %s (row %d) has %d confirms" % (etxid, row, confirms)]
467                         if category in ('generate', 'immature'):
468                             status_item.category = category
469                         elif hasattr(status_item, 'category'):
470                             del status_item.category
471                         self.transactions_table.update_confirmation(row, confirms, adjustment=False)
472                         if self.confirmation_stage(category, confirms) < 0x100:
473                             txdone = False
474                     del utx[etxid]
475                     if txdone:
476                         for i in xrange(len(self.unconfirmed_tx)):
477                             if self.unconfirmed_tx[i][0] == etxid:
478                                 self.unconfirmed_tx[i:i+1] = ()
479                                 break
480
481         if SpesmiloSettings.debugMode:
482             self._refresh_transactions_debug += [debuglog]
483
484     def refresh_balance(self):
485         self.balance = self.core.balance()
486         self.refresh_balance_label()
487
488     def refresh_balance_label(self):
489         bltext = self.tr('Balance: %s') % (humanAmount(self.balance, wantTLA=True),)
490         self.balance_label.setText(bltext)
491         if hasattr(self, 'tray') and self.tray:
492             self.tray.setToolTip(bltext)
493
494     def create_actions(self):
495         self.send_act = QAction(icon('go-next'), self.tr('Send'),
496             self, toolTip=self.tr('Send bitcoins to another person'),
497             triggered=self.new_send_dialog)
498         self.newaddy_act = QAction(icon('document-new'),
499             self.tr('New address'), self,
500             toolTip=self.tr('Create new address for accepting bitcoins'),
501             triggered=self.new_address)
502         self.copyaddy_act = QAction(icon('copy-bitcoin-address', 'klipper', 'tool_clipboard', 'edit-copy', 'icons/edit-copy.png'),
503             self.tr('Copy address'),
504             self, toolTip=self.tr('Copy address to clipboard'),
505             triggered=self.copy_address)
506         self.settings_act = QAction(icon('configure', 'icons/preferences-system.png'),
507             self.tr('Settings'),
508             self, toolTip=self.tr('Configure Spesmilo'),
509             triggered=self.open_settings)
510
511     def new_send_dialog(self):
512         if self.parent() is not None:
513             send_dialog = send.SendDialog(self.core, self.parent())
514         else:
515             send_dialog = send.SendDialog(self.core, self)
516
517     def new_address(self):
518         self.addy.setText(self.core.new_address())
519
520     def copy_address(self):
521         self.clipboard.setText(self.addy.text())
522
523     def open_settings(self):
524         if hasattr(self, 'settingsdlg'):
525             self.settingsdlg.show()
526             self.settingsdlg.setFocus()
527         else:
528             import settings
529             self.settingsdlg = settings.SettingsDialog(self)
530
531 if __name__ == '__main__':
532     import os
533     import sys
534     import core_interface
535     from settings import SpesmiloSettings
536     os.system('/home/genjix/src/bitcoin/bitcoind')
537     app = QApplication(sys.argv)
538     SpesmiloSettings.loadTranslator()
539     uri = SpesmiloSettings.getEffectiveURI()
540     core = core_interface.CoreInterface(uri)
541     clipboard = qApp.clipboard()
542     cashier = Cashier(core, clipboard)
543     cashier.show()
544     sys.exit(app.exec_())