- Made the storage model slightly more flexible
- Made a small P-o-C GUI application in GTK
- Polished accounting.client
- models.Transaction.id is now a str
- Fixed transaction.id marshalling for storage.ledgercli
This commit is contained in:
Joar Wandborg 2013-12-16 07:33:56 +01:00
parent 124bd1706d
commit f2b9decf27
7 changed files with 180 additions and 74 deletions

View file

@ -2,6 +2,7 @@ import sys
import argparse import argparse
import json import json
import logging import logging
import locale
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@ -11,6 +12,8 @@ import requests
from accounting.models import Transaction, Posting, Amount from accounting.models import Transaction, Posting, Amount
from accounting.transport import AccountingDecoder, AccountingEncoder from accounting.transport import AccountingDecoder, AccountingEncoder
locale.setlocale(locale.LC_ALL, '')
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -43,15 +46,20 @@ class Client:
return self._decode_response(requests.post(self.host + path, **kw)) 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( t = Transaction(
date=datetime.today(), date=datetime.today(),
payee='PayPal donation', payee=payee,
postings=[ postings=[
Posting(account=from_acc, Posting(account=from_acc,
amount=Amount(symbol='$', amount=-amount)), amount=Amount(symbol=symbol, amount=-amount)),
Posting(account=to_acc, Posting(account=to_acc,
amount=Amount(symbol='$', amount=amount)) amount=Amount(symbol=symbol, amount=amount))
] ]
) )
@ -100,9 +108,16 @@ def main(argv=None, prog=None):
insert = actions.add_parser('insert', insert = actions.add_parser('insert',
aliases=['in']) aliases=['in'])
insert.add_argument('payee',
help='The payee line of the transaction')
insert.add_argument('from_account') insert.add_argument('from_account')
insert.add_argument('to_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']) actions.add_parser('balance', aliases=['bal'])
@ -122,7 +137,8 @@ def main(argv=None, prog=None):
if args.action in ['insert', 'in']: if args.action in ['insert', 'in']:
print(client.simple_transaction(args.from_account, args.to_account, 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']: elif args.action in ['balance', 'bal']:
print_balance_accounts(client.get_balance()) print_balance_accounts(client.get_balance())
elif args.action in ['register', 'reg']: elif args.action in ['register', 'reg']:

93
accounting/gtkclient.py Normal file
View file

@ -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())

View file

@ -15,10 +15,10 @@ class Transaction:
self.generate_id() self.generate_id()
def generate_id(self): def generate_id(self):
self.id = uuid.uuid4() self.id = str(uuid.uuid4())
def __repr__(self): def __repr__(self):
return ('<{self.__class__.__name__} {date}' + return ('<{self.__class__.__name__} {self.id} {date}' +
' {self.payee} {self.postings}').format( ' {self.payee} {self.postings}').format(
self=self, self=self,
date=self.date.strftime('%Y-%m-%d')) date=self.date.strftime('%Y-%m-%d'))

View file

@ -14,7 +14,10 @@ _log = logging.getLogger(__name__)
class Ledger(Storage): 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: if ledger_file is None:
raise ValueError('ledger_file cannot be None') raise ValueError('ledger_file cannot be None')
@ -158,11 +161,14 @@ class Ledger(Storage):
:class:`~accounting.models.Transaction` instance in :class:`~accounting.models.Transaction` instance in
:data:`transaction`. :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.generate_id()
transaction.metadata.update({'Id': transaction.id})
transaction_template = ('\n{date} {t.payee}\n' transaction_template = ('\n{date} {t.payee}\n'
'{tags}' '{metadata}'
'{postings}') '{postings}')
metadata_template = ' ;{0}: {1}\n' metadata_template = ' ;{0}: {1}\n'
@ -178,7 +184,7 @@ class Ledger(Storage):
output += transaction_template.format( output += transaction_template.format(
date=transaction.date.strftime('%Y-%m-%d'), date=transaction.date.strftime('%Y-%m-%d'),
t=transaction, t=transaction,
tags=''.join([ metadata=''.join([
metadata_template.format(k, v) metadata_template.format(k, v)
for k, v in transaction.metadata.items()]), for k, v in transaction.metadata.items()]),
postings=''.join([posting_template.format( postings=''.join([posting_template.format(
@ -239,6 +245,9 @@ class Ledger(Storage):
return accounts return accounts
def get_transactions(self):
return self.reg()
def reg(self): def reg(self):
output = self.send_command('xml') output = self.send_command('xml')
@ -301,6 +310,9 @@ class Ledger(Storage):
return entries return entries
def update_transaction(self, transaction):
_log.debug('DUMMY: Updated transaction: %s', transaction)
def main(argv=None): def main(argv=None):
import argparse import argparse

View file

@ -3,6 +3,7 @@ import json
from flask.ext.sqlalchemy import SQLAlchemy from flask.ext.sqlalchemy import SQLAlchemy
from accounting.exceptions import AccountingException
from accounting.storage import Storage from accounting.storage import Storage
from accounting.models import Transaction, Posting, Amount from accounting.models import Transaction, Posting, Amount
@ -11,8 +12,12 @@ db = None
class SQLStorage(Storage): class SQLStorage(Storage):
def __init__(self, app): def __init__(self, app=None):
global db global db
if not app:
raise Exception('Missing app keyword argument')
self.app = app self.app = app
db = self.db = SQLAlchemy(app) db = self.db = SQLAlchemy(app)
@ -47,12 +52,19 @@ class SQLStorage(Storage):
return transactions 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): def add_transaction(self, transaction):
if transaction.id is None: if transaction.id is None:
transaction.generate_id() transaction.generate_id()
_t = self.Transaction() _t = self.Transaction()
_t.uuid = str(transaction.id) _t.uuid = transaction.id
_t.date = transaction.date _t.date = transaction.date
_t.payee = transaction.payee _t.payee = transaction.payee
_t.meta = json.dumps(transaction.metadata) _t.meta = json.dumps(transaction.metadata)
@ -61,7 +73,7 @@ class SQLStorage(Storage):
for posting in transaction.postings: for posting in transaction.postings:
_p = self.Posting() _p = self.Posting()
_p.transaction_uuid = str(transaction.id) _p.transaction_uuid = transaction.id
_p.account = posting.account _p.account = posting.account
_p.meta = json.dumps(posting.metadata) _p.meta = json.dumps(posting.metadata)
_p.amount = self.Amount(symbol=posting.amount.symbol, _p.amount = self.Amount(symbol=posting.amount.symbol,

View file

@ -4,6 +4,7 @@ from flask import json
from accounting.models import Amount, Transaction, Posting, Account from accounting.models import Amount, Transaction, Posting, Account
class AccountingEncoder(json.JSONEncoder): class AccountingEncoder(json.JSONEncoder):
def default(self, o): def default(self, o):
if isinstance(o, Account): if isinstance(o, Account):
@ -16,6 +17,7 @@ class AccountingEncoder(json.JSONEncoder):
elif isinstance(o, Transaction): elif isinstance(o, Transaction):
return dict( return dict(
__type__=o.__class__.__name__, __type__=o.__class__.__name__,
id=o.id,
date=o.date.strftime('%Y-%m-%d'), date=o.date.strftime('%Y-%m-%d'),
payee=o.payee, payee=o.payee,
postings=o.postings, postings=o.postings,
@ -42,6 +44,7 @@ class AccountingEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, o) return json.JSONEncoder.default(self, o)
class AccountingDecoder(json.JSONDecoder): class AccountingDecoder(json.JSONDecoder):
def __init__(self): def __init__(self):
json.JSONDecoder.__init__(self, object_hook=self.dict_to_object) json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)

View file

@ -21,8 +21,9 @@ from accounting.decorators import jsonify_exceptions
app = Flask('accounting') app = Flask('accounting')
app.config.from_pyfile('config.py') app.config.from_pyfile('config.py')
storage = SQLStorage(app) storage = Ledger(app=app)
if isinstance(storage, SQLStorage):
# TODO: Move migration stuff into SQLStorage # TODO: Move migration stuff into SQLStorage
db = storage.db db = storage.db
migrate = Migrate(app, db) migrate = Migrate(app, db)
@ -59,6 +60,24 @@ def transaction_get():
''' '''
return jsonify(transactions=storage.get_transactions()) return jsonify(transactions=storage.get_transactions())
@app.route('/transaction/<string:transaction_id>', 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']) @app.route('/transaction', methods=['POST'])
@jsonify_exceptions @jsonify_exceptions
@ -118,56 +137,7 @@ def transaction_post():
for transaction in transactions: for transaction in transactions:
storage.add_transaction(transaction) storage.add_transaction(transaction)
return jsonify(foo='bar') return jsonify(status='OK')
@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':
# [<Account "None" [
# <Amount $ 0>, <Amount KARMA 0>]
# [<Account "Assets" [
# <Amount $ 50>, <Amount KARMA 10>]
# [<Account "Assets:Checking" [
# <Amount $ 50>] []>,
# <Account "Assets:Karma Account" [
# <Amount KARMA 10>] []>]>,
# <Account "Expenses" [
# <Amount $ 500>]
# [<Account "Expenses:Blah" [
# <Amount $ 250>]
# [<Account "Expenses:Blah:Hosting" [
# <Amount $ 250>] []>]>,
# <Account "Expenses:Foo" [
# <Amount $ 250>] [
# <Account "Expenses:Foo:Hosting" [
# <Amount $ 250>] []>]>]>,
# <Account "Income" [
# <Amount $ -550>,
# <Amount KARMA -10>]
# [<Account "Income:Donation" [
# <Amount $ -50>] []>,
# <Account "Income:Foo" [
# <Amount $ -500>]
# [<Account "Income:Foo:Donation" [
# <Amount $ -500>] []>]>,
# <Account "Income:Karma" [
# <Amount KARMA -10>] []>]>]>]}
'''
app.logger.debug('json data: %s', request.json)
return jsonify(foo='bar')
def main(argv=None): def main(argv=None):