diff --git a/.gitignore b/.gitignore index 66f30e8..19a3866 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # bt_expense folders and files to ignore *.xlsx -!Expenses.xlsx +!Expenses_Template.xlsx # Byte-compiled / optimized / DLL files __pycache__/ @@ -110,3 +110,7 @@ ENV/ *.PNG *.pdf + +\.pytest_cache/v/cache/ + +*.jpg diff --git a/.travis.yml b/.travis.yml index 4683ef8..db9d181 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,7 @@ env: - CC_TEST_REPORTER_ID=6ea71d63c94102cdc54e78a76a58e3803e36f5247d1ba4cc04a1ed9af2e5623b language: python python: + - "3.4" - "3.5" - "3.6" # - "3.7-dev" @@ -20,8 +21,9 @@ before_script: - ./cc-test-reporter before-build # command to run tests script: + - pwd - ls - - pytest --cov-report term --cov-report xml --cov=bt_expense tests/ + - pytest -v --cov-report term --cov-report xml --cov=bt_expense tests/ after_script: - ./cc-test-reporter after-build -t coverage.py --exit-code $TRAVIS_TEST_RESULT after_success: diff --git a/bt_expense/Expenses.xlsx b/bt_expense/Expenses.xlsx deleted file mode 100644 index ca57604..0000000 Binary files a/bt_expense/Expenses.xlsx and /dev/null differ diff --git a/bt_expense/Expenses_Template.xlsx b/bt_expense/Expenses_Template.xlsx new file mode 100644 index 0000000..b7d7388 Binary files /dev/null and b/bt_expense/Expenses_Template.xlsx differ diff --git a/bt_expense/bt_expense.py b/bt_expense/bt_expense.py index c026568..83c0d88 100644 --- a/bt_expense/bt_expense.py +++ b/bt_expense/bt_expense.py @@ -4,13 +4,14 @@ Pull expenses from Excel Spreadsheet and upload to BigTime via REST HTTP call. """ import os +import json from pprint import pprint as pp -# import openpyxl as opxl -# import requests as r +# from pprint import pformat as pf +import requests as r from openpyxl import load_workbook # CD -os.chdir('bt_expense') +# os.chdir('bt_expense') # Constants BASE = 'https://iq.bigtime.net/BigtimeData/api/v2' @@ -20,8 +21,107 @@ 'cat': {}, } +class Authorizer(object): + """authorizes a BitTime REST API session. + Can authorize using a user login and password or an API key. + + User login and password will be used to obtain an API key. + If API key is provided, skip the step of obtaining API key""" + + def __init__(self, workbook_filename='Expenses.xlsx'): + self.wb_name = workbook_filename + self.staffsid = None + self.auth_header = self._build_credentials() + self.userid = self.auth_header['userid'] + self.userpwd = self.auth_header['pwd'] + self.api_key = None + self._authorized = False + self.header = self.authorize_session() + + def _build_credentials(self): + """Pulls Login information from the `Setup` worksheet. Return dictionary + for Auth Header.""" + keys = get_values('Setup', 'A1', 'A4', workbook_name=self.wb_name) + values = get_values('Setup', 'B1', 'B4', workbook_name=self.wb_name) + header = {k: v for (k, v) in zip(keys, values)} + header['Content-Type'] = 'application/json' + return header + + def authorize_session(self): + response = r.post('{}/session'.format(BASE), + headers={'Content-Type': 'application/json'}, + data=json.dumps(self.auth_header).encode('utf-8')) + if str(response.status_code)[0] is not '2': + # TODO Raise Requests HTTP Error + raise(ConnectionRefusedError) + response_dict = json.loads(response.text) + self.api_key = response_dict['token'] + self.staffsid = response_dict['staffsid'] + header = {'X-Auth-Token': self.api_key, + 'X-Auth-Realm': self.auth_header['Firm'], + 'Content-Type': self.auth_header['Content-Type']} + self._authorized = True + # print('Session Header\n', header) + return header + + +class Expensor(Authorizer): + + def prep_expenses(self, save=True): + pnames = get_values('Expenses', 'A2', 'A102') + projs = get_values('Expenses', 'F2', 'F102') + cats = get_values('Expenses', 'G2', 'G102') + dates = get_values('Expenses', 'C2', 'C102') + costs = get_values('Expenses', 'D2', 'D102') + notes = get_values('Expenses', 'E2', 'E102') + expense_entries = [] + total_cost = 0 + for proj, cat, date, cost, note, pname in zip(projs, cats, + dates, costs, + notes, pnames): + if cost and date: + content = {'staffsid': int(self.staffsid), + 'projectsid': int(proj), + 'catsid': int(cat), + 'dt': str(date)[:10], + 'CostIN': float('{0:.2f}'.format(cost)), + 'Nt': note, + # 'ProjectNm': pname, + 'ApprovalStatus': 0} + total_cost += float('{0:.2f}'.format(cost)) + expense_entries.append(content) + if save: + json_to_file(expense_entries, 'entries.json') + return expense_entries, float('{0:.2f}'.format(total_cost)) + + def post_expenses(self, upload=False): + expense_url = '{}/expense/detail'.format(BASE) + expense_entries, total = self.prep_expenses() + if upload is not True: + input_map = {'Y': True, 'N': False} + inp = input('Upload ${} {}\n{}'.format(total, + 'worth of entries?', + '(y/n)') + ).upper() + upload = input_map[inp] + if upload: + for entry in expense_entries: + print(r.post(expense_url, headers=self.header, + data=json.dumps(entry).encode()), + entry['dt'], entry['CostIN']) + else: + print('\t${} expense entries not uploaded!'.format(total)) + return len(expense_entries) + + def get_active_reports(self): + response = r.get('{0}/expense/reports'.format(BASE), + headers=self.header) + print(response.status_code) + return response.json() + + def get_wb(workbook_name='Expenses.xlsx'): - return load_workbook(filename=workbook_name) + return load_workbook(filename=workbook_name, data_only=True) def build_lookup_dictn_from_excel(): @@ -35,11 +135,11 @@ def build_lookup_dictn_from_excel(): return project_ids, category_ids -def get_values(sheet_name, start, stop=None): +def get_values(sheet_name, start, stop=None, workbook_name='Expenses.xlsx'): """Pulls a column (or section) of values from a Worksheet. Returns a list.""" values = [] - sheet = get_wb()[sheet_name] + sheet = get_wb(workbook_name)[sheet_name] if not stop: stop = sheet.max_row cells = [c[0].value for c in sheet[start:stop]] @@ -47,30 +147,32 @@ def get_values(sheet_name, start, stop=None): return values -def build_credentials(): - """Pulls Login information from the `Setup` worksheet. Return dictionary - for Auth Header.""" - keys = get_values('Setup', 'A1', 'A4') - values = get_values('Setup', 'B1', 'B4') - header = {k: v for (k, v) in zip(keys, values)} - # TODO: Format for BigTime - return header - - -def get_picklist(picklist_name): +def get_picklist(auth_object, picklist_name): """Pulls a BigTime 'Picklist' - Use to build project and expense catagory lookup tables""" + Use to build project and expense catagory lookup tables. + Requires Admin account.""" # TODO: complete `get_picklist()` function valid_picklists = ['projects', 'ExpenseCodes'] if picklist_name not in valid_picklists: raise ValueError('Not a valid picklist') - header = build_credentials() - return picklist_name + pick_list_url = '{0}/picklist/{1}'.format(BASE, picklist_name) + print(pick_list_url) + response = r.get(pick_list_url, headers=auth_object.header) + return response.json() + # return response.json() + + +def json_to_file(json_obj, filename='data.json'): + with open(filename, 'w') as f_out: + json.dump(json_obj, f_out) + return filename if __name__ == '__main__': print(__doc__) print('**DIR:', os.getcwd()) - build_lookup_dictn_from_excel() - pp(BT_LOOKUP) - pp(build_credentials()) + print('*' * 79) + exp1 = Expensor() + exp_entries = exp1.prep_expenses() + print(len(exp_entries)) + pp(exp1.post_expenses()) diff --git a/tests/context.py b/tests/context.py index 8a7a844..9d93d09 100644 --- a/tests/context.py +++ b/tests/context.py @@ -6,15 +6,19 @@ """ import os import sys -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../bt_expense'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), + '../bt_expense'))) import bt_expense + def fixpath(path): path = os.path.normpath(os.path.expanduser(path)) - if path.startswith("\\"): return "C:" + path + if path.startswith("\\"): + return "C:" + path return path + print('USING context.py') if __name__ == '__main__': diff --git a/tests/test_basic.py b/tests/test_basic.py index 43cf5be..efd2ca6 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -4,36 +4,48 @@ simple tests for bt_expense. """ import os -import unittest - +# import unittest +import pytest +from sys import version_info # import pytest from context import bt_expense as bte from context import fixpath +PYTHON_VER = version_info[0] + TEST_DIR = fixpath(os.path.abspath(os.path.dirname(__file__))) ROOT_DIR = fixpath(os.path.dirname(TEST_DIR)) MAIN_DIR = fixpath('{}/bt_expense'.format(ROOT_DIR)) +WORKBOOK_NAME = 'bt_expense/Expenses_Template.xlsx' + + +def test_pulling_column_values(): + a1 = bte.get_values('Expenses', 'A1', + workbook_name=WORKBOOK_NAME)[0] + assert a1, 'Project' -class SmokeTest(unittest.TestCase): - """Test that nothing is on fire.""" - def setUp(self): - os.chdir(TEST_DIR) +def test_pulling_auth_info(): + expected_keys = ['userid', 'pwd', 'Firm', 'AuthType'] + actual_keys = bte.get_values('Setup', 'A1', 'A4', + workbook_name=WORKBOOK_NAME) + for key in expected_keys: + assert key in actual_keys - def tearDown(self): - os.chdir(ROOT_DIR) - def test_pulling_column_values(self): - os.chdir(MAIN_DIR) - a1 = bte.get_values('Expenses', 'A1')[0] - self.assertEqual(a1, 'Project') +def test_authorizer_object_creation(): + try: + bte.Authorizer(workbook_filename=WORKBOOK_NAME) + except ConnectionRefusedError as E: + print(E) if __name__ == "__main__": + print('Python {}'.format(PYTHON_VER)) print(__doc__) print(__file__) print('root:', ROOT_DIR) print('test:', TEST_DIR) print('main:', MAIN_DIR) - unittest.main() + pytest.main(args=['-v'])