Skip to content

Commit

Permalink
Merge pull request #260 from jantman/plaid-change-env-docs
Browse files Browse the repository at this point in the history
plaid_update updates, docs, misc CI fixes
  • Loading branch information
jantman committed Dec 30, 2022
2 parents a818739 + 4e6ee2a commit 51e3aa2
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 32 deletions.
10 changes: 10 additions & 0 deletions .github/workflows/run-tox-suite.yml
Expand Up @@ -238,6 +238,12 @@ jobs:
run: python -m pip install --upgrade pip && pip install tox pymysql
- name: Setup test DB
run: python dev/setup_test_db.py
- name: Login to Docker Hub if master branch
if: github.ref_type == 'branch' && github.ref_name == 'master'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run tests
run: tox -e ${{ github.job }}
- name: Archive code coverage results
Expand All @@ -250,6 +256,10 @@ jobs:
results/
coverage.xml
htmlcov/
- name: Docker Push if master
if: github.ref_type == 'branch' && github.ref_name == 'master'
run: |
docker push jantman/biweeklybudget:${{ steps.docker-build.outputs.DOCKER_IMG_TAG }}
migrations:
runs-on: ubuntu-latest
strategy:
Expand Down
10 changes: 10 additions & 0 deletions CHANGES.rst
@@ -1,6 +1,16 @@
Changelog
=========

Unreleased Changes
------------------

* Docker build - don't include ``-dirty`` in version/tag when building in GHA
* Document how to change Plaid environments
* GHA - Push built Docker images to Docker Hub, for builds of master branch
* Document triggering a Plaid update via the ``/plaid-update`` endpoint.
* Change ``/plaid-update`` endpoint argument name from ``account_ids`` to ``item_ids``.
* Add ``num_days`` parameter support to ``/plaid-update`` endpoint.

1.1.0 (2022-12-29)
------------------

Expand Down
60 changes: 42 additions & 18 deletions biweeklybudget/flaskapp/views/plaid.py
Expand Up @@ -240,51 +240,75 @@ class PlaidUpdate(MethodView):
This single endpoint has multiple functions:
* If called with no query parameters, displays a form template to use to
* If GET with no query parameters, displays a form template to use to
interactively update Plaid accounts.
* If called with an ``item_ids`` query argument, performs a Plaid update
of the specified CSV list of Plaid Item IDs, or all Plaid-enabled accounts
if the value is ``ALL``. The response from this endpoint can be in one of
three forms:
* If GET or POST with an ``item_ids`` query parameter, performs a Plaid
update (via :py:meth:`~._update`) of the specified CSV list of Plaid Item
IDs, or all Plaid Items if the value is ``ALL``. The POST method also
accepts an optional ``num_days`` parameter specifying an integer number of
days of transactions to update. The response from this endpoint can be in
one of three forms:
* If the ``Accept`` HTTP header is set to ``application/json``, return a
JSON list of update results. Each list item is the JSON-ified value of
:py:attr:`~.PlaidUpdateResult.as_dict`.
* If the ``Accept`` HTTP header is set to ``text/plain``, return a plain
text human-readable summary of the update operation.
* Otherwise, return a templated view of the update operation results.
* Otherwise, return a templated view of the update operation results, as
would be returned to a browser.
"""

def post(self):
"""
Handle POST. If the ``account_ids`` query parameter is set, then return
:py:meth:`~._update`, else return a HTTP 400.
Handle POST. If the ``item_ids`` query parameter is set, then return
:py:meth:`~._update`, else return a HTTP 400. If the optional
``num_days`` query parameter is set, pass that on to the update method.
"""
ids = request.args.get('account_ids')
ids = request.args.get('item_ids')
if ids is None and request.form:
ids = ','.join([
x.replace('item_', '') for x in request.form.keys()
])
if ids is None:
return jsonify({
'success': False,
'message': 'Missing parameter: account_ids'
'message': 'Missing parameter: item_ids'
}), 400
return self._update(ids)
kwargs = {}
if 'num_days' in request.args:
kwargs['num_days'] = int(request.args['num_days'])
return self._update(ids, **kwargs)

def get(self):
"""
Handle GET. If the ``account_ids`` query parameter is set, then return
Handle GET. If the ``item_ids`` query parameter is set, then return
:py:meth:`~._update`, else return :py:meth:`~._form`.
"""
ids = request.args.get('account_ids')
ids = request.args.get('item_ids')
if ids is None:
return self._form()
return self._update(ids)
kwargs = {}
if 'num_days' in request.args:
kwargs['num_days'] = int(request.args['num_days'])
return self._update(ids, **kwargs)

def _update(self, ids):
"""Handle an update for Plaid accounts."""
logger.info('Handle Plaid Update request; item_ids=%s', ids)
def _update(self, ids: str, num_days: int = 30):
"""
Handle an update for Plaid accounts by instantiating a
:py:class:`~.PlaidUpdater`, calling its :py:meth:`~.PlaidUpdater.update`
method with the proper arguments, and then returning the result in a
form determined by the ``Accept`` header.
:param ids: a comma-separated string listing the :py:class:`~.PlaidItem`
IDs to update, or the string ``ALL`` to update all Items.
:type ids: str
:param num_days: number of days to retrieve transactions for; default 30
:type num_days: int
"""
logger.info(
'Handle Plaid Update request; item_ids=%s num_days=%d',
ids, num_days
)
updater = PlaidUpdater()
if ids == 'ALL':
items = PlaidUpdater.available_items()
Expand All @@ -293,7 +317,7 @@ def _update(self, ids):
items = [
db_session.query(PlaidItem).get(x) for x in ids
]
results = updater.update(items=items)
results = updater.update(items=items, days=num_days)
if request.headers.get('accept') == 'text/plain':
s = ''
num_updated = 0
Expand Down
2 changes: 2 additions & 0 deletions biweeklybudget/tests/docker_build.py
Expand Up @@ -166,6 +166,8 @@ def _find_git_info(self):
repo = Repo(path=self._gitdir, search_parent_directories=False)
res['sha'] = repo.head.commit.hexsha
res['dirty'] = repo.is_dirty(untracked_files=True)
if os.environ.get('GITHUB_ACTIONS') == 'true':
res['dirty'] = False
res['tag'] = None
for tag in repo.tags:
# each is a git.Tag object
Expand Down
128 changes: 119 additions & 9 deletions biweeklybudget/tests/unit/flaskapp/views/test_plaid.py
Expand Up @@ -771,7 +771,7 @@ def setup_method(self):

def test_post(self):
mock_update = Mock()
mock_request = Mock(form=None, args={'account_ids': 'id1,id2'})
mock_request = Mock(form=None, args={'item_ids': 'id1,id2'})
with patch(f'{pbm}.request', mock_request):
with patch(f'{self.pb}._update') as m_update:
with patch(f'{pbm}.jsonify') as m_jsonify:
Expand All @@ -781,6 +781,20 @@ def test_post(self):
assert m_update.mock_calls == [call('id1,id2')]
assert m_jsonify.mock_calls == []

def test_post_num_days(self):
mock_update = Mock()
mock_request = Mock(
form=None, args={'item_ids': 'id1,id2', 'num_days': '12'}
)
with patch(f'{pbm}.request', mock_request):
with patch(f'{self.pb}._update') as m_update:
with patch(f'{pbm}.jsonify') as m_jsonify:
m_update.return_value = mock_update
res = self.cls.post()
assert res == mock_update
assert m_update.mock_calls == [call('id1,id2', num_days=12)]
assert m_jsonify.mock_calls == []

def test_post_form(self):
mock_update = Mock()
mock_request = Mock(
Expand All @@ -807,7 +821,7 @@ def test_post_ids_none(self):
assert m_update.mock_calls == []
assert m_jsonify.mock_calls == [
call({
'success': False, 'message': 'Missing parameter: account_ids'
'success': False, 'message': 'Missing parameter: item_ids'
})
]

Expand All @@ -826,7 +840,7 @@ def test_get_form(self):
assert m_update.mock_calls == []

def test_get_update(self):
mock_req = Mock(args={'account_ids': '1,2,3'})
mock_req = Mock(args={'item_ids': '1,2,3'})
mock_form = Mock()
mock_update = Mock()
with patch(f'{self.pb}._form', autospec=True) as m_form:
Expand All @@ -839,6 +853,20 @@ def test_get_update(self):
assert m_form.mock_calls == []
assert m_update.mock_calls == [call(self.cls, '1,2,3')]

def test_get_update_num_days(self):
mock_req = Mock(args={'item_ids': '1,2,3', 'num_days': '25'})
mock_form = Mock()
mock_update = Mock()
with patch(f'{self.pb}._form', autospec=True) as m_form:
m_form.return_value = mock_form
with patch(f'{self.pb}._update', autospec=True) as m_update:
m_update.return_value = mock_update
with patch(f'{pbm}.request', mock_req):
res = self.cls.get()
assert res == mock_update
assert m_form.mock_calls == []
assert m_update.mock_calls == [call(self.cls, '1,2,3', num_days=25)]

def test_form(self):
accts = [
Mock(spec_set=Account, id='AID1'),
Expand Down Expand Up @@ -951,10 +979,10 @@ def db_get(_id):
assert mocks['PlaidUpdater'].mock_calls == [
call(),
call.available_items(),
call().update(items=items)
call().update(items=items, days=30)
]
assert mock_updater.mock_calls == [
call.update(items=items)
call.update(items=items, days=30)
]
assert mocks['render_template'].mock_calls == [call(
'plaid_result.html',
Expand Down Expand Up @@ -1025,10 +1053,10 @@ def db_get(_id):
assert res == mock_json
assert mocks['PlaidUpdater'].mock_calls == [
call(),
call().update(items=[items[0]])
call().update(items=[items[0]], days=30)
]
assert mock_updater.mock_calls == [
call.update(items=[items[0]])
call.update(items=[items[0]], days=30)
]
assert mocks['render_template'].mock_calls == []
assert mocks['jsonify'].mock_calls == [
Expand Down Expand Up @@ -1109,10 +1137,92 @@ def db_get(_id):
"TOTAL: 1 updated, 2 added, 1 account(s) failed"
assert mocks['PlaidUpdater'].mock_calls == [
call(),
call().update(items=[items[0]])
call().update(items=[items[0]], days=30)
]
assert mock_updater.mock_calls == [
call.update(items=[items[0]], days=30)
]
assert mocks['render_template'].mock_calls == []
assert mocks['jsonify'].mock_calls == []
assert mock_db.mock_calls == [
call.query(PlaidItem),
call.query().get('Item1'),
]

def test_update_plain_nondefault_num_days(self):
mock_req = Mock(headers={'accept': 'text/plain'})
accts = [
Mock(spec_set=Account, id='AID1'),
Mock(spec_set=Account, id='AID2')
]
type(accts[0]).name = 'AName1'
type(accts[1]).name = 'AName2'
plaid_accts = [
Mock(spec_set=PlaidAccount, mask='XXX1', account=accts[0]),
Mock(spec_set=PlaidAccount, mask='XXX2', account=None),
Mock(spec_set=PlaidAccount, mask='XXX3', account=accts[1]),
]
type(plaid_accts[0]).name = 'Acct1'
type(plaid_accts[1]).name = 'Acct2'
type(plaid_accts[2]).name = 'Acct3'
items = [
Mock(
spec_set=PlaidItem, item_id='Item1',
all_accounts=[plaid_accts[0], plaid_accts[1]],
institution_name='InstName1'
),
Mock(
spec_set=PlaidItem, item_id='Item2',
all_accounts=[plaid_accts[2]],
institution_name='InstName2'
)
]

def db_get(_id):
if _id == 'Item1':
return items[0]
if _id == 'Item2':
return items[1]

mock_db = Mock()
mock_query = Mock()
mock_query.get.side_effect = db_get
mock_db.query.return_value = mock_query
mock_updater = Mock()
rendered = Mock()
mock_json = Mock()
result = [
Mock(
success=True, updated=1, added=2, as_dict='res1',
stmt_ids='SID1,SID2,SID3', item=items[0]
),
Mock(
success=False, item=items[1], exc='MyException'
)
]
mock_updater.update.return_value = result
with patch.multiple(
pbm,
PlaidUpdater=DEFAULT,
render_template=DEFAULT,
jsonify=DEFAULT
) as mocks:
mocks['PlaidUpdater'].return_value = mock_updater
mocks['PlaidUpdater'].available_accounts.return_value = items
mocks['render_template'].return_value = rendered
mocks['jsonify'].return_value = mock_json
with patch(f'{pbm}.request', mock_req):
with patch(f'{pbm}.db_session', mock_db):
res = self.cls._update('Item1', num_days=12)
assert res == "InstName1 (Item1): 1 updated, 2 added (stmts: SID1," \
"SID2,SID3)\nInstName2 (Item2): Failed: MyException\n" \
"TOTAL: 1 updated, 2 added, 1 account(s) failed"
assert mocks['PlaidUpdater'].mock_calls == [
call(),
call().update(items=[items[0]], days=12)
]
assert mock_updater.mock_calls == [
call.update(items=[items[0]])
call.update(items=[items[0]], days=12)
]
assert mocks['render_template'].mock_calls == []
assert mocks['jsonify'].mock_calls == []
Expand Down
2 changes: 1 addition & 1 deletion biweeklybudget/version.py
Expand Up @@ -35,5 +35,5 @@
################################################################################
"""

VERSION = '1.1.0'
VERSION = '1.1.1'
PROJECT_URL = 'https://github.com/jantman/biweeklybudget'

0 comments on commit 51e3aa2

Please sign in to comment.