SQL, GTK
- Made the storage model slightly more flexible - Made a small P-o-C GUI application in GTK - Polished accounting.client - models.Transaction.id is now a str - Fixed transaction.id marshalling for storage.ledgercli
This commit is contained in:
parent
124bd1706d
commit
f2b9decf27
7 changed files with 180 additions and 74 deletions
|
@ -2,6 +2,7 @@ import sys
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import locale
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
@ -11,6 +12,8 @@ import requests
|
||||||
from accounting.models import Transaction, Posting, Amount
|
from accounting.models import Transaction, Posting, Amount
|
||||||
from accounting.transport import AccountingDecoder, AccountingEncoder
|
from accounting.transport import AccountingDecoder, AccountingEncoder
|
||||||
|
|
||||||
|
locale.setlocale(locale.LC_ALL, '')
|
||||||
|
|
||||||
_log = logging.getLogger(__name__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,15 +46,20 @@ class Client:
|
||||||
|
|
||||||
return self._decode_response(requests.post(self.host + path, **kw))
|
return self._decode_response(requests.post(self.host + path, **kw))
|
||||||
|
|
||||||
def simple_transaction(self, from_acc, to_acc, amount):
|
def simple_transaction(self, from_acc, to_acc, amount, symbol=None,
|
||||||
|
payee=None):
|
||||||
|
if symbol is None:
|
||||||
|
# Get the currency from the environment locale
|
||||||
|
symbol = locale.localeconv()['int_curr_symbol'].strip()
|
||||||
|
|
||||||
t = Transaction(
|
t = Transaction(
|
||||||
date=datetime.today(),
|
date=datetime.today(),
|
||||||
payee='PayPal donation',
|
payee=payee,
|
||||||
postings=[
|
postings=[
|
||||||
Posting(account=from_acc,
|
Posting(account=from_acc,
|
||||||
amount=Amount(symbol='$', amount=-amount)),
|
amount=Amount(symbol=symbol, amount=-amount)),
|
||||||
Posting(account=to_acc,
|
Posting(account=to_acc,
|
||||||
amount=Amount(symbol='$', amount=amount))
|
amount=Amount(symbol=symbol, amount=amount))
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -86,7 +94,7 @@ def print_balance_accounts(accounts, level=0):
|
||||||
print(' ' * level + ' {amount.symbol} {amount.amount}'.format(
|
print(' ' * level + ' {amount.symbol} {amount.amount}'.format(
|
||||||
amount=amount))
|
amount=amount))
|
||||||
|
|
||||||
print_balance_accounts(account.accounts, level+1)
|
print_balance_accounts(account.accounts, level + 1)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None, prog=None):
|
def main(argv=None, prog=None):
|
||||||
|
@ -100,9 +108,16 @@ def main(argv=None, prog=None):
|
||||||
|
|
||||||
insert = actions.add_parser('insert',
|
insert = actions.add_parser('insert',
|
||||||
aliases=['in'])
|
aliases=['in'])
|
||||||
|
insert.add_argument('payee',
|
||||||
|
help='The payee line of the transaction')
|
||||||
insert.add_argument('from_account')
|
insert.add_argument('from_account')
|
||||||
insert.add_argument('to_account')
|
insert.add_argument('to_account')
|
||||||
insert.add_argument('amount', type=Decimal)
|
insert.add_argument('amount', type=Decimal,
|
||||||
|
help='The amount deducted from from_account and added'
|
||||||
|
' to to_account')
|
||||||
|
insert.add_argument('-s', '--symbol',
|
||||||
|
help='The symbol for the amount, e.g. $ or USD for'
|
||||||
|
' USD. Defaults to your locale\'s setting.')
|
||||||
|
|
||||||
actions.add_parser('balance', aliases=['bal'])
|
actions.add_parser('balance', aliases=['bal'])
|
||||||
|
|
||||||
|
@ -122,7 +137,8 @@ def main(argv=None, prog=None):
|
||||||
|
|
||||||
if args.action in ['insert', 'in']:
|
if args.action in ['insert', 'in']:
|
||||||
print(client.simple_transaction(args.from_account, args.to_account,
|
print(client.simple_transaction(args.from_account, args.to_account,
|
||||||
args.amount))
|
args.amount, payee=args.payee,
|
||||||
|
symbol=args.symbol))
|
||||||
elif args.action in ['balance', 'bal']:
|
elif args.action in ['balance', 'bal']:
|
||||||
print_balance_accounts(client.get_balance())
|
print_balance_accounts(client.get_balance())
|
||||||
elif args.action in ['register', 'reg']:
|
elif args.action in ['register', 'reg']:
|
||||||
|
|
93
accounting/gtkclient.py
Normal file
93
accounting/gtkclient.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from gi.repository import Gtk
|
||||||
|
from gi.repository import GLib
|
||||||
|
from gi.repository import GObject
|
||||||
|
|
||||||
|
from accounting.client import Client
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Accounting(Gtk.Window):
|
||||||
|
def __init__(self):
|
||||||
|
Gtk.Window.__init__(self, title='Accounting Client')
|
||||||
|
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
self.set_border_width(3)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
|
||||||
|
self.table = Gtk.Table(3, 2, True)
|
||||||
|
self.add(self.table)
|
||||||
|
|
||||||
|
# Button
|
||||||
|
|
||||||
|
self.btn_load_transactions = Gtk.Button(label='Load transactions')
|
||||||
|
self.btn_load_transactions.connect('clicked', self.on_button_clicked)
|
||||||
|
|
||||||
|
self.spinner = Gtk.Spinner()
|
||||||
|
|
||||||
|
# Transaction stuff
|
||||||
|
|
||||||
|
self.transaction_store = Gtk.ListStore(str, str)
|
||||||
|
self.transaction_view = Gtk.TreeView(self.transaction_store)
|
||||||
|
|
||||||
|
renderer = Gtk.CellRendererText()
|
||||||
|
date_column = Gtk.TreeViewColumn('Date', renderer, text=0)
|
||||||
|
payee_column = Gtk.TreeViewColumn('Payee', renderer, text=1)
|
||||||
|
|
||||||
|
self.transaction_view.append_column(date_column)
|
||||||
|
self.transaction_view.append_column(payee_column)
|
||||||
|
|
||||||
|
# Layout
|
||||||
|
self.table.attach(self.btn_load_transactions, 0, 1, 0, 1)
|
||||||
|
self.table.attach(self.spinner, 1, 2, 0, 1)
|
||||||
|
self.table.attach(self.transaction_view, 0, 2, 1, 3)
|
||||||
|
|
||||||
|
# Show
|
||||||
|
self.show_all()
|
||||||
|
self.spinner.hide()
|
||||||
|
|
||||||
|
|
||||||
|
def on_button_clicked(self, widget):
|
||||||
|
def load_transactions():
|
||||||
|
transactions = self.client.get_register()
|
||||||
|
GLib.idle_add(self.on_transactions_loaded, transactions)
|
||||||
|
|
||||||
|
self.spinner.show()
|
||||||
|
self.spinner.start()
|
||||||
|
|
||||||
|
threading.Thread(target=load_transactions).start()
|
||||||
|
|
||||||
|
def on_transactions_loaded(self, transactions):
|
||||||
|
self.spinner.stop()
|
||||||
|
self.spinner.hide()
|
||||||
|
_log.debug('transactions: %s', transactions)
|
||||||
|
|
||||||
|
self.transaction_store.clear()
|
||||||
|
|
||||||
|
for transaction in transactions:
|
||||||
|
self.transaction_store.append([
|
||||||
|
transaction.date.strftime('%Y-%m-%d'),
|
||||||
|
transaction.payee
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
GObject.threads_init()
|
||||||
|
|
||||||
|
accounting_win = Accounting()
|
||||||
|
accounting_win.connect('delete-event', Gtk.main_quit)
|
||||||
|
|
||||||
|
Gtk.main()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
|
@ -15,10 +15,10 @@ class Transaction:
|
||||||
self.generate_id()
|
self.generate_id()
|
||||||
|
|
||||||
def generate_id(self):
|
def generate_id(self):
|
||||||
self.id = uuid.uuid4()
|
self.id = str(uuid.uuid4())
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return ('<{self.__class__.__name__} {date}' +
|
return ('<{self.__class__.__name__} {self.id} {date}' +
|
||||||
' {self.payee} {self.postings}').format(
|
' {self.payee} {self.postings}').format(
|
||||||
self=self,
|
self=self,
|
||||||
date=self.date.strftime('%Y-%m-%d'))
|
date=self.date.strftime('%Y-%m-%d'))
|
||||||
|
|
|
@ -14,7 +14,10 @@ _log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Ledger(Storage):
|
class Ledger(Storage):
|
||||||
def __init__(self, ledger_file=None, ledger_bin=None):
|
def __init__(self, app=None, ledger_file=None, ledger_bin=None):
|
||||||
|
if app:
|
||||||
|
ledger_file = app.config['LEDGER_FILE']
|
||||||
|
|
||||||
if ledger_file is None:
|
if ledger_file is None:
|
||||||
raise ValueError('ledger_file cannot be None')
|
raise ValueError('ledger_file cannot be None')
|
||||||
|
|
||||||
|
@ -158,11 +161,14 @@ class Ledger(Storage):
|
||||||
:class:`~accounting.models.Transaction` instance in
|
:class:`~accounting.models.Transaction` instance in
|
||||||
:data:`transaction`.
|
:data:`transaction`.
|
||||||
'''
|
'''
|
||||||
if not transaction.metadata.get('Id'):
|
if transaction.id is None:
|
||||||
|
_log.debug('No ID found. Generating an ID.')
|
||||||
transaction.generate_id()
|
transaction.generate_id()
|
||||||
|
|
||||||
|
transaction.metadata.update({'Id': transaction.id})
|
||||||
|
|
||||||
transaction_template = ('\n{date} {t.payee}\n'
|
transaction_template = ('\n{date} {t.payee}\n'
|
||||||
'{tags}'
|
'{metadata}'
|
||||||
'{postings}')
|
'{postings}')
|
||||||
|
|
||||||
metadata_template = ' ;{0}: {1}\n'
|
metadata_template = ' ;{0}: {1}\n'
|
||||||
|
@ -178,7 +184,7 @@ class Ledger(Storage):
|
||||||
output += transaction_template.format(
|
output += transaction_template.format(
|
||||||
date=transaction.date.strftime('%Y-%m-%d'),
|
date=transaction.date.strftime('%Y-%m-%d'),
|
||||||
t=transaction,
|
t=transaction,
|
||||||
tags=''.join([
|
metadata=''.join([
|
||||||
metadata_template.format(k, v)
|
metadata_template.format(k, v)
|
||||||
for k, v in transaction.metadata.items()]),
|
for k, v in transaction.metadata.items()]),
|
||||||
postings=''.join([posting_template.format(
|
postings=''.join([posting_template.format(
|
||||||
|
@ -239,6 +245,9 @@ class Ledger(Storage):
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
|
|
||||||
|
def get_transactions(self):
|
||||||
|
return self.reg()
|
||||||
|
|
||||||
def reg(self):
|
def reg(self):
|
||||||
output = self.send_command('xml')
|
output = self.send_command('xml')
|
||||||
|
|
||||||
|
@ -301,6 +310,9 @@ class Ledger(Storage):
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
def update_transaction(self, transaction):
|
||||||
|
_log.debug('DUMMY: Updated transaction: %s', transaction)
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
import argparse
|
import argparse
|
||||||
|
|
|
@ -3,6 +3,7 @@ import json
|
||||||
|
|
||||||
from flask.ext.sqlalchemy import SQLAlchemy
|
from flask.ext.sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
from accounting.exceptions import AccountingException
|
||||||
from accounting.storage import Storage
|
from accounting.storage import Storage
|
||||||
from accounting.models import Transaction, Posting, Amount
|
from accounting.models import Transaction, Posting, Amount
|
||||||
|
|
||||||
|
@ -11,8 +12,12 @@ db = None
|
||||||
|
|
||||||
|
|
||||||
class SQLStorage(Storage):
|
class SQLStorage(Storage):
|
||||||
def __init__(self, app):
|
def __init__(self, app=None):
|
||||||
global db
|
global db
|
||||||
|
|
||||||
|
if not app:
|
||||||
|
raise Exception('Missing app keyword argument')
|
||||||
|
|
||||||
self.app = app
|
self.app = app
|
||||||
db = self.db = SQLAlchemy(app)
|
db = self.db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
@ -47,12 +52,19 @@ class SQLStorage(Storage):
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
|
|
||||||
|
def update_transaction(self, transaction):
|
||||||
|
if transaction.id is None:
|
||||||
|
raise AccountingException('The transaction id must be set for'
|
||||||
|
' update_transaction calls')
|
||||||
|
|
||||||
|
_log.debug('DUMMY: Update transaction: %s', transaction)
|
||||||
|
|
||||||
def add_transaction(self, transaction):
|
def add_transaction(self, transaction):
|
||||||
if transaction.id is None:
|
if transaction.id is None:
|
||||||
transaction.generate_id()
|
transaction.generate_id()
|
||||||
|
|
||||||
_t = self.Transaction()
|
_t = self.Transaction()
|
||||||
_t.uuid = str(transaction.id)
|
_t.uuid = transaction.id
|
||||||
_t.date = transaction.date
|
_t.date = transaction.date
|
||||||
_t.payee = transaction.payee
|
_t.payee = transaction.payee
|
||||||
_t.meta = json.dumps(transaction.metadata)
|
_t.meta = json.dumps(transaction.metadata)
|
||||||
|
@ -61,7 +73,7 @@ class SQLStorage(Storage):
|
||||||
|
|
||||||
for posting in transaction.postings:
|
for posting in transaction.postings:
|
||||||
_p = self.Posting()
|
_p = self.Posting()
|
||||||
_p.transaction_uuid = str(transaction.id)
|
_p.transaction_uuid = transaction.id
|
||||||
_p.account = posting.account
|
_p.account = posting.account
|
||||||
_p.meta = json.dumps(posting.metadata)
|
_p.meta = json.dumps(posting.metadata)
|
||||||
_p.amount = self.Amount(symbol=posting.amount.symbol,
|
_p.amount = self.Amount(symbol=posting.amount.symbol,
|
||||||
|
|
|
@ -4,6 +4,7 @@ from flask import json
|
||||||
|
|
||||||
from accounting.models import Amount, Transaction, Posting, Account
|
from accounting.models import Amount, Transaction, Posting, Account
|
||||||
|
|
||||||
|
|
||||||
class AccountingEncoder(json.JSONEncoder):
|
class AccountingEncoder(json.JSONEncoder):
|
||||||
def default(self, o):
|
def default(self, o):
|
||||||
if isinstance(o, Account):
|
if isinstance(o, Account):
|
||||||
|
@ -16,6 +17,7 @@ class AccountingEncoder(json.JSONEncoder):
|
||||||
elif isinstance(o, Transaction):
|
elif isinstance(o, Transaction):
|
||||||
return dict(
|
return dict(
|
||||||
__type__=o.__class__.__name__,
|
__type__=o.__class__.__name__,
|
||||||
|
id=o.id,
|
||||||
date=o.date.strftime('%Y-%m-%d'),
|
date=o.date.strftime('%Y-%m-%d'),
|
||||||
payee=o.payee,
|
payee=o.payee,
|
||||||
postings=o.postings,
|
postings=o.postings,
|
||||||
|
@ -42,6 +44,7 @@ class AccountingEncoder(json.JSONEncoder):
|
||||||
|
|
||||||
return json.JSONEncoder.default(self, o)
|
return json.JSONEncoder.default(self, o)
|
||||||
|
|
||||||
|
|
||||||
class AccountingDecoder(json.JSONDecoder):
|
class AccountingDecoder(json.JSONDecoder):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)
|
json.JSONDecoder.__init__(self, object_hook=self.dict_to_object)
|
||||||
|
@ -50,8 +53,8 @@ class AccountingDecoder(json.JSONDecoder):
|
||||||
if '__type__' not in d:
|
if '__type__' not in d:
|
||||||
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]}
|
||||||
|
|
||||||
_type = d.pop('__type__')
|
_type = d.pop('__type__')
|
||||||
|
|
||||||
|
|
|
@ -21,14 +21,15 @@ from accounting.decorators import jsonify_exceptions
|
||||||
app = Flask('accounting')
|
app = Flask('accounting')
|
||||||
app.config.from_pyfile('config.py')
|
app.config.from_pyfile('config.py')
|
||||||
|
|
||||||
storage = SQLStorage(app)
|
storage = Ledger(app=app)
|
||||||
|
|
||||||
# TODO: Move migration stuff into SQLStorage
|
if isinstance(storage, SQLStorage):
|
||||||
db = storage.db
|
# TODO: Move migration stuff into SQLStorage
|
||||||
migrate = Migrate(app, db)
|
db = storage.db
|
||||||
|
migrate = Migrate(app, db)
|
||||||
|
|
||||||
manager = Manager(app)
|
manager = Manager(app)
|
||||||
manager.add_command('db', MigrateCommand)
|
manager.add_command('db', MigrateCommand)
|
||||||
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
|
@ -59,6 +60,24 @@ def transaction_get():
|
||||||
'''
|
'''
|
||||||
return jsonify(transactions=storage.get_transactions())
|
return jsonify(transactions=storage.get_transactions())
|
||||||
|
|
||||||
|
@app.route('/transaction/<string:transaction_id>', methods=['POST'])
|
||||||
|
@jsonify_exceptions
|
||||||
|
def transaction_update(transaction_id=None):
|
||||||
|
if transaction_id is None:
|
||||||
|
raise AccountingException('The transaction ID cannot be None.')
|
||||||
|
|
||||||
|
transaction = request.json['transaction']
|
||||||
|
|
||||||
|
if transaction.id is not None and not transaction.id == transaction_id:
|
||||||
|
raise AccountingException('The transaction data has an ID attribute and'
|
||||||
|
' it is not the same ID as in the path')
|
||||||
|
elif transaction.id is None:
|
||||||
|
transaction.id = transaction_id
|
||||||
|
|
||||||
|
storage.update_transaction(transaction)
|
||||||
|
|
||||||
|
return jsonify(status='OK')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/transaction', methods=['POST'])
|
@app.route('/transaction', methods=['POST'])
|
||||||
@jsonify_exceptions
|
@jsonify_exceptions
|
||||||
|
@ -118,56 +137,7 @@ def transaction_post():
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
storage.add_transaction(transaction)
|
storage.add_transaction(transaction)
|
||||||
|
|
||||||
return jsonify(foo='bar')
|
return jsonify(status='OK')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/parse-json', methods=['POST'])
|
|
||||||
def parse_json():
|
|
||||||
r'''
|
|
||||||
Parses a __type__-annotated JSON payload and debug-logs the decoded version
|
|
||||||
of it.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
wget http://127.0.0.1:5000/balance -O balance.json
|
|
||||||
curl -X POST -H 'Content-Type: application/json' -d @balance.json \
|
|
||||||
http://127.0.0.1/parse-json
|
|
||||||
# Logging output (linebreaks added for clarity)
|
|
||||||
# DEBUG:accounting:json data: {'balance_report':
|
|
||||||
# [<Account "None" [
|
|
||||||
# <Amount $ 0>, <Amount KARMA 0>]
|
|
||||||
# [<Account "Assets" [
|
|
||||||
# <Amount $ 50>, <Amount KARMA 10>]
|
|
||||||
# [<Account "Assets:Checking" [
|
|
||||||
# <Amount $ 50>] []>,
|
|
||||||
# <Account "Assets:Karma Account" [
|
|
||||||
# <Amount KARMA 10>] []>]>,
|
|
||||||
# <Account "Expenses" [
|
|
||||||
# <Amount $ 500>]
|
|
||||||
# [<Account "Expenses:Blah" [
|
|
||||||
# <Amount $ 250>]
|
|
||||||
# [<Account "Expenses:Blah:Hosting" [
|
|
||||||
# <Amount $ 250>] []>]>,
|
|
||||||
# <Account "Expenses:Foo" [
|
|
||||||
# <Amount $ 250>] [
|
|
||||||
# <Account "Expenses:Foo:Hosting" [
|
|
||||||
# <Amount $ 250>] []>]>]>,
|
|
||||||
# <Account "Income" [
|
|
||||||
# <Amount $ -550>,
|
|
||||||
# <Amount KARMA -10>]
|
|
||||||
# [<Account "Income:Donation" [
|
|
||||||
# <Amount $ -50>] []>,
|
|
||||||
# <Account "Income:Foo" [
|
|
||||||
# <Amount $ -500>]
|
|
||||||
# [<Account "Income:Foo:Donation" [
|
|
||||||
# <Amount $ -500>] []>]>,
|
|
||||||
# <Account "Income:Karma" [
|
|
||||||
# <Amount KARMA -10>] []>]>]>]}
|
|
||||||
'''
|
|
||||||
app.logger.debug('json data: %s', request.json)
|
|
||||||
return jsonify(foo='bar')
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
|
|
Loading…
Reference in a new issue