#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Download all financial information, and format it into a PDF.
TO-DO
=====
Change scraping code. After each site is scraped, all dependent accounts pull their data from it *THEN*, before the next site is scraped. Site scrapes will have to know whether they were successful, then if so, a new function will cause all associated accounts to update themselves.
Add library page that shows date due dates. Not really a financial thing, but until I make a separate program that gives me general reminders, it'll do.
Look for opportunities to use the newer str.format() method instead of %.
Should I try to use the logging module instead of all these print statements?
"""
# Make all string constants unicode. Sadly, this breaks ReportLab. :-(
#from __future__ import unicode_literals
from __future__ import print_function # Use print function instead of statement.
from collections import OrderedDict
from cStringIO import StringIO
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_HALF_DOWN
from functools import partial
import glob
from itertools import chain
import locale
import os
import os.path
from pprint import pprint
from random import randint, shuffle
import re
import shutil
import sqlite3
import sys
import tempfile
import time
import cairo # http://www.cairographics.org/documentation/pycairo/2/reference/
import dateutil # http://labix.org/python-dateutil
from dateutil.relativedelta import relativedelta
import rsvg
from reportlab.graphics.charts.barcharts import VerticalBarChart
from reportlab.graphics.charts.lineplots import LinePlot
from reportlab.graphics.charts.piecharts import Pie, Pie3d, Rect
from reportlab.graphics.charts.legends import Legend, LineLegend
from reportlab.graphics.shapes import Drawing
from reportlab.graphics.widgets.markers import makeMarker
from reportlab.lib import colors
from reportlab.lib.boxstuff import aspectRatioFix
from reportlab.lib.enums import TA_RIGHT
from reportlab.lib.pagesizes import letter, landscape, portrait
from reportlab.lib.pdfencrypt import StandardEncryption
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.platypus import Flowable, Image, KeepTogether
from reportlab.platypus.doctemplate import (BaseDocTemplate, Frame,
IndexingFlowable, NextPageTemplate, PageTemplate, Paragraph)
from reportlab.platypus.tables import Table, ParagraphStyle, TableStyle
from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from financial_accts_and_sites import *
################################################################################
# Set our location. Important for currency formatting.
################################################################################
locale.setlocale(locale.LC_ALL, ('en_CA', 'UTF-8'))
################################################################################
# Module variables.
################################################################################
_today = date.today()
_script_dir = os.path.dirname(os.path.abspath(__file__))
_db_connection = None
_db_path = os.path.join(_script_dir, "financial_info.db")
_demo_dates = [_today + relativedelta(months=-(i+1), day=1) for i in range(5)] \
+ [_today]
_demo_mode = False
_logo_path = "/mnt/hgfs/ServerBackup/Pictures/BME_Logo_transparent.svg"
_p = Paragraph
_pdf_path = "/home/bryon/Desktop/BryonMikeFinancialInfo_%s.pdf" \
% _today.strftime("%Y-%m-%d")
_temp_dir = None
_ONE_MONTH = timedelta(days=31)
_ONE_DAY = timedelta(days=1)
################################################################################
# Helper classes.
################################################################################
class AccountsMeta(type):
"""
Metaclass for Accounts to add some class-level properties.
"""
@property
def allocated_inv_types(cls):
"""
List of the inv_acct types to consider when doing asset allocation.
"""
if not hasattr(cls, "__allocated_inv_types"):
cls.__allocated_inv_types = cls.goal_allocation.keys()
cls.__allocated_inv_types.remove("stocks")
return cls.__allocated_inv_types
@property
def goal_allocation(cls):
if not hasattr(cls, "__goal_allocation"):
cls.__goal_allocation = {}
cls.__goal_allocation["bonds"] = Decimal(".4")
stock_pct = Decimal("1") - cls.__goal_allocation["bonds"]
cls.__goal_allocation["stocks"] = stock_pct
cls.__goal_allocation["domestic"] = stock_pct * Decimal(".6")
cls.__goal_allocation["int'l"] = stock_pct * Decimal(".2")
cls.__goal_allocation["smallcap"] = stock_pct * Decimal(".2")
return cls.__goal_allocation
class Accounts(list):
"""
Specialized list to encapsulate the functions I want to perform against
the accounts.
"""
__metaclass__ = AccountsMeta
# Categories of account types.
asset_acct_types = ("check", "inv", "savings")
debt_acct_types = ("bill", "cc",)
@property
def assets(self):
"""
Return a list of just the assets.
"""
return filter(Account.p_is_asset, self)
@property
def debts(self):
"""
Return a list of just the debts.
"""
return filter(Account.p_is_debt, self)
def extract_outdated_values(self):
"""
Instruct all outdated accounts to extract their values from their
respective source files.
"""
map(Account.extract_value, self.outdated)
def fetch_values(self):
"""
Instruct all accounts to fetch their values from the database.
"""
map(Account.fetch_values, self)
@staticmethod
def get_month_end_dates(accts):
"""
Given a collection of accounts, return a set containing the
end-of-month dates of every date found in any of the accounts.
"""
# Create a set of all months we're dealing with.
all_y_m = set((d.year,d.month) for acct in accts for d in acct.values)
endify=lambda d:(date(d[0],d[1],1)+_ONE_MONTH).replace(day=1) - _ONE_DAY
end_dates = sorted(map(endify, all_y_m))
# Return a sorted list.
return end_dates
@property
def outdated(self):
"""
Return a list of all outdated accounts.
"""
return filter(Account.p_is_outdated, self)
def sum(self, predicate=None):
"""
Add up all accounts. If predicates is supplied, only add up those
that satisfy it.
"""
if predicate:
sum_amt = sum([a.value or 0 for a in self if predicate(a)])
else:
sum_amt = sum([a.value or 0 for a in self])
return sum_amt
# Convenience abbreviation.
A = Accounts
class Account(object):
"""
Encapsulation of an account.
"""
# Convenience predicates.
p_is_asset = lambda a: a.acct_type in Accounts.asset_acct_types
p_is_debt = lambda a: a.acct_type in Accounts.debt_acct_types
p_is_cad = lambda a: a.currency == "cad"
p_is_usd = lambda a: a.currency == "usd"
p_is_outdated = lambda a: a.value_date != _today
def __init__(self, name, acct_num, owner, acct_type, retire_type,
inv_type, currency, src_file, src_site, regex):
self.name = name
self.acct_num = acct_num
self.owner = owner
self.acct_type = acct_type
self.retire_type = retire_type
self.inv_type = inv_type
self.currency = currency
self.src_file = src_file
self.src_site = eval(src_site, globals())
if regex.find("%s") >= 0:
regex_str = regex % acct_num
else:
regex_str = regex
self.regex = re.compile(regex_str) if regex else ""
self.values = {}
global _demo_mode
if _demo_mode:
self.acct_num = "".join(
[str(randint(0,9)) if c.isdigit() else c for c in self.acct_num]
)
def extract_value(self):
"""
Extract the value for this account from the html src_file.
"""
html_filename = os.path.join(get_temp_dir(), self.src_file)
if not os.path.exists(html_filename):
return
with open(html_filename) as html_file:
match = self.regex.search(html_file.read())
if match:
value = Decimal(match.group(1).replace(",",""))
modified_ts = os.path.getmtime(html_filename)
value_date=datetime.fromtimestamp(modified_ts).date()
self.values[value_date] = value
(conn, cur) = get_db_cursor()
cur.execute(
"INSERT INTO value_history (account, value, value_date)"
"VALUES (?, ?, ?)",
(self.acct_num,str(value),value_date.strftime("%Y-%m-%d")))
conn.commit()
def fetch_values(self):
"""
Fetch all values for this account from the database.
"""
global _demo_dates, _demo_mode
if _demo_mode:
for d in _demo_dates:
if self.acct_type == "check":
self.values[d] = Decimal(str(randint(1000, 3000)))
elif self.acct_type == "inv":
self.values[d] = Decimal(str(randint(3000, 10000)))
elif self.acct_type == "savings":
self.values[d] = Decimal(str(randint(2000, 5000)))
elif self.acct_type == "bill":
self.values[d] = Decimal(str(randint(100, 300)))
elif self.acct_type == "cc":
self.values[d] = Decimal(str(randint(200, 1200)))
else:
self.values[d] = 0.0
self.values[d] += Decimal(str(randint(0,99) / 100))
else:
(conn, cur) = get_db_cursor()
cur.execute("""
SELECT value, value_date
FROM value_history
WHERE account = ?
""", (self.acct_num,))
for value, value_date in cur.fetchall():
value = Decimal(value)
value_date = datetime.strptime(value_date, "%Y-%m-%d").date()
self.values[value_date] = value
def get_value(self, as_of):
"""
Get the value of the account as of the passed as_of date.
"""
# Make sure we have something to give.
if not self.values:
return None
# If it asks for a date we have, return that value.
if as_of in self.values:
return self.values[as_of]
# Otherwise, return the most recent value we have for that date.
dates = sorted(self.values.viewkeys())
value_date = None
for date_index in range(len(dates)):
# Have we gone too far?
if as_of >= dates[date_index]:
# No. Remember this date and keep going.
value_date = dates[date_index]
else:
# Yes. Use the last not-too-far date.
break
return self.values[value_date] if value_date else None
@property
def is_asset(self):
return (self.acct_type in Accounts.asset_acct_types)
@property
def is_debt(self):
return (self.acct_type in Accounts.debt_acct_types)
@property
def value(self):
if self.values:
return self.values[self.value_date]
else:
return None
@property
def value_date(self):
if self.values:
return max(self.values.viewkeys())
else:
return None
# Ultimately, I did not use this class. However, it could be useful in other
# situations, so I'm keeping it here for reference purposes.
#class NumberedCanvas(canvas.Canvas):
# """
# Specialized canvas that draws "Page x of y" on all pages.
#
# Taken from here: http://code.activestate.com/recipes/576832/ and:
# http://stackoverflow.com/questions/637800/showing-page-count-with-reportlab
#
# Unfortunately, its use messes up bookmarks and outlines. :-(
# """
# def __init__(self, *args, **kwargs):
# canvas.Canvas.__init__(self, *args, **kwargs)
# self._saved_page_states = []
#
# def showPage(self):
# self._saved_page_states.append(dict(self.__dict__))
# self._startPage()
#
# def save(self):
# """add page info to each page (page x of y)"""
# num_pages = len(self._saved_page_states)
# for state in self._saved_page_states:
# self.__dict__.update(state)
# if self._pageNumber > 1:
# self.draw_page_number(num_pages)
# canvas.Canvas.showPage(self)
# canvas.Canvas.save(self)
#
# def draw_page_number(self, page_count):
# self.setFont("Helvetica", 8)
# self.drawCentredString((letter[0] / 2), (.25 * inch),
# "Page %d of %d" % (self._pageNumber, page_count))
class ForceBuildPasses(IndexingFlowable):
"""
Invisible flowable whose only purpose is to cause multiBuild to make
at least a certain number of passes.
Requires that you use multiBuild() instead of build().
"""
def __init__(self, passes, *args, **kwargs):
# Capture the parameter(s).
self._passes = passes
# Initialize the instance variables.
self._build_pass = 0
# Let the ancestor(s) do their magic.
IndexingFlowable.__init__(self, *args, **kwargs)
def isSatisfied(self):
return (self._build_pass >= self._passes)
def notify(self, kind, stuff):
"""This will be called by the framework wherever 'stuff' happens.
'kind' will be a value that can be used to decide whether to
pay attention or not."""
if kind == "PASS":
self._build_pass = stuff
def draw(self):
pass
def wrap(self, availWidth, availHeight):
return (0,0)
# Ultimately, I did not use this class. However, it could be useful in other
# situations, so I'm keeping it here for reference purposes.
#class LastPageNumberForm(Flowable):
# """
# A specialized flowable to capture the last page number of the document
# and save it in a "form". This *must* be the last flowable added to
# the story before the document is built.
#
# This class is based off of:
# http://two.pairlist.net/pipermail/reportlab-users/2002-May/000020.html
#
# It works, but it turned out to not be the best way to accomplish this.
# """
# def __init__(self, doc, *args, **kwargs):
# # Capture the parameters.
# self.doc = doc
#
# # Let the ancestor(s) do their magic.
# Flowable.__init__(self, *args, **kwargs)
#
# def draw(self):
# if not self.canv.hasForm("last_page_number"):
# self.canv.beginForm("last_page_number")
# self.canv.saveState()
# self.canv.setStrokeColor(colors.black)
# self.canv.setFillColor(colors.black)
# self.canv.setFont("Helvetica", 8)
# self.canv.drawRightString(
# self.doc.pagesize[0] - self.doc.rightMargin,
# (self.doc.bottomMargin / 2),
# str(self.canv.getPageNumber())
# )
# self.canv.restoreState()
# self.canv.endForm()
class BMFDocTemplate(BaseDocTemplate):
"""
Specialized behaviour for the Bryon & Mike Financial report.
"""
def __init__(self, *args, **kwargs):
# Configure the encryption object.
global _demo_mode, demo_pdf_pw, pdf_pw
if _demo_mode:
encryption = StandardEncryption(userPassword=demo_pdf_pw+"x",
ownerPassword=demo_pdf_pw, strength=128)
else:
encryption = StandardEncryption(userPassword=pdf_pw+"x",
ownerPassword=pdf_pw, strength=128)
# Set the parameters the way *we* want.
kwargs["pagesize"] = letter
kwargs["showBoundary"] = 0
kwargs["leftMargin"] = (.5 * inch)
kwargs["rightMargin"] = (.5 * inch)
kwargs["topMargin"] = (.5 * inch)
kwargs["bottomMargin"] = (.5 * inch)
kwargs["allowSplitting"] = 1
kwargs["title"] = "Bryon and Mike Financial Info for %s" \
% _today.strftime("%Y-%m-%d")
kwargs["author"] = "Bryon M. Elliott"
kwargs["subject"] = "Daily report depicting Mike and Bryon's overall "\
"financial picture."
kwargs["creator"] = "Bryon M. Elliott"
kwargs["pageCompression"] = 1
kwargs["_pageBreakQuick"] = 1
kwargs["encrypt"] = encryption
# Let the ancestor(s) do their magic.
BaseDocTemplate.__init__(self, *args, **kwargs)
# Initiate instance variables.
self.pass_num = 0
self.section_index = 0
self.total_pages = 0
self.__logo_cache = {}
# Register an onProgress notification handler.
self.setProgressCallBack(partial(BMFDocTemplate.handle_onprogress,self))
def afterFlowable(self, flowable):
"""
Add the flowable to the document's "outline".
"""
if (not flowable) or (not hasattr(flowable, "style")):
return
if flowable.style.name == "Header1_Style":
self.section_index += 1
key = "section_%d" % self.section_index
self.canv.bookmarkPage(key, fit="XYZ", left=self.frame._x,
top=(self.frame._y + flowable.height))
self.canv.addOutlineEntry(flowable.getPlainText(), key, 0, False)
if flowable.style.name == "Header2_Style":
self.section_index += 1
key = "subsection_%d" % self.section_index
self.canv.bookmarkPage(key, fit="XYZ", left=self.frame._x,
top=(self.frame._y + flowable.height))
self.canv.addOutlineEntry(flowable.getPlainText(), key, 1, False)
def get_logo_image(self, whiten_background=True, alpha=1):
"""
Return an ImageReader instance containing the logo.
"""
key = (whiten_background, alpha)
if key not in self.__logo_cache:
# Cobble the path of the logo file in the temp directory.
logo_filename = os.path.basename(_logo_path)
logo_temp_path = os.path.join(get_temp_dir(), logo_filename)
# Open the SVG file.
svg = rsvg.Handle(file=logo_temp_path)
svg_width = svg.props.width
svg_height = svg.props.height
# Create the Cairo surface & context onto which we'll render the SVG
scale = 1
width = svg_width * scale
height = svg_height * scale
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
context = cairo.Context(surface)
#context.set_antialias(cairo.ANTIALIAS_BEST)
context.scale(scale, scale)
# Optionally, set the background to white since ReportLab does a
# terrible job antialiasing paths onto transparent backgrounds.
if whiten_background:
# Make the background white.
context.set_source_rgb(1.0, 1.0, 1.0)
context.rectangle(0, 0, width, height)
context.fill()
# Render the SVG over the background.
context.push_group()
svg.render_cairo(context)
context.pop_group_to_source()
context.paint_with_alpha(alpha)
# Render the surface to PNG format.
buff = StringIO()
surface.write_to_png(buff)
#surface.write_to_png("/home/bryon/Desktop/cairo_output_%s.png"
# % alpha)
# Turn the PNG into an ImageReader for repeated rendering.
buff.seek(0)
img = ImageReader(buff, "logo")
img.getRGBData() # Force it to load the image from the buffer.
buff.close()
self.__logo_cache[key] = img
return self.__logo_cache[key]
def handle_onprogress(self, typ, value):
"""
Handle multiBuild's onProgress notifications.
"""
if typ == "PASS":
self.notify(typ, value)
self.pass_num = value
elif typ == "PAGE":
if value > self.total_pages:
self.total_pages = value
class ReportPDF(object):
"""
Encapsulation of the report we want to generate.
"""
chart_colours = [
#colors.purple,
#colors.plum,
#colors.blueviolet,
#colors.darkmagenta,
#colors.darkorchid,
#colors.darkviolet,
#colors.indigo,
#colors.mediumorchid,
#colors.mediumpurple,
#colors.mediumvioletred,
colors.green,
colors.blue,
colors.purple,
colors.orange,
colors.red,
colors.cyan,
colors.mediumvioletred, # "magenta"
]
max_screenshot_height = 670
row_backgrounds = [colors.ghostwhite, colors.whitesmoke]
table_style = TableStyle([
('LINEABOVE', (0,-1), (-1,-1), 0.25, colors.black),
('LINEBELOW', (0,0), (-1,0), 0.25, colors.black),
('ROWBACKGROUNDS', (0,1), (-1,-2), row_backgrounds),
('VALIGN', (0,1), (0,-1), 'TOP'),
])
type_titles = {
"check": "Checking Accounts",
"inv": "Investment Accounts",
"savings": "Savings Accounts",
"domestic": "Domestic Stocks",
"int'l": "International Stocks",
"smallcap": "Small Capitalization Stocks",
"ira": "IRAs",
"rollover": "Rollover IRAs",
"roth": "Roth IRAs",
"rrsp": "Registered Retirement Savings Plan",
}
def __init__(self, output_path, accounts,):
# Capture the parameters.
self.accounts = accounts
self.output_path = output_path
# ReportLab Styles.
# Built-in: BodyText Bullet Code Definition Heading1 Heading2 Heading3
# Heading4, Heading5 Heading6 Italic Normal Title
stylesheet=getSampleStyleSheet()
self.cell_head_left_style = ParagraphStyle("CellHeadLeft_Style",
stylesheet['Normal'],
fontName = "Times-Bold",
fontSize = stylesheet['Normal'].fontSize + 4,
leading = stylesheet['Normal'].leading + 3,
allowWidows = False,
)
self.cell_head_right_style = ParagraphStyle("CellHeadRight_Style",
stylesheet['Normal'],
fontName = "Times-Bold",
fontSize = stylesheet['Normal'].fontSize + 4,
leading = stylesheet['Normal'].leading + 3,
alignment = TA_RIGHT,
allowWidows = False,
)
self.head1_style = ParagraphStyle("Header1_Style",
stylesheet['Heading1'],
fontName = "Times-Bold",
textColor = "darkred",
spaceBefore = .25 * inch,
spaceAfter = 0,
allowWidows = False,
)
self.head2_style = ParagraphStyle("Header2_Style",
stylesheet['Heading2'],
fontName = "Times-Bold",
fontSize = stylesheet['Heading2'].fontSize + 1,
leading = stylesheet['Heading2'].leading + 1,
textColor = "darkred",
spaceBefore = .1 * inch,
spaceAfter = 0,
allowWidows = False,
)
self.normal_right_style = ParagraphStyle("NormalRight_Style",
stylesheet['Normal'],
alignment = TA_RIGHT,
allowWidows = False,
)
self.normal_style = stylesheet['Normal']
self.pct_style = ParagraphStyle("Percent_Style",
stylesheet['Normal'],
alignment = TA_RIGHT,
fontSize = stylesheet['Normal'].fontSize - 1,
leading = stylesheet['Normal'].leading - 1,
allowWidows = False,
)
self.row_head_style = ParagraphStyle("RowHead_Style",
stylesheet['Normal'],
fontSize = stylesheet['Normal'].fontSize + 1,
allowWidows = False,
)
self.small_right_style = ParagraphStyle("SmallRight_Style",
stylesheet['Normal'],
fontSize = stylesheet['Normal'].fontSize - 2,
leading = stylesheet['Normal'].leading - 1,
alignment = TA_RIGHT,
allowWidows = False,
)
self.small_warn_right_style = ParagraphStyle(
"SmallWarningRight_Style",
self.small_right_style,
backColor = colors.red,
)
self.title_style = ParagraphStyle("Title_Style",
stylesheet['Title'],
fontName = "Times-Bold",
fontSize = 24,
textColor = "maroon",
spaceBefore = 0,
spaceAfter = 0,
allowWidows = False,
)
self.total_left_style = ParagraphStyle("TotalLeft_Style",
stylesheet['Normal'],
fontName = "Helvetica-Bold",
allowWidows = False,
)
self.total_right_style = ParagraphStyle("CellHeadRight_Style",
stylesheet['Normal'],
fontName = "Helvetica-Bold",
alignment = TA_RIGHT,
allowWidows = False,
)
# First, create the overall document object.
self.doc = BMFDocTemplate(self.output_path)
# Working from the bottom up, create the frames which our content will
# be flowed into.
first_frame = Frame(self.doc.leftMargin,
self.doc.bottomMargin, self.doc.width,
self.doc.height, leftPadding=0,
bottomPadding=0, rightPadding=0, topPadding=0,
id="first_frame", showBoundary=False)
later_frame = Frame(self.doc.leftMargin,
self.doc.bottomMargin, self.doc.width,
self.doc.height, leftPadding=0,
bottomPadding=0, rightPadding=0, topPadding=0,
id="later_frame", showBoundary=False)
# Create the page templates.
first_page = PageTemplate(id="first_page", pagesize=self.doc.pagesize,
frames=[first_frame],
onPage=ReportPDF.page_watermarks,
onPageEnd=ReportPDF.page_big_watermark,
)
later_page = PageTemplate(id="later_page", pagesize=self.doc.pagesize,
frames=[later_frame],
onPage=ReportPDF.page_setup,
onPageEnd=ReportPDF.page_big_watermark,
)
# Finally, add the page templates to the document.
self.doc.addPageTemplates((first_page, later_page,))
def adjust_col_widths(self, data, col_headers):
"""
Adjust column widths to take column headers into account
and to fit within the available content width.
"""
frame = self.doc.pageTemplates[-1].frames[-1]
# Get the maximum and minimum horizontal space necessary
# for each of the columns.
(min_widths,max_widths) = self.get_min_max_col_widths(data, col_headers)
# Add some inter-column padding.
max_widths = map(lambda x: x + 7, max_widths)
min_widths = map(lambda x: x + 3, min_widths)
# Fit within the content, either expanding or shrinking appropriately.
num_cols = len(max_widths)
total_width = sum(max_widths)
if total_width == frame.width:
# Yeah, like this will ever happen.
widths = max_widths
elif total_width < frame.width:
# Expand table: Split the extra space equally among the columns.
extra = float(frame.width - total_width) / num_cols
widths = map(lambda x: x + extra, max_widths)
else:
# Shrink: Remove the extra space proportionally fm all columns,
# Making sure that no column becomes narrower than its minimum.
cols_at_min = []
widths = max_widths[:]
while True:
too_much = sum(widths) - frame.width
if (too_much <= 0.0) or (len(cols_at_min) >= num_cols):
break
malleable_width = sum(w for i,w in enumerate(widths)
if i not in cols_at_min)
for (idx, width) in enumerate(widths):
if idx in cols_at_min:
continue
widths[idx] = width - (too_much * (width / malleable_width))
if widths[idx] < min_widths[idx]:
widths[idx] = min_widths[idx]
cols_at_min.append(idx)
return widths
def flowable_width(self, flowable):
"""
Returns the width of a flowable *IF* it doesn't wrap.
"""
# Initializations and convenience variables.
canv = canvas.Canvas("dummy.pdf", pagesize=self.doc.pagesize)
fontname = flowable.style.fontName
fontsize = flowable.style.fontSize
# Return the widest width.
pieces = flowable.text.split("
")
return max([canv.stringWidth(p, fontname, fontsize) for p in pieces])
def generate(self):
"""
Create the PDF file.
"""
# Add the report title to the PDF.
story = [_p(self.doc.title, self.title_style),]
# Force two passes so that the "Page x of y" stuff works.
story.append(ForceBuildPasses(2))
# Now that we've got something on the first page, make sure the
# remaining pages use the non-first page template.
story.append(NextPageTemplate('later_page'))
# Add the Debts table to the PDF.
story.append(self.get_accts_list_table("Debts", self.accounts.debts))
# Add the Assets table to the PDF.
story.append(self.get_accts_list_table("Assets", self.accounts.assets))
# Add the different "types" tables.
story += self.get_accts_types_table("Account Types", "acct_type")
story += self.get_accts_types_table("Investment Types", "inv_type")
story += self.get_accts_types_table("Retirement Types","retire_type")
story += self.get_allocation_tables()
# Add the Latest Transactions pics keep up with latest purchases.
story += [
self.get_screen_shot("Canadian Tire", "CanadianTire_1.png",
"Latest Transactions"),
self.get_screen_shot("Chase", "Chase_1.png"),
self.get_screen_shot("President's Choice", "PresidentChoice.png"),
self.get_screen_shot("Wells Fargo", "WellsFargo_1.png"),
]
if not _demo_mode:
story += [
self.get_screen_shot("TD Visa", "TD_1305516450429_3.png"),
self.get_screen_shot("TD US Visa", "TD_1305516450429_4.png"),
self.get_screen_shot("TD Chequing", "TD_1305516450429_1.png"),
self.get_screen_shot("TD US Checking","TD_1305516450429_2.png"),
self.get_screen_shot("Bryon TFSA", "TD_1305516450429_5.png"),
self.get_screen_shot("Mike TFSA", "TD_1305516450437_6.png"),
self.get_screen_shot("Vanguard (Bryon)", "Vanguard_bryone.png"),
self.get_screen_shot("Vanguard (Mike)","Vanguard_mhreaser.png"),
self.get_screen_shot("Sensus Retirement",
"PEO Canada - Great West Life_1.png"),
]
# Add the Landing Page pics so to see what happened when we logged in.
if not _demo_mode:
story += [
self.get_screen_shot("Canadian Tire", "CanadianTire.png",
"Landing Pages"),
self.get_screen_shot("Chase", "Chase.png"),
self.get_screen_shot("Hydro One", "HydroOne.png"),
self.get_screen_shot("President's Choice","PresidentChoice.png"),
self.get_screen_shot("TD (Bryon)", "TD_1305516450429.png"),
self.get_screen_shot("TD (Mike)", "TD_1305516450437.png"),
self.get_screen_shot("Vanguard (Bryon)", "Vanguard_bryone.png"),
self.get_screen_shot("Vanguard (Mike)","Vanguard_mhreaser.png"),
self.get_screen_shot("Wells Fargo", "WellsFargo.png"),
self.get_screen_shot("Sensus Retirement",
"PEO Canada - Great West Life.png"),
]
## Adds a "form" to the PDF which draws the total pages number in the
## bottom-right corner. This MUST be the last flowable in the story.
#story.append(LastPageNumberForm(self.doc))
print("Building %s" % self.output_path)
#self.doc.build(story, canvasmaker=NumberedCanvas)
self.doc.multiBuild(story)
def get_accts_list_table(self, title, accts):
"""
Return a story element, containing title and table of passed accounts.
"""
# Create some convenient subsets.
cad_accts = frozenset(filter(Account.p_is_cad, accts))
usd_accts = frozenset(filter(Account.p_is_usd, accts))
# Cobble the data for the table.
col_headers = [
_p("Account", self.cell_head_left_style),
_p("Account #", self.cell_head_left_style),
_p("CAD", self.cell_head_right_style),
_p("USD", self.cell_head_right_style),
_p("Total", self.cell_head_right_style),
_p("As Of", self.cell_head_right_style),
]
tbl_data = [col_headers]
for a in sorted(accts, key=lambda a: a.name.lower()):
if (a.value is not None) and (a in cad_accts):
cad_value = moneycomma(a.value)
else:
cad_value = ""
if (a.value is not None) and (a in usd_accts):
usd_value = moneycomma(a.value)
else:
usd_value = ""
as_of=a.value_date.strftime("%Y/%m/%d") if a.value_date else "none"
tbl_data.append([
_p(a.name, self.row_head_style),
_p(a.acct_num, self.normal_style),
_p(cad_value, self.normal_right_style),
_p(usd_value, self.normal_right_style),
_p("", self.normal_right_style),
])
if (a.value_date is None) or (a.value_date < _today):
tbl_data[-1].append(_p(as_of, self.small_warn_right_style))
else:
tbl_data[-1].append(_p(as_of, self.small_right_style))
total_cad = sum(a.value or 0 for a in cad_accts)
total_usd = sum(a.value or 0 for a in usd_accts)
total = sum(a.value or 0 for a in accts)
tbl_data.append([
_p("Total", self.total_left_style),
_p("", self.total_left_style),
_p(moneycomma(total_cad), self.total_right_style),
_p(moneycomma(total_usd), self.total_right_style),
_p(moneycomma(total), self.total_right_style),
_p("", self.small_right_style),
])
# Generate the table.
column_widths = self.adjust_col_widths(tbl_data, col_headers)
table = Table(tbl_data, colWidths=column_widths, rowHeights=None,
style=ReportPDF.table_style, splitByRow=1, repeatRows=1,
repeatCols=0)
return KeepTogether([_p(title, self.head1_style), table])
def get_accts_types_table(self, title, typ_attr):
"""
Return a list of story elements, containing title and table of accounts
broken down by type.
"""
# Compute some values for the upcoming table.
accts = self.accounts
types = {getattr(a, typ_attr) for a in accts
if a.is_asset
if getattr(a, typ_attr) is not None}
p_in_types = lambda a: getattr(a, typ_attr) in types
total_cad = accts.sum(lambda a: p_in_types(a) and Account.p_is_cad(a))
total_usd = accts.sum(lambda a: p_in_types(a) and Account.p_is_usd(a))
total = accts.sum(p_in_types)
# Cobble the data for the table and pie chart.
col_headers = [
_p("Type", self.cell_head_left_style),
_p("CAD", self.cell_head_right_style),
_p("USD", self.cell_head_right_style),
_p("Total", self.cell_head_right_style),
]
pie_data = OrderedDict()
tbl_data = [col_headers]
for atype in sorted(types):
p_type = lambda a: getattr(a, typ_attr) == atype
type_total_cad=accts.sum(lambda a:p_type(a) and Account.p_is_cad(a))
type_total_usd=accts.sum(lambda a:p_type(a) and Account.p_is_usd(a))
type_total = accts.sum(p_type)
row_header = self.get_type_title(atype)
tbl_data.append([
_p(row_header, self.row_head_style),
_p(moneycomma(type_total_cad), self.normal_right_style),
_p(moneycomma(type_total_usd), self.normal_right_style),
_p(moneycomma(type_total), self.normal_right_style),
])
pie_data[row_header] = float(type_total)
tbl_data.append([
_p("Total", self.total_left_style),
_p(moneycomma(total_cad), self.total_right_style),
_p(moneycomma(total_usd), self.total_right_style),
_p(moneycomma(total), self.total_right_style),
])
# Generate the table.
column_widths = self.adjust_col_widths(tbl_data, col_headers)
table = Table(tbl_data, colWidths=column_widths, rowHeights=None,
style=ReportPDF.table_style, splitByRow=1, repeatRows=1,
repeatCols=0)
# Generate the pie chart.
pie = self.get_pie_chart(pie_data)
# Generate the bar chart.
accts = filter(p_in_types, self.accounts)
dates = Accounts.get_month_end_dates(accts)
data = []
for t in sorted(types):
data.append([])
for d in sorted(dates):
data[-1].append(float(sum(a.get_value(d) or 0
for a in accts if getattr(a,typ_attr)==t)))
legend_names = [self.get_type_title(t) for t in sorted(types)]
#bar = self.get_bar_chart(dates, data, legend_names)
lp = self.get_line_plot(dates, data, legend_names)
return [KeepTogether([_p(title,self.head1_style), table, pie, lp])]
def get_allocation_tables(self):
"""
Return a list of story elements, containing title and table of accounts
broken down by type.
"""
# Target Asset Allocation table.
col_headers = [
_p("Investment Type", self.cell_head_left_style),
_p("Target Percentage", self.cell_head_right_style),
]
pie_data = OrderedDict()
tbl_data = [col_headers]
for atype in sorted(A.allocated_inv_types):
goal_pct = A.goal_allocation[atype] * 100
row_header = self.get_type_title(atype)
tbl_data.append([
_p(row_header, self.row_head_style),
_p("%05.2f%%" % goal_pct, self.normal_right_style),
])
pie_data[row_header] = float(goal_pct)
table_style = TableStyle([
('LINEBELOW', (0,0), (-1,0), 0.25, colors.black),
('ROWBACKGROUNDS', (0,1), (-1,-1), ReportPDF.row_backgrounds),
])
column_widths = self.adjust_col_widths(tbl_data, col_headers)
table = Table(tbl_data, colWidths=column_widths, rowHeights=None,
style=table_style, splitByRow=1, repeatRows=1,
repeatCols=0)
novella = [KeepTogether([
_p("Asset Allocation", self.head1_style),
_p("Target Allocation Percentages", self.head2_style),
table,
self.get_pie_chart(pie_data),
]),
]
# Asset Allocation table.
accts = self.accounts
p_in_types = lambda a: a.inv_type in A.allocated_inv_types
total_cad = accts.sum(lambda a: p_in_types(a) and Account.p_is_cad(a))
total_usd = accts.sum(lambda a: p_in_types(a) and Account.p_is_usd(a))
total = accts.sum(p_in_types)
col_headers = [
_p("Investment Type", self.cell_head_left_style),
_p("CAD", self.cell_head_right_style),
_p("USD", self.cell_head_right_style),
_p("Total", self.cell_head_right_style),
]
pie_data = OrderedDict()
tbl_data = [col_headers]
for atype in sorted(A.allocated_inv_types):
p_type = lambda a: a.inv_type == atype
type_total_cad=accts.sum(lambda a:p_type(a) and Account.p_is_cad(a))
type_total_usd=accts.sum(lambda a:p_type(a) and Account.p_is_usd(a))
type_total = accts.sum(p_type)
row_header = self.get_type_title(atype)
tbl_data.append([
_p(row_header, self.row_head_style),
self.pct_fmt(type_total_cad, total_cad, atype),
self.pct_fmt(type_total_usd, total_usd, atype),
self.pct_fmt(type_total, total, atype),
])
pie_data[row_header] = float(type_total)
tbl_data.append([
_p("Total", self.total_left_style),
_p(moneycomma(total_cad), self.total_right_style),
_p(moneycomma(total_usd), self.total_right_style),
_p(moneycomma(total), self.total_right_style),
])
column_widths = self.adjust_col_widths(tbl_data, col_headers)
table = Table(tbl_data, colWidths=column_widths, rowHeights=None,
style=ReportPDF.table_style, splitByRow=1, repeatRows=1,
repeatCols=0)
# Asset Allocation bar chart.
accts = filter(p_in_types, self.accounts)
dates = Accounts.get_month_end_dates(accts)
data = []
for t in sorted(A.allocated_inv_types):
data.append([])
for d in sorted(dates):
data[-1].append(float(sum(a.get_value(d) or 0
for a in accts if a.inv_type == t)))
legend_names = [self.get_type_title(t)
for t in sorted(A.allocated_inv_types)]
#bar = self.get_bar_chart(dates, data, legend_names, 3.25)
lp = self.get_line_plot(dates, data, legend_names, 3.25)
novella.append(KeepTogether([
_p("Current Allocation", self.head2_style),
table,
self.get_pie_chart(pie_data),
lp,
]),
)
return novella
def get_bar_chart(self, dates, data, legend_names, height=4):
"""
Return a flowable drawing containing a bar chart with
the passed data.
"""
frame = self.doc.pageTemplates[-1].frames[-1]
drawing = Drawing(frame.width, height * inch)
#drawing.add(Rect(0,0,drawing.width, drawing.height, fillColor=None))
# Add the legend to the bottom.
legend = Legend()
legend.x = drawing.width
legend.y = 0
legend.alignment = 'right'
legend.columnMaximum = 1
legend.fontSize = 9
legend.boxAnchor = "se"
legend.deltax = None
legend.deltay = None
legend.autoXPadding = 10
legend.autoYPadding = 0
legend.dxTextSpace = 2
legend.variColumn = True
legend.colorNamePairs = zip(ReportPDF.chart_colours, legend_names)
drawing.add(legend, "legend")
#legend.dumpProperties()
# Add the bar chart.
bar = VerticalBarChart()
bar.x = .4 * inch
bar.y = legend.dy + (.35 * inch)
bar.height = drawing.height - bar.y - (.1 * inch)
bar.width = drawing.width - bar.x
bar.data = data
bar.barLabelFormat = lambda v: "$%0.0fk" % (v / 1000)
bar.barLabels.boxFillColor = colors.Color(255, 255, 255, .4)
bar.barLabels.fontName = "Helvetica"
bar.barLabels.fontSize = 8
bar.barLabels.leftPadding = 2
bar.barLabels.rightPadding = 2
bar.valueAxis.labels.fontName = "Helvetica"
bar.valueAxis.labels.fontSize = 8
bar.valueAxis.valueMin = 0
bar.valueAxis.labelTextFormat = lambda v: "$%0.0fk" % (v / 1000)
bar.categoryAxis.style = 'stacked'
bar.categoryAxis.labels.boxAnchor = "n"
bar.categoryAxis.labels.angle = 20
bar.categoryAxis.labels.fontName = "Helvetica"
bar.categoryAxis.labels.fontSize = 8
bar.categoryAxis.categoryNames = [d.strftime("%Y-%m")
for d in sorted(dates)]
for (colour_index, colour) in enumerate(ReportPDF.chart_colours):
bar.bars[colour_index].fillColor = colour
drawing.add(bar, "bar_chart")
#bar.dumpProperties()
return drawing
def get_line_plot(self, dates, data, legend_names, height=4):
"""
Return a flowable drawing containing a line plot with
the passed data.
"""
marker_kinds = ['Square', 'Diamond', 'Circle', 'Cross', 'Triangle',
'StarSix', 'Pentagon', 'Hexagon', 'Heptagon', 'Octagon',
'StarFive', 'FilledSquare', 'FilledCircle',
'FilledDiamond', 'FilledCross', 'FilledTriangle',
'FilledStarSix', 'FilledPentagon', 'FilledHexagon',
'FilledHeptagon', 'FilledOctagon', 'FilledStarFive',
'ArrowHead', 'FilledArrowHead']
shuffle(marker_kinds)
frame = self.doc.pageTemplates[-1].frames[-1]
drawing = Drawing(frame.width, height * inch)
#drawing.add(Rect(0,0,drawing.width, drawing.height, fillColor=None))
# Add the legend to the bottom.
legend_names += ["Total"]
legend = LineLegend()
legend.x = drawing.width
legend.y = 0
legend.alignment = 'right'
legend.columnMaximum = 1
legend.fontSize = 9
legend.boxAnchor = "se"
legend.deltax = None
legend.deltay = None
legend.autoXPadding = 10
legend.autoYPadding = 0
legend.dxTextSpace = 2
legend.variColumn = True
legend.dx = 20
legend.colorNamePairs = zip(ReportPDF.chart_colours, legend_names)
drawing.add(legend, "legend")
#legend.dumpProperties()
# Add an overall "total" line to the data set.
total = []
for month_index in range(len(data[0])):
total.append(sum(line[month_index] for line in data))
data.append(total)
# Finesse the data into x,y coordinates.
first_date = dates[0].replace(day=1)
date_ints = []
for d in dates:
delta = relativedelta(d.replace(day=1), first_date)
date_ints.append((delta.years * 12) + delta.months)
plot_data = map(lambda line: zip(date_ints, line), data)
# Add the line plot.
lp = LinePlot()
lp.x = .4 * inch
lp.y = legend.dy + (.45 * inch)
lp.height = drawing.height - lp.y - (.1 * inch)
lp.width = drawing.width - lp.x
lp.data = plot_data
lp.joinedLines = True
for (kind_index, kind) in enumerate(marker_kinds):
lp.lines[kind_index].symbol = makeMarker(kind)
for (colour_index, colour) in enumerate(ReportPDF.chart_colours):
lp.lines[colour_index].strokeColor = colour
lp.lineLabelFormat = lambda v: "$%0.0fk" % (v / 1000)
lp.lineLabels.boxFillColor = colors.Color(255, 255, 255, .4)
lp.lineLabels.fontName = "Helvetica"
lp.lineLabels.fontSize = 8
lp.lineLabels.leftPadding = 2
lp.lineLabels.rightPadding = 2
lp.yValueAxis.labels.fontName = "Helvetica"
lp.yValueAxis.labels.fontSize = 8
lp.yValueAxis.labelTextFormat = lambda v: "$%0.0fk" % (v / 1000)
lp.xValueAxis.valueMin = -0.25
lp.xValueAxis.valueMax = max(date_ints) + 0.5
lp.xValueAxis.valueSteps = date_ints
lp.xValueAxis.labels.boxAnchor = "n"
lp.xValueAxis.labels.angle = 20
lp.xValueAxis.labels.fontName = "Helvetica"
lp.xValueAxis.labels.fontSize = 8
lp.xValueAxis.labelTextFormat = lambda v: \
(first_date+relativedelta(months=int(v))).strftime("%Y-%m")
drawing.add(lp, "line_plot")
#lp.dumpProperties()
return drawing
def get_min_max_col_widths(self, row_data, col_heads):
"""
Return lists of minimum required column widths, and maximum desired
column widths.
"""
# Initilization(s).
num_cols = len(col_heads)
max_widths = [0] * num_cols
min_widths = [0] * num_cols
# Find min/max widths for each column.
for (row_idx, row) in enumerate(chain([col_heads], row_data)):
for (col_idx, cell) in enumerate(row):
lines = cell if isinstance(cell, (tuple, list)) else [cell]
max_widths[col_idx] = max(
max_widths[col_idx],
max(self.flowable_width(L) for L in lines)
)
wrapped_lines=[_p("
".join(L.text.split()),L.style)
for L in lines]
min_widths[col_idx] = max(
min_widths[col_idx],
max(self.flowable_width(L) for L in wrapped_lines)
)
return (min_widths, max_widths)
def get_pie_chart(self, data_by_label):
"""
Return a flowable drawing containing a pie chart with
the passed data.
Dev Note: data_by_label must be an OrderedDict.
"""
# Draw the chart.
frame = self.doc.pageTemplates[-1].frames[-1]
drawing = Drawing(frame.width, 3 * inch)
#drawing.add(Rect(0,0,drawing.width, drawing.height, fillColor=None))
pie = Pie()
pie.y = .1 * drawing.height
pie.x = (frame.width - drawing.height) / 2
pie.height = drawing.height * .8
pie.width = pie.height
pie.startAngle = 0
pie.data = data_by_label.values()
pie.labels = data_by_label.keys()
pie.checkLabelOverlap = True
pie.simpleLabels = False
pie.slices.labelRadius = 1.01
pie.slices.strokeWidth = 0.75
for (colour_index, colour) in enumerate(ReportPDF.chart_colours):
pie.slices[colour_index].fillColor = colour
drawing.add(pie, "pie_chart")
#pie.dumpProperties()
return drawing
def get_screen_shot(self, title, screenshot_filename, header=None):
"""
Return a story component composed of a title and a scaled screen shot.
"""
frame = self.doc.pageTemplates[-1].frames[-1]
# Header/Title.
novella = [_p(header, self.head1_style)] if header else []
novella.append(_p(title, self.head2_style))
# Figure out the aspect ratio which will make the image the width of
# its frame.
img_path = os.path.join(get_temp_dir(), screenshot_filename)
img_info = ImageReader(img_path)
(img_width, img_height) = img_info.getSize()
(x,y,scaled_width,scaled_height,scale) = aspectRatioFix(
preserve=True,
anchor="n",
x=0,
y=0,
width=frame.width,
height=ReportPDF.max_screenshot_height,
imWidth=img_width,
imHeight=img_height)
# Open the image with the scaled dimensions.
img = Image(img_path, height=scaled_height, width=scaled_width)
img.hAlign = 'CENTER'
novella.append(img)
return KeepTogether(novella)
def get_type_title(self, type_name):
"""
Substitute a more official name for the passed type.
"""
return ReportPDF.type_titles.get(type_name, type_name.capitalize())
def pct_fmt(self, type_total, total, account_type):
pct = (type_total/total) if total else Decimal("0")
goal_pct = A.goal_allocation[account_type]
attain_amt = (goal_pct - pct) * total
return [
_p(moneycomma(type_total), self.normal_right_style),
_p("%05.2f%%" % (pct * 100), self.pct_style),
_p("Goal: %05.2f%%" % (goal_pct * 100), self.pct_style),
_p("Attain: %s" % moneycomma(attain_amt), self.pct_style),
]
@staticmethod
def page_big_watermark(canvas, doc):
"""
Low-level function to draw my watermark overtop the page.
"""
canvas.saveState()
global _demo_mode
if _demo_mode:
font_size = 240
alpha = .4
grey = .5
canvas.setFont("Helvetica", font_size)
canvas.setStrokeAlpha(alpha)
canvas.setStrokeGray(grey)
canvas.setFillAlpha(alpha)
canvas.setFillGray(grey)
canvas.translate(((doc.pagesize[0] / 2) + (1 * inch)),
((doc.pagesize[1] / 2) - (font_size / 4)))
canvas.rotate(45)
canvas.drawCentredString(0, 0, "DEMO")
else:
img = doc.get_logo_image(whiten_background=False, alpha=.05)
canvas.drawImage(img,
x = 0,
y = 0,
width = doc.pagesize[0],
height = doc.pagesize[1],
mask = "auto",
preserveAspectRatio = True,
anchor = "c",
)
canvas.restoreState()
@staticmethod
def page_setup(canvas, doc):
"""
Low-level function to set up each page before Platypus populates it.
"""
canvas.saveState()
# Header.
canvas.setStrokeColor(colors.maroon)
canvas.setFillColor(colors.maroon)
canvas.setFont("Times-Bold", 13)
canvas.drawCentredString((doc.pagesize[0] / 2),
(doc.pagesize[1] - ((doc.topMargin / 3) * 2)),
doc.title)
# Watermarks.
ReportPDF.page_watermarks(canvas, doc)
# Footer.
canvas.setStrokeColor(colors.black)
canvas.setFillColor(colors.black)
canvas.setFont("Helvetica", 8)
canvas.drawString(doc.leftMargin, (doc.bottomMargin / 2),
"Mike Reaser & Bryon M. Elliott")
# Page x of y.
if doc.pass_num > 1:
canvas.drawRightString(
(doc.pagesize[0] - doc.rightMargin),
(doc.bottomMargin / 2),
"Page %d of %d" % (doc.page, doc.total_pages)
)
#pg_num_w = canvas.stringWidth("XX", canvas._fontname, canvas._fontsize)
#canvas.drawRightString((doc.pagesize[0] - doc.rightMargin - pg_num_w),
# (doc.bottomMargin / 2), "Page %d of" % doc.page)
#canvas.doForm("last_page_number")
canvas.restoreState()
@staticmethod
def page_watermarks(canvas, doc):
"""
Low-level function to draw my watermarks on the page.
"""
# Get the ImageReader instance containing the logo.
img = doc.get_logo_image(whiten_background=True)
## Draw it in the four corners.
#canvas.drawImage(img,
# (.05 * inch), (.05 * inch),
# (.4 * inch), (.4 * inch), mask="auto")
#canvas.drawImage(img,
# doc.pagesize[0] - (.45 * inch), (.05 * inch),
# (.4 * inch), (.4 * inch), mask="auto")
#canvas.drawImage(img,
# (.05 * inch), doc.pagesize[1] - (.45 * inch),
# (.4 * inch), (.4 * inch), mask="auto")
#canvas.drawImage(img,
# doc.pagesize[0] - (.45 * inch),
# doc.pagesize[1] - (.45 * inch),
# (.4 * inch), (.4 * inch), mask="auto")
# Draw it in the bottom-middle.
canvas.drawImage(img,
(doc.pagesize[0] / 2),
(.05 * inch),
(.4 * inch), (.4 * inch), mask="auto")
class Site(object):
"""
Encapsulation of a web site, including the behaviour necessary to log in
and save local copies of particular pages.
"""
_implicit_wait = 120
_web_driver = None
def __init__(self, user_id, password, screenshot_fn_base):
self.user_id = user_id
self.password = password
self.screenshot_fn_base = screenshot_fn_base
def answer_challenge(self, questions, page_loaded_wait_fn, question_regex,
answer_fld_id, continue_btn_ids):
"""
Some sites commonly pop up challenge questions. Answer them.
"""
print("\tAnswering challenge question (if given).")
try:
self._web_driver.implicitly_wait(30)
WebDriverWait(self._web_driver,30).until(page_loaded_wait_fn)
print("\tFound challenge page.")
regex = re.compile(question_regex.lower())
match = regex.search(self._web_driver.page_source.lower())
if match:
given_question = match.group(1).lower()
print("\tFound question:", given_question)
question_answered = False
for (question, answer) in questions:
if given_question.find(question.lower()) >= 0:
self._web_driver.find_element_by_id(answer_fld_id)\
.send_keys(answer)
question_answered = True
break
if question_answered:
for btn_id in continue_btn_ids:
self._web_driver.find_element_by_id(btn_id).click()
time.sleep(.5)
else:
print("\tDon't have an answer to the question.")
raw_input("\nPress Return to continue.\n")
else:
print("\tCould not find challenge question.")
except TimeoutException:
# Security question wasn't given. Move along...
print("\tNo challenge given.")
self._web_driver.implicitly_wait(Site._implicit_wait)
@classmethod
def cleanup(cls):
"""
Shut down the Selenium web driver.
"""
if cls._web_driver:
print("\tShutting down browser.")
cls._web_driver.quit()
cls._web_driver = None
def fetch(self):
"""
Pull the HTML and screen shots from the site.
"""
web_driver = Site.get_web_driver()
try:
self.pre_fetch(web_driver)
print("Logging into %s." % self._name)
web_driver.get(self._url)
self.handle_fetch(web_driver)
except Exception as e:
# Something went wrong. Capture the screen so we can see what.
screenshot_fn = os.path.join(get_temp_dir(), self.landing_pic)
print("\tException! Writing %s" % screenshot_fn)
print("\t\t%s" % e)
web_driver.get_screenshot_as_file(screenshot_fn)
finally:
#self.print_cookies()
web_driver.close()
Site.cleanup()
def get_screenshot_fn(self, num):
"""
Return filename to be used for #num-th screenshot.
"""
return "%s_%s.png" % (self.screenshot_fn_base, num)
@classmethod
def get_web_driver(cls):
"""
Create and return a Selenium web driver.
"""
if not cls._web_driver:
options = Options()
options.binary_location="/usr/lib/chromium-browser/chromium-browser"
cls._web_driver = webdriver.Chrome(
executable_path="/usr/local/bin/chromedriver",
chrome_options=options,
);
cls._web_driver.implicitly_wait(Site._implicit_wait)
#pprint(dir(cls._web_driver))
return cls._web_driver
@property
def landing_pic(self):
"""
Return filename to be used for the "Landing Picture".
"""
return "%s.png" % self.screenshot_fn_base
def pre_fetch(self, web_driver):
"""
Hook for derived classes to insert behaviour.
"""
pass
def print_cookies(self):
print("Cookies:")
for cookie in Site._web_driver.get_cookies():
print("\t%s -> %s" % (cookie['name'], cookie['value']))
def save_screenshot(self, filename):
"""
Save the current page as a PNG.
"""
screenshot_fn = os.path.join(get_temp_dir(), filename)
print("\tWriting %s" % screenshot_fn)
Site._web_driver.get_screenshot_as_file(screenshot_fn)
pic_data = buffer(open(screenshot_fn, "rb").read())
(conn, cur) = get_db_cursor()
cur.execute(
"SELECT COUNT(*) FROM screenshots "
"WHERE filename = ?",
(filename,))
num_rows = cur.fetchone()[0]
if num_rows > 0:
cur.execute(
"UPDATE screenshots "
"SET pic_data = ? "
"WHERE filename = ?",
(pic_data, filename,))
else:
cur.execute(
"INSERT INTO screenshots (filename, pic_data) "
"VALUES (?, ?) ",
(filename, pic_data,))
conn.commit()
def save_source(self, filename):
"""
Save the page's source HTML to a file.
"""
out_fn = os.path.join(get_temp_dir(), filename)
print("\tWriting %s" % out_fn)
with open(out_fn, "w") as outfile:
outfile.write(Site._web_driver.page_source)
class CanadianTireSite(Site):
"""
Canadian Tire MasterCard site.
"""
_name = "Canadian Tire"
_url = "http://www.ctfs.com/"
def handle_fetch(self, web_driver):
# Something about this hateful site requires repeated attempts to
# get it to come up.
attempts = 0
lamb = lambda d: d.find_element_by_id("acctnum")
web_driver.implicitly_wait(10)
while True:
attempts += 1
print("\tAttempt #%d..." % attempts)
try:
success = True
WebDriverWait(web_driver,10).until(lamb)
except TimeoutException:
success = False
if success or (attempts > 10):
break
else:
web_driver.get(self._url)
web_driver.implicitly_wait(Site._implicit_wait)
if not success:
return
print("\tEntering user name.")
web_driver.find_element_by_id("acctnum").send_keys(self.user_id)
web_driver.find_element_by_id("Continue").click()
lamb = lambda d: d.find_element_by_id("pac")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering password.")
web_driver.find_element_by_id("pac").send_keys(self.password)
web_driver.find_element_by_id("Continue").click()
self.answer_challenge(**ct_questions)
print("\tTaking screenshot of landing page.")
lamb = lambda d: d.find_element_by_link_text("View Account Summary")
WebDriverWait(web_driver,120).until(lamb)
self.save_screenshot(self.landing_pic)
print("\tClicking Account Summary.")
web_driver.find_element_by_link_text("View Account Summary").click()
time.sleep(10)
self.save_source("CanadianTire.html")
web_driver.get("https://www.ctfs.com/MyOnlineAccount/AccountSummary/AccountTransactions/")
lamb = lambda d: d.find_element_by_id("Continue")
WebDriverWait(web_driver,120).until(lamb)
self.save_screenshot(self.get_screenshot_fn(1))
print("\tLogging out.")
web_driver.find_element_by_link_text("log out").click()
time.sleep(1)
class ChaseSite(Site):
"""
Chase MasterCard site.
"""
_name = "Chase"
_url = "http://www.chase.com/"
def handle_fetch(self, web_driver):
time.sleep(1)
lamb = lambda d: d.find_element_by_id("usr_password")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name and password.")
for e in web_driver.find_elements_by_id("usr_name_home"):
if e.is_displayed():
e.send_keys(self.user_id)
break
for e in web_driver.find_elements_by_id("usr_password_home"):
if e.is_displayed():
e.send_keys(self.password)
break
btn_xpath = "//a[@data-pt-name='unknwnlogin']"
web_driver.find_element_by_xpath(btn_xpath).click()
time.sleep(10)
self.save_source("Chase.html")
self.save_screenshot(self.landing_pic)
web_driver.get("https://cards.chase.com/cc/Account/Activity/129444160")
lamb = lambda d: d.find_element_by_id("DownloadPostedTransactions")
WebDriverWait(web_driver,120).until(lamb)
self.save_screenshot(self.get_screenshot_fn(1))
print("\tLogging out.")
web_driver.find_element_by_partial_link_text("LOG OFF").click()
print("\tClicked LOG OFF.")
time.sleep(1)
class HydroOneSite(Site):
"""
Hydro One Electric site.
"""
_name = "Hydro One"
_url = "https://www.myaccount.hydroone.com/unsecure/EC/ecustomer/en/user/"\
"ECCustomerSignInPage.html"
def handle_fetch(self, web_driver):
lamb = lambda d: d.find_element_by_name("userid")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name and password.")
web_driver.find_element_by_name("userid").send_keys(self.user_id)
web_driver.find_element_by_name("password").send_keys(self.password)
web_driver.find_element_by_xpath("//input[@alt='Sign In']").click()
time.sleep(10)
self.save_source("HydroOne.html")
self.save_screenshot(self.landing_pic)
print("\tLogging out.")
btn_xpath = "//img[@alt='Customer logout']"
web_driver.find_element_by_xpath(btn_xpath).click()
time.sleep(1)
class PEOCanadaSite(Site):
"""
Sensus' retirement account site, which is really Great-West Life
via PEO Canada.
"""
_name = "PEO Canada"
_url = "http://www.grsaccess.com/"
def handle_fetch(self, web_driver):
time.sleep(1)
lamb = lambda d: d.find_element_by_name("username")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name and password.")
web_driver.find_element_by_name("username").send_keys(self.user_id)
web_driver.find_element_by_name("password").send_keys(self.password)
web_driver.find_element_by_name("Submit").click()
lamb = lambda d:d.find_element_by_partial_link_text("View your portfolio")
WebDriverWait(web_driver,120).until(lamb)
time.sleep(10)
self.save_source("PEOCanada.html")
self.save_screenshot(self.landing_pic)
web_driver.get("https://ssl.grsaccess.com/english/member/activity_reports.aspx?ObjID=2010")
lamb = lambda d: d.find_element_by_name("txtEffStartDate")
WebDriverWait(web_driver,120).until(lamb)
start = (_today + relativedelta(months=-2, day=1)).strftime("%m/%d/%Y")
lamb(web_driver).send_keys(start)
web_driver.find_element_by_name("Submit").click()
lamb = lambda d:d.find_element_by_partial_link_text("Print All Reports")
WebDriverWait(web_driver,120).until(lamb)
self.save_screenshot(self.get_screenshot_fn(1))
print("\tLogging out.")
web_driver.find_element_by_partial_link_text("Sign out").click()
print("\tClicked Sign out.")
time.sleep(1)
class PresidentsChoiceSite(Site):
"""
President's Choice Financial site.
"""
_name = "President's Choice Financial"
_url = "https://online.pcmastercard.ca/PCB_Consumer/Login.do?LAN=EN"
def handle_fetch(self, web_driver):
lamb = lambda d: d.find_element_by_id("username")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name and password.")
web_driver.find_element_by_id("username").send_keys(self.user_id)
web_driver.find_element_by_id("password").send_keys(self.password)
web_driver.find_elements_by_xpath("//input[@type='submit']")[0].click()
lamb = lambda d: d.find_element_by_name("hintanswer")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering security answer.")
web_driver.find_element_by_name("hintanswer").send_keys(pc_answer_1)
web_driver.find_element_by_name("submitNext").click()
time.sleep(10)
self.save_source("PresidentChoice.html")
self.save_screenshot(self.landing_pic)
print("\tLogging out.")
web_driver.find_element_by_link_text("Sign Off").click()
time.sleep(1)
class TDBankSite(Site):
"""
TD Bank site.
"""
_name = "TD Canada Trust"
_url = "https://easywebsoc.td.com/waw/idp/login.htm"
def handle_fetch(self, web_driver):
user = self.user_id # Convenience
pw = self.password # Convenience
lamb = lambda d: d.find_element_by_id("login:AccessCard")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name and password.")
web_driver.find_element_by_id("login:AccessCard").send_keys(user)
web_driver.find_element_by_id("login:Webpassword").send_keys(pw)
web_driver.find_element_by_id("login:login").click()
time.sleep(10)
self.answer_challenge(**td_questions)
web_driver.switch_to_frame("tddetails")
self.save_source("TD_%s.html" % user)
self.save_screenshot(self.landing_pic)
for (acct_index, acct_num) in enumerate(td_acct_nums):
# Only capture the correct accounts for the correct users.
if user == td_bryon_parms[0]:
if acct_index > 4:
continue
elif acct_index < 5:
continue
web_driver.find_element_by_partial_link_text(acct_num).click()
lamb = lambda d: d.find_element_by_name("period")
WebDriverWait(web_driver,120).until(lamb)
self.save_screenshot(self.get_screenshot_fn(acct_index+1))
web_driver.back()
time.sleep(2)
print("\tLogging out.")
web_driver.find_elements_by_class_name("logout")[0].click()
time.sleep(2)
class VanguardSite(Site):
"""
Vanguard investment site.
"""
_name = "Vanguard"
_url = "http://www.vanguard.com"
def handle_fetch(self, web_driver):
f=lambda d:d.find_element_by_partial_link_text("Personal Investors site")
WebDriverWait(web_driver,120).until(f)
print("\tClick on Personal Investors site link.")
web_driver.find_element_by_partial_link_text("Personal Investors site").click()
lamb = lambda d: d.find_element_by_id("USER")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name.")
web_driver.find_element_by_id("USER").send_keys(self.user_id)
btn_xpath = "//button[@title='Log On']"
web_driver.find_element_by_xpath(btn_xpath).click()
self.answer_challenge(**v_questions)
lamb = lambda d: d.find_element_by_id("LoginForm:PASSWORD")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering password.")
web_driver.find_element_by_id("LoginForm:PASSWORD").send_keys(self.password)
web_driver.find_element_by_id("LoginForm:submitInput").click()
time.sleep(4)
#print("\tNavigating to Landing page.")
#web_driver.get("https://personalp.vanguard.com/us/MyHome")
#time.sleep(4)
self.save_screenshot(self.landing_pic)
print("\tNavigating to Download page.")
web_driver.get("https://personalp.vanguard.com/us/OfxWelcome")
lamb = lambda d: d.find_element_by_link_text("Download spreadsheet")
WebDriverWait(web_driver,120).until(lamb)
self.save_source("Vanguard_%s.html" % self.user_id)
print("\tLogging out.")
web_driver.get("https://personalp.vanguard.com/us/MyHome")
time.sleep(2)
lamb = lambda d: d.find_element_by_link_text("LOG OFF")
WebDriverWait(web_driver,120).until(lamb)
web_driver.find_element_by_link_text("LOG OFF").click()
time.sleep(1)
class WellsFargoSite(Site):
"""
Wells Fargo bank site.
"""
_name = "Wells Fargo"
_url = "http://www.wellsfargo.com/"
def handle_fetch(self, web_driver):
lamb = lambda d: d.find_element_by_id("btnSignon")
WebDriverWait(web_driver,120).until(lamb)
print("\tEntering user name and password.")
web_driver.find_element_by_id("userid").send_keys(self.user_id)
web_driver.find_element_by_id("password").send_keys(self.password)
web_driver.find_element_by_id("btnSignon").click()
time.sleep(10)
self.save_source("WellsFargo.html")
self.save_screenshot(self.landing_pic)
web_driver.find_element_by_partial_link_text("CHECKING").click()
lamb = lambda d:d.find_element_by_partial_link_text("Online Statements")
WebDriverWait(web_driver,120).until(lamb)
self.save_screenshot(self.get_screenshot_fn(1))
print("\tLogging out.")
web_driver.find_element_by_link_text("Sign Off").click()
time.sleep(1)
################################################################################
# Helper functions.
################################################################################
def cleanup_db():
"""
Nicely release the connection to the database.
"""
global _db_connection
if _db_connection:
_db_connection.close()
def cleanup_temp_dir():
"""
Empty the trash. Delete the temporary directory and its contents.
"""
global _temp_dir
if _temp_dir:
print("Deleting %s" % _temp_dir)
filenames = glob.glob(os.path.join(_temp_dir, "*.*"))
map(os.remove, filenames)
os.rmdir(_temp_dir)
_temp_dir = None
def get_db_cursor():
"""
Return connection and cursor objects to the SQLite database containing
historical financial data.
"""
global _db_connection
# First check whether we're creating a new database.
is_new_db = (not _db_connection) and (not os.path.exists(_db_path))
# Connect to it (creating it if necessary).
if not _db_connection:
_db_connection = sqlite3.connect(_db_path)
# Get the cursor we need to interact with it.
cursor = _db_connection.cursor()
# Initialize it if necessary.
if is_new_db:
cursor.execute(
"CREATE TABLE value_history ( "
"account TEXT, "
"value TEXT, "
"value_date TEXT "
")"
)
cursor.execute(
"CREATE TABLE screenshots ( "
"filename TEXT, "
"pic_data BLOB "
")"
)
# Return the cursor.
return (_db_connection, cursor)
def get_temp_dir():
"""
Create a temporary directory to keep junk in.
"""
global _temp_dir
if not _temp_dir:
# Create the directory.
_temp_dir = tempfile.mkdtemp()
print("Created temporary directory %s" % _temp_dir)
# Copy our cache of screenshots into it.
(conn, cur) = get_db_cursor()
cur.execute("SELECT filename, pic_data FROM screenshots ")
for filename, pic_data in cur.fetchall():
screenshot_fn = os.path.join(_temp_dir, filename)
print("\tRestoring from cache: %s" % screenshot_fn)
with open(screenshot_fn, "wb") as pic_file:
pic_file.write(pic_data)
# Make sure the logo file is here.
logo_filename = os.path.basename(_logo_path)
logo_temp_path = os.path.join(_temp_dir, logo_filename)
if not os.path.exists(logo_temp_path):
shutil.copyfile(_logo_path, logo_temp_path)
pic_data = buffer(open(logo_temp_path, "rb").read())
cur.execute(
"INSERT INTO screenshots (filename, pic_data) "
"VALUES (?, ?) ",
(logo_filename, pic_data,))
conn.commit()
return _temp_dir
def moneycomma(f):
"""
Convenience function which takes a float (decimal) value
and returns a string formatted according to locale's currency rules.
"""
# Only deal with floats and decimals, and integers.
if not isinstance(f, (Decimal, float, int)):
return f
# Round.
if isinstance(f, Decimal):
f = f.quantize(Decimal(".01"), rounding=ROUND_HALF_DOWN)
elif isinstance(f, float):
f = round(f,2)
# Return the comma-fied string.
return locale.currency(f, symbol=True, grouping=True)
################################################################################
# Web site information.
################################################################################
can_tire_site = CanadianTireSite(*can_tire_parms)
chase_site = ChaseSite(*chase_parms)
hydroone_site = HydroOneSite(*hydroone_parms)
pc_site = PresidentsChoiceSite(*pc_parms)
peocanada_site = PEOCanadaSite(*peo_parms)
td_site_bryon = TDBankSite(*td_bryon_parms)
td_site_mike = TDBankSite(*td_mike_parms)
vanguard_site_bryon = VanguardSite(*vanguard_bryon_parms)
vanguard_site_mike = VanguardSite(*vanguard_mike_parms)
wellsfargo_site = WellsFargoSite(*wellsfargo_parms)
################################################################################
# Main script.
################################################################################
if __name__ == '__main__':
try:
## TESTING.
#peocanada_site.fetch()
#sys.exit(1)
# Fetch account values from the database.
accounts = Accounts(Account(*ap) for ap in account_parms)
accounts.fetch_values()
# Scrape all sites needed to fill in outdated data.
needed_sites = {a.src_site for a in accounts.outdated}
map(Site.fetch, needed_sites)
# Extract account values from the HTML pages.
accounts.extract_outdated_values()
# Generate the Report PDF.
if _demo_mode:
_pdf_path = "%s_DEMO%s" % os.path.splitext(_pdf_path)
report_pdf = ReportPDF(_pdf_path, accounts)
report_pdf.generate()
finally:
cleanup_db()
Site.cleanup()
cleanup_temp_dir()