diff --git a/README.md b/README.md index 04684489ca5e94ea72dd025edd4e63cdcfef59c3..070476feb5b97aa2b6daf03bd3e84f2e63a94cff 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ # BPEngine -### Requirements +## Requirements + python: 3.6.4 pip: 9.0.3 -### Installation +## Installation + `pip install git+ssh://git@github.com/Blocp/bpengine.git` -- For reporting, install python-pptx -`pip install git+ssh://git@github.com/Blocp/python-pptx.git` +## Developer Setup -### Developer Setup -``` +```bash mkvirtualenv bpeng pip install -r requirements-dev.txt ``` -#### Run Tests and Coverage +### Download Report Templates + +Download report templates and save in dev-templates directory to use when generation offline + +- PNS_NonNYC_Templates to `bpeng/bis/templates` directory + - PNS_NonNYC_Template_1.pptx + - PNS_NonNYC_Template_2.pptx + - PNS_NonNYC_Template_3.pptx + - PNS_NonNYC_Template_4.pptx +- PNA_Report_Template.pptx + +### Run Tests and Coverage + - Copy `pytest.default.ini` --> `pytest.ini` - `pytest bpeng tests` diff --git a/bpeng/bis/__init__.py b/bpeng/bis/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bpeng/bis/field_format.py b/bpeng/bis/field_format.py new file mode 100644 index 0000000000000000000000000000000000000000..f1c5cc00855428cd05a261eeb3311be6e2014f6e --- /dev/null +++ b/bpeng/bis/field_format.py @@ -0,0 +1,10 @@ +from datetime import date + +def format_usd(val): + """ + Format an integer as a string representation of US Dollars. + """ + return '${:,}'.format(val) + +def format_date(val): + return val.strftime("%m/%d/%Y") diff --git a/bpeng/bis/report.py b/bpeng/bis/report.py new file mode 100644 index 0000000000000000000000000000000000000000..369ecaf0ca8e0ae159d7e25177a8668c1a6c0c4e --- /dev/null +++ b/bpeng/bis/report.py @@ -0,0 +1,151 @@ +import os +import copy +import math +from datetime import date +import uuid +import six # Add to requirements +from pptx import Presentation +from pptx.enum.text import ( # pylint: disable=no-name-in-module + MSO_ANCHOR, + PP_ALIGN +) +from .template import * +from .field_format import * + +def _get_blank_slide_layout(pres): + layout_items_count = [len(layout.placeholders) for layout in pres.slide_layouts] + min_items = min(layout_items_count) + blank_layout_id = layout_items_count.index(min_items) + return pres.slide_layouts[blank_layout_id] + +def duplicate_slide(pres, index): + """Duplicate the slide with the given index in source pres to pres. + + Adds slide to the end of the presentation from other presentation""" + + source = pres.slides[index] + + blank_slide_layout = _get_blank_slide_layout(pres) + dest = pres.slides.add_slide(blank_slide_layout) + + # Remove shapes in blank slide layout + for shp in dest.shapes: + el = shp.element + el.getparent().remove(el) + + for shp in source.shapes: + el = shp.element + newel = copy.deepcopy(el) + dest.shapes._spTree.insert_element_before(newel, 'p:extLst') + + for key, value in six.iteritems(source.part.rels): + # Make sure we don't copy a notesSlide relation as that won't exist + if not "notesSlide" in value.reltype: + dest.part.rels.add_relationship(value.reltype, value._target, value.rId) + + return dest + +def generate_pns_report(building_info, recommendations): + """ + Generate an PNS Report from dicts + + building_info - dict containing these keys: + - address (string) + recommendations - list of recommendation dicts + + Returns a python-pptx presentation object + """ + recommendations_slide_index = 2 + recs_per_slide = 3 + total_recs = len(recommendations) + num_recommendation_slides = math.ceil(float(total_recs)/recs_per_slide) + # Select Template Based on the number of recommendations with a max of 12 recommendations + template_path = os.environ['NON_NYC_PPTX_TEMPLATE_' + str(num_recommendation_slides)] + report = TemplateInstantiator(template_path) + # Provide template with address, date for title page + address = building_info['address'] + addr_sub = Substitution( + 'ADDRESS', + address, + font_size=18, + font = 'Avenir', + align=PP_ALIGN.LEFT, + bold=False, + color=(173, 216, 216), + ) + + date_sub = Substitution( + 'DATE', + format_date(date.today()), + font_size=9, + color=(173, 216, 216), + ) + + title_slide = report.templateSlideNumber(0) + title_slide.add_substitutions([ + date_sub, + addr_sub + ]) + + # Add Recommendations to Recommedations Slide Template + recommendations_inserted = 0 + + while recommendations_inserted < total_recs: + # Insert max 3 recommendations per slide + recs_in_page = 0 + subs = [] + recommentation_slide = report.templateSlideNumber(recommendations_slide_index) + while recs_in_page < recs_per_slide: + if recommendations_inserted < total_recs: + rec_title = Substitution( + 'TITLE'+str(recs_in_page), + recommendations[recommendations_inserted]['ecm_name'], + font_size=11, + font='Avenir', + align=PP_ALIGN.LEFT, + color=(15, 45, 65), + bold=True + ) + subs.append(rec_title) + rec_description = Substitution( + 'DESC'+str(recs_in_page), + recommendations[recommendations_inserted]['short_description'], + font_size=10, + font='Avenir', + align=PP_ALIGN.LEFT, + color=(15, 45, 65) + ) + else: + rec_title = Substitution( + 'TITLE'+str(recs_in_page), + " ", # Not using empty string because it displays text when empty string is passed + font_size=11, + font='Avenir', + align=PP_ALIGN.LEFT, + color=(15, 45, 65), + bold=True + ) + subs.append(rec_title) + rec_description = Substitution( + 'DESC'+str(recs_in_page), + " ", # Not using empty string because it displays text when empty string is passed + font_size=10, + font='Avenir', + align=PP_ALIGN.LEFT, + color=(15, 45, 65) + ) + subs.append(rec_description) + recs_in_page += 1 + recommendations_inserted += 1 + # Add substitutions a slide at a time + recommentation_slide.add_substitutions(subs) + recommendations_slide_index += 1 + + # Save PPT to a temporary file, and pass a reference to BlocLink + tmp_dir = '/tmp/bpeng/{}'.format(uuid.uuid4()) + escaped_addr = address.replace(' ', '_') # TODO: replace special characters + save_to = '{}/BIS_{}.pptx'.format(tmp_dir, escaped_addr) + if not os.path.exists(tmp_dir): + os.makedirs(tmp_dir) + report.export().save(save_to) + return save_to diff --git a/bpeng/bis/template.py b/bpeng/bis/template.py new file mode 100644 index 0000000000000000000000000000000000000000..508941cbba4c6ad91e104fce9fdd5922d8fb38a2 --- /dev/null +++ b/bpeng/bis/template.py @@ -0,0 +1,148 @@ +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 * + +# TODO: Review this and merge with python file in PNA directory `../pna/template.py` +class TemplateInstantiator(): + """ + Creates a PNA report from a template for non NYC buildings + """ + def __init__(self, presentation_path): + self.presentation = Presentation(presentation_path) + self.instantiateSlideTemplates() + + def templateSlideNumber(self, index): + return self.slideInstantiator[index] + + def instantiateSlideTemplates(self): + self.slideInstantiator = [TemplateSlideInstantiator() for slide in self.presentation.slides] + + def export(self): + index = 0 + for slide in self.presentation.slides: + slide = self.slideInstantiator[index].render(slide) + index += 1 + + return self.presentation + +class TemplateSlideInstantiator(): + """ + Instantiates slides - TODO: Figure out where did we get this code from. + """ + def __init__(self): + self.substitutions = [] + self.extra_functions = [] + + def add_substitutions(self, subs): + for sub in subs: + self.substitutions.append(sub) + + def add_function(self, fun): + self.extra_functions.append(fun) + + def render(self, slide): + for sub in self.substitutions: + # Render substitution in slide + sub.render(slide) + + for function in self.extra_functions: + # Apply Function to slide + 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='Avenir', font_size=21, 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) + 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 Provided By User' + + if formatter is None: + self.formatter = lambda x: x + else: + self.formatter = formatter + + 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 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) + ) + shape.text = new_text + + for p in shape.text_frame.paragraphs: + p.font.name = self.font + 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: + 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.name = self.font + 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 diff --git a/tests/bis/__init__.py b/tests/bis/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/bis/test_report_generation.py b/tests/bis/test_report_generation.py new file mode 100644 index 0000000000000000000000000000000000000000..ec6767cf75a59303d35ea7adca538600cc2f7b23 --- /dev/null +++ b/tests/bis/test_report_generation.py @@ -0,0 +1,60 @@ +from bpeng.bis.report import generate_pns_report + +def test_report_generation_1_rec(): + building_info = {'address':'2148 Broadway, Oakland, CA 94612'} + recommendation_list = [ + {'ecm_name':'Heat Pump Water Heater', 'short_description': 'Install a Heat Pump Water Heater (HPWH) in replacement of an outdated electric water heater. HPWHs are more than three times more efficient than electric resistance water heaters. They are an easy retrofit, simple to operate, and they save energy and money every day.'}, + ] # TODO: Load data from json and create fixtures + + report = generate_pns_report(building_info, recommendation_list) + + print(report) + assert 0 == 1 + +def test_report_generation_2_recs(): + building_info = {'address':'2148 Broadway, Oakland, CA 94612'} + recommendation_list = [ + {'ecm_name':'Heat Pump Water Heater', 'short_description': 'Install a Heat Pump Water Heater (HPWH) in replacement of an outdated electric water heater. HPWHs are more than three times more efficient than electric resistance water heaters. They are an easy retrofit, simple to operate, and they save energy and money every day.'}, + {'ecm_name':'Smart DHW Recirculation Pumps', 'short_description': 'Install smart hot water recirculation pumps to precisely control the availability of domestic hot water at showerheads and sink taps. Using a smart hot water recirculation pump provides fully automated and temperature-controlled operation, reduces electric consumption (as a an existing circulation pump retrofit) and can prevent thousands of gallons of water from flowing down the drain as it warms.'}, + ] # TODO: Load data from json and create fixtures + + report = generate_pns_report(building_info, recommendation_list) + + print(report) + assert 0 == 1 + +def test_report_generation_4_recs(): + building_info = {'address':'2148 Broadway, Oakland, CA 94612'} + recommendation_list = [ + {'ecm_name':'Heat Pump Water Heater', 'short_description': 'Install a Heat Pump Water Heater (HPWH) in replacement of an outdated electric water heater. HPWHs are more than three times more efficient than electric resistance water heaters. They are an easy retrofit, simple to operate, and they save energy and money every day.'}, + {'ecm_name':'Smart DHW Recirculation Pumps', 'short_description': 'Install smart hot water recirculation pumps to precisely control the availability of domestic hot water at showerheads and sink taps. Using a smart hot water recirculation pump provides fully automated and temperature-controlled operation, reduces electric consumption (as a an existing circulation pump retrofit) and can prevent thousands of gallons of water from flowing down the drain as it warms.'}, + {'ecm_name':'Smart Thermostat', 'short_description': "Install a smart thermostat to control heating and cooling system either centrally or room by room. Smart thermostats provide accurate temperature control for conditioned spaces, remote access to setpoints and ambiant temperatures measurements, cultivate people's energy-friendly habits, and save energy and money on utility bills."}, + {'ecm_name':'LED Lighting', 'short_description': 'Replace any existing incandescent /fluorescent/halogen light bulbs with LEDs. LEDs use up to 70 percent less energy than incandescent bulbs and last longer than conventional lighting.'}, + ] # TODO: Load data from json and create fixtures + + report = generate_pns_report(building_info, recommendation_list) + + print(report) + assert 0 == 1 + + +def test_report_generation_11_recs(): + building_info = {'address':'2148 Broadway, Oakland, CA 94612'} + recommendation_list = [ + {'ecm_name':'Heat Pump Water Heater', 'short_description': 'Install a Heat Pump Water Heater (HPWH) in replacement of an outdated electric water heater. HPWHs are more than three times more efficient than electric resistance water heaters. They are an easy retrofit, simple to operate, and they save energy and money every day.'}, + {'ecm_name':'Smart DHW Recirculation Pumps', 'short_description': 'Install smart hot water recirculation pumps to precisely control the availability of domestic hot water at showerheads and sink taps. Using a smart hot water recirculation pump provides fully automated and temperature-controlled operation, reduces electric consumption (as a an existing circulation pump retrofit) and can prevent thousands of gallons of water from flowing down the drain as it warms.'}, + {'ecm_name':'Smart Thermostat', 'short_description': "Install a smart thermostat to control heating and cooling system either centrally or room by room. Smart thermostats provide accurate temperature control for conditioned spaces, remote access to setpoints and ambiant temperatures measurements, cultivate people's energy-friendly habits, and save energy and money on utility bills."}, + {'ecm_name':'LED Lighting', 'short_description': 'Replace any existing incandescent /fluorescent/halogen light bulbs with LEDs. LEDs use up to 70 percent less energy than incandescent bulbs and last longer than conventional lighting.'}, + {'ecm_name':'Heat Pump Water Heater', 'short_description': 'Install a Heat Pump Water Heater (HPWH) in replacement of an outdated electric water heater. HPWHs are more than three times more efficient than electric resistance water heaters. They are an easy retrofit, simple to operate, and they save energy and money every day.'}, + {'ecm_name':'Smart DHW Recirculation Pumps', 'short_description': 'Install smart hot water recirculation pumps to precisely control the availability of domestic hot water at showerheads and sink taps. Using a smart hot water recirculation pump provides fully automated and temperature-controlled operation, reduces electric consumption (as a an existing circulation pump retrofit) and can prevent thousands of gallons of water from flowing down the drain as it warms.'}, + {'ecm_name':'Smart Thermostat', 'short_description': "Install a smart thermostat to control heating and cooling system either centrally or room by room. Smart thermostats provide accurate temperature control for conditioned spaces, remote access to setpoints and ambiant temperatures measurements, cultivate people's energy-friendly habits, and save energy and money on utility bills."}, + {'ecm_name':'LED Lighting', 'short_description': 'Replace any existing incandescent /fluorescent/halogen light bulbs with LEDs. LEDs use up to 70 percent less energy than incandescent bulbs and last longer than conventional lighting.'}, + {'ecm_name':'Heat Pump Water Heater', 'short_description': 'Install a Heat Pump Water Heater (HPWH) in replacement of an outdated electric water heater. HPWHs are more than three times more efficient than electric resistance water heaters. They are an easy retrofit, simple to operate, and they save energy and money every day.'}, + {'ecm_name':'Smart DHW Recirculation Pumps', 'short_description': 'Install smart hot water recirculation pumps to precisely control the availability of domestic hot water at showerheads and sink taps. Using a smart hot water recirculation pump provides fully automated and temperature-controlled operation, reduces electric consumption (as a an existing circulation pump retrofit) and can prevent thousands of gallons of water from flowing down the drain as it warms.'}, + {'ecm_name':'Smart Thermostat', 'short_description': "Install a smart thermostat to control heating and cooling system either centrally or room by room. Smart thermostats provide accurate temperature control for conditioned spaces, remote access to setpoints and ambiant temperatures measurements, cultivate people's energy-friendly habits, and save energy and money on utility bills."}, + ] # TODO: Load data from json and create fixtures + + report = generate_pns_report(building_info, recommendation_list) + + print(report) + assert 0 == 1