Added SQL storage backend
- Added Storage ABC - Moved Ledger class to storage/ledgercli.py - Added SQL requirements to requirements.txt
This commit is contained in:
parent
caf0de1979
commit
124bd1706d
9 changed files with 505 additions and 356 deletions
|
@ -1,320 +0,0 @@
|
|||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
from xml.etree import ElementTree
|
||||
from contextlib import contextmanager
|
||||
|
||||
from accounting.models import Account, Transaction, Posting, Amount
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
class Ledger:
|
||||
def __init__(self, ledger_file=None, ledger_bin=None):
|
||||
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):
|
||||
r'''
|
||||
Context manager that checks that the ledger process is not already
|
||||
locked, then "locks" the process and yields the process handle and
|
||||
unlocks the process when execution is returned.
|
||||
|
||||
Since this decorated as a :func:`contextlib.contextmanager` the
|
||||
recommended use is with the ``with``-statement.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with self.locked_process() as p:
|
||||
p.stdin.write(b'bal\n')
|
||||
|
||||
output = self.read_until_prompt(p)
|
||||
|
||||
'''
|
||||
if self.locked:
|
||||
raise RuntimeError('The process has already been locked,'
|
||||
' something\'s out of order.')
|
||||
|
||||
# XXX: This code has no purpose in a single-threaded process
|
||||
timeout = 5 # Seconds
|
||||
|
||||
for i in range(1, timeout + 2):
|
||||
if i > timeout:
|
||||
raise RuntimeError('Ledger process is already locked')
|
||||
|
||||
if not self.locked:
|
||||
break
|
||||
else:
|
||||
_log.info('Waiting for one second... %d/%d', i, timeout)
|
||||
time.sleep(1)
|
||||
|
||||
process = self.get_process()
|
||||
|
||||
self.locked = True
|
||||
_log.debug('Lock enabled')
|
||||
|
||||
yield process
|
||||
|
||||
self.locked = False
|
||||
_log.debug('Lock disabled')
|
||||
|
||||
def assemble_arguments(self):
|
||||
'''
|
||||
Returns a list of arguments suitable for :class:`subprocess.Popen` based on
|
||||
:attr:`self.ledger_bin` and :attr:`self.ledger_file`.
|
||||
'''
|
||||
return [
|
||||
self.ledger_bin,
|
||||
'-f',
|
||||
self.ledger_file,
|
||||
]
|
||||
|
||||
def init_process(self):
|
||||
'''
|
||||
Creates a new (presumably) ledger subprocess based on the args from
|
||||
:meth:`Ledger.assemble_arguments()` and then runs
|
||||
:meth:`Ledger.read_until_prompt()` once (which should return the banner
|
||||
text) and discards the output.
|
||||
'''
|
||||
_log.debug('Starting ledger process...')
|
||||
self.ledger_process = subprocess.Popen(
|
||||
self.assemble_arguments(),
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
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):
|
||||
'''
|
||||
Returns :attr:`self.ledger_process` if it evaluates to ``True``. If
|
||||
:attr:`self.ledger_process` is not set the result of
|
||||
:meth:`self.init_process() <Ledger.init_process>` is returned.
|
||||
'''
|
||||
return self.ledger_process or self.init_process()
|
||||
|
||||
def read_until_prompt(self, process):
|
||||
r'''
|
||||
Reads from the subprocess instance :data:`process` until it finds a
|
||||
combination of ``\n]\x20`` (the prompt), then returns the output
|
||||
without the prompt.
|
||||
'''
|
||||
output = b''
|
||||
|
||||
while True:
|
||||
line = process.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):
|
||||
'''
|
||||
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'):
|
||||
transaction.generate_id()
|
||||
|
||||
transaction_template = ('\n{date} {t.payee}\n'
|
||||
'{tags}'
|
||||
'{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_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:
|
||||
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:
|
||||
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,
|
||||
'%Y/%m/%d')
|
||||
payee = transaction.find('./payee').text
|
||||
|
||||
postings = []
|
||||
|
||||
for posting in transaction.findall('./postings/posting'):
|
||||
account = posting.find('./account/name').text
|
||||
amount = posting.find('./post-amount/amount/quantity').text
|
||||
symbol = posting.find(
|
||||
'./post-amount/amount/commodity/symbol').text
|
||||
|
||||
# Get the posting metadata
|
||||
metadata = {}
|
||||
|
||||
values = posting.findall('./metadata/value')
|
||||
if values:
|
||||
for value in values:
|
||||
key = value.get('key')
|
||||
value = value.find('./string').text
|
||||
|
||||
_log.debug('metadata: %s: %s', key, value)
|
||||
|
||||
metadata.update({key: value})
|
||||
|
||||
postings.append(
|
||||
Posting(account=account,
|
||||
metadata=metadata,
|
||||
amount=Amount(amount=amount, symbol=symbol)))
|
||||
|
||||
# Get the transaction metadata
|
||||
metadata = {}
|
||||
|
||||
values = transaction.findall('./metadata/value')
|
||||
if values:
|
||||
for value in values:
|
||||
key = value.get('key')
|
||||
value = value.find('./string').text
|
||||
|
||||
_log.debug('metadata: %s: %s', key, value)
|
||||
|
||||
metadata.update({key: value})
|
||||
|
||||
# Add a Transaction instance to the list
|
||||
entries.append(
|
||||
Transaction(date=date, payee=payee, postings=postings,
|
||||
metadata=metadata))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
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'))
|
||||
|
||||
args = parser.parse_args(argv[1:])
|
||||
logging.basicConfig(level=getattr(logging, args.verbosity, 'INFO'))
|
||||
ledger = Ledger(ledger_file='non-profit-test-data.ledger')
|
||||
print(ledger.bal())
|
||||
print(ledger.reg())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
|
@ -58,9 +58,9 @@ class Client:
|
|||
return self.post('/transaction', {'transactions': [t]})
|
||||
|
||||
def get_register(self):
|
||||
register = self.get('/register')
|
||||
register = self.get('/transaction')
|
||||
|
||||
return register['register_report']
|
||||
return register['transactions']
|
||||
|
||||
|
||||
def print_transactions(transactions):
|
||||
|
@ -71,18 +71,21 @@ def print_transactions(transactions):
|
|||
|
||||
for posting in transaction.postings:
|
||||
print(' ' + posting.account +
|
||||
' ' * (80 - len(posting.account) - len(posting.amount.symbol) -
|
||||
len(str(posting.amount.amount)) - 1 - 1) +
|
||||
posting.amount.symbol + ' ' + str(posting.amount.amount))
|
||||
' ' * (80 - len(posting.account) -
|
||||
len(posting.amount.symbol) -
|
||||
len(str(posting.amount.amount)) - 1 - 1) +
|
||||
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)
|
||||
|
||||
|
||||
|
@ -101,14 +104,14 @@ def main(argv=None, prog=None):
|
|||
insert.add_argument('to_account')
|
||||
insert.add_argument('amount', type=Decimal)
|
||||
|
||||
balance = actions.add_parser('balance', aliases=['bal'])
|
||||
actions.add_parser('balance', aliases=['bal'])
|
||||
|
||||
register = actions.add_parser('register', aliases=['reg'])
|
||||
actions.add_parser('register', aliases=['reg'])
|
||||
|
||||
parser.add_argument('-v', '--verbosity',
|
||||
default='WARNING',
|
||||
help=('Filter logging output. Possible values:' +
|
||||
' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
|
||||
' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
|
||||
parser.add_argument('--host', default='http://localhost:5000')
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
|
|
@ -4,3 +4,6 @@ LEDGER_FILE = os.environ.get('LEDGER_FILE', None)
|
|||
DEBUG = bool(int(os.environ.get('DEBUG', 0)))
|
||||
PORT = int(os.environ.get('PORT', 5000))
|
||||
HOST = os.environ.get('HOST', '127.0.0.1')
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get(
|
||||
'DATABASE_URI',
|
||||
'sqlite:///../accounting-api.sqlite')
|
||||
|
|
|
@ -3,8 +3,9 @@ from decimal import Decimal
|
|||
|
||||
|
||||
class Transaction:
|
||||
def __init__(self, date=None, payee=None, postings=None, metadata=None,
|
||||
_generate_id=False):
|
||||
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
|
||||
|
@ -14,7 +15,7 @@ class Transaction:
|
|||
self.generate_id()
|
||||
|
||||
def generate_id(self):
|
||||
self.metadata.update({'Id': uuid.uuid4()})
|
||||
self.id = uuid.uuid4()
|
||||
|
||||
def __repr__(self):
|
||||
return ('<{self.__class__.__name__} {date}' +
|
||||
|
|
19
accounting/storage/__init__.py
Normal file
19
accounting/storage/__init__.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
|
||||
class Storage:
|
||||
'''
|
||||
ABC for accounting storage
|
||||
'''
|
||||
def __init__(self, *args, **kw):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_transactions(self, *args, **kw):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_transaction(self, *args, **kw):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_account(self, *args, **kw):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_accounts(self, *args, **kw):
|
||||
raise NotImplementedError()
|
324
accounting/storage/ledgercli.py
Normal file
324
accounting/storage/ledgercli.py
Normal file
|
@ -0,0 +1,324 @@
|
|||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
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):
|
||||
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):
|
||||
r'''
|
||||
Context manager that checks that the ledger process is not already
|
||||
locked, then "locks" the process and yields the process handle and
|
||||
unlocks the process when execution is returned.
|
||||
|
||||
Since this decorated as a :func:`contextlib.contextmanager` the
|
||||
recommended use is with the ``with``-statement.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
with self.locked_process() as p:
|
||||
p.stdin.write(b'bal\n')
|
||||
|
||||
output = self.read_until_prompt(p)
|
||||
|
||||
'''
|
||||
if self.locked:
|
||||
raise RuntimeError('The process has already been locked,'
|
||||
' something\'s out of order.')
|
||||
|
||||
# XXX: This code has no purpose in a single-threaded process
|
||||
timeout = 5 # Seconds
|
||||
|
||||
for i in range(1, timeout + 2):
|
||||
if i > timeout:
|
||||
raise RuntimeError('Ledger process is already locked')
|
||||
|
||||
if not self.locked:
|
||||
break
|
||||
else:
|
||||
_log.info('Waiting for one second... %d/%d', i, timeout)
|
||||
time.sleep(1)
|
||||
|
||||
process = self.get_process()
|
||||
|
||||
self.locked = True
|
||||
_log.debug('Lock enabled')
|
||||
|
||||
yield process
|
||||
|
||||
self.locked = False
|
||||
_log.debug('Lock disabled')
|
||||
|
||||
def assemble_arguments(self):
|
||||
'''
|
||||
Returns a list of arguments suitable for :class:`subprocess.Popen`
|
||||
based on :attr:`self.ledger_bin` and :attr:`self.ledger_file`.
|
||||
'''
|
||||
return [
|
||||
self.ledger_bin,
|
||||
'-f',
|
||||
self.ledger_file,
|
||||
]
|
||||
|
||||
def init_process(self):
|
||||
'''
|
||||
Creates a new (presumably) ledger subprocess based on the args from
|
||||
:meth:`Ledger.assemble_arguments()` and then runs
|
||||
:meth:`Ledger.read_until_prompt()` once (which should return the banner
|
||||
text) and discards the output.
|
||||
'''
|
||||
_log.debug('Starting ledger process...')
|
||||
self.ledger_process = subprocess.Popen(
|
||||
self.assemble_arguments(),
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
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):
|
||||
'''
|
||||
Returns :attr:`self.ledger_process` if it evaluates to ``True``. If
|
||||
:attr:`self.ledger_process` is not set the result of
|
||||
:meth:`self.init_process() <Ledger.init_process>` is returned.
|
||||
'''
|
||||
return self.ledger_process or self.init_process()
|
||||
|
||||
def read_until_prompt(self, process):
|
||||
r'''
|
||||
Reads from the subprocess instance :data:`process` until it finds a
|
||||
combination of ``\n]\x20`` (the prompt), then returns the output
|
||||
without the prompt.
|
||||
'''
|
||||
output = b''
|
||||
|
||||
while True:
|
||||
line = process.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):
|
||||
'''
|
||||
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'):
|
||||
transaction.generate_id()
|
||||
|
||||
transaction_template = ('\n{date} {t.payee}\n'
|
||||
'{tags}'
|
||||
'{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_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:
|
||||
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:
|
||||
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,
|
||||
'%Y/%m/%d')
|
||||
payee = transaction.find('./payee').text
|
||||
|
||||
postings = []
|
||||
|
||||
for posting in transaction.findall('./postings/posting'):
|
||||
account = posting.find('./account/name').text
|
||||
amount = posting.find('./post-amount/amount/quantity').text
|
||||
symbol = posting.find(
|
||||
'./post-amount/amount/commodity/symbol').text
|
||||
|
||||
# Get the posting metadata
|
||||
metadata = {}
|
||||
|
||||
values = posting.findall('./metadata/value')
|
||||
if values:
|
||||
for value in values:
|
||||
key = value.get('key')
|
||||
value = value.find('./string').text
|
||||
|
||||
_log.debug('metadata: %s: %s', key, value)
|
||||
|
||||
metadata.update({key: value})
|
||||
|
||||
postings.append(
|
||||
Posting(account=account,
|
||||
metadata=metadata,
|
||||
amount=Amount(amount=amount, symbol=symbol)))
|
||||
|
||||
# Get the transaction metadata
|
||||
metadata = {}
|
||||
|
||||
values = transaction.findall('./metadata/value')
|
||||
if values:
|
||||
for value in values:
|
||||
key = value.get('key')
|
||||
value = value.find('./string').text
|
||||
|
||||
_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 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'))
|
||||
|
||||
args = parser.parse_args(argv[1:])
|
||||
logging.basicConfig(level=getattr(logging, args.verbosity, 'INFO'))
|
||||
ledger = Ledger(ledger_file='non-profit-test-data.ledger')
|
||||
print(ledger.bal())
|
||||
print(ledger.reg())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
72
accounting/storage/sql/__init__.py
Normal file
72
accounting/storage/sql/__init__.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
import logging
|
||||
import json
|
||||
|
||||
from flask.ext.sqlalchemy import SQLAlchemy
|
||||
|
||||
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):
|
||||
global db
|
||||
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
|
||||
|
||||
def get_transactions(self, *args, **kw):
|
||||
transactions = []
|
||||
|
||||
for transaction in self.Transaction.query.all():
|
||||
dict_transaction = transaction.as_dict()
|
||||
dict_postings = dict_transaction.pop('postings')
|
||||
|
||||
postings = []
|
||||
|
||||
for dict_posting in dict_postings:
|
||||
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 add_transaction(self, transaction):
|
||||
if transaction.id is None:
|
||||
transaction.generate_id()
|
||||
|
||||
_t = self.Transaction()
|
||||
_t.uuid = str(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.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()
|
53
accounting/storage/sql/models.py
Normal file
53
accounting/storage/sql/models.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
import json
|
||||
|
||||
from . import db
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
id = db.Column(db.Integer(), primary_key=True)
|
||||
uuid = db.Column(db.String, unique=True, nullable=False)
|
||||
date = db.Column(db.DateTime)
|
||||
payee = db.Column(db.String())
|
||||
meta = db.Column(db.String())
|
||||
|
||||
def as_dict(self):
|
||||
return dict(
|
||||
id=self.uuid,
|
||||
date=self.date,
|
||||
payee=self.payee,
|
||||
postings=[p.as_dict() for p in self.postings],
|
||||
metadata=json.loads(self.meta)
|
||||
)
|
||||
|
||||
|
||||
class Posting(db.Model):
|
||||
id = db.Column(db.Integer(), primary_key=True)
|
||||
|
||||
transaction_uuid = db.Column(db.String, db.ForeignKey('transaction.uuid'))
|
||||
transaction = db.relationship('Transaction', backref='postings')
|
||||
|
||||
account = db.Column(db.String, nullable=False)
|
||||
|
||||
amount_id = db.Column(db.Integer, db.ForeignKey('amount.id'))
|
||||
amount = db.relationship('Amount')
|
||||
|
||||
meta = db.Column(db.String)
|
||||
|
||||
def as_dict(self):
|
||||
return dict(
|
||||
account=self.account,
|
||||
amount=self.amount.as_dict(),
|
||||
metadata=json.loads(self.meta)
|
||||
)
|
||||
|
||||
|
||||
class Amount(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
symbol = db.Column(db.String)
|
||||
amount = db.Column(db.Numeric)
|
||||
|
||||
def as_dict(self):
|
||||
return dict(
|
||||
symbol=self.symbol,
|
||||
amount=self.amount
|
||||
)
|
|
@ -7,8 +7,12 @@ import logging
|
|||
import argparse
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from flask.ext.sqlalchemy import SQLAlchemy
|
||||
from flask.ext.script import Manager
|
||||
from flask.ext.migrate import Migrate, MigrateCommand
|
||||
|
||||
from accounting import Ledger
|
||||
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
|
||||
|
@ -17,7 +21,15 @@ from accounting.decorators import jsonify_exceptions
|
|||
app = Flask('accounting')
|
||||
app.config.from_pyfile('config.py')
|
||||
|
||||
ledger = None
|
||||
storage = SQLStorage(app)
|
||||
|
||||
# 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():
|
||||
|
@ -26,7 +38,7 @@ def init_ledger():
|
|||
:py:class:`accounting.Ledger` object.
|
||||
'''
|
||||
global ledger
|
||||
ledger = Ledger(ledger_file=app.config['LEDGER_FILE'])
|
||||
#ledger = Ledger(ledger_file=app.config['LEDGER_FILE'])
|
||||
|
||||
|
||||
# These will convert output from our internal classes to JSON and back
|
||||
|
@ -40,21 +52,13 @@ def index():
|
|||
return 'Hello World!'
|
||||
|
||||
|
||||
@app.route('/balance')
|
||||
def balance_report():
|
||||
'''
|
||||
Returns the JSON-serialized result of :meth:`accounting.Ledger.bal`
|
||||
'''
|
||||
report_data = ledger.bal()
|
||||
|
||||
return jsonify(balance_report=report_data)
|
||||
|
||||
@app.route('/transaction', methods=['GET'])
|
||||
def transaction_get():
|
||||
'''
|
||||
Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
|
||||
'''
|
||||
return jsonify(transactions=ledger.reg())
|
||||
return jsonify(transactions=storage.get_transactions())
|
||||
|
||||
|
||||
@app.route('/transaction', methods=['POST'])
|
||||
@jsonify_exceptions
|
||||
|
@ -112,7 +116,7 @@ def transaction_post():
|
|||
raise AccountingException('No transaction data provided')
|
||||
|
||||
for transaction in transactions:
|
||||
ledger.add_transaction(transaction)
|
||||
storage.add_transaction(transaction)
|
||||
|
||||
return jsonify(foo='bar')
|
||||
|
||||
|
@ -166,16 +170,6 @@ def parse_json():
|
|||
return jsonify(foo='bar')
|
||||
|
||||
|
||||
@app.route('/register')
|
||||
def register_report():
|
||||
'''
|
||||
Returns the JSON-serialized output of :py:meth:`accounting.Ledger.reg`
|
||||
'''
|
||||
report_data = ledger.reg()
|
||||
|
||||
return jsonify(register_report=report_data)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
prog = __name__
|
||||
if argv is None:
|
||||
|
@ -186,7 +180,7 @@ def main(argv=None):
|
|||
parser.add_argument('-v', '--verbosity',
|
||||
default='INFO',
|
||||
help=('Filter logging output. Possible values:' +
|
||||
' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
|
||||
' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
|
|
Loading…
Reference in a new issue