Source code for biweeklybudget.flaskapp.views.budgets
"""
The latest version of this package is available at:
<http://github.com/jantman/biweeklybudget>
################################################################################
Copyright 2016 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
This file is part of biweeklybudget, also known as biweeklybudget.
biweeklybudget is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
biweeklybudget is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with biweeklybudget. If not, see <http://www.gnu.org/licenses/>.
The Copyright and Authors attributions contained herein may not be removed or
otherwise altered, except to add the Author attribution of a contributor to
this work. (Additional Terms pursuant to Section 7b of the AGPL v3)
################################################################################
While not legally required, I sincerely request that anyone who finds
bugs please submit them at <https://github.com/jantman/biweeklybudget> or
to me via email, and that you send any contributions or improvements
either as a pull request on GitHub, or to me via email.
################################################################################
AUTHORS:
Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
################################################################################
"""
import logging
from flask.views import MethodView
from flask import render_template, jsonify
from datetime import datetime
from decimal import Decimal
from biweeklybudget.flaskapp.app import app
from biweeklybudget.db import db_session
from biweeklybudget.models.budget_model import Budget
from biweeklybudget.models.budget_transaction import BudgetTransaction
from biweeklybudget.flaskapp.views.formhandlerview import FormHandlerView
from biweeklybudget.models.account import Account
from biweeklybudget.models.utils import do_budget_transfer
from biweeklybudget.biweeklypayperiod import BiweeklyPayPeriod
from biweeklybudget.models.transaction import Transaction
from biweeklybudget.utils import dtnow
logger = logging.getLogger(__name__)
[docs]class BudgetsView(MethodView):
"""
Render the GET /budgets view using the ``budgets.html`` template.
"""
[docs] def get(self):
standing = db_session.query(Budget).filter(
Budget.is_periodic.__eq__(False)
).order_by(Budget.name).all()
periodic = db_session.query(Budget).filter(
Budget.is_periodic.__eq__(True)
).order_by(Budget.name).all()
accts = {a.name: a.id for a in db_session.query(Account).all()}
budgets = {}
active_budgets = {}
for b in db_session.query(Budget).all():
k = b.name
if b.is_income:
k = '%s (i)' % b.name
budgets[b.id] = k
if b.is_active:
active_budgets[b.id] = k
return render_template(
'budgets.html',
standing=standing,
periodic=periodic,
accts=accts,
budgets=budgets,
active_budgets=active_budgets
)
[docs]class OneBudgetView(MethodView):
"""
Render the GET /budgets/<int:budget_id> view using the ``budgets.html``
template.
"""
[docs] def get(self, budget_id):
standing = db_session.query(Budget).filter(
Budget.is_periodic.__eq__(False)
).order_by(Budget.name).all()
periodic = db_session.query(Budget).filter(
Budget.is_periodic.__eq__(True)
).order_by(Budget.name).all()
accts = {a.name: a.id for a in db_session.query(Account).all()}
budgets = {}
active_budgets = {}
for b in db_session.query(Budget).all():
k = b.name
if b.is_income:
k = '%s (i)' % b.name
budgets[b.id] = k
if b.is_active:
active_budgets[b.id] = k
return render_template(
'budgets.html',
standing=standing,
periodic=periodic,
budget_id=budget_id,
accts=accts,
budgets=budgets,
active_budgets=active_budgets
)
[docs]class BudgetAjax(MethodView):
"""
Handle GET /ajax/budget/<int:budget_id> endpoint.
"""
[docs] def get(self, budget_id):
budget = db_session.query(Budget).get(budget_id)
return jsonify(budget.as_dict)
[docs]class BudgetFormHandler(FormHandlerView):
"""
Handle POST /forms/budget
"""
[docs] def validate(self, data):
"""
Validate the form data. Return None if it is valid, or else a hash of
field names to list of error strings for each field.
:param data: submitted form data
:type data: dict
:return: None if no errors, or hash of field name to errors for that
field
"""
have_errors = False
errors = {k: [] for k in data.keys()}
if data.get('name', '').strip() == '':
errors['name'].append('Name cannot be empty')
have_errors = True
if (
data['is_periodic'] is True and
data['starting_balance'].strip() == ''
):
errors['starting_balances'].append(
'Starting balance must be specified for periodic budgets.'
)
have_errors = True
if (
data['is_periodic'] is False and
data['current_balance'].strip() == ''
):
errors['current_balances'].append(
'Current balance must be specified for standing budgets.'
)
have_errors = True
if have_errors:
return errors
return None
[docs] def submit(self, data):
"""
Handle form submission; create or update models in the DB. Raises an
Exception for any errors.
:param data: submitted form data
:type data: dict
:return: message describing changes to DB (i.e. link to created record)
:rtype: str
"""
if 'id' in data and data['id'].strip() != '':
# updating an existing budget
budget = db_session.query(Budget).get(int(data['id']))
if budget is None:
raise RuntimeError("Error: no Budget with ID %s" % data['id'])
action = 'updating Budget ' + data['id']
else:
budget = Budget()
action = 'creating new Budget'
budget.name = data['name'].strip()
budget.description = data['description'].strip()
budget.is_periodic = data['is_periodic']
if data['is_periodic'] is True:
budget.starting_balance = Decimal(data['starting_balance'])
else:
budget.current_balance = Decimal(data['current_balance'])
budget.is_active = data['is_active']
budget.is_income = data['is_income']
budget.omit_from_graphs = data['omit_from_graphs']
logger.info('%s: %s', action, budget.as_dict)
db_session.add(budget)
db_session.commit()
return 'Successfully saved Budget %d in database.' % budget.id
[docs]class BudgetTxfrFormHandler(FormHandlerView):
"""
Handle POST /forms/budget_transfer
"""
[docs] def validate(self, data):
"""
Validate the form data. Return None if it is valid, or else a hash of
field names to list of error strings for each field.
:param data: submitted form data
:type data: dict
:return: None if no errors, or hash of field name to errors for that
field
"""
have_errors = False
errors = {k: [] for k in data.keys()}
if data['date'].strip() == '':
errors['date'].append('Transactions must have a date')
have_errors = True
else:
try:
datetime.strptime(data['date'], '%Y-%m-%d').date()
except Exception:
errors['date'].append(
'Date "%s" is not valid (YYYY-MM-DD)' % data['date']
)
have_errors = True
if float(data['amount']) == 0:
errors['amount'].append('Amount cannot be zero')
have_errors = True
if float(data['amount']) < 0:
errors['amount'].append('Amount cannot be negative')
have_errors = True
if data['account'] == 'None':
errors['account'].append('Transactions must have an account')
have_errors = True
if data['from_budget'] == 'None':
errors['from_budget'].append('from_budget cannot be empty')
have_errors = True
to_budget = db_session.query(Budget).get(int(data['to_budget']))
if to_budget is None:
errors['to_budget'].append('to_budget ID does not exist')
have_errors = True
if data['to_budget'] == 'None':
errors['to_budget'].append('to_budget cannot be empty')
have_errors = True
if have_errors:
return errors
return None
[docs] def submit(self, data):
"""
Handle form submission; create or update models in the DB. Raises an
Exception for any errors.
:param data: submitted form data
:type data: dict
:return: message describing changes to DB (i.e. link to created record)
:rtype: str
"""
# get the data
trans_date = datetime.strptime(data['date'], '%Y-%m-%d').date()
amt = Decimal(data['amount'])
acct = db_session.query(Account).get(int(data['account']))
if acct is None:
raise RuntimeError(
"Error: no Account with ID %s" % data['account']
)
from_budget = db_session.query(Budget).get(int(data['from_budget']))
if from_budget is None:
raise RuntimeError(
"Error: no Budget with ID %s" % data['from_budget']
)
to_budget = db_session.query(Budget).get(int(data['to_budget']))
if to_budget is None:
raise RuntimeError(
"Error: no Budget with ID %s" % data['to_budget']
)
notes = data['notes'].strip()
desc = 'Budget Transfer - %s from %s (%d) to %s (%d)' % (
amt, from_budget.name, from_budget.id, to_budget.name,
to_budget.id
)
logger.info(desc)
res = do_budget_transfer(db_session, trans_date, amt, acct,
from_budget, to_budget, notes=notes)
db_session.commit()
return 'Successfully saved Transactions %d and %d in database.' % (
res[0].id, res[1].id
)
[docs]class BudgetSpendingChartView(MethodView):
"""
Handle GET /ajax/chart-data/budget-spending/<str:aggregation> endpoint.
"""
[docs] def get(self, aggregation):
if aggregation == 'by-pay-period':
return self._by_pay_period()
elif aggregation == 'by-month':
return self._by_month()
raise RuntimeError('Unknown aggregation type: %s' % aggregation)
[docs] def _by_pay_period(self):
min_txn = db_session.query(Transaction).order_by(
Transaction.date.asc()
).first()
budget_names = self._budget_names()
logger.debug('budget_names=%s', budget_names)
records = []
budgets_present = set()
pp = BiweeklyPayPeriod.period_for_date(min_txn.date, db_session)
dt_now = dtnow().date()
while pp.end_date <= dt_now:
sums = pp.budget_sums
logger.debug('sums=%s', sums)
records.append({
budget_names[y]: sums[y]['spent'] for y in sums.keys()
if y in budget_names
})
records[-1]['date'] = pp.start_date.strftime('%Y-%m-%d')
budgets_present.update(
[budget_names[y] for y in sums.keys() if y in budget_names]
)
pp = pp.next
res = {
'data': records,
'keys': sorted(list(budgets_present))
}
return jsonify(res)
[docs] def _by_month(self):
dt_now = dtnow().date()
budget_names = self._budget_names()
logger.debug('budget_names=%s', budget_names)
records = {}
budgets_present = set()
for t in db_session.query(Transaction).filter(
Transaction.budget_transactions.any(
BudgetTransaction.budget.has(is_active=True)
),
Transaction.date.__le__(dt_now)
).all():
for bt in t.budget_transactions:
if bt.budget_id not in budget_names:
continue
budg_name = bt.budget.name
budgets_present.add(budg_name)
ds = t.date.strftime('%Y-%m')
if ds not in records:
records[ds] = {'date': ds}
if budg_name not in records[ds]:
records[ds][budg_name] = Decimal('0')
records[ds][budg_name] += bt.amount
result = [records[k] for k in sorted(records.keys())]
res = {
'data': result,
'keys': sorted(list(budgets_present))
}
return jsonify(res)
[docs] def _budget_names(self):
return {
x.id: x.name for x in db_session.query(Budget).filter(
Budget.is_income.__eq__(False),
Budget.omit_from_graphs.__eq__(False)
).all()
}
app.add_url_rule('/budgets', view_func=BudgetsView.as_view('budgets_view'))
app.add_url_rule(
'/budgets/<int:budget_id>',
view_func=OneBudgetView.as_view('one_budget_view')
)
app.add_url_rule(
'/ajax/budget/<int:budget_id>',
view_func=BudgetAjax.as_view('budget_ajax')
)
app.add_url_rule(
'/forms/budget',
view_func=BudgetFormHandler.as_view('budget_form')
)
app.add_url_rule(
'/forms/budget_transfer',
view_func=BudgetTxfrFormHandler.as_view('budget_transfer_form')
)
app.add_url_rule(
'/ajax/chart-data/budget-spending/<string:aggregation>',
view_func=BudgetSpendingChartView.as_view('budget_spending_chart_view')
)