Changeset - f2b9decf2752
[Not reviewed]
0 6 1
Joar Wandborg - 10 years ago 2013-12-16 06:33:56
joar@wandborg.se
SQL, GTK

- 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
7 files changed with 180 insertions and 74 deletions:
0 comments (0 inline, 0 general)
accounting/client.py
Show inline comments
 
import sys
 
import argparse
 
import json
 
import logging
 
import locale
 

	
 
from datetime import datetime
 
from decimal import Decimal
 

	
 
import requests
 

	
 
from accounting.models import Transaction, Posting, Amount
 
from accounting.transport import AccountingDecoder, AccountingEncoder
 

	
 
locale.setlocale(locale.LC_ALL, '')
 

	
 
_log = logging.getLogger(__name__)
 

	
 

	
 
class Client:
 
    def __init__(self, host=None, json_encoder=None,
 
                 json_decoder=None):
 
        self.host = host or 'http://localhost:5000'
 
        self.json_encoder = json_encoder or AccountingEncoder
 
        self.json_decoder = json_decoder or AccountingDecoder
 

	
 
    def get_balance(self):
 
        balance = self.get('/balance')
...
 
@@ -34,33 +37,38 @@ class Client:
 
        response_data = response.json(cls=self.json_decoder)
 

	
 
        _log.debug('response_data: %s', response_data)
 

	
 
        return response_data
 

	
 
    def post(self, path, payload, **kw):
 
        kw.update({'headers': {'Content-Type': 'application/json'}})
 
        kw.update({'data': json.dumps(payload, cls=self.json_encoder)})
 

	
 
        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))
 
            ]
 
        )
 

	
 
        return self.post('/transaction', {'transactions': [t]})
 

	
 
    def get_register(self):
 
        register = self.get('/transaction')
 

	
 
        return register['transactions']
 

	
 

	
 
def print_transactions(transactions):
...
 
@@ -77,58 +85,66 @@ def print_transactions(transactions):
 
                  posting.amount.symbol + ' ' + str(posting.amount.amount))
 

	
 

	
 
def print_balance_accounts(accounts, level=0):
 
    for account in accounts:
 
        print(' ' * level + ' + {account.name}'.format(account=account) +
 
              ' ' + '-' * (80 - len(str(account.name)) - level))
 

	
 
        for amount in account.amounts:
 
            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):
 
    global HOST
 
    if argv is None:
 
        prog = sys.argv[0]
 
        argv = sys.argv[1:]
 

	
 
    parser = argparse.ArgumentParser(prog=prog)
 
    actions = parser.add_subparsers(title='Actions', dest='action')
 

	
 
    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'])
 

	
 
    actions.add_parser('register', aliases=['reg'])
 

	
 
    parser.add_argument('-v', '--verbosity',
 
                        default='WARNING',
 
                        help=('Filter logging output. Possible values:' +
 
                              ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
 
    parser.add_argument('--host', default='http://localhost:5000')
 

	
 
    args = parser.parse_args(argv)
 

	
 
    logging.basicConfig(level=getattr(logging, args.verbosity))
 

	
 
    client = Client(args.host)
 

	
 
    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']:
 
        print_transactions(client.get_register())
 
    else:
 
        parser.print_help()
 

	
 
if __name__ == '__main__':
 
    sys.exit(main())
accounting/gtkclient.py
Show inline comments
 
new file 100644
 
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())
accounting/models.py
Show inline comments
...
 
@@ -6,28 +6,28 @@ class Transaction:
 
    def __init__(self, id=None, date=None, payee=None, postings=None,
 
                 metadata=None, _generate_id=False):
 
        self.id = id
 
        self.date = date
 
        self.payee = payee
 
        self.postings = postings
 
        self.metadata = metadata if metadata is not None else {}
 

	
 
        if _generate_id:
 
            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'))
 

	
 

	
 
class Posting:
 
    def __init__(self, account=None, amount=None, metadata=None):
 
        self.account = account
 
        self.amount = amount
 
        self.metadata = metadata if metadata is not None else {}
 

	
 
    def __repr__(self):
accounting/storage/ledgercli.py
Show inline comments
...
 
@@ -5,25 +5,28 @@ import time
 

	
 
from datetime import datetime
 
from xml.etree import ElementTree
 
from contextlib import contextmanager
 

	
 
from accounting.models import Account, Transaction, Posting, Amount
 
from accounting.storage import Storage
 

	
 
_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')
 

	
 
        self.ledger_bin = ledger_bin or 'ledger'
 
        self.ledger_file = ledger_file
 
        _log.info('ledger file: %s', ledger_file)
 

	
 
        self.locked = False
 
        self.ledger_process = None
 

	
 
    @contextmanager
 
    def locked_process(self):
...
 
@@ -149,45 +152,48 @@ class Ledger(Storage):
 
            self.ledger_process.wait()
 
            self.ledger_process = None
 

	
 
            return output
 

	
 
    def add_transaction(self, transaction):
 
        '''
 
        Writes a transaction to the ledger file by opening it in 'ab' mode and
 
        writing a ledger transaction based on the
 
        :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'
 

	
 
        # TODO: Generate metadata for postings
 
        posting_template = ('  {account} {p.amount.symbol}'
 
                            ' {p.amount.amount}\n')
 

	
 
        output = b''
 

	
 
        # XXX: Even I hardly understands what this does, however I indent it it
 
        # stays unreadable.
 
        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(
 
                p=p,
 
                account=p.account + ' ' * (
 
                    80 - (len(p.account) + len(p.amount.symbol) +
 
                          len(str(p.amount.amount)) + 1 + 2)
 
                )) for p in transaction.postings
 
            ])
 
        ).encode('utf8')
 

	
 
        with open(self.ledger_file, 'ab') as f:
...
 
@@ -230,24 +236,27 @@ class Ledger(Storage):
 
                    symbol = amount.find('./commodity/symbol').text
 

	
 
                    amounts.append(Amount(amount=quantity, symbol=symbol))
 
            else:
 
                _log.warning('Account %s does not have any amounts', name)
 

	
 
            accounts.append(Account(name=name,
 
                                    amounts=amounts,
 
                                    accounts=self._recurse_accounts(account)))
 

	
 
        return accounts
 

	
 
    def get_transactions(self):
 
        return self.reg()
 

	
 
    def reg(self):
 
        output = self.send_command('xml')
 

	
 
        if output is None:
 
            raise RuntimeError('reg call returned no output')
 

	
 
        entries = []
 

	
 
        reg_xml = ElementTree.fromstring(output.decode('utf8'))
 

	
 
        for transaction in reg_xml.findall('./transactions/transaction'):
 
            date = datetime.strptime(transaction.find('./date').text,
...
 
@@ -292,24 +301,27 @@ class Ledger(Storage):
 
                    _log.debug('metadata: %s: %s', key, value)
 

	
 
                    metadata.update({key: value})
 

	
 
            # Add a Transaction instance to the list
 
            id = metadata.pop('Id')
 
            entries.append(
 
                Transaction(id=id, date=date, payee=payee, postings=postings,
 
                            metadata=metadata))
 

	
 
        return entries
 

	
 
    def update_transaction(self, transaction):
 
        _log.debug('DUMMY: Updated transaction: %s', transaction)
 

	
 

	
 
def main(argv=None):
 
    import argparse
 
    if argv is None:
 
        argv = sys.argv
 

	
 
    parser = argparse.ArgumentParser()
 
    parser.add_argument('-v', '--verbosity',
 
                        default='INFO',
 
                        help=('Filter logging output. Possible values:' +
 
                              ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
 

	
accounting/storage/sql/__init__.py
Show inline comments
 
import logging
 
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
 

	
 
_log = logging.getLogger(__name__)
 
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)
 

	
 
        from .models import Transaction as SQLTransaction, \
 
            Posting as SQLPosting, Amount as SQLAmount
 

	
 
        db.create_all()
 

	
 
        self.Transaction = SQLTransaction
 
        self.Posting = SQLPosting
 
        self.Amount = SQLAmount
 

	
...
 
@@ -38,35 +43,42 @@ class SQLStorage(Storage):
 
                dict_amount = dict_posting.pop('amount')
 
                posting = Posting(**dict_posting)
 
                posting.amount = Amount(**dict_amount)
 

	
 
                postings.append(posting)
 

	
 
            dict_transaction.update({'postings': postings})
 

	
 
            transactions.append(Transaction(**dict_transaction))
 

	
 
        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)
 

	
 
        self.db.session.add(_t)
 

	
 
        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,
 
                                    amount=posting.amount.amount)
 

	
 
            self.db.session.add(_p)
 

	
 
        self.db.session.commit()
accounting/transport.py
Show inline comments
 
from datetime import datetime
 

	
 
from flask import json
 

	
 
from accounting.models import Amount, Transaction, Posting, Account
 

	
 

	
 
class AccountingEncoder(json.JSONEncoder):
 
    def default(self, o):
 
        if isinstance(o, Account):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                name=o.name,
 
                amounts=o.amounts,
 
                accounts=o.accounts
 
            )
 
        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,
 
                metadata=o.metadata
 
            )
 
        elif isinstance(o, Posting):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                account=o.account,
 
                amount=o.amount,
 
                metadata=o.metadata
 
            )
...
 
@@ -33,29 +35,30 @@ class AccountingEncoder(json.JSONEncoder):
 
                __type__=o.__class__.__name__,
 
                amount=str(o.amount),
 
                symbol=o.symbol
 
            )
 
        elif isinstance(o, Exception):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                args=o.args
 
            )
 

	
 
        return json.JSONEncoder.default(self, o)
 

	
 

	
 
class AccountingDecoder(json.JSONDecoder):
 
    def __init__(self):
 
        json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)
 

	
 
    def dict_to_object(self, d):
 
        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__')
 

	
 
        if _type == 'Transaction':
 
            d['date'] = datetime.strptime(d['date'], '%Y-%m-%d')
 

	
 
        return types[_type](**d)
accounting/web.py
Show inline comments
...
 
@@ -12,32 +12,33 @@ from flask.ext.script import Manager
 
from flask.ext.migrate import Migrate, MigrateCommand
 

	
 
from accounting.storage.ledgercli import Ledger
 
from accounting.storage.sql import SQLStorage
 
from accounting.transport import AccountingEncoder, AccountingDecoder
 
from accounting.exceptions import AccountingException
 
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
 
def init_ledger():
 
    '''
 
    :py:meth:`flask.Flask.before_request`-decorated method that initializes an
 
    :py:class:`accounting.Ledger` object.
 
    '''
 
    global ledger
 
    #ledger = Ledger(ledger_file=app.config['LEDGER_FILE'])
 

	
 

	
...
 
@@ -50,24 +51,42 @@ app.json_decoder = AccountingDecoder
 
def index():
 
    ''' Hello World! '''
 
    return 'Hello World!'
 

	
 

	
 
@app.route('/transaction', methods=['GET'])
 
def transaction_get():
 
    '''
 
    Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
 
    '''
 
    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'])
 
@jsonify_exceptions
 
def transaction_post():
 
    '''
 
    REST/JSON endpoint for transactions.
 

	
 
    Current state:
 

	
 
    Takes a POST request with a ``transactions`` JSON payload and writes it to
 
    the ledger file.
 

	
...
 
@@ -109,74 +128,25 @@ def transaction_post():
 
        2013-01-01 Kindly T. Donor
 
          Income:Foo:Donation                                         $ -100
 
          Assets:Checking                                              $ 100
 
    '''
 
    transactions = request.json.get('transactions')
 

	
 
    if not transactions:
 
        raise AccountingException('No transaction data provided')
 

	
 
    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':
 
        #    [<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')
 
    return jsonify(status='OK')
 

	
 

	
 
def main(argv=None):
 
    prog = __name__
 
    if argv is None:
 
        prog = sys.argv[0]
 
        argv = sys.argv[1:]
 

	
 
    parser = argparse.ArgumentParser(prog=prog)
 
    parser.add_argument('-v', '--verbosity',
 
                        default='INFO',
 
                        help=('Filter logging output. Possible values:' +
0 comments (0 inline, 0 general)