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):
...
 
@@ -40,21 +43,26 @@ class Client:
 
    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):
...
 
@@ -83,13 +91,13 @@ def print_balance_accounts(accounts, level=0):
 
              ' ' + '-' * (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]
...
 
@@ -97,15 +105,22 @@ def main(argv=None, prog=None):
 

	
 
    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',
...
 
@@ -119,13 +134,14 @@ def main(argv=None, prog=None):
 
    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()
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
...
 
@@ -12,16 +12,16 @@ class Transaction:
 
        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:
accounting/storage/ledgercli.py
Show inline comments
...
 
@@ -11,13 +11,16 @@ 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)
...
 
@@ -155,17 +158,20 @@ class Ledger(Storage):
 
        '''
 
        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}'
...
 
@@ -175,13 +181,13 @@ class Ledger(Storage):
 

	
 
        # 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) +
...
 
@@ -236,12 +242,15 @@ class Ledger(Storage):
 
            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')
 

	
...
 
@@ -298,12 +307,15 @@ class Ledger(Storage):
 
            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
 

	
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
 

	
...
 
@@ -44,27 +49,34 @@ class SQLStorage(Storage):
 
            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)
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):
...
 
@@ -39,22 +41,23 @@ class AccountingEncoder(json.JSONEncoder):
 
                __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')
 

	
accounting/web.py
Show inline comments
...
 
@@ -18,20 +18,21 @@ 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
...
 
@@ -56,12 +57,30 @@ def index():
 
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.
...
 
@@ -115,62 +134,13 @@ def transaction_post():
 
    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]
0 comments (0 inline, 0 general)