diff --git a/MANIFEST.in b/MANIFEST.in index fa8ca8f8..97c4711f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,5 @@ include LICENSE include README.rst include requirements.txt recursive-include biweeklybudget/alembic *.ini *.py +recursive-include biweeklybudget/flaskapp/templates *.html +recursive-include biweeklybudget/flaskapp/static * \ No newline at end of file diff --git a/docs/make_screenshots.py b/docs/make_screenshots.py new file mode 100644 index 00000000..732ee834 --- /dev/null +++ b/docs/make_screenshots.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python + +import sys +import os +import re +from collections import defaultdict + +index_head = """Screenshots +=========== + +""" + +import os +import glob +import socket +import logging +from time import sleep +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session +import biweeklybudget.settings +from biweeklybudget.settings import PAY_PERIOD_START_DATE +from biweeklybudget.tests.fixtures.sampledata import SampleDataLoader +try: + from pytest_flask.fixtures import LiveServer +except ImportError: + pass + +from selenium import webdriver +from selenium.webdriver.common.action_chains import ActionChains +from PIL import Image + +format = "%(asctime)s [%(levelname)s %(filename)s:%(lineno)s - " \ + "%(name)s.%(funcName)s() ] %(message)s" +logging.basicConfig(level=logging.DEBUG, format=format) +logger = logging.getLogger() + +connstr = os.environ.get('DB_CONNSTRING', None) +if connstr is None: + connstr = 'mysql+pymysql://budgetTester:jew8fu0ue@127.0.0.1:3306/' \ + 'budgettest?charset=utf8mb4' + os.environ['DB_CONNSTRING'] = connstr +biweeklybudget.settings.DB_CONNSTRING = connstr + +import biweeklybudget.db # noqa +import biweeklybudget.models.base # noqa +from biweeklybudget.flaskapp.app import app # noqa +from biweeklybudget.models.txn_reconcile import TxnReconcile +from biweeklybudget.models.ofx_transaction import OFXTransaction + +engine = create_engine( + connstr, convert_unicode=True, echo=False, + connect_args={'sql_mode': 'STRICT_ALL_TABLES'}, + pool_size=10, pool_timeout=120 +) + + +class Screenshotter(object): + """ + Generate sample screenshots for the documentation, and the + ``screenshots.rst`` documentation page. + """ + + screenshots = [ + { + 'path': '/', + 'filename': 'index', + 'title': 'Index Page', + 'description': 'Main landing page.' + }, + { + 'path': '/payperiods', + 'filename': 'payperiods', + 'title': 'Pay Periods View', + 'description': 'Summary of previous, current and upcoming pay ' + 'periods, plus date selector to find a pay period.' + }, + { + 'path': '/payperiod/%s' % PAY_PERIOD_START_DATE.strftime( + '%Y-%m-%d' + ), + 'filename': 'payperiod', + 'title': 'Single Pay Period View', + 'description': 'Shows a pay period (current in this example) ' + 'balances (income, allocated, spent, remaining), ' + 'budgets and transactions (previous/manually-' + 'entered and scheduled).' + }, + { + 'path': '/accounts', + 'filename': 'accounts', + 'title': 'Accounts View' + }, + { + 'path': '/accounts/1', + 'filename': 'account1', + 'title': 'Account Details', + 'description': 'Details of a single account.' + }, + { + 'path': '/ofx', + 'filename': 'ofx', + 'title': 'OFX Transactions', + 'description': 'Shows transactions imported from OFX statements.' + }, + { + 'path': '/transactions', + 'filename': 'transactions', + 'title': 'Transactions View', + 'description': 'Shows all manually-entered transactions.' + }, + { + 'path': '/transactions/2', + 'filename': 'transaction2', + 'title': 'Transaction Detail', + 'description': 'Transaction detail modal to view and edit a ' + 'transaction.' + }, + { + 'path': '/budgets', + 'filename': 'budgets', + 'title': 'Budgets', + 'description': 'List all budgets' + }, + { + 'path': '/budgets/2', + 'filename': 'budget2', + 'title': 'Single Budget View', + 'description': 'Budget detail modal to view and edit a budget.' + }, + { + 'path': '/scheduled', + 'filename': 'scheduled', + 'title': 'Scheduled Transactions', + 'description': 'List all scheduled transactions (active and ' + 'inactive).' + }, + { + 'path': '/scheduled/1', + 'filename': 'scheduled1', + 'title': 'Specific Date Scheduled Transaction', + 'description': 'Scheduled transactions can occur one-time on a ' + 'single specific date.' + }, + { + 'path': '/scheduled/2', + 'filename': 'scheduled2', + 'title': 'Monthly Scheduled Transaction', + 'description': 'Scheduled transactions can occur monthly on a ' + 'given date.' + }, + { + 'path': '/scheduled/3', + 'filename': 'scheduled3', + 'title': 'Number Per-Period Scheduled Transactions', + 'description': 'Scheduled transactions can occur a given number ' + 'of times per pay period.' + }, + { + 'path': '/reconcile', + 'filename': 'reconcile', + 'title': 'Reconcile Transactions with OFX', + 'description': 'OFX Transactions reported by financial institutions' + ' can be marked as reconciled with a corresponding ' + 'Transaction.', + 'preshot_func': '_reconcile_preshot' + }, + { + 'path': '/reconcile', + 'filename': 'reconcile-drag', + 'title': 'Drag-and-Drop Reconciling', + 'description': 'To reconcile an OFX transaction with a Transaction,' + ' just drag and drop.', + 'preshot_func': '_reconcile_drag_preshot' + } + ] + + def __init__(self, toxinidir): + """ + Initialize class + + :param toxinidir: tox.ini directory + :type toxinidir: str + """ + logger.info('Starting Screenshotter, toxinidir=%s', toxinidir) + self.toxinidir = toxinidir + self.srcdir = os.path.realpath(os.path.join( + toxinidir, 'docs', 'source' + )) + self.browser = None + self.server = self._create_server() + + def run(self): + logger.info('Removing old screenshots') + for f in glob.glob('docs/source/*.png'): + os.unlink(f) + self._refreshdb() + logger.info('Starting server...') + self.server.start() + logger.info('LiveServer running at: %s', self.base_url) + for sdict in self.screenshots: + self.take_screenshot(**sdict) + logger.info('Stopping server...') + self.server.stop() + self.make_rst() + + def make_rst(self): + r = index_head + for sdict in self.screenshots: + r += sdict['title'] + "\n" + r += '-' * len(sdict['title']) + "\n\n" + if 'description' in sdict: + r += "%s\n\n" % sdict['description'] + r += '.. image:: %s_sm.png' % sdict['filename'] + "\n" + r += ' :target: %s.png' % sdict['filename'] + "\n" + r += "\n" + r_path = os.path.join(self.srcdir, 'screenshots.rst') + if os.path.exists(r_path): + os.unlink(r_path) + with open(r_path, 'w') as fh: + fh.write(r) + logger.info('screenshots.rst written to: %s', r_path) + + def take_screenshot(self, path=None, filename=None, title=None, + preshot_func=None, description=None): + """Take a screenshot and save it.""" + self.get(path) + sleep(1) + if preshot_func is not None: + getattr(self, preshot_func)() + sleep(1) + # get_screenshot_as_png() -> binary data + fpath = os.path.join(self.srcdir, '%s.png' % filename) + logger.info('Screenshotting "%s" to %s', path, fpath) + self.browser.get_screenshot_as_file(fpath) + self._resize_image(filename) + + def _reconcile_preshot(self): + logger.info('Reconcile preshot') + self._update_db() + self.get('/reconcile') + sleep(1) + + def _reconcile_drag_preshot(self): + ofxdiv = self.browser.find_element_by_id('ofx-2-0') + logger.info('ofxdiv location: %s size: %s', + ofxdiv.location, ofxdiv.size) + pos_x = (ofxdiv.location['x'] - 400) + (ofxdiv.size['width'] / 4) + pos_y = (ofxdiv.location['y'] - 50) + (ofxdiv.size['height'] / 2) + self.browser.execute_script( + "$('body').append($('%s'));" % self._cursor_script(pos_x, pos_y) + ) + actions = ActionChains(self.browser) + actions.move_to_element(ofxdiv) + actions.click_and_hold() + actions.move_by_offset(-400, -50) + actions.perform() + self.browser.get_screenshot_as_file('docs/source/foo.png') + + def _cursor_script(self, x_pos, y_pos): + s = '' % ( + x_pos, y_pos + ) + return s + + def _resize_image(self, fname): + """Resize an image""" + fpath = os.path.join(self.srcdir, '%s.png' % fname) + smallpath = os.path.join(self.srcdir, '%s_sm.png' % fname) + logger.info('Generating 640x480 to: %s', smallpath) + im = Image.open(fpath) + im.thumbnail((640, 480)) + im.save(smallpath, "PNG") + + def get(self, path): + """ + Get a page, via selenium. + + :param path: relative path to get on site + :type path: str + """ + url = '%s%s' % (self.base_url, path) + logger.debug('GET %s', url) + if self.browser is None: + self.browser = self._get_browser() + self.browser.get(url) + + def _get_browser(self): + b = webdriver.PhantomJS() + b.set_window_size(1920, 1080) + b.implicitly_wait(2) + return b + + def _create_server(self): + """ + This is a version of pytest-flask's live_server fixture, modified for + session use. + """ + # Bind to an open port + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(('', 0)) + port = s.getsockname()[1] + s.close() + logger.info('LiveServer will listen on port %s', port) + return LiveServer(app, port) + + @property + def base_url(self): + """ + Simple fixture to return ``testflask`` base URL + """ + return self.server.url() + + def _refreshdb(self): + """ + Refresh/Load DB data before tests + """ + if 'NO_REFRESH_DB' in os.environ: + logger.info('Skipping session-scoped DB refresh') + return + # setup the connection + conn = engine.connect() + logger.info('Refreshing DB (session-scoped)') + # clean the database + biweeklybudget.models.base.Base.metadata.reflect(engine) + biweeklybudget.models.base.Base.metadata.drop_all(engine) + biweeklybudget.models.base.Base.metadata.create_all(engine) + # load the sample data + data_sess = scoped_session( + sessionmaker(autocommit=False, autoflush=False, bind=conn) + ) + SampleDataLoader(data_sess).load() + data_sess.flush() + data_sess.commit() + data_sess.close() + conn.close() + logger.info('DB refreshed.') + + def _update_db(self): + conn = engine.connect() + logger.info('Updating DB') + data_sess = scoped_session( + sessionmaker(autocommit=False, autoflush=False, bind=conn) + ) + for t in data_sess.query(OFXTransaction).filter( + OFXTransaction.account_id.__eq__(1) + ).all(): + if t.reconcile is not None: + continue + data_sess.delete(t) + data_sess.flush() + data_sess.commit() + data_sess.close() + conn.close() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("USAGE: make_jsdoc.py TOXINIDIR") + raise SystemExit(1) + Screenshotter(sys.argv[1]).run() diff --git a/docs/source/account1.png b/docs/source/account1.png new file mode 100644 index 00000000..de115dce Binary files /dev/null and b/docs/source/account1.png differ diff --git a/docs/source/account1_sm.png b/docs/source/account1_sm.png new file mode 100644 index 00000000..de9ebcf4 Binary files /dev/null and b/docs/source/account1_sm.png differ diff --git a/docs/source/accounts.png b/docs/source/accounts.png new file mode 100644 index 00000000..efe21397 Binary files /dev/null and b/docs/source/accounts.png differ diff --git a/docs/source/accounts_sm.png b/docs/source/accounts_sm.png new file mode 100644 index 00000000..9c999b80 Binary files /dev/null and b/docs/source/accounts_sm.png differ diff --git a/docs/source/budget2.png b/docs/source/budget2.png new file mode 100644 index 00000000..ae467fa1 Binary files /dev/null and b/docs/source/budget2.png differ diff --git a/docs/source/budget2_sm.png b/docs/source/budget2_sm.png new file mode 100644 index 00000000..fba3ce38 Binary files /dev/null and b/docs/source/budget2_sm.png differ diff --git a/docs/source/budgets.png b/docs/source/budgets.png new file mode 100644 index 00000000..2526ce00 Binary files /dev/null and b/docs/source/budgets.png differ diff --git a/docs/source/budgets_sm.png b/docs/source/budgets_sm.png new file mode 100644 index 00000000..390305af Binary files /dev/null and b/docs/source/budgets_sm.png differ diff --git a/docs/source/conf.py b/docs/source/conf.py index e218a1ea..87ca3175 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -15,6 +15,7 @@ import sys import os import re +import glob # to let sphinx find the actual source... sys.path.insert(0, os.path.abspath("../..")) from biweeklybudget.version import VERSION, PROJECT_URL @@ -150,7 +151,7 @@ # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +html_extra_path = glob.glob('*.png') # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/docs/source/foo.png b/docs/source/foo.png new file mode 100644 index 00000000..5a5a06b8 Binary files /dev/null and b/docs/source/foo.png differ diff --git a/docs/source/index.png b/docs/source/index.png new file mode 100644 index 00000000..b2d464ae Binary files /dev/null and b/docs/source/index.png differ diff --git a/docs/source/index_sm.png b/docs/source/index_sm.png new file mode 100644 index 00000000..02c72d35 Binary files /dev/null and b/docs/source/index_sm.png differ diff --git a/docs/source/ofx.png b/docs/source/ofx.png new file mode 100644 index 00000000..9e479660 Binary files /dev/null and b/docs/source/ofx.png differ diff --git a/docs/source/ofx_sm.png b/docs/source/ofx_sm.png new file mode 100644 index 00000000..c3900aa4 Binary files /dev/null and b/docs/source/ofx_sm.png differ diff --git a/docs/source/payperiod.png b/docs/source/payperiod.png new file mode 100644 index 00000000..4ae5f645 Binary files /dev/null and b/docs/source/payperiod.png differ diff --git a/docs/source/payperiod_sm.png b/docs/source/payperiod_sm.png new file mode 100644 index 00000000..336e027e Binary files /dev/null and b/docs/source/payperiod_sm.png differ diff --git a/docs/source/payperiods.png b/docs/source/payperiods.png new file mode 100644 index 00000000..221cf172 Binary files /dev/null and b/docs/source/payperiods.png differ diff --git a/docs/source/payperiods_sm.png b/docs/source/payperiods_sm.png new file mode 100644 index 00000000..87615372 Binary files /dev/null and b/docs/source/payperiods_sm.png differ diff --git a/docs/source/reconcile-drag.png b/docs/source/reconcile-drag.png new file mode 100644 index 00000000..5a5a06b8 Binary files /dev/null and b/docs/source/reconcile-drag.png differ diff --git a/docs/source/reconcile-drag_sm.png b/docs/source/reconcile-drag_sm.png new file mode 100644 index 00000000..930cd408 Binary files /dev/null and b/docs/source/reconcile-drag_sm.png differ diff --git a/docs/source/reconcile.png b/docs/source/reconcile.png new file mode 100644 index 00000000..b020e94e Binary files /dev/null and b/docs/source/reconcile.png differ diff --git a/docs/source/reconcile_sm.png b/docs/source/reconcile_sm.png new file mode 100644 index 00000000..e21cc71b Binary files /dev/null and b/docs/source/reconcile_sm.png differ diff --git a/docs/source/scheduled.png b/docs/source/scheduled.png new file mode 100644 index 00000000..fcb4c503 Binary files /dev/null and b/docs/source/scheduled.png differ diff --git a/docs/source/scheduled1.png b/docs/source/scheduled1.png new file mode 100644 index 00000000..f9891039 Binary files /dev/null and b/docs/source/scheduled1.png differ diff --git a/docs/source/scheduled1_sm.png b/docs/source/scheduled1_sm.png new file mode 100644 index 00000000..c7e0c95a Binary files /dev/null and b/docs/source/scheduled1_sm.png differ diff --git a/docs/source/scheduled2.png b/docs/source/scheduled2.png new file mode 100644 index 00000000..51a90b6e Binary files /dev/null and b/docs/source/scheduled2.png differ diff --git a/docs/source/scheduled2_sm.png b/docs/source/scheduled2_sm.png new file mode 100644 index 00000000..061ae919 Binary files /dev/null and b/docs/source/scheduled2_sm.png differ diff --git a/docs/source/scheduled3.png b/docs/source/scheduled3.png new file mode 100644 index 00000000..92a37d3c Binary files /dev/null and b/docs/source/scheduled3.png differ diff --git a/docs/source/scheduled3_sm.png b/docs/source/scheduled3_sm.png new file mode 100644 index 00000000..4e022240 Binary files /dev/null and b/docs/source/scheduled3_sm.png differ diff --git a/docs/source/scheduled_sm.png b/docs/source/scheduled_sm.png new file mode 100644 index 00000000..31f07a3d Binary files /dev/null and b/docs/source/scheduled_sm.png differ diff --git a/docs/source/screenshots.rst b/docs/source/screenshots.rst index 74e1cb91..96b01e1b 100644 --- a/docs/source/screenshots.rst +++ b/docs/source/screenshots.rst @@ -1,6 +1,129 @@ -.. _screenshots: - Screenshots =========== -Nothing here yet. +Index Page +---------- + +Main landing page. + +.. image:: index_sm.png + :target: index.png + +Pay Periods View +---------------- + +Summary of previous, current and upcoming pay periods, plus date selector to find a pay period. + +.. image:: payperiods_sm.png + :target: payperiods.png + +Single Pay Period View +---------------------- + +Shows a pay period (current in this example) balances (income, allocated, spent, remaining), budgets and transactions (previous/manually-entered and scheduled). + +.. image:: payperiod_sm.png + :target: payperiod.png + +Accounts View +------------- + +.. image:: accounts_sm.png + :target: accounts.png + +Account Details +--------------- + +Details of a single account. + +.. image:: account1_sm.png + :target: account1.png + +OFX Transactions +---------------- + +Shows transactions imported from OFX statements. + +.. image:: ofx_sm.png + :target: ofx.png + +Transactions View +----------------- + +Shows all manually-entered transactions. + +.. image:: transactions_sm.png + :target: transactions.png + +Transaction Detail +------------------ + +Transaction detail modal to view and edit a transaction. + +.. image:: transaction2_sm.png + :target: transaction2.png + +Budgets +------- + +List all budgets + +.. image:: budgets_sm.png + :target: budgets.png + +Single Budget View +------------------ + +Budget detail modal to view and edit a budget. + +.. image:: budget2_sm.png + :target: budget2.png + +Scheduled Transactions +---------------------- + +List all scheduled transactions (active and inactive). + +.. image:: scheduled_sm.png + :target: scheduled.png + +Specific Date Scheduled Transaction +----------------------------------- + +Scheduled transactions can occur one-time on a single specific date. + +.. image:: scheduled1_sm.png + :target: scheduled1.png + +Monthly Scheduled Transaction +----------------------------- + +Scheduled transactions can occur monthly on a given date. + +.. image:: scheduled2_sm.png + :target: scheduled2.png + +Number Per-Period Scheduled Transactions +---------------------------------------- + +Scheduled transactions can occur a given number of times per pay period. + +.. image:: scheduled3_sm.png + :target: scheduled3.png + +Reconcile Transactions with OFX +------------------------------- + +OFX Transactions reported by financial institutions can be marked as reconciled with a corresponding Transaction. + +.. image:: reconcile_sm.png + :target: reconcile.png + +Drag-and-Drop Reconciling +------------------------- + +To reconcile an OFX transaction with a Transaction, just drag and drop. + +.. image:: reconcile-drag_sm.png + :target: reconcile-drag.png + diff --git a/docs/source/transaction2.png b/docs/source/transaction2.png new file mode 100644 index 00000000..b14ee9d9 Binary files /dev/null and b/docs/source/transaction2.png differ diff --git a/docs/source/transaction2_sm.png b/docs/source/transaction2_sm.png new file mode 100644 index 00000000..03be6f80 Binary files /dev/null and b/docs/source/transaction2_sm.png differ diff --git a/docs/source/transactions.png b/docs/source/transactions.png new file mode 100644 index 00000000..191f155d Binary files /dev/null and b/docs/source/transactions.png differ diff --git a/docs/source/transactions_sm.png b/docs/source/transactions_sm.png new file mode 100644 index 00000000..bda3621e Binary files /dev/null and b/docs/source/transactions_sm.png differ diff --git a/tox.ini b/tox.ini index 2cb90c1b..7f360400 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py33,py34,py35,py36,docs,jsdoc,integration,acceptance27,acceptance36 +envlist = py27,py33,py34,py35,py36,docs,jsdoc,screenshots,integration,acceptance27,acceptance36 [testenv] deps = @@ -84,6 +84,24 @@ commands = pip freeze python {toxinidir}/docs/make_jsdoc.py {toxinidir} +[testenv:screenshots] +# generate screenshots for documentation +deps = + -r{toxinidir}/requirements.txt + pytest-flask + Pillow==4.1.1 +passenv = {[testenv]passenv} +setenv = {[testenv]setenv} +basepython = python3.6 +sitepackages = False +whitelist_externals = env test +commands = + python --version + virtualenv --version + pip --version + pip freeze + python {toxinidir}/docs/make_screenshots.py {toxinidir} + [testenv:integration] deps = -r{toxinidir}/requirements.txt