diff --git a/README.rst b/README.rst index 0b2e2b85..433d29cb 100644 --- a/README.rst +++ b/README.rst @@ -64,7 +64,11 @@ Main Features +++++++++++++ * Budgeting on a biweekly (fortnightly; every other week) basis, for those of us who are paid that way. -* Optional automatic downloading of transactions/statements from your financial institutions. +* Periodic (per-pay-period) or standing budgets. +* Optional automatic downloading of transactions/statements from your financial institutions and reconciling transactions (bank, credit, and investment accounts). +* Scheduled transactions - specific date or recurring (date-of-month, or number of times per pay period). +* 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. Requirements ------------ diff --git a/biweeklybudget/tests/acceptance/flaskapp/views/test_base_template.py b/biweeklybudget/tests/acceptance/flaskapp/views/test_base_template.py index 50e9fc95..38e00c1f 100644 --- a/biweeklybudget/tests/acceptance/flaskapp/views/test_base_template.py +++ b/biweeklybudget/tests/acceptance/flaskapp/views/test_base_template.py @@ -238,7 +238,7 @@ def test_2_confirm_pp(self, testdb): assert stand_bal == 132939.07 pp_bal = NotificationsController.pp_sum(testdb) # floating point awfulness - assert "%.2f" % pp_bal == '222.20' + assert "%.2f" % pp_bal == '11.76' unrec_amt = NotificationsController.budget_account_unreconciled(testdb) assert unrec_amt == -333.33 @@ -250,8 +250,8 @@ def test_3_notification(self, base_url, selenium): )[1] assert div.text == 'Combined balance of all budget-funding accounts ' \ '($12,889.24) is less than all allocated funds ' \ - 'total of $132,827.94 ($132,939.07 standing ' \ - 'budgets; $222.20 current pay period remaining; ' \ + 'total of $132,617.50 ($132,939.07 standing ' \ + 'budgets; $11.76 current pay period remaining; ' \ '-$333.33 unreconciled)!' a = div.find_elements_by_tag_name('a') assert self.relurl(a[0].get_attribute('href')) == '/accounts' @@ -323,7 +323,7 @@ def test_1_confirm_pp(self, testdb): assert stand_bal == 11099.85 pp_bal = NotificationsController.pp_sum(testdb) # floating point awfulness - assert "%.2f" % pp_bal == '222.20' + assert "%.2f" % pp_bal == '11.76' unrec_amt = NotificationsController.budget_account_unreconciled(testdb) assert unrec_amt == 33666.67 @@ -335,8 +335,8 @@ def test_2_notification(self, base_url, selenium): )[1] assert div.text == 'Combined balance of all budget-funding accounts ' \ '($12,889.24) is less than all allocated funds ' \ - 'total of $44,988.72 ($11,099.85 standing ' \ - 'budgets; $222.20 current pay period remaining; ' \ + 'total of $44,778.28 ($11,099.85 standing ' \ + 'budgets; $11.76 current pay period remaining; ' \ '$33,666.67 unreconciled)!' a = div.find_elements_by_tag_name('a') assert self.relurl(a[0].get_attribute('href')) == '/accounts' diff --git a/biweeklybudget/tests/acceptance/flaskapp/views/test_budgets.py b/biweeklybudget/tests/acceptance/flaskapp/views/test_budgets.py index c99386f3..cf99871f 100644 --- a/biweeklybudget/tests/acceptance/flaskapp/views/test_budgets.py +++ b/biweeklybudget/tests/acceptance/flaskapp/views/test_budgets.py @@ -765,11 +765,11 @@ def test_2_transfer_modal(self, base_url, selenium, testdb): ptexts = self.tbody2textlist(ptable) assert ptexts[2] == ['yes', 'Periodic2 (2)', '$234.00'] pp = BiweeklyPayPeriod.period_for_date(datetime.now(), testdb) - assert float(pp.budget_sums[2]['allocated']) == 444.44 + assert float(pp.budget_sums[2]['allocated']) == 222.22 assert float(pp.budget_sums[2]['budget_amount']) == 234.0 - assert float(pp.budget_sums[2]['remaining']) == -210.44 + assert "%.2f" % float(pp.budget_sums[2]['remaining']) == '11.78' assert float(pp.budget_sums[2]['spent']) == 222.22 - assert float(pp.budget_sums[2]['trans_total']) == 444.44 + assert float(pp.budget_sums[2]['trans_total']) == 222.22 link = selenium.find_element_by_id('btn_budget_txfr') link.click() modal, title, body = self.get_modal_parts(selenium) @@ -858,13 +858,15 @@ def test_2_transfer_modal(self, base_url, selenium, testdb): assert ptexts[2] == ['yes', 'Periodic2 (2)', '$234.00'] def test_3_verify_db(self, testdb): - pp = BiweeklyPayPeriod.period_for_date(datetime.now(), testdb) - assert float(pp.budget_sums[2]['allocated']) == 320.99 + d = datetime.now().date() + pp = BiweeklyPayPeriod.period_for_date(d, testdb) + print('Found period for %s: %s' % (d, pp)) + assert float(pp.budget_sums[2]['allocated']) == 98.77 assert float(pp.budget_sums[2]['budget_amount']) == 234.0 # ugh, floating point issues... - assert "%.2f" % pp.budget_sums[2]['remaining'] == '-86.99' + assert "%.2f" % pp.budget_sums[2]['remaining'] == '135.23' assert float(pp.budget_sums[2]['spent']) == 98.77 - assert float(pp.budget_sums[2]['trans_total']) == 320.99 + assert float(pp.budget_sums[2]['trans_total']) == 98.77 desc = 'Budget Transfer - 123.45 from Standing2 (5) to Periodic2 (2)' t1 = testdb.query(Transaction).get(4) assert t1.date == datetime.now().date() diff --git a/biweeklybudget/tests/docker_build.py b/biweeklybudget/tests/docker_build.py index ac8d3c49..4b044738 100755 --- a/biweeklybudget/tests/docker_build.py +++ b/biweeklybudget/tests/docker_build.py @@ -97,7 +97,7 @@ RUN /usr/local/bin/virtualenv /app && \ /app/bin/pip install -r /tmp/requirements.txt && \ /app/bin/pip install {install} && \ - /app/bin/pip install gunicorn==19.7.1 + /app/bin/pip install gunicorn==19.7.1 wishlist ENV DEBIAN_FRONTEND=noninteractive diff --git a/biweeklybudget/wishlist2project.py b/biweeklybudget/wishlist2project.py new file mode 100644 index 00000000..4ced0976 --- /dev/null +++ b/biweeklybudget/wishlist2project.py @@ -0,0 +1,280 @@ +""" +The latest version of this package is available at: + + +################################################################################ +Copyright 2016 Jason Antman + + 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 . + +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 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 +################################################################################ +""" + +import argparse +import logging +import atexit +import sys +from types import MethodType + +from biweeklybudget.models.projects import Project, BoMItem +from biweeklybudget.db import init_db, db_session, cleanup_db +from biweeklybudget.cliutils import set_log_debug, set_log_info + +logger = logging.getLogger(__name__) + +# suppress requests logging +requests_log = logging.getLogger("requests") +requests_log.setLevel(logging.WARNING) +requests_log.propagate = True + + +class WishlistToProject(object): + + def __init__(self): + atexit.register(cleanup_db) + init_db() + try: + from wishlist.core import Wishlist + except ImportError: + sys.stderr.write('ERROR: wishlist could not be imported. Please ' + '"pip install wishlist".\n') + raise SystemExit(1) + self._wlist = Wishlist() + + def run(self): + """ + Run the synchronization. + + :return: 2-tuple; count of successful syncs, total count of projects + with associated wishlists + :rtype: tuple + """ + logger.debug('Beginning wishlist sync run') + success = 0 + total = 0 + for list_url, proj in self._get_wishlist_projects(): + total += 1 + try: + if self._do_project(list_url, proj): + success += 1 + except Exception: + logger.error('Exception updating project %s with list %s', + proj, list_url, exc_info=True) + return success, total + + def _do_project(self, list_url, project): + """ + Update a project with information from its wishlist. + + :param list_url: Amazon wishlist URL + :type list_url: str + :param project: the project to update + :type project: Project + :return: whether or not the update was successful + :rtype: bool + """ + logger.debug('Handling project: %s', project) + pitems = self._project_items(project) + witems = self._wishlist_items(list_url) + logger.debug('Project has %d items; wishlist has %d', + len(pitems), len(witems)) + for url, item in pitems.items(): + if url not in witems: + logger.info( + '%s (%s) removed from amazon list; setting inactive', + item, url + ) + item.is_active = False + db_session.add(item) + for url, item in witems.items(): + if url in pitems: + bitem = pitems[url] + logger.info('Updating %s from Amazon wishlist', bitem) + else: + bitem = BoMItem() + bitem.project = project + logger.info('Adding new BoMItem for wishlist %s', url) + bitem.url = url + bitem.is_active = True + bitem.quantity = item['quantity'] + bitem.unit_cost = item['cost'] + bitem.name = item['name'] + db_session.add(bitem) + logger.info('Committing changes for project %s url %s', + project, list_url) + db_session.commit() + return True + + def _project_items(self, proj): + """ + Return all of the BoMItems for the specified project, as a dict of + URL to BoMItem. + + :param proj: the project to get items for + :type proj: Project + :return: item URLs to BoMItems + :rtype: dict + """ + res = {} + for i in db_session.query(BoMItem).filter( + BoMItem.project.__eq__(proj) + ).all(): + res[i.url] = i + return res + + def _wishlist_items(self, list_url): + """ + Get the items on the specified wishlist. + + :param list_url: wishlist URL + :type list_url: str + :return: dict of item URL to item details dict + :rtype: dict + """ + res = {} + list_name = list_url.split('/')[-1] + logger.debug('Getting wishlist items for wishlist: %s', list_name) + items = self._get_wishlist(list_name) + logger.debug("Found %d items in list" % len(items)) + for item in items: + d = {'name': item.title, 'url': item.url} + try: + d['quantity'] = item.wanted_count + except Exception: + d['quantity'] = 1 + if item.price > 0: + d['cost'] = item.price + else: + d['cost'] = item.marketplace_price + res[item.url] = d + return res + + def _get_wishlist(self, list_name): + """ + Workaround for a bug in wishlist==0.1.2 + + :param list_name: wishlist name to get + :type list_name: str + :return: list of items in wishlist + :rtype: list + """ + logger.debug('Wishlist bug workaround - list name: %s', list_name) + try: + items = [i for i in self._wlist.get(list_name)] + assert len(items) > 0 + except Exception: + logger.info('Hit pagination bug in wishlist package') + + def hack(cls, _): + return 1 + + self._wlist.get_total_pages_from_body = MethodType( + hack, self._wlist + ) + items = [ + i for i in self._wlist.get(list_name, start_page=1, stop_page=1) + ] + return items + + def _get_wishlist_projects(self): + """ + Find all projects with descriptions that begin with a wishlist URL. + + :return: list of (url, Project object) tuples + :rtype: list + """ + res = [] + logger.debug('Querying active projects for wishlist URLs') + q = db_session.query(Project).filter(Project.is_active.__eq__(True)) + total_p = 0 + for p in q.all(): + total_p += 1 + if p.notes.strip() == '': + continue + u = p.notes.split(' ')[0] + if self._url_is_wishlist(u): + res.append((u, p)) + logger.info('Found %d of %d projects with wishlist URLs', + len(res), total_p) + return res + + @staticmethod + def _url_is_wishlist(url): + """ + Determine if the given string or URL matches a wishlist. + + :param url: URL or string to test + :type url: str + :return: whether url is a wishlist URL + :rtype: bool + """ + return url.startswith('https://www.amazon.com/gp/registry/wishlist/') + + +def parse_args(): + p = argparse.ArgumentParser( + description='Synchronize Amazon wishlists to projects, for projects ' + 'with Notes fields beginning with an Amazon public ' + 'wishlist URL' + ) + p.add_argument('-v', '--verbose', dest='verbose', action='count', default=0, + help='verbose output. specify twice for debug-level output.') + args = p.parse_args() + return args + + +def main(): + global logger + format = "[%(asctime)s %(levelname)s] %(message)s" + logging.basicConfig(level=logging.WARNING, format=format) + logger = logging.getLogger() + + args = parse_args() + + # set logging level + if args.verbose > 1: + set_log_debug(logger) + elif args.verbose == 1: + set_log_info(logger) + if args.verbose <= 1: + # if we're not in verbose mode, suppress routine logging for cron + lgr = logging.getLogger('alembic') + lgr.setLevel(logging.WARNING) + lgr = logging.getLogger('biweeklybudget.db') + lgr.setLevel(logging.WARNING) + + syncer = WishlistToProject() + success, total = syncer.run() + if success != total: + logger.warning('Synced %d of %d project wishlists', success, total) + raise SystemExit(1) + raise SystemExit(0) + + +if __name__ == "__main__": + main() diff --git a/docs/make_screenshots.py b/docs/make_screenshots.py index 9c6eef4d..600e684e 100644 --- a/docs/make_screenshots.py +++ b/docs/make_screenshots.py @@ -213,6 +213,18 @@ class Screenshotter(object): 'filename': 'fuel', 'title': 'Fuel Log', 'description': 'Vehicle fuel log and fuel economy tracking.' + }, + { + 'path': '/projects', + 'filename': 'projects', + 'title': 'Project Tracking', + 'description': 'Track projects and their cost.' + }, + { + 'path': '/projects/1', + 'filename': 'bom', + 'title': 'Projects - Bill of Materials', + 'description': 'Track individual items/materials for projects.' } ] diff --git a/docs/source/biweeklybudget.rst b/docs/source/biweeklybudget.rst index 10dd2b80..3d404957 100644 --- a/docs/source/biweeklybudget.rst +++ b/docs/source/biweeklybudget.rst @@ -33,4 +33,5 @@ Submodules biweeklybudget.settings_example biweeklybudget.utils biweeklybudget.version + biweeklybudget.wishlist2project diff --git a/docs/source/biweeklybudget.wishlist2project.rst b/docs/source/biweeklybudget.wishlist2project.rst new file mode 100644 index 00000000..eac589c4 --- /dev/null +++ b/docs/source/biweeklybudget.wishlist2project.rst @@ -0,0 +1,7 @@ +biweeklybudget\.wishlist2project module +======================================= + +.. automodule:: biweeklybudget.wishlist2project + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index e51c625f..1b36f601 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -117,3 +117,4 @@ instructions above. * ``loaddata`` - Entrypoint for dropping **all** existing data and loading test fixture data, or your base data. This is an awful, manual hack right now. * ``ofxbackfiller`` - Entrypoint to backfill OFX Statements to DB from disk. * ``ofxgetter`` - Entrypoint to download OFX Statements for one or all accounts, save to disk, and load to DB. See :ref:`OFX `. +* ``wishlist2project`` - For any projects with "Notes" fields matching an Amazon wishlist URL of a public wishlist (``^https://www.amazon.com/gp/registry/wishlist/``), synchronize the wishlist items to the project. Requires ``wishlist==0.1.2``. diff --git a/setup.py b/setup.py index 3b7323dc..0215211a 100644 --- a/setup.py +++ b/setup.py @@ -99,6 +99,7 @@ ofxgetter = biweeklybudget.ofxgetter:main ofxbackfiller = biweeklybudget.backfill_ofx:main initdb = biweeklybudget.initdb:main + wishlist2project = biweeklybudget.wishlist2project:main [flask.commands] rundev=biweeklybudget.flaskapp.cli_commands:rundev_command """,