[test] Added unit tests
- Moved the TransactionNotFound exception to a more appropriate place. - Changed the serialization for AccountingExceptions - Override the Exception.__init__ method in AccountingException - Added __eq__ methods to accounting.models.* - Catch the TransactionNotFound exception in transaction_get and return a 404 instead. This could be improved, perhaps in the jsonify_exceptions decorator so that all endpoints that raise a TransactionNotFound exception automatically return a 404.
This commit is contained in:
parent
36d91dd0b3
commit
281d6fed47
7 changed files with 70 additions and 41 deletions
|
@ -7,4 +7,11 @@ class AccountingException(Exception):
|
||||||
Used as a base for exceptions that are returned to the caller via the
|
Used as a base for exceptions that are returned to the caller via the
|
||||||
jsonify_exceptions decorator
|
jsonify_exceptions decorator
|
||||||
'''
|
'''
|
||||||
|
def __init__(self, message, **kw):
|
||||||
|
self.message = message
|
||||||
|
for key, value in kw.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionNotFound(AccountingException):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
# Part of accounting-api project:
|
# Part of accounting-api project:
|
||||||
# https://gitorious.org/conservancy/accounting-api
|
# https://gitorious.org/conservancy/accounting-api
|
||||||
# License: AGPLv3-or-later
|
# License: AGPLv3-or-later
|
||||||
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
class Transaction:
|
class Transaction:
|
||||||
def __init__(self, id=None, date=None, payee=None, postings=None,
|
def __init__(self, id=None, date=None, payee=None, postings=None,
|
||||||
metadata=None, _generate_id=False):
|
metadata=None, _generate_id=False):
|
||||||
|
if type(date) == datetime.datetime:
|
||||||
|
date = date.date()
|
||||||
|
|
||||||
self.id = id
|
self.id = id
|
||||||
self.date = date
|
self.date = date
|
||||||
self.payee = payee
|
self.payee = payee
|
||||||
|
@ -21,6 +25,9 @@ class Transaction:
|
||||||
def generate_id(self):
|
def generate_id(self):
|
||||||
self.id = str(uuid.uuid4())
|
self.id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.__dict__ == other.__dict__
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return ('<{self.__class__.__name__} {self.id} {date}' +
|
return ('<{self.__class__.__name__} {self.id} {date}' +
|
||||||
' {self.payee} {self.postings}').format(
|
' {self.payee} {self.postings}').format(
|
||||||
|
@ -34,6 +41,9 @@ class Posting:
|
||||||
self.amount = amount
|
self.amount = amount
|
||||||
self.metadata = metadata if metadata is not None else {}
|
self.metadata = metadata if metadata is not None else {}
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.__dict__ == other.__dict__
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return ('<{self.__class__.__name__} "{self.account}"' +
|
return ('<{self.__class__.__name__} "{self.account}"' +
|
||||||
' {self.amount}>').format(self=self)
|
' {self.amount}>').format(self=self)
|
||||||
|
@ -44,6 +54,9 @@ class Amount:
|
||||||
self.amount = Decimal(amount)
|
self.amount = Decimal(amount)
|
||||||
self.symbol = symbol
|
self.symbol = symbol
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.__dict__ == other.__dict__
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return ('<{self.__class__.__name__} {self.symbol}' +
|
return ('<{self.__class__.__name__} {self.symbol}' +
|
||||||
' {self.amount}>').format(self=self)
|
' {self.amount}>').format(self=self)
|
||||||
|
@ -55,6 +68,9 @@ class Account:
|
||||||
self.amounts = amounts
|
self.amounts = amounts
|
||||||
self.accounts = accounts
|
self.accounts = accounts
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.__dict__ == other.__dict__
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return ('<{self.__class__.__name__} "{self.name}" {self.amounts}' +
|
return ('<{self.__class__.__name__} "{self.name}" {self.amounts}' +
|
||||||
' {self.accounts}>').format(self=self)
|
' {self.accounts}>').format(self=self)
|
||||||
|
|
|
@ -4,8 +4,6 @@
|
||||||
|
|
||||||
from abc import ABCMeta, abstractmethod
|
from abc import ABCMeta, abstractmethod
|
||||||
|
|
||||||
from accounting.exceptions import AccountingException
|
|
||||||
|
|
||||||
|
|
||||||
class Storage:
|
class Storage:
|
||||||
'''
|
'''
|
||||||
|
@ -47,7 +45,3 @@ class Storage:
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def reverse_transaction(self, transaction_id):
|
def reverse_transaction(self, transaction_id):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class TransactionNotFound(AccountingException):
|
|
||||||
pass
|
|
||||||
|
|
|
@ -12,9 +12,9 @@ from datetime import datetime
|
||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from accounting.exceptions import AccountingException
|
from accounting.exceptions import AccountingException, TransactionNotFound
|
||||||
from accounting.models import Account, Transaction, Posting, Amount
|
from accounting.models import Account, Transaction, Posting, Amount
|
||||||
from accounting.storage import Storage, TransactionNotFound
|
from accounting.storage import Storage
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -129,6 +129,10 @@ class Ledger(Storage):
|
||||||
while True:
|
while True:
|
||||||
line = process.stdout.read(1) # XXX: This is a hack
|
line = process.stdout.read(1) # XXX: This is a hack
|
||||||
|
|
||||||
|
if len(line) > 0:
|
||||||
|
pass
|
||||||
|
#_log.debug('line: %s', line)
|
||||||
|
|
||||||
output += line
|
output += line
|
||||||
|
|
||||||
if b'\n] ' in output:
|
if b'\n] ' in output:
|
||||||
|
@ -148,6 +152,8 @@ class Ledger(Storage):
|
||||||
if isinstance(command, str):
|
if isinstance(command, str):
|
||||||
command = command.encode('utf8')
|
command = command.encode('utf8')
|
||||||
|
|
||||||
|
_log.debug('Sending command: %r', command)
|
||||||
|
|
||||||
p.stdin.write(command + b'\n')
|
p.stdin.write(command + b'\n')
|
||||||
p.stdin.flush()
|
p.stdin.flush()
|
||||||
|
|
||||||
|
@ -263,8 +269,8 @@ class Ledger(Storage):
|
||||||
if transaction.id == transaction_id:
|
if transaction.id == transaction_id:
|
||||||
return transaction
|
return transaction
|
||||||
|
|
||||||
raise TransactionNotFound('No transaction with id %s found',
|
raise TransactionNotFound(
|
||||||
transaction_id)
|
'No transaction with id {0} found'.format(transaction_id))
|
||||||
|
|
||||||
def reg(self):
|
def reg(self):
|
||||||
output = self.send_command('xml')
|
output = self.send_command('xml')
|
||||||
|
@ -321,7 +327,13 @@ class Ledger(Storage):
|
||||||
metadata.update({key: value})
|
metadata.update({key: value})
|
||||||
|
|
||||||
# Add a Transaction instance to the list
|
# Add a Transaction instance to the list
|
||||||
id = metadata.pop('Id')
|
try:
|
||||||
|
id = metadata.pop('Id')
|
||||||
|
except KeyError:
|
||||||
|
_log.warning('Transaction on %s with payee %s does not have an'
|
||||||
|
' Id attribute. A temporary ID will be used.',
|
||||||
|
date, payee)
|
||||||
|
id = 'NO-ID'
|
||||||
entries.append(
|
entries.append(
|
||||||
Transaction(id=id, date=date, payee=payee, postings=postings,
|
Transaction(id=id, date=date, payee=payee, postings=postings,
|
||||||
metadata=metadata))
|
metadata=metadata))
|
||||||
|
|
|
@ -6,6 +6,7 @@ from datetime import datetime
|
||||||
|
|
||||||
from flask import json
|
from flask import json
|
||||||
|
|
||||||
|
from accounting.exceptions import AccountingException
|
||||||
from accounting.models import Amount, Transaction, Posting, Account
|
from accounting.models import Amount, Transaction, Posting, Account
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,10 +41,10 @@ class AccountingEncoder(json.JSONEncoder):
|
||||||
amount=str(o.amount),
|
amount=str(o.amount),
|
||||||
symbol=o.symbol
|
symbol=o.symbol
|
||||||
)
|
)
|
||||||
elif isinstance(o, Exception):
|
elif isinstance(o, AccountingException):
|
||||||
return dict(
|
return dict(
|
||||||
__type__=o.__class__.__name__,
|
type=o.__class__.__name__,
|
||||||
args=o.args
|
message=o.message
|
||||||
)
|
)
|
||||||
|
|
||||||
return json.JSONEncoder.default(self, o)
|
return json.JSONEncoder.default(self, o)
|
||||||
|
@ -58,7 +59,7 @@ class AccountingDecoder(json.JSONDecoder):
|
||||||
return d
|
return d
|
||||||
|
|
||||||
types = {c.__name__: c for c in [Amount, Transaction, Posting,
|
types = {c.__name__: c for c in [Amount, Transaction, Posting,
|
||||||
Account]}
|
Account, AccountingException]}
|
||||||
|
|
||||||
_type = d.pop('__type__')
|
_type = d.pop('__type__')
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import sys
|
||||||
import logging
|
import logging
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from flask import Flask, jsonify, request, render_template
|
from flask import Flask, jsonify, request, render_template, abort
|
||||||
from flask.ext.script import Manager
|
from flask.ext.script import Manager
|
||||||
from flask.ext.migrate import Migrate, MigrateCommand
|
from flask.ext.migrate import Migrate, MigrateCommand
|
||||||
|
|
||||||
|
@ -19,32 +19,18 @@ from accounting.storage import Storage
|
||||||
from accounting.storage.ledgercli import Ledger
|
from accounting.storage.ledgercli import Ledger
|
||||||
from accounting.storage.sql import SQLStorage
|
from accounting.storage.sql import SQLStorage
|
||||||
from accounting.transport import AccountingEncoder, AccountingDecoder
|
from accounting.transport import AccountingEncoder, AccountingDecoder
|
||||||
from accounting.exceptions import AccountingException
|
from accounting.exceptions import AccountingException, TransactionNotFound
|
||||||
from accounting.decorators import jsonify_exceptions, cors
|
from accounting.decorators import jsonify_exceptions, cors
|
||||||
|
|
||||||
|
|
||||||
app = Flask('accounting')
|
app = Flask('accounting')
|
||||||
app.config.from_pyfile('config.py')
|
app.config.from_pyfile('config.py')
|
||||||
|
|
||||||
storage = Storage()
|
app.ledger = Storage()
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def init_ledger():
|
def init_ledger():
|
||||||
'''
|
app.ledger = Ledger(app)
|
||||||
: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'])
|
|
||||||
|
|
||||||
|
|
||||||
# These will convert output from our internal classes to JSON and back
|
# These will convert output from our internal classes to JSON and back
|
||||||
|
@ -85,9 +71,12 @@ def transaction_get(transaction_id=None):
|
||||||
Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
|
Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
|
||||||
'''
|
'''
|
||||||
if transaction_id is None:
|
if transaction_id is None:
|
||||||
return jsonify(transactions=storage.get_transactions())
|
return jsonify(transactions=app.ledger.get_transactions())
|
||||||
|
|
||||||
return jsonify(transaction=storage.get_transaction(transaction_id))
|
try:
|
||||||
|
return jsonify(transaction=app.ledger.get_transaction(transaction_id))
|
||||||
|
except TransactionNotFound:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/transaction/<string:transaction_id>', methods=['POST'])
|
@app.route('/transaction/<string:transaction_id>', methods=['POST'])
|
||||||
|
@ -105,7 +94,7 @@ def transaction_update(transaction_id=None):
|
||||||
elif transaction.id is None:
|
elif transaction.id is None:
|
||||||
transaction.id = transaction_id
|
transaction.id = transaction_id
|
||||||
|
|
||||||
storage.update_transaction(transaction)
|
app.ledger.update_transaction(transaction)
|
||||||
|
|
||||||
return jsonify(status='OK')
|
return jsonify(status='OK')
|
||||||
|
|
||||||
|
@ -117,7 +106,7 @@ def transaction_delete(transaction_id=None):
|
||||||
if transaction_id is None:
|
if transaction_id is None:
|
||||||
raise AccountingException('Transaction ID cannot be None')
|
raise AccountingException('Transaction ID cannot be None')
|
||||||
|
|
||||||
storage.delete_transaction(transaction_id)
|
app.ledger.delete_transaction(transaction_id)
|
||||||
|
|
||||||
return jsonify(status='OK')
|
return jsonify(status='OK')
|
||||||
|
|
||||||
|
@ -184,7 +173,7 @@ def transaction_post():
|
||||||
transaction_ids = []
|
transaction_ids = []
|
||||||
|
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
transaction_ids.append(storage.add_transaction(transaction))
|
transaction_ids.append(app.ledger.add_transaction(transaction))
|
||||||
|
|
||||||
return jsonify(status='OK', transaction_ids=transaction_ids)
|
return jsonify(status='OK', transaction_ids=transaction_ids)
|
||||||
|
|
||||||
|
@ -201,8 +190,7 @@ def main(argv=None):
|
||||||
help=('Filter logging output. Possible values:' +
|
help=('Filter logging output. Possible values:' +
|
||||||
' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
|
' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
|
||||||
|
|
||||||
global storage
|
init_ledger()
|
||||||
storage = Ledger(app=app)
|
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
Assets:Checking $50.00
|
Assets:Checking $50.00
|
||||||
|
|
||||||
2011/04/20 (2) Baz Hosting Services, LLC
|
2011/04/20 (2) Baz Hosting Services, LLC
|
||||||
|
;Party: Baz Hosting Services, LLC
|
||||||
;Id: this is probably unique
|
;Id: this is probably unique
|
||||||
Expenses:Blah:Hosting $250.00
|
Expenses:Blah:Hosting $250.00
|
||||||
;Receipt: Projects/Blah/Expenses/hosting/AprilHostingReceipt.pdf
|
;Receipt: Projects/Blah/Expenses/hosting/AprilHostingReceipt.pdf
|
||||||
|
@ -44,3 +45,13 @@
|
||||||
;Id: 31048b9d-a5b6-41d7-951a-e7128e7c53c0
|
;Id: 31048b9d-a5b6-41d7-951a-e7128e7c53c0
|
||||||
Income:Donations:PayPal $ -20.18
|
Income:Donations:PayPal $ -20.18
|
||||||
Assets:Checking $ 20.18
|
Assets:Checking $ 20.18
|
||||||
|
|
||||||
|
2013-12-19 AngularJS Donation
|
||||||
|
;Id: 593d921c-07f9-496d-a1b7-47bb68ff00d4
|
||||||
|
Assets:Checking USD -10.00
|
||||||
|
Expenses:Donations USD 10.00
|
||||||
|
|
||||||
|
2013-12-20 PageKite donation
|
||||||
|
;Id: b3576a94-2a53-4d40-bd92-06b2532937f5
|
||||||
|
Assets:Checking USD -10.00
|
||||||
|
Expenses:Donations USD 10.00
|
||||||
|
|
Loading…
Reference in a new issue