Changeset - 6f2c875c7b76
[Not reviewed]
0 3 2
Joar Wandborg - 11 years ago 2013-12-10 23:25:16
joar@wandborg.se
Added /transaction endpoint
5 files changed with 125 insertions and 2 deletions:
0 comments (0 inline, 0 general)
accounting/__init__.py
Show inline comments
...
 
@@ -66,96 +66,120 @@ class Ledger:
 
            stderr=subprocess.PIPE)
 

	
 
        # Swallow the banner
 
        with self.locked_process() as p:
 
            self.read_until_prompt(p)
 

	
 
        return self.ledger_process
 

	
 
    def get_process(self):
 
        return self.ledger_process or self.init_process()
 

	
 
    def read_until_prompt(self, p):
 
        output = b''
 

	
 
        while True:
 
            line = p.stdout.read(1)  # XXX: This is a hack
 

	
 
            output += line
 

	
 
            if b'\n] ' in output:
 
                _log.debug('Found prompt!')
 
                break
 

	
 
        output = output[:-3]  # Cut away the prompt
 

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

	
 
        return output
 

	
 
    def send_command(self, command):
 
        output = None
 

	
 
        with self.locked_process() as p:
 
            if isinstance(command, str):
 
                command = command.encode('utf8')
 

	
 
            p.stdin.write(command + b'\n')
 
            p.stdin.flush()
 

	
 
            output = self.read_until_prompt(p)
 

	
 
            self.ledger_process.send_signal(subprocess.signal.SIGTERM)
 
            _log.debug('Waiting for ledger to shut down')
 
            self.ledger_process.wait()
 
            self.ledger_process = None
 

	
 
            return output
 

	
 
    def add_transaction(self, transaction):
 
        transaction_template = ('\n{date} {t.payee}\n'
 
                                '{postings}')
 

	
 
        posting_template = ('  {account} {p.amount.symbol}'
 
                            ' {p.amount.amount}\n')
 

	
 
        output  = b''
 

	
 
        output += transaction_template.format(
 
            date=transaction.date.strftime('%Y-%m-%d'),
 
            t=transaction,
 
            postings=''.join([posting_template.format(
 
                p=p,
 
                account=p.account + ' ' * (
 
                    80 - (len(p.account) + len(p.amount.symbol) +
 
                    len(p.amount.amount) + 1 + 2)
 
                )) for p in transaction.postings])).encode('utf8')
 

	
 
        with open(self.ledger_file, 'ab') as f:
 
            f.write(output)
 

	
 
        _log.debug('written to file: %s', output)
 

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

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

	
 
        accounts = []
 

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

	
 
        accounts = self._recurse_accounts(xml.find('./accounts'))
 

	
 
        return accounts
 

	
 
    def _recurse_accounts(self, root):
 
        accounts = []
 

	
 
        for account in root.findall('./account'):
 
            name = account.find('./fullname').text
 

	
 
            amounts = []
 

	
 
            # Try to find an account total value, then try to find the account
 
            # balance
 
            account_amounts = account.findall(
 
                './account-total/balance/amount') or \
 
                    account.findall('./account-amount/amount') or \
 
                    account.findall('./account-total/amount')
 

	
 
            if account_amounts:
 
                for amount in account_amounts:
 
                    quantity = amount.find('./quantity').text
 
                    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 reg(self):
 
        output = self.send_command('xml')
 

	
 
        if output is None:
accounting/decorators.py
Show inline comments
 
new file 100644
 
from functools import wraps
 

	
 
from flask import jsonify
 

	
 
from accounting.exceptions import AccountingException
 

	
 

	
 
def jsonify_exceptions(func):
 
    '''
 
    Wraps a Flask endpoint and catches any AccountingException-based
 
    exceptions which are returned to the client as JSON.
 
    '''
 
    @wraps(func)
 
    def wrapper(*args, **kw):
 
        try:
 
            return func(*args, **kw)
 
        except AccountingException as exc:
 
            return jsonify(error=exc)
 

	
 
    return wrapper
accounting/exceptions.py
Show inline comments
 
new file 100644
 
class AccountingException(Exception):
 
    '''
 
    Used as a base for exceptions that are returned to the caller via the
 
    jsonify_exceptions decorator
 
    '''
 
    pass
accounting/transport.py
Show inline comments
 
from datetime import datetime
 

	
 
from flask import json
 

	
 
from accounting 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__,
 
                date=o.date.strftime('%Y-%m-%d'),
 
                payee=o.payee,
 
                postings=o.postings
 
            )
 
        elif isinstance(o, Posting):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                account=o.account,
 
                amount=o.amount,
 
            )
 
        elif isinstance(o, Amount):
 
            return dict(
 
                __type__=o.__class__.__name__,
 
                amount=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]}
 

	
 
        _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
 
import sys
 
import logging
 
import argparse
 

	
 
from flask import Flask, g, jsonify, json, request
 
from flask import Flask, jsonify, request
 

	
 
from accounting import Ledger, Account, Posting, Transaction, Amount
 
from accounting import Ledger
 
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')
 

	
 
ledger = Ledger(ledger_file=app.config['LEDGER_FILE'])
 

	
 

	
 
# These will convert output from our internal classes to JSON and back
 
app.json_encoder = AccountingEncoder
 
app.json_decoder = AccountingDecoder
 

	
 

	
 
@app.route('/')
 
def index():
 
    return 'Hello World!'
 

	
 

	
 
@app.route('/balance')
 
def balance_report():
 
    ''' Returns the balance report from ledger '''
 
    report_data = ledger.bal()
 

	
 
    return jsonify(balance_report=report_data)
 

	
 

	
 
@app.route('/transaction', methods=['POST'])
 
@jsonify_exceptions
 
def transaction():
 
    '''
 
    REST/JSON endpoint for transactions.
 

	
 
    Current state:
 

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

	
 
    Requires the ``transactions`` payload to be __type__-annotated:
 

	
 
    .. code-block:: json
 

	
 
        {
 
          "transactions": [
 
            {
 
              "__type__": "Transaction",
 
              "date": "2013-01-01",
 
              "payee": "Kindly T. Donor",
 
              "postings": [
 
                {
 
                  "__type__": "Posting",
 
                  "account": "Income:Foo:Donation",
 
                  "amount": {
 
                    "__type__": "Amount",
 
                    "amount": "-100",
 
                    "symbol": "$"
 
                  }
 
                },
 
                {
 
                  "__type__": "Posting",
 
                  "account": "Assets:Checking",
 
                  "amount": {
 
                    "__type__": "Amount",
 
                    "amount": "100",
 
                    "symbol": "$"
 
                  }
 
                }
 
              ]
 
            }
 
        }
 

	
 
    becomes::
 

	
 
        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:
 
        ledger.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:
 

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

	
 

	
 
@app.route('/register')
0 comments (0 inline, 0 general)