#!/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()