diff --git a/doc/build/html/.buildinfo b/doc/build/html/.buildinfo new file mode 100644 index 0000000..ead248e --- /dev/null +++ b/doc/build/html/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: b1e0a7a37d51c2c1ffe67d5d69a96004 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/doc/build/html/_modules/accounting.html b/doc/build/html/_modules/accounting.html new file mode 100644 index 0000000..4b7754e --- /dev/null +++ b/doc/build/html/_modules/accounting.html @@ -0,0 +1,412 @@ + + + + +
+ + +
+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__)
+
+[docs]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
+[docs] 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')
+
+[docs] 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,
+ ]
+
+[docs] 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
+
+[docs] 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()
+
+[docs] 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
+
+[docs] 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
+
+[docs] 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)
+
+[docs] 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
+
+[docs] 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
+
+
+[docs]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())
+
+import sys
+import argparse
+import json
+import logging
+
+from datetime import datetime
+from decimal import Decimal
+
+import requests
+
+from accounting.models import Transaction, Posting, Amount
+from accounting.transport import AccountingDecoder, AccountingEncoder
+
+_log = logging.getLogger(__name__)
+
+
+[docs]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
+
+
+[docs] def get(self, path):
+ response = requests.get(self.host + path)
+
+ return self._decode_response(response)
+
+ def _decode_response(self, response):
+ response_data = response.json(cls=self.json_decoder)
+
+ _log.debug('response_data: %s', response_data)
+
+ return response_data
+
+[docs] 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))
+
+[docs] def simple_transaction(self, from_acc, to_acc, amount):
+ t = Transaction(
+ date=datetime.today(),
+ payee='PayPal donation',
+ postings=[
+ Posting(account=from_acc,
+ amount=Amount(symbol='$', amount=-amount)),
+ Posting(account=to_acc,
+ amount=Amount(symbol='$', amount=amount))
+ ]
+ )
+
+ return self.post('/transaction', {'transactions': [t]})
+
+[docs] def get_register(self):
+ register = self.get('/register')
+
+ return register['register_report']
+
+
+[docs]def print_transactions(transactions):
+ for transaction in transactions:
+ print('{date} {t.payee:.<69}'.format(
+ date=transaction.date.strftime('%Y-%m-%d'),
+ t=transaction))
+
+ 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))
+
+
+[docs]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)
+
+
+[docs]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('from_account')
+ insert.add_argument('to_account')
+ insert.add_argument('amount', type=Decimal)
+
+ balance = actions.add_parser('balance', aliases=['bal'])
+
+ register = 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))
+ 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())
+
+from functools import wraps
+
+from flask import jsonify
+
+from accounting.exceptions import AccountingException
+
+
+[docs]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
+
+[docs]class AccountingException(Exception):
+ '''
+ Used as a base for exceptions that are returned to the caller via the
+ jsonify_exceptions decorator
+ '''
+ pass
+
+import sys
+import logging
+import threading
+import pkg_resources
+
+from functools import wraps
+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__)
+
+
+[docs]def indicate_activity(func_or_str):
+ description = None
+
+ def decorator(func):
+ @wraps(func)
+ def wrapper(self, *args, **kw):
+ self.status_description.set_text(description)
+ self.activity_indicator.show()
+ self.activity_indicator.start()
+
+ return func(self, *args, **kw)
+
+ return wrapper
+
+ if callable(func_or_str):
+ description = 'Working'
+ return decorator(func_or_str)
+ else:
+ description = func_or_str
+ return decorator
+
+
+[docs]def indicate_activity_done(func):
+ @wraps(func)
+ def wrapper(self, *args, **kw):
+ self.status_description.set_text('')
+ self.activity_indicator.stop()
+ self.activity_indicator.hide()
+
+ return func(self, *args, **kw)
+
+ return wrapper
+
+
+[docs]class AccountingApplication:
+ def __init__(self):
+ #Gtk.Window.__init__(self, title='Accounting Client')
+
+ self.client = Client()
+
+ self.load_ui(pkg_resources.resource_filename(
+ 'accounting', 'res/client-ui.glade'))
+
+ self.aboutdialog.set_transient_for(self.accounting_window)
+
+ self.accounting_window.connect('delete-event', Gtk.main_quit)
+ self.accounting_window.set_border_width(0)
+ self.accounting_window.set_default_geometry(640, 360)
+
+ self.accounting_window.show_all()
+
+[docs] def load_ui(self, path):
+ _log.debug('Loading UI...')
+ builder = Gtk.Builder()
+ builder.add_from_file(path)
+ builder.connect_signals(self)
+
+ for element in builder.get_objects():
+ _log.debug('Loaded %s', Gtk.Buildable.get_name(element))
+ setattr(self, Gtk.Buildable.get_name(element), element)
+
+ _log.debug('UI loaded')
+
+[docs] def on_transaction_selected(self, widget):
+ selection = self.transaction_view.get_selection()
+ selection.set_mode(Gtk.SelectionMode.SINGLE)
+ xact_store, xact_iter = selection.get_selected()
+
+ xact_id = xact_store.get_value(xact_iter, 0)
+ _log.debug('selection: %s', xact_id)
+
+ for transaction in self.transaction_data:
+ if transaction.id == xact_id:
+ self.lbl_payee.set_text(transaction.payee)
+
+ self.posting_store.clear()
+
+ for posting in transaction.postings:
+ self.posting_store.append([
+ posting.account,
+ str(posting.amount.amount),
+ posting.amount.symbol
+ ])
+
+ self.detail_view.show()
+ break
+
+[docs] def on_show_about_activate(self, widget):
+ _log.debug('Showing About')
+ self.aboutdialog.show_all()
+
+[docs] def on_aboutdialog_close(self, widget):
+ _log.debug('Closing About')
+ self.aboutdialog.hide_all()
+
+ @indicate_activity('Refreshing Transactions')
+[docs] def on_refresh_transactions_activate(self, widget):
+ def load_transactions():
+ transactions = self.client.get_register()
+ GLib.idle_add(self.on_transactions_loaded, transactions)
+
+ threading.Thread(target=load_transactions).start()
+
+ @indicate_activity_done
+[docs] def on_transactions_loaded(self, transactions):
+ _log.debug('transactions: %s', transactions)
+
+ self.transaction_data = transactions
+ self.transaction_store.clear()
+
+ for transaction in transactions:
+ self.transaction_store.append([
+ transaction.id,
+ transaction.date.strftime('%Y-%m-%d'),
+ transaction.payee
+ ])
+
+
+[docs]def main(argv=None):
+ logging.basicConfig(level=logging.DEBUG)
+
+ GObject.threads_init()
+
+ accounting = AccountingApplication()
+ #accounting_win.connect('delete-event', Gtk.main_quit)
+
+ Gtk.main()
+
+if __name__ == '__main__':
+ sys.exit(main())
+
+import uuid
+from decimal import Decimal
+
+
+[docs]class Transaction:
+ def __init__(self, date=None, payee=None, postings=None, metadata=None,
+ _generate_id=False):
+ 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 __repr__(self):
+ return ('<{self.__class__.__name__} {date}' +
+ ' {self.payee} {self.postings}').format(
+ self=self,
+ date=self.date.strftime('%Y-%m-%d'))
+
+
+[docs]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):
+ return ('<{self.__class__.__name__} "{self.account}"' +
+ ' {self.amount}>').format(self=self)
+
+
+[docs]class Amount:
+ def __init__(self, amount=None, symbol=None):
+ self.amount = Decimal(amount)
+ self.symbol = symbol
+
+ def __repr__(self):
+ return ('<{self.__class__.__name__} {self.symbol}' +
+ ' {self.amount}>').format(self=self)
+
+
+[docs]class Account:
+ def __init__(self, name=None, amounts=None, accounts=None):
+ self.name = name
+ self.amounts = amounts
+ self.accounts = accounts
+
+ def __repr__(self):
+ return ('<{self.__class__.__name__} "{self.name}" {self.amounts}' +
+ ' {self.accounts}>').format(self=self)
+
+class Storage:
+[docs] '''
+ ABC for accounting storage
+ '''
+ def __init__(self, *args, **kw):
+ raise NotImplementedError()
+
+ def get_transactions(self, *args, **kw):
+
+
+
+
+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__)
+
+
+[docs]class Ledger(Storage):
+ 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
+[docs] 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')
+
+[docs] 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,
+ ]
+
+[docs] 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
+
+[docs] 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()
+
+[docs] 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
+
+[docs] 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
+
+[docs] 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)
+
+[docs] 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
+
+
+[docs] 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
+
+[docs] def update_transaction(self, transaction):
+ _log.debug('DUMMY: Updated transaction: %s', transaction)
+
+
+[docs]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())
+
+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
+
+
+[docs]class SQLStorage(Storage):
+ 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
+
+[docs] 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
+
+[docs] 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)
+
+[docs] def add_transaction(self, transaction):
+ if transaction.id is None:
+ transaction.generate_id()
+
+ _t = self.Transaction()
+ _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 = 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()
+
+import json
+
+from . import db
+
+
+[docs]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())
+
+[docs] 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)
+ )
+
+
+[docs]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)
+
+[docs] def as_dict(self):
+ return dict(
+ account=self.account,
+ amount=self.amount.as_dict(),
+ metadata=json.loads(self.meta)
+ )
+
+
+
+
+from datetime import datetime
+
+from flask import json
+
+from accounting.models import Amount, Transaction, Posting, Account
+
+[docs]class AccountingEncoder(json.JSONEncoder):
+[docs] 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,
+ metadata=o.metadata
+ )
+ elif isinstance(o, Posting):
+ return dict(
+ __type__=o.__class__.__name__,
+ account=o.account,
+ amount=o.amount,
+ metadata=o.metadata
+ )
+ elif isinstance(o, Amount):
+ return dict(
+ __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)
+
+[docs]class AccountingDecoder(json.JSONDecoder):
+ def __init__(self):
+ json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)
+
+[docs] 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)
+
+'''
+This module contains the high-level webservice logic such as the Flask setup
+and the Flask endpoints.
+'''
+import sys
+import logging
+import argparse
+
+from flask import Flask, jsonify, request
+
+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 = None
+
+@app.before_request
+[docs]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'])
+
+
+# These will convert output from our internal classes to JSON and back
+app.json_encoder = AccountingEncoder
+app.json_decoder = AccountingDecoder
+
+
+@app.route('/')
+
+@app.route('/balance')
+[docs]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'])
+[docs]def transaction_get():
+ '''
+ Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
+ '''
+ return jsonify(transactions=ledger.reg())
+
+@app.route('/transaction', methods=['POST'])
+@jsonify_exceptions
+[docs]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.
+
+ 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'])
+[docs]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')
+
+
+@app.route('/register')
+[docs]def register_report():
+ '''
+ Returns the JSON-serialized output of :py:meth:`accounting.Ledger.reg`
+ '''
+ report_data = ledger.reg()
+
+ return jsonify(register_report=report_data)
+
+
+[docs]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:' +
+ ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
+
+ args = parser.parse_args(argv)
+
+ logging.basicConfig(level=getattr(logging, args.verbosity, 'INFO'))
+
+ app.run(host=app.config['HOST'], port=app.config['PORT'])
+
+if __name__ == '__main__':
+ main()
+
' + _('Hide Search Matches') + '
') + .appendTo($('#searchbox')); + } + }, + + /** + * init the domain index toggle buttons + */ + initIndexTable : function() { + var togglers = $('img.toggler').click(function() { + var src = $(this).attr('src'); + var idnum = $(this).attr('id').substr(7); + $('tr.cg-' + idnum).toggle(); + if (src.substr(-9) == 'minus.png') + $(this).attr('src', src.substr(0, src.length-9) + 'plus.png'); + else + $(this).attr('src', src.substr(0, src.length-8) + 'minus.png'); + }).css('display', ''); + if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) { + togglers.click(); + } + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords : function() { + $('#searchbox .highlight-link').fadeOut(300); + $('span.highlighted').removeClass('highlighted'); + }, + + /** + * make the url absolute + */ + makeURL : function(relativeURL) { + return DOCUMENTATION_OPTIONS.URL_ROOT + '/' + relativeURL; + }, + + /** + * get the current relative url + */ + getCurrentURL : function() { + var path = document.location.pathname; + var parts = path.split(/\//); + $.each(DOCUMENTATION_OPTIONS.URL_ROOT.split(/\//), function() { + if (this == '..') + parts.pop(); + }); + var url = parts.join('/'); + return path.substring(url.lastIndexOf('/') + 1, path.length - 1); + } +}; + +// quick alias for translations +_ = Documentation.gettext; + +$(document).ready(function() { + Documentation.init(); +}); diff --git a/doc/build/html/_static/down-pressed.png b/doc/build/html/_static/down-pressed.png new file mode 100644 index 0000000..6f7ad78 Binary files /dev/null and b/doc/build/html/_static/down-pressed.png differ diff --git a/doc/build/html/_static/down.png b/doc/build/html/_static/down.png new file mode 100644 index 0000000..3003a88 Binary files /dev/null and b/doc/build/html/_static/down.png differ diff --git a/doc/build/html/_static/file.png b/doc/build/html/_static/file.png new file mode 100644 index 0000000..d18082e Binary files /dev/null and b/doc/build/html/_static/file.png differ diff --git a/doc/build/html/_static/jquery.js b/doc/build/html/_static/jquery.js new file mode 100644 index 0000000..83589da --- /dev/null +++ b/doc/build/html/_static/jquery.js @@ -0,0 +1,2 @@ +/*! jQuery v1.8.3 jquery.com | jquery.org/license */ +(function(e,t){function _(e){var t=M[e]={};return v.each(e.split(y),function(e,n){t[n]=!0}),t}function H(e,n,r){if(r===t&&e.nodeType===1){var i="data-"+n.replace(P,"-$1").toLowerCase();r=e.getAttribute(i);if(typeof r=="string"){try{r=r==="true"?!0:r==="false"?!1:r==="null"?null:+r+""===r?+r:D.test(r)?v.parseJSON(r):r}catch(s){}v.data(e,n,r)}else r=t}return r}function B(e){var t;for(t in e){if(t==="data"&&v.isEmptyObject(e[t]))continue;if(t!=="toJSON")return!1}return!0}function et(){return!1}function tt(){return!0}function ut(e){return!e||!e.parentNode||e.parentNode.nodeType===11}function at(e,t){do e=e[t];while(e&&e.nodeType!==1);return e}function ft(e,t,n){t=t||0;if(v.isFunction(t))return v.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return v.grep(e,function(e,r){return e===t===n});if(typeof t=="string"){var r=v.grep(e,function(e){return e.nodeType===1});if(it.test(t))return v.filter(t,r,!n);t=v.filter(t,r)}return v.grep(e,function(e,r){return v.inArray(e,t)>=0===n})}function lt(e){var t=ct.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}function Lt(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function At(e,t){if(t.nodeType!==1||!v.hasData(e))return;var n,r,i,s=v._data(e),o=v._data(t,s),u=s.events;if(u){delete o.handle,o.events={};for(n in u)for(r=0,i=u[n].length;r").appendTo(i.body),n=t.css("display");t.remove();if(n==="none"||n===""){Pt=i.body.appendChild(Pt||v.extend(i.createElement("iframe"),{frameBorder:0,width:0,height:0}));if(!Ht||!Pt.createElement)Ht=(Pt.contentWindow||Pt.contentDocument).document,Ht.write(""),Ht.close();t=Ht.body.appendChild(Ht.createElement(e)),n=Dt(t,"display"),i.body.removeChild(Pt)}return Wt[e]=n,n}function fn(e,t,n,r){var i;if(v.isArray(t))v.each(t,function(t,i){n||sn.test(e)?r(e,i):fn(e+"["+(typeof i=="object"?t:"")+"]",i,n,r)});else if(!n&&v.type(t)==="object")for(i in t)fn(e+"["+i+"]",t[i],n,r);else r(e,t)}function Cn(e){return function(t,n){typeof t!="string"&&(n=t,t="*");var r,i,s,o=t.toLowerCase().split(y),u=0,a=o.length;if(v.isFunction(n))for(;u)[^>]*$|#([\w\-]*)$)/,E=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,S=/^[\],:{}\s]*$/,x=/(?:^|:|,)(?:\s*\[)+/g,T=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,N=/"[^"\\\r\n]*"|true|false|null|-?(?:\d\d*\.|)\d+(?:[eE][\-+]?\d+|)/g,C=/^-ms-/,k=/-([\da-z])/gi,L=function(e,t){return(t+"").toUpperCase()},A=function(){i.addEventListener?(i.removeEventListener("DOMContentLoaded",A,!1),v.ready()):i.readyState==="complete"&&(i.detachEvent("onreadystatechange",A),v.ready())},O={};v.fn=v.prototype={constructor:v,init:function(e,n,r){var s,o,u,a;if(!e)return this;if(e.nodeType)return this.context=this[0]=e,this.length=1,this;if(typeof e=="string"){e.charAt(0)==="<"&&e.charAt(e.length-1)===">"&&e.length>=3?s=[null,e,null]:s=w.exec(e);if(s&&(s[1]||!n)){if(s[1])return n=n instanceof v?n[0]:n,a=n&&n.nodeType?n.ownerDocument||n:i,e=v.parseHTML(s[1],a,!0),E.test(s[1])&&v.isPlainObject(n)&&this.attr.call(e,n,!0),v.merge(this,e);o=i.getElementById(s[2]);if(o&&o.parentNode){if(o.id!==s[2])return r.find(e);this.length=1,this[0]=o}return this.context=i,this.selector=e,this}return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e)}return v.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),v.makeArray(e,this))},selector:"",jquery:"1.8.3",length:0,size:function(){return this.length},toArray:function(){return l.call(this)},get:function(e){return e==null?this.toArray():e<0?this[this.length+e]:this[e]},pushStack:function(e,t,n){var r=v.merge(this.constructor(),e);return r.prevObject=this,r.context=this.context,t==="find"?r.selector=this.selector+(this.selector?" ":"")+n:t&&(r.selector=this.selector+"."+t+"("+n+")"),r},each:function(e,t){return v.each(this,e,t)},ready:function(e){return v.ready.promise().done(e),this},eq:function(e){return e=+e,e===-1?this.slice(e):this.slice(e,e+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(l.apply(this,arguments),"slice",l.call(arguments).join(","))},map:function(e){return this.pushStack(v.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:[].sort,splice:[].splice},v.fn.init.prototype=v.fn,v.extend=v.fn.extend=function(){var e,n,r,i,s,o,u=arguments[0]||{},a=1,f=arguments.length,l=!1;typeof u=="boolean"&&(l=u,u=arguments[1]||{},a=2),typeof u!="object"&&!v.isFunction(u)&&(u={}),f===a&&(u=this,--a);for(;at |
+ |
|
+
+ | + |
+ | + |
+ | + |
+ |
+ | + |
|
+ + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
+ | + |
Contents:
+ ++ a | ||
+ | + accounting | + |
+ | + accounting.client | + |
+ | + accounting.config | + |
+ | + accounting.decorators | + |
+ | + accounting.exceptions | + |
+ | + accounting.gtkclient | + |
+ | + accounting.models | + |
+ | + accounting.storage | + |
+ | + accounting.storage.ledgercli | + |
+ | + accounting.storage.sql | + |
+ | + accounting.storage.sql.models | + |
+ | + accounting.transport | + |
+ | + accounting.web | + |
+ Please activate JavaScript to enable the search + functionality. +
++ From here you can search these documents. Enter your search + words into the box below and click "search". Note that the search + function will automatically search for all of the words. Pages + containing fewer words won't appear in the result list. +
+ + +