diff --git a/accounting/client.py b/accounting/client.py index 450cb63..361a00a 100644 --- a/accounting/client.py +++ b/accounting/client.py @@ -2,6 +2,7 @@ import sys import argparse import json import logging +import locale from datetime import datetime from decimal import Decimal @@ -11,6 +12,8 @@ import requests from accounting.models import Transaction, Posting, Amount from accounting.transport import AccountingDecoder, AccountingEncoder +locale.setlocale(locale.LC_ALL, '') + _log = logging.getLogger(__name__) @@ -43,15 +46,20 @@ class Client: return self._decode_response(requests.post(self.host + path, **kw)) - def simple_transaction(self, from_acc, to_acc, amount): + def simple_transaction(self, from_acc, to_acc, amount, symbol=None, + payee=None): + if symbol is None: + # Get the currency from the environment locale + symbol = locale.localeconv()['int_curr_symbol'].strip() + t = Transaction( date=datetime.today(), - payee='PayPal donation', + payee=payee, postings=[ Posting(account=from_acc, - amount=Amount(symbol='$', amount=-amount)), + amount=Amount(symbol=symbol, amount=-amount)), Posting(account=to_acc, - amount=Amount(symbol='$', amount=amount)) + amount=Amount(symbol=symbol, amount=amount)) ] ) @@ -86,7 +94,7 @@ def print_balance_accounts(accounts, level=0): print(' ' * level + ' {amount.symbol} {amount.amount}'.format( amount=amount)) - print_balance_accounts(account.accounts, level+1) + print_balance_accounts(account.accounts, level + 1) def main(argv=None, prog=None): @@ -100,9 +108,16 @@ def main(argv=None, prog=None): insert = actions.add_parser('insert', aliases=['in']) + insert.add_argument('payee', + help='The payee line of the transaction') insert.add_argument('from_account') insert.add_argument('to_account') - insert.add_argument('amount', type=Decimal) + insert.add_argument('amount', type=Decimal, + help='The amount deducted from from_account and added' + ' to to_account') + insert.add_argument('-s', '--symbol', + help='The symbol for the amount, e.g. $ or USD for' + ' USD. Defaults to your locale\'s setting.') actions.add_parser('balance', aliases=['bal']) @@ -122,7 +137,8 @@ def main(argv=None, prog=None): if args.action in ['insert', 'in']: print(client.simple_transaction(args.from_account, args.to_account, - args.amount)) + args.amount, payee=args.payee, + symbol=args.symbol)) elif args.action in ['balance', 'bal']: print_balance_accounts(client.get_balance()) elif args.action in ['register', 'reg']: diff --git a/accounting/gtkclient.py b/accounting/gtkclient.py new file mode 100644 index 0000000..5605852 --- /dev/null +++ b/accounting/gtkclient.py @@ -0,0 +1,93 @@ +import sys +import logging +import threading + +from datetime import datetime + +from gi.repository import Gtk +from gi.repository import GLib +from gi.repository import GObject + +from accounting.client import Client + +_log = logging.getLogger(__name__) + + +class Accounting(Gtk.Window): + def __init__(self): + Gtk.Window.__init__(self, title='Accounting Client') + + self.client = Client() + + self.set_border_width(3) + + # Table + + self.table = Gtk.Table(3, 2, True) + self.add(self.table) + + # Button + + self.btn_load_transactions = Gtk.Button(label='Load transactions') + self.btn_load_transactions.connect('clicked', self.on_button_clicked) + + self.spinner = Gtk.Spinner() + + # Transaction stuff + + self.transaction_store = Gtk.ListStore(str, str) + self.transaction_view = Gtk.TreeView(self.transaction_store) + + renderer = Gtk.CellRendererText() + date_column = Gtk.TreeViewColumn('Date', renderer, text=0) + payee_column = Gtk.TreeViewColumn('Payee', renderer, text=1) + + self.transaction_view.append_column(date_column) + self.transaction_view.append_column(payee_column) + + # Layout + self.table.attach(self.btn_load_transactions, 0, 1, 0, 1) + self.table.attach(self.spinner, 1, 2, 0, 1) + self.table.attach(self.transaction_view, 0, 2, 1, 3) + + # Show + self.show_all() + self.spinner.hide() + + + def on_button_clicked(self, widget): + def load_transactions(): + transactions = self.client.get_register() + GLib.idle_add(self.on_transactions_loaded, transactions) + + self.spinner.show() + self.spinner.start() + + threading.Thread(target=load_transactions).start() + + def on_transactions_loaded(self, transactions): + self.spinner.stop() + self.spinner.hide() + _log.debug('transactions: %s', transactions) + + self.transaction_store.clear() + + for transaction in transactions: + self.transaction_store.append([ + transaction.date.strftime('%Y-%m-%d'), + transaction.payee + ]) + + +def main(argv=None): + logging.basicConfig(level=logging.DEBUG) + + GObject.threads_init() + + accounting_win = Accounting() + accounting_win.connect('delete-event', Gtk.main_quit) + + Gtk.main() + +if __name__ == '__main__': + sys.exit(main()) diff --git a/accounting/models.py b/accounting/models.py index 97e7c32..c09dc57 100644 --- a/accounting/models.py +++ b/accounting/models.py @@ -15,10 +15,10 @@ class Transaction: self.generate_id() def generate_id(self): - self.id = uuid.uuid4() + self.id = str(uuid.uuid4()) def __repr__(self): - return ('<{self.__class__.__name__} {date}' + + return ('<{self.__class__.__name__} {self.id} {date}' + ' {self.payee} {self.postings}').format( self=self, date=self.date.strftime('%Y-%m-%d')) diff --git a/accounting/storage/ledgercli.py b/accounting/storage/ledgercli.py index 4a95b89..b6e5e49 100644 --- a/accounting/storage/ledgercli.py +++ b/accounting/storage/ledgercli.py @@ -14,7 +14,10 @@ _log = logging.getLogger(__name__) class Ledger(Storage): - def __init__(self, ledger_file=None, ledger_bin=None): + def __init__(self, app=None, ledger_file=None, ledger_bin=None): + if app: + ledger_file = app.config['LEDGER_FILE'] + if ledger_file is None: raise ValueError('ledger_file cannot be None') @@ -158,11 +161,14 @@ class Ledger(Storage): :class:`~accounting.models.Transaction` instance in :data:`transaction`. ''' - if not transaction.metadata.get('Id'): + if transaction.id is None: + _log.debug('No ID found. Generating an ID.') transaction.generate_id() + transaction.metadata.update({'Id': transaction.id}) + transaction_template = ('\n{date} {t.payee}\n' - '{tags}' + '{metadata}' '{postings}') metadata_template = ' ;{0}: {1}\n' @@ -178,7 +184,7 @@ class Ledger(Storage): output += transaction_template.format( date=transaction.date.strftime('%Y-%m-%d'), t=transaction, - tags=''.join([ + metadata=''.join([ metadata_template.format(k, v) for k, v in transaction.metadata.items()]), postings=''.join([posting_template.format( @@ -239,6 +245,9 @@ class Ledger(Storage): return accounts + def get_transactions(self): + return self.reg() + def reg(self): output = self.send_command('xml') @@ -301,6 +310,9 @@ class Ledger(Storage): return entries + def update_transaction(self, transaction): + _log.debug('DUMMY: Updated transaction: %s', transaction) + def main(argv=None): import argparse diff --git a/accounting/storage/sql/__init__.py b/accounting/storage/sql/__init__.py index 2145134..69c26a2 100644 --- a/accounting/storage/sql/__init__.py +++ b/accounting/storage/sql/__init__.py @@ -3,6 +3,7 @@ import json from flask.ext.sqlalchemy import SQLAlchemy +from accounting.exceptions import AccountingException from accounting.storage import Storage from accounting.models import Transaction, Posting, Amount @@ -11,8 +12,12 @@ db = None class SQLStorage(Storage): - def __init__(self, app): + def __init__(self, app=None): global db + + if not app: + raise Exception('Missing app keyword argument') + self.app = app db = self.db = SQLAlchemy(app) @@ -47,12 +52,19 @@ class SQLStorage(Storage): return transactions + def update_transaction(self, transaction): + if transaction.id is None: + raise AccountingException('The transaction id must be set for' + ' update_transaction calls') + + _log.debug('DUMMY: Update transaction: %s', transaction) + def add_transaction(self, transaction): if transaction.id is None: transaction.generate_id() _t = self.Transaction() - _t.uuid = str(transaction.id) + _t.uuid = transaction.id _t.date = transaction.date _t.payee = transaction.payee _t.meta = json.dumps(transaction.metadata) @@ -61,7 +73,7 @@ class SQLStorage(Storage): for posting in transaction.postings: _p = self.Posting() - _p.transaction_uuid = str(transaction.id) + _p.transaction_uuid = transaction.id _p.account = posting.account _p.meta = json.dumps(posting.metadata) _p.amount = self.Amount(symbol=posting.amount.symbol, diff --git a/accounting/transport.py b/accounting/transport.py index 4e0e98e..d397084 100644 --- a/accounting/transport.py +++ b/accounting/transport.py @@ -4,6 +4,7 @@ from flask import json from accounting.models import Amount, Transaction, Posting, Account + class AccountingEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Account): @@ -16,6 +17,7 @@ class AccountingEncoder(json.JSONEncoder): elif isinstance(o, Transaction): return dict( __type__=o.__class__.__name__, + id=o.id, date=o.date.strftime('%Y-%m-%d'), payee=o.payee, postings=o.postings, @@ -42,6 +44,7 @@ class AccountingEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) + class AccountingDecoder(json.JSONDecoder): def __init__(self): json.JSONDecoder.__init__(self, object_hook=self.dict_to_object) @@ -50,8 +53,8 @@ class AccountingDecoder(json.JSONDecoder): if '__type__' not in d: return d - types = {c.__name__ : c for c in [Amount, Transaction, Posting, - Account]} + types = {c.__name__: c for c in [Amount, Transaction, Posting, + Account]} _type = d.pop('__type__') diff --git a/accounting/web.py b/accounting/web.py index 8140ad1..a6f17ab 100644 --- a/accounting/web.py +++ b/accounting/web.py @@ -21,14 +21,15 @@ from accounting.decorators import jsonify_exceptions app = Flask('accounting') app.config.from_pyfile('config.py') -storage = SQLStorage(app) +storage = Ledger(app=app) -# TODO: Move migration stuff into SQLStorage -db = storage.db -migrate = Migrate(app, db) +if isinstance(storage, SQLStorage): + # TODO: Move migration stuff into SQLStorage + db = storage.db + migrate = Migrate(app, db) -manager = Manager(app) -manager.add_command('db', MigrateCommand) + manager = Manager(app) + manager.add_command('db', MigrateCommand) @app.before_request @@ -59,6 +60,24 @@ def transaction_get(): ''' return jsonify(transactions=storage.get_transactions()) +@app.route('/transaction/', methods=['POST']) +@jsonify_exceptions +def transaction_update(transaction_id=None): + if transaction_id is None: + raise AccountingException('The transaction ID cannot be None.') + + transaction = request.json['transaction'] + + if transaction.id is not None and not transaction.id == transaction_id: + raise AccountingException('The transaction data has an ID attribute and' + ' it is not the same ID as in the path') + elif transaction.id is None: + transaction.id = transaction_id + + storage.update_transaction(transaction) + + return jsonify(status='OK') + @app.route('/transaction', methods=['POST']) @jsonify_exceptions @@ -118,56 +137,7 @@ def transaction_post(): for transaction in transactions: storage.add_transaction(transaction) - return jsonify(foo='bar') - - -@app.route('/parse-json', methods=['POST']) -def parse_json(): - r''' - Parses a __type__-annotated JSON payload and debug-logs the decoded version - of it. - - Example: - - .. code-block:: bash - - wget http://127.0.0.1:5000/balance -O balance.json - curl -X POST -H 'Content-Type: application/json' -d @balance.json \ - http://127.0.0.1/parse-json - # Logging output (linebreaks added for clarity) - # DEBUG:accounting:json data: {'balance_report': - # [, ] - # [, ] - # [] []>, - # ] []>]>, - # ] - # [] - # [] []>]>, - # ] [ - # ] []>]>]>, - # , - # ] - # [] []>, - # ] - # [] []>]>, - # ] []>]>]>]} - ''' - app.logger.debug('json data: %s', request.json) - return jsonify(foo='bar') + return jsonify(status='OK') def main(argv=None):