Skip to content

Commit

Permalink
Merge pull request #98 from jantman/wishlist
Browse files Browse the repository at this point in the history
Wishlist
  • Loading branch information
jantman committed Jul 9, 2017
2 parents c7a6c38 + 11b7469 commit c66cbae
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 15 deletions.
6 changes: 5 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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'
Expand Down Expand Up @@ -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

Expand All @@ -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'
Expand Down
16 changes: 9 additions & 7 deletions biweeklybudget/tests/acceptance/flaskapp/views/test_budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion biweeklybudget/tests/docker_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
280 changes: 280 additions & 0 deletions biweeklybudget/wishlist2project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
"""
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 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()
12 changes: 12 additions & 0 deletions docs/make_screenshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
}
]

Expand Down

0 comments on commit c66cbae

Please sign in to comment.