diff --git a/bpeng/pna/__init__.py b/bpeng/pna/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bpeng/pna/discrete_bar_graph.py b/bpeng/pna/discrete_bar_graph.py new file mode 100644 index 0000000000000000000000000000000000000000..413d1a13b5834b410185c130bdf4a91f807e3e8a --- /dev/null +++ b/bpeng/pna/discrete_bar_graph.py @@ -0,0 +1,35 @@ +from pptx.dml.color import RGBColor + +def create_graph_factory(name_in_ppt, num_colored, total, horizontal=True, blank_color=(255,255,255), fill_color=(0,255,50)): + """ + Search for a table containing a cell at (0,0) with text string name_in_ppt, and create a "graph" + by coloring in a dynamic number of cells. + + name_in_ppt - a text identifier that we can use to find the table that we want to use as a "graph" + num_colored - number of boxes to color in with fill_color + total - total number of cells (rows or cols) in the (1-D) table + horizontal - True (unless you want the table to be vertical, which I haven't actually coded yet) + blank_color - the color that all of total cells should be initially filled in (default white) + fill_color - the color that the num_colored / total cells should be colored (default green) + """ + def graph_factory(slide): + for shape in slide.shapes: + if shape is None: + continue + if shape.has_table: + if shape.table.cell(0,0).text.find('___' + name_in_ppt + '___') != -1: + print("found table graph") + if horizontal: + row = shape.table.rows[0] + i = 0 + for cell in row.cells: + cell.text = '' + #cell.fill.solid() + cell.fill.fore_color.rgb = RGBColor(fill_color[0], fill_color[1], fill_color[2]) + i += 1 + if i > num_colored: + cell.fill.fore_color.rgb = RGBColor(blank_color[0], blank_color[1], blank_color[2]) + print("ayy") + else: + print("vertical not programmed yet sorry") + return graph_factory diff --git a/bpeng/pna/field_format.py b/bpeng/pna/field_format.py new file mode 100644 index 0000000000000000000000000000000000000000..375df1fa20dfb502d32a6bbd5b2d773e85d937d6 --- /dev/null +++ b/bpeng/pna/field_format.py @@ -0,0 +1,16 @@ +from datetime import date + +def format_usd(val): + """ + Format an integer as a string representation of US Dollars. + """ + return '${:,}'.format(val) + +def format_bool(val): + if b: + return 'Yes' + else: + return 'No' + +def format_date(val): + return val.strftime("%d/%m/%Y") diff --git a/bpeng/pna/pna.py b/bpeng/pna/pna.py new file mode 100644 index 0000000000000000000000000000000000000000..dfc0982f9c3df94823d62e6d574ff268bc9ba7b2 --- /dev/null +++ b/bpeng/pna/pna.py @@ -0,0 +1,186 @@ +from pptx import Presentation +import os +from datetime import date +import uuid +from pptx.enum.text import ( # pylint: disable=no-name-in-module + MSO_ANCHOR, + PP_ALIGN +) + +from .template import * +from .field_format import * +from .discrete_bar_graph import create_graph_factory + +def load_ashp_template_v1(): + """ + TODO: load a PPTX from a stable, versioned link + (or at the very least, something that's in SCM) + """ + pna = Presentation("./dev-templates/ashp.pptx") + return pna + +def generate_ashp_pna(building_info, hvac, cost_estimates, scores, contact_info, + external=False): + """ + TODO: write documentation + building_info - dict containing these keys: + - address (string) + - owner (string) + hvac - dict containing these keys (engineering-related data): + - heating_system + - heating_age + - heating_fuel + - dhw_heat_same + cost_estimates - dict with these keys: + - project_high + - project_low + scores - dict with scoring metrics + contact_info - dict with contact info of bizdev member + external - whether report was generated by an external user + (as opposed to a member of the bizdev team) + + Returns a Python open file object, referring to a PDF or PPT + depending on whether external=True or false. + """ + v1_template = load_ashp_template_v1() + pna_generator = TemplateInstantiator(v1_template) + addr = building_info['address'] + + # Provide template with address, client name, and date for title page + addr_sub = Substitution( + 'ADDRESS', + addr, + font_size=24, + align=PP_ALIGN.CENTER, + bold=True + ) + client_sub = Substitution( + 'CLIENT', + building_info['owner'], + font_size=18 + ) + date_sub = Substitution( + 'DATE', + format_date(date.today()), + #formatter=format_date, + font_size=18 + ) + title_slide = pna_generator.templateSlideNumber(0) + title_slide.add_substitutions([ + date_sub, + client_sub, + addr_sub + ]) + + # Fill in HVAC Fields + addr_sub_2 = Substitution( + 'ADDRESS', + addr + ) + heating_system_sub = Substitution( + 'HEATING_SYSTEM', + hvac['heating_system'] + ) + heating_system_age_sub = Substitution( + 'HEATING_SYSTEM_AGE', + hvac['heating_age'] + ) + fuel_sub = Substitution( + 'HEATING_FUEL_TYPE', + hvac['heating_fuel'] + ) + dhw_sub = Substitution( + 'SAME_DHW_SYSTEM', + hvac['dhw_heat_same'] + ) + hvac_slide = pna_generator.templateSlideNumber(3) + hvac_slide.add_substitutions([ + addr_sub_2, + heating_system_sub, + heating_system_age_sub, + fuel_sub, + dhw_sub + ]) + + # Fill in scores using graph_factory + addr_sub_2a = Substitution('ADDRESS', addr) + graph_factory1 = create_graph_factory('OPIMP', scores['operation_improvement'], 5) + graph_factory2 = create_graph_factory('UTSAV', scores['utility_savings'], 5) + graph_factory3 = create_graph_factory('RLVNC', scores['relevance_now'], 5) + graph_factory4 = create_graph_factory('AVFIN', scores['financing_availability'], 5) + + graph_slide = pna_generator.templateSlideNumber(4) + + graph_slide.add_substitutions([addr_sub_2a]) + graph_slide.add_function(graph_factory1) + graph_slide.add_function(graph_factory2) + graph_slide.add_function(graph_factory3) + graph_slide.add_function(graph_factory4) + + # Populate cost estimates slide + cost_slide = pna_generator.templateSlideNumber(5) + addr_sub_3 = Substitution('ADDRESS', addr) + window_sub = Substitution( + 'COST_WINDOWS', + '${:,} - ${:,}'.format(cost_estimates['windows_low'], + cost_estimates['windows_high']), + bold=True, + align=PP_ALIGN.CENTER + ) + ashp_sub = Substitution( + 'COST_ASHP', + '${:,} - ${:,}'.format(cost_estimates['ashp_low'], cost_estimates['ashp_high']), + bold=True, + align=PP_ALIGN.CENTER + ) + cost_slide.add_substitutions([ + addr_sub_3, + window_sub, + ashp_sub + ]) + + # Populate last slide with contact info + name = '\n' + contact_info['name'] + email = contact_info['email'] + phone = contact_info['phone'] + pronoun = 'me' + if external: + pronoun = 'us' + name = ' ' + email = 'BusinessDevelopment@blocpower.io' + phone = '(718) 924-2873' # TODO: don't hardcode these values + + pronoun_sub = Substitution( + 'PRONOUN', + pronoun, + color=(0,0,255), + underline=True + ) + name_sub = Substitution( + 'NAME', + name, + color=(0,0,255) + ) + email_sub = Substitution( + 'BP_EMAIL', + email, + color=(0,0,255) + ) + phone_sub = Substitution( + 'BP_PHONE', + phone, + color=(0,0,255) + ) + final_slide = pna_generator.templateSlideNumber(8) + final_slide.add_substitutions([pronoun_sub, name_sub, email_sub, phone_sub]) + + + # Save PPT to a temporary file, and pass a reference to BlocLink + tmp_dir = '/tmp/bpeng/{}'.format(uuid.uuid4()) + escaped_addr = addr.replace(' ', '_') # TODO: replace special characters + save_to = '{}/PNS_{}.pptx'.format(tmp_dir, escaped_addr) + if not os.path.exists(tmp_dir): + os.makedirs(tmp_dir) + pna_generator.export(save_to) + + return open(save_to) diff --git a/bpeng/pna/template.py b/bpeng/pna/template.py new file mode 100644 index 0000000000000000000000000000000000000000..87de3855b1c0ed65119c2f0e44ed3b56e112201c --- /dev/null +++ b/bpeng/pna/template.py @@ -0,0 +1,155 @@ +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import ( # pylint: disable=no-name-in-module + MSO_ANCHOR, + PP_ALIGN +) +from pptx.util import Inches, Pt + +from .field_format import * + +class TemplateInstantiator(): + """ + Creates a PNA from a template + """ + def __init__(self, presentation): + self.presentation = presentation + self.slideInstantiator = [TemplateSlideInstantiator() for slide in presentation.slides] + + def templateSlideNumber(self, index): + return self.slideInstantiator[index] + + def export(self, path): + i=0 + for slide in self.presentation.slides: + slide = self.slideInstantiator[i].render(slide) + i=i+1 + + self.presentation.save(path) + +class TemplateSlideInstantiator(): + def __init__(self): + self.substitutions = [] + self.extra_functions = [] + + def add_substitutions(self, subs): + print("add subs") + for sub in subs: + print(sub.name_in_ppt) + self.substitutions.append(sub) + + def add_function(self, fun): + self.extra_functions.append(fun) + + def render(self, slide): + print("render") + print(self.substitutions) + for sub in self.substitutions: + print("iterating through subs: do search for " + sub.name_in_ppt) + sub.render(slide) + + for function in self.extra_functions: + function(slide) + + return slide + +class Substitution(): + """ + Represents a single text-based substitution of a value from a database field + into a template. + """ + def __init__(self, name_in_ppt, value, external_value=None, + font='Trebuchet MS', font_size=16, color=(0,0,0), no_data_color=(255,0,0), formatter=None, + bold=False, align=PP_ALIGN.LEFT, underline=False): + """ + We use 3 underscores to denote a field in the powerpoint template. + These fields are filled in using substitutions. + + name_in_ppt - the name (without the added triple underscores) in PPT + template that we are looking to replace + value - the value to replace it with + external_value - if user is external, we can optionally replace the + PPT template name with a different value + Font size - in Pt. + Color - an RGB tuple + formatter - a lambda function, or a function from field_format.py, + to format the string for better presentation form + """ + self.name_in_ppt = '___{}___'.format(name_in_ppt) + print(self.name_in_ppt) + self.value = str(value) + self.font = font + self.font_size = font_size + self.bold = bold + self.underline = underline + self.alignment = align + + self.color = color + if len(self.value) == 0: + self.color = no_data_color + self.value = 'No Information Available' + + if formatter is None: + self.formatter = lambda x: x + else: + self.formatter = formatter + + # if external_value is None: + # self.exteternal_value = value + # else: + # self.exteternal_value = external_value + + def ___repr___(self): + return "[" + self.name_in_ppt + ", " + self.formatter(self.value) + "]" + + def ___str___(self): + return self.___repr___() + + def toString(self): + return self.___repr___() + + def render(self, slide): + if slide is None: + print("slide is None") + return + + for shape in slide.shapes: + if shape is None: + continue + if shape.has_text_frame: + print("text frame found: " + shape.text) + print("searching for: " + self.name_in_ppt) + print(shape.text.find(self.name_in_ppt)) + if shape.has_text_frame and shape.text.find(self.name_in_ppt) != -1: + new_text = shape.text + new_text = new_text.replace( + self.name_in_ppt, # underscores from constructor + self.formatter(self.value) + ) + print("did update") + shape.text = new_text + + for p in shape.text_frame.paragraphs: + p.font.size = Pt(self.font_size) + p.font.bold = self.bold + p.font.color.rgb = RGBColor(self.color[0], self.color[1], self.color[2]) + p.font.underline = self.underline + p.alignment = self.alignment + + if shape.has_table: + for row in shape.table.rows: + for cell in row.cells: + #cell = shape.table.cell(r,c) + if cell.text is not None and cell.text.find(self.name_in_ppt) != -1: + cell.text = cell.text.replace( + self.name_in_ppt, # underscores from constructor + self.formatter(self.value) + ) + + for p in cell.text_frame.paragraphs: + p.font.size = Pt(self.font_size) + p.font.bold = self.bold + p.font.color.rgb = RGBColor(self.color[0], self.color[1], self.color[2]) + p.font.underline = self.underline + p.alignment = self.alignment + \ No newline at end of file diff --git a/dev-templates/ashp.pptx b/dev-templates/ashp.pptx new file mode 100644 index 0000000000000000000000000000000000000000..19d143168fe4c76d7e591dbb8eae600e4c395974 Binary files /dev/null and b/dev-templates/ashp.pptx differ diff --git a/requirements.txt b/requirements.txt index c1bedd9138c3519ba616d359fc30f22c8183d7ae..21af8635752f7cd6d8ab30a03d58bda744de9986 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ oplus==4.6.0 pvlib==0.4.4 requests==2.12.4 xlrd==1.0.0 +python-pptx==0.6.18 \ No newline at end of file diff --git a/tests/pna/test_basic.py b/tests/pna/test_basic.py new file mode 100644 index 0000000000000000000000000000000000000000..f5c7e440dd55ece55dd7b31fdb2e6ae59cce457b --- /dev/null +++ b/tests/pna/test_basic.py @@ -0,0 +1,39 @@ +from bpeng.pna.pna import generate_ashp_pna + +def test(): + building_info = { + 'address': '81 Prospect Street', + 'owner': 'Onyema Osugawa', + } + hvac = { + 'heating_system': 'Electric Baseboard', + 'heating_fuel': 'Natural Gas', + 'dhw_heat_same': "", + 'heating_age': '11-20 years', + 'windows_age': '4-10 years', + 'roof_insulation': 'Yes, very well', + } + cost_estimates = { + 'windows_high': 15000, + 'windows_low': 23000, + 'ashp_high': 16000, + 'ashp_low': 24000, + } + scores = { + 'operation_improvement': 1, + 'utility_savings': 4, + 'relevance_now': 3, + 'financing_availability': 2, + } + contact_info = { + 'name': 'Gavin Gratson', + 'email': 'gavin@blocpower.io', + 'phone': '555-555-5555', + } + external_user = False + + result_ppt_file = generate_ashp_pna(building_info, hvac, cost_estimates, scores, contact_info, external=external_user) + result_ppt_path = result_ppt_file.name + result_ppt_file.close() + + print(result_ppt_path)