Skip to content

Commit

Permalink
Merge pull request #191 from /issues/178
Browse files Browse the repository at this point in the history
Fixes #178 - UI support for budget splits
  • Loading branch information
jantman committed Mar 25, 2018
2 parents 80b7635 + 324b922 commit 88dc786
Show file tree
Hide file tree
Showing 88 changed files with 2,250 additions and 626 deletions.
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ Unreleased Changes

* Upgrade chromedriver in TravisCI builds from 2.33 to 2.36, to fix failing acceptance tests caused by Ubuntu upgrade from Chrome 64 to 65.
* Fix bug in ``/budgets`` view where "Spending By Budget, Per Calendar Month" chart was showing only inactive budgets instead of only active budgets.
* `Issue #178 <https://github.com/jantman/biweeklybudget/issues/178>`_ - UI support for splitting Transactions between multiple Budgets.
* Have frontend forms submit as JSON POST instead of urlencoded.
* Properly capture Chrome console logs during acceptance tests.
* Bump ``versionfinder`` requirement version to 0.1.3 to work with pip 9.0.2.
* On help view, show long version string if we have it.
* `Issue #177 <https://github.com/jantman/biweeklybudget/issues/177>`_ - Fix bug in ``flask rundev`` logging.
* Many workarounds for flaky acceptance tests, including some for the selenium/Chrome "Element is not clickable at point... Other element would receive the click" error.

0.7.1 (2018-01-10)
------------------
Expand Down
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Main Features
* Tracking of vehicle fuel fills (fuel log) and graphing of fuel economy.
* Cost tracking for multiple projects, including bills-of-materials for them. Optional synchronization from Amazon Wishlists to projects.
* Calculation of estimated credit card payoff amount and time, with configurable payment methods, payment increases on specific dates, and additional payments on specific dates.
* Ability to split a Transaction across multiple Budgets.

Requirements
------------
Expand Down
124 changes: 80 additions & 44 deletions biweeklybudget/biweeklypayperiod.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,36 +428,67 @@ def _make_budget_sums(self):
'is_income': b.is_income
}
for t in self.transactions_list:
if t['budget_id'] not in res:
# Issue #161 - inactive budget, but transaction for it in this
# period. Add it if it's a periodic budget.
b = self._db.query(Budget).get(t['budget_id'])
if not b.is_periodic:
continue
res[b.id] = {
'budget_amount': b.starting_balance,
'allocated': Decimal('0.0'),
'spent': Decimal('0.0'),
'trans_total': Decimal('0.0'),
'is_income': b.is_income
}
# if a ScheduledTransaction, update some values and then continue
if t['type'] == 'ScheduledTransaction':
res[t['budget_id']]['allocated'] += t['amount']
res[t['budget_id']]['trans_total'] += t['amount']
for budg_id, budg_data in t['budgets'].items():
if budg_id not in res:
# Issue #161 - inactive budget, but transaction for it
# in this period. Add it if it's a periodic budget.
b = self._db.query(Budget).get(budg_id)
if not b.is_periodic:
continue
res[b.id] = {
'budget_amount': b.starting_balance,
'allocated': Decimal('0.0'),
'spent': Decimal('0.0'),
'trans_total': Decimal('0.0'),
'is_income': b.is_income
}
res[budg_id]['allocated'] += budg_data['amount']
res[budg_id]['trans_total'] += budg_data['amount']
continue
# t['type'] == 'Transaction'
res[t['budget_id']]['trans_total'] += t['amount']
if t['budgeted_amount'] is None:
res[t['budget_id']]['allocated'] += t['amount']
res[t['budget_id']]['spent'] += t['amount']
else:
# NOT a ScheduledTransaction; must be an actual Transaction
for budg_id, budg_data in t['budgets'].items():
if budg_id not in res:
# Issue #161 - inactive budget, but transaction for it
# in this period. Add it if it's a periodic budget.
b = self._db.query(Budget).get(budg_id)
if not b.is_periodic:
continue
res[b.id] = {
'budget_amount': b.starting_balance,
'allocated': Decimal('0.0'),
'spent': Decimal('0.0'),
'trans_total': Decimal('0.0'),
'is_income': b.is_income
}
# update the budget's transactions total and spent amount
res[budg_id]['trans_total'] += budg_data['amount']
res[budg_id]['spent'] += budg_data['amount']
if t['budgeted_amount'] is None:
res[budg_id]['allocated'] += budg_data['amount']
if t['budgeted_amount'] is not None:
# has a budgeted amount. It _shouldn't_ be possible to have a
# budgeted_amount without a planned_budget_id, but if that
# happens, allocate to the first budget.
if t.get('planned_budget_id', None) is None:
res[t['budget_id']]['allocated'] += t[
'budgeted_amount']
bid = list(t['budgets'].keys())[0]
else:
res[t['planned_budget_id']]['allocated'] += t[
'budgeted_amount']
res[t['budget_id']]['spent'] += t['amount']
bid = t['planned_budget_id']
if bid not in res:
# Issue #161 - inactive budget, but transaction for it
# in this period. Add it if it's a periodic budget.
b = self._db.query(Budget).get(bid)
if not b.is_periodic:
continue
res[bid] = {
'budget_amount': b.starting_balance,
'allocated': Decimal('0.0'),
'spent': Decimal('0.0'),
'trans_total': Decimal('0.0'),
'is_income': b.is_income
}
res[bid]['allocated'] += t['budgeted_amount']
for b in res.keys():
if res[b]['trans_total'] > res[b]['allocated']:
res[b]['remaining'] = res[
Expand Down Expand Up @@ -549,10 +580,9 @@ def _trans_dict(self, t):
against.
* ``account_name`` (**str**) the name of the Account the transaction is
against.
* ``budget_id`` (**int**) the id of the Budget the transaction is
against.
* ``budget_name`` (**str**) the name of the Budget the transaction is
against.
* ``budgets`` (**dict**) dict of information on the Budgets this
Transaction is against. Keys are budget IDs (**int**), values are
dicts with keys "amount" (**Decimal**) and "name" (**string**).
* ``reconcile_id`` (**int**) the ID of the TxnReconcile, or None
* ``planned_budget_id`` (**int**) the id of the Budget the transaction
was planned against, if any. May be None.
Expand Down Expand Up @@ -591,15 +621,14 @@ def _dict_for_trans(self, t):
against.
* ``account_name`` (**str**) the name of the Account the transaction is
against.
* ``budget_id`` (**int**) the id of the Budget the transaction is
against.
* ``budget_name`` (**str**) the name of the Budget the transaction is
against.
* ``reconcile_id`` (**int**) the ID of the TxnReconcile, or None
* ``planned_budget_id`` (**int**) the id of the Budget the transaction
was planned against, if any. May be None.
* ``planned_budget_name`` (**str**) the name of the Budget the
transaction was planned against, if any. May be None.
* ``budgets`` (**dict**) dict of information on the Budgets this
Transaction is against. Keys are budget IDs (**int**), values are
dicts with keys "amount" (**Decimal**) and "name" (**string**).
:param t: transaction to describe
:type t: Transaction
Expand All @@ -616,10 +645,14 @@ def _dict_for_trans(self, t):
'amount': t.actual_amount,
'account_id': t.account_id,
'account_name': t.account.name,
'budget_id': t.budget_transactions[0].budget_id,
'budget_name': t.budget_transactions[0].budget.name,
'planned_budget_id': t.planned_budget_id,
'planned_budget_name': None
'planned_budget_name': None,
'budgets': {
bt.budget_id: {
'amount': bt.amount,
'name': bt.budget.name
} for bt in t.budget_transactions
}
}
if t.reconcile is None:
res['reconcile_id'] = None
Expand Down Expand Up @@ -654,11 +687,10 @@ def _dict_for_sched_trans(self, t):
against.
* ``account_name`` (**str**) the name of the Account the transaction is
against.
* ``budget_id`` (**int**) the id of the Budget the transaction is
against.
* ``budget_name`` (**str**) the name of the Budget the transaction is
against.
* ``reconcile_id`` (**int**) the ID of the TxnReconcile, or None
* ``budgets`` (**dict**) dict of information on the Budgets this
Transaction is against. Keys are budget IDs (**int**), values are
dicts with keys "amount" (**Decimal**) and "name" (**string**).
:param t: ScheduledTransaction to describe
:type t: ScheduledTransaction
Expand All @@ -675,9 +707,13 @@ def _dict_for_sched_trans(self, t):
'budgeted_amount': None,
'account_id': t.account_id,
'account_name': t.account.name,
'budget_id': t.budget_id,
'budget_name': t.budget.name,
'reconcile_id': None
'reconcile_id': None,
'budgets': {
t.budget_id: {
'name': t.budget.name,
'amount': t.amount
}
}
}
if t.schedule_type == 'date':
res['date'] = t.date
Expand Down
7 changes: 7 additions & 0 deletions biweeklybudget/flaskapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
import logging
import os

# workaround for https://github.com/jantman/versionfinder/issues/5
# caused by versionfinder import in ``views/help.py``
try:
import pip # noqa
except (ImportError, KeyError):
pass

from flask import Flask

from biweeklybudget.db import init_db, cleanup_db
Expand Down
5 changes: 2 additions & 3 deletions biweeklybudget/flaskapp/cli_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,8 @@ def send_response(self, *args, **kw):
def log_request(self, code='-', size='-'):
duration = int((self._time_finished - self._time_started) * 1000)
self.log(
'info', '"{0}" {1} {2} [{3}ms]'.format(
self.requestline, code, size, duration
)
'info', '"%s" %s %s [%sms]',
self.requestline, code, size, duration
)


Expand Down
18 changes: 18 additions & 0 deletions biweeklybudget/flaskapp/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,21 @@ def monthsyears(num):
if num < 12:
return '%f months' % num
return '%.1f years' % (num / 12.0)


@app.template_filter('budgetCell')
def budget_cell_filter(d):
"""
Given a dictionary of budget IDs to names and amounts like that returned by
:py:meth:`~.BiweeklyPayPeriod._dict_for_trans`, return the ``<td>`` content
for those budgets.
"""
if len(d) == 1:
foo = list(d.keys())[0]
return '<a href="/budgets/%d">%s</a>' % (foo, d[foo]['name'])
items = [
'<a href="/budgets/%d">%s</a> (%s)' % (
k, d[k]['name'], dollars_filter(d[k]['amount'])
) for k in sorted(d.keys(), key=lambda x: d[x]['amount'], reverse=True)
]
return '<br />'.join(items)
7 changes: 6 additions & 1 deletion biweeklybudget/flaskapp/static/js/formBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,13 +310,18 @@ FormBuilder.prototype.addRadioInline = function(name, label, options) {
* @param {String} name - The name of the form element
* @param {String} label - The label text for the form element
* @param {Boolean} checked - Whether to default to checked or not
* @param {Object} options
* @param {String} options.inputHtml - extra HTML string to include in the
* actual ``input`` element *(optional; defaults to null)*
* @return {FormBuilder} this
*/
FormBuilder.prototype.addCheckbox = function(id, name, label, checked) {
FormBuilder.prototype.addCheckbox = function(id, name, label, checked, options) {
if(options === undefined) { options = {}; }
this.html += '<div class="form-group" id="' + id + '_group">' +
'<label class="checkbox-inline control-label" for="' + id + '">' +
'<input type="checkbox" id="' + id + '" name="' + name + '"';
if (checked === true) { this.html += ' checked'; }
if ('inputHtml' in options) { this.html += ' ' + options.inputHtml; }
this.html += '> ' + label + '</label></div>\n';
return this;
};
16 changes: 13 additions & 3 deletions biweeklybudget/flaskapp/static/js/forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,25 @@ Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
* @param {string} form_id - The ID of the form itself.
* @param {string} post_url - Relative URL to post form data to.
* @param {Object} dataTableObj - passed on to ``handleFormSubmitted()``
* @param {Object} serialize_func - If set (i.e. not ``undefined``), this is
* a function used serialize the form in place of :js:func:`serializeForm`.
* This function will be passed the ID of the form (``form_id``) and should
* return an Object suitable for passing to ``JSON.stringify()``.
*/
function handleForm(container_id, form_id, post_url, dataTableObj) {
var data = serializeForm(form_id);
function handleForm(container_id, form_id, post_url, dataTableObj, serialize_func) {
if (serialize_func === undefined) {
var data = serializeForm(form_id);
} else {
var data = serialize_func(form_id);
}
$('.formfeedback').remove();
$('.has-error').each(function(index) { $(this).removeClass('has-error'); });
$.ajax({
type: "POST",
url: post_url,
data: data,
data: JSON.stringify(data),
dataType: "json",
contentType: "application/json",
success: function(data) {
handleFormSubmitted(data, container_id, form_id, dataTableObj);
},
Expand Down
36 changes: 28 additions & 8 deletions biweeklybudget/flaskapp/static/js/reconcile.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,22 @@ function reconcileDoUnreconcile(trans_id, acct_id, fitid) {
* @param {Object} trans - ajax JSON object representing one Transaction
*/
function reconcileTransDiv(trans) {
var div = '<div class="row">'
var div = '<div class="row">';
div += '<div class="col-lg-3">' + trans['date']['str'] + '</div>';
div += '<div class="col-lg-3">' + fmt_currency(trans['actual_amount']) + '</div>';
div += '<div class="col-lg-3"><strong>Acct:</strong> <span style="white-space: nowrap;"><a href="/accounts/' + trans['account_id'] + '">' + trans['account_name'] + ' (' + trans['account_id'] + ')</a></span></div>';
div += '<div class="col-lg-3"><strong>Budget:</strong> <span style="white-space: nowrap;"><a href="/budgets/' + trans['budget_id'] + '">' + trans['budget_name'] + ' (' + trans['budget_id'] + ')</a></span></div>';
div += '<div class="col-lg-3"><strong>Budget:</strong> <span style="white-space: nowrap;">';
for (var index = 0; index < trans['budgets'].length; ++index) {
var budg = trans['budgets'][index];
var txt = budg['name'];
txt = txt + ' (' + budg['id'] + ')';
if (trans['budgets'].length > 1) {
txt = txt + ' (' + fmt_currency(budg['amount']) + ')';
}
div += '<a href="/budgets/' + budg['id'] + '">' + txt + '</a>';
if(index < trans['budgets'].length - 1) { div += '<br>'; }
}
div += '</span></div>';
div += '</div>';
div += '<div class="row"><div class="col-lg-12">';
div += '<div style="float: left;"><a href="javascript:transModal(' + trans['id'] + ', function () { updateReconcileTrans(' + trans['id'] + ') })">Trans ' + trans['id'] + '</a>: ' + trans['description'] + '</div>';
Expand Down Expand Up @@ -343,12 +354,21 @@ function makeTransFromOfx(acct_id, fitid) {
$('#modalSaveButton').off();
$('#modalSaveButton').click(
function() {
handleForm(
'modalBody', 'transForm', '/forms/transaction',
function(data) {
makeTransSaveCallback(data, acct_id, fitid);
}
);
var valid = validateTransModalSplits();
if (valid == null) {
$('#modalSaveButton').prop('disabled', false);
$('#budget-split-feedback').html('');
handleForm(
'modalBody', 'transForm', '/forms/transaction',
function(data) {
makeTransSaveCallback(data, acct_id, fitid);
},
transModalFormSerialize
);
} else {
$('#budget-split-feedback').html('<p class="text-danger">' + valid + '</p>');
$('#modalSaveButton').prop('disabled', true);
}
}
).show();
$('#trans_frm_date_input_group').datepicker({
Expand Down
13 changes: 12 additions & 1 deletion biweeklybudget/flaskapp/static/js/reconcile_modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,18 @@ function txnReconcileModalDiv(msg) {
frm += '<tr><th>Budgeted Amount</th><td>' + fmt_currency(msg['transaction']['budgeted_amount']) + '</td></tr>\n';
frm += '<tr><th>Description</th><td>' + msg['transaction']['description'] + '</td></tr>\n';
frm += '<tr><th>Account</th><td><a href="/accounts/' + msg['acct_id'] + '">' + msg['acct_name'] + ' (' + msg['acct_id'] + ')</a></td></tr>\n';
frm += '<tr><th>Budget</th><td><a href="/budgets/' + msg['budget_id'] + '">' + msg['budget_name'] + ' (' + msg['budget_id'] + ')</a></td></tr>\n';
frm += '<tr><th>Budget</th><td>';
for (var index = 0; index < msg['transaction']['budgets'].length; ++index) {
var budg = msg['transaction']['budgets'][index];
var txt = budg['name'];
txt = txt + ' (' + budg['id'] + ')';
if (msg['transaction']['budgets'].length > 1) {
txt = txt + ' (' + fmt_currency(budg['amount']) + ')';
}
frm += '<a href="/budgets/' + budg['id'] + '">' + txt + '</a>';
if(index < msg['transaction']['budgets'].length - 1) { frm += '<br>'; }
}
frm += '</td></tr>\n';
frm += '<tr><th>Notes</th><td>' + msg['transaction']['notes'] + '</td></tr>\n';
frm += '<tr><th>Scheduled?</th><td>';
if (msg['transaction']['scheduled_trans_id'] !== null) {
Expand Down
21 changes: 20 additions & 1 deletion biweeklybudget/flaskapp/static/js/transactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,27 @@ $(document).ready(function() {
},
{
data: "DT_RowData.budget",
orderable: false, // split-budget transactions breaks sorting
"render": function(data, type, row) {
return $("<div>").append($("<a/>").attr("href", "/budgets/" + row.DT_RowData.budget_id).text(data)).html();
if(row.DT_RowData.budgets.length == 1) {
var budg = row.DT_RowData.budgets[0];
var txt = budg['name'];
if(budg['is_income'] === true) { txt = txt + ' (income)'; }
txt = txt + ' (' + budg['id'] + ')';
return $("<div>").append($("<a/>").attr("href", "/budgets/" + budg['id']).text(txt)).html();
} else {
var div = $("<div>");
for (index = 0; index < row.DT_RowData.budgets.length; ++index) {
var budg = row.DT_RowData.budgets[index];
var txt = budg['name'];
if(budg['is_income'] === true) { txt = txt + ' (income)'; }
txt = txt + ' (' + budg['id'] + ')';
txt = txt + ' (' + fmt_currency(budg['amount']) + ')';
div.append($("<a/>").attr("href", "/budgets/" + budg['id']).text(txt));
if(index < row.DT_RowData.budgets.length - 1) { div.append($('<br />')); }
}
return div.html();
}
}
},
{
Expand Down

0 comments on commit 88dc786

Please sign in to comment.