From 6305fbbb1e14484d3745cc9b2a8d3d5b3b32c0d1 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Thu, 29 Feb 2024 11:05:29 -0700 Subject: [PATCH] AH - Revise analysis permissions for creating columns (#4551) * update analyses to only create columns for root owner * update to allow root owners and members to create analysis columns * restore co2 analysis modal * fix test * make can_create a method on analysis class * tox * fix test * can_create function call --- seed/analysis_pipelines/better/pipeline.py | 47 +++-- seed/analysis_pipelines/co2.py | 73 ++++--- seed/analysis_pipelines/eeej.py | 180 +++++++++--------- seed/analysis_pipelines/eui.py | 68 ++++--- seed/lib/superperms/orgs/models.py | 24 +++ seed/models/analyses.py | 3 + ...entory_detail_analyses_modal_controller.js | 10 +- .../inventory_detail_controller.js | 3 +- .../controllers/inventory_list_controller.js | 3 +- seed/tests/test_analysis_pipelines.py | 4 +- 10 files changed, 250 insertions(+), 165 deletions(-) diff --git a/seed/analysis_pipelines/better/pipeline.py b/seed/analysis_pipelines/better/pipeline.py index 68cbf0ea5a..4f71adc075 100644 --- a/seed/analysis_pipelines/better/pipeline.py +++ b/seed/analysis_pipelines/better/pipeline.py @@ -414,6 +414,11 @@ def _process_results(self, analysis_id): # gather all columns to store BETTER_VALID_MODEL_E_COL = 'better_valid_model_electricity' BETTER_VALID_MODEL_F_COL = 'better_valid_model_fuel' + + # if user is at root level and has role member or owner, columns can be created + # otherwise set the 'missing_columns' flag for later + missing_columns = False + column_data_paths = [ # Combined Savings ExtraDataColumnPath( @@ -523,19 +528,27 @@ def _process_results(self, analysis_id): # check if the column exists with the bare minimum required pieces of data. For example, # don't check column_description and display_name because they may be changed by # the user at a later time. - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name=column_data_path.column_name, - organization=analysis.organization, - table_name='PropertyState', - ) - - # add in the other fields of the columns only if it is a new column. - if created: - column.display_name = column_data_path.column_display_name - column.column_description = column_data_path.column_display_name - - column.save() + # if column doesn't exist, and user has permission to create, then create + try: + Column.objects.get( + is_extra_data=True, + column_name=column_data_path.column_name, + organization=analysis.organization, + table_name='PropertyState', + ) + except Exception: + if analysis.can_create(): + column, created = Column.objects.create( + is_extra_data=True, + column_name=column_data_path.column_name, + organization=analysis.organization, + table_name='PropertyState', + ) + column.display_name = column_data_path.column_display_name + column.column_description = column_data_path.column_display_name + column.save() + else: + missing_columns = True # Update the original PropertyView's PropertyState with analysis results of interest analysis_property_views = analysis.analysispropertyview_set.prefetch_related('property', 'cycle').all() @@ -596,9 +609,11 @@ def _process_results(self, analysis_id): else: cleaned_results[col_name] = value - original_property_state = property_view_by_apv_id[analysis_property_view.id].state - original_property_state.extra_data.update(cleaned_results) - original_property_state.save() + # if no columns are missing, save back to property + if not missing_columns: + original_property_state = property_view_by_apv_id[analysis_property_view.id].state + original_property_state.extra_data.update(cleaned_results) + original_property_state.save() @shared_task(bind=True) diff --git a/seed/analysis_pipelines/co2.py b/seed/analysis_pipelines/co2.py index 4193367357..637b15fca7 100644 --- a/seed/analysis_pipelines/co2.py +++ b/seed/analysis_pipelines/co2.py @@ -350,27 +350,43 @@ def _run_analysis(self, meter_readings_by_analysis_property_view, analysis_id): # displayname and description if the column already exists because # the user might have changed them which would re-create new columns # here. - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_co2', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Average Annual CO2 (kgCO2e)' - column.column_description = 'Average Annual CO2 (kgCO2e)' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_co2_coverage', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Average Annual CO2 Coverage (% of the year)' - column.column_description = 'Average Annual CO2 Coverage (% of the year)' - column.save() + + # if user is at root level and has role member or owner, columns can be created + # otherwise set the 'missing_columns' flag for later + missing_columns = False + + column_meta = [ + { + 'column_name': 'analysis_co2', + 'display_name': 'Average Annual CO2 (kgCO2e)', + 'description': 'Average Annual CO2 (kgCO2e)' + }, { + 'column_name': 'analysis_co2_coverage', + 'display_name': 'Average Annual CO2 Coverage (% of the year)', + 'description': 'Average Annual CO2 Coverage (% of the year)' + } + ] + + for col in column_meta: + try: + Column.objects.get( + column_name=col["column_name"], + organization=analysis.organization, + table_name='PropertyState', + ) + except Exception: + if analysis.can_create(): + column = Column.objects.create( + is_extra_data=True, + column_name=col["column_name"], + organization=analysis.organization, + table_name='PropertyState', + ) + column.display_name = col["display_name"] + column.column_description = col["description"] + column.save() + else: + missing_columns = True # fix the meter readings dict b/c celery messes with it when serializing meter_readings_by_analysis_property_view = { @@ -431,10 +447,17 @@ def _run_analysis(self, meter_readings_by_analysis_property_view, analysis_id): } analysis_property_view.save() if save_co2_results: - # Convert the analysis results which reports in kgCO2e to MtCO2e which is the canonical database field units - property_view.state.total_ghg_emissions = co2['average_annual_kgco2e'] / 1000 - property_view.state.total_ghg_emissions_intensity = co2['average_annual_kgco2e'] / property_view.state.gross_floor_area.magnitude - property_view.state.save() + # only save to property view if columns exist + if not missing_columns: + # store the extra_data columns from the analysis + property_view.state.extra_data.update({ + 'analysis_co2': co2['average_annual_kgco2e'], + 'analysis_co2_coverage': co2['annual_coverage_percent'] + }) + # Also Convert the analysis results which reports in kgCO2e to MtCO2e which is the canonical database field units + property_view.state.total_ghg_emissions = co2['average_annual_kgco2e'] / 1000 + property_view.state.total_ghg_emissions_intensity = co2['average_annual_kgco2e'] / property_view.state.gross_floor_area.magnitude + property_view.state.save() # all done! pipeline.set_analysis_status_to_completed() diff --git a/seed/analysis_pipelines/eeej.py b/seed/analysis_pipelines/eeej.py index 614375f959..581f7219c1 100644 --- a/seed/analysis_pipelines/eeej.py +++ b/seed/analysis_pipelines/eeej.py @@ -51,29 +51,38 @@ EJSCREEN_URL_STUB = 'https://ejscreen.epa.gov/mapper/EJscreen_SOE_report.aspx?namestr=&geometry={"spatialReference":{"wkid":4326},"x":LONG,"y":LAT}&distance=1&unit=9035&areatype=&areaid=&f=report' -def _get_data_for_census_tract_fetch(property_view_ids, organization): +def _get_data_for_census_tract_fetch(property_view_ids, organization, can_create_columns): """Performs basic validation of the properties for running EEEJ and returns any errors Fetches census tract information based on address if it doesn't exist already :param property_view_ids :param organization + :param can_create_columns - does the user have permission to create columns :returns: dictionary[id:str], dictionary of property_view_ids to error message """ # invalid_location = [] loc_data_by_property_view = {} errors_by_property_view_id = {} - # make sure the Census Tract column exists - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name=TRACT_FIELDNAME, - organization=organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Census Tract' - column.column_description = '2010 Census Tract' - column.save() + # check that Census Tract column exists. If not, create if you can + try: + Column.objects.get( + column_name=TRACT_FIELDNAME, + organization=organization, + table_name='PropertyState', + ) + except Exception: + # does user have permission to create? + if can_create_columns: + column = Column.objects.create( + column_name=TRACT_FIELDNAME, + organization=organization, + table_name='PropertyState', + is_extra_data=True, + ) + column.display_name = 'Census Tract' + column.column_description = '2010 Census Tract' + column.save() property_views = PropertyView.objects.filter(id__in=property_view_ids) for property_view in property_views: @@ -327,8 +336,8 @@ def _prepare_analysis(self, property_view_ids, start_analysis=True): # current implementation will *always* start the analysis immediately analysis = Analysis.objects.get(id=self._analysis_id) - # TODO: check that we have the data we need to retrieve census tract for each property - loc_data_by_property_view, errors_by_property_view_id = _get_data_for_census_tract_fetch(property_view_ids, analysis.organization) + # check that we have the data we need to retrieve census tract for each property + loc_data_by_property_view, errors_by_property_view_id = _get_data_for_census_tract_fetch(property_view_ids, analysis.organization, analysis.can_create()) if not loc_data_by_property_view: AnalysisMessage.log_and_create( @@ -393,76 +402,59 @@ def _run_analysis(self, loc_data_by_analysis_property_view, analysis_id): progress_data = pipeline.set_analysis_status_to_running() progress_data.step('Calculating EEEJ Indicators') analysis = Analysis.objects.get(id=analysis_id) + # if user is at root level and has role member or owner, columns can be created + # otherwise set the 'missing_columns' flag for later + missing_columns = False + + # make sure we have the extra data columns we need + column_meta = [ + { + 'column_name': 'analysis_dac', + 'display_name': 'Disadvantaged Community', + 'description': 'Property located in a Disadvantaged Community as defined by CEJST' + }, { + 'column_name': 'analysis_energy_burden_low_income', + 'display_name': 'Energy Burden and low Income?', + 'description': 'Is this property located in an energy burdened census tract. Energy Burden defined by CEJST as greater than or equal to the 90th percentile for energy burden and is low income.' + }, { + 'column_name': 'analysis_energy_burden_percentile', + 'display_name': 'Energy Burden Percentile', + 'description': 'Energy Burden Percentile as identified by CEJST' + }, { + 'column_name': 'analysis_low_income', + 'display_name': 'Low Income?', + 'description': 'Is this property located in a census tract identified as Low Income by CEJST?' + }, { + 'column_name': 'analysis_share_neighbors_disadvantaged', + 'display_name': 'Share of Neighboring Tracts Identified as Disadvantaged', + 'description': 'The percentage of neighboring census tracts that have been identified as disadvantaged by CEJST' + }, { + 'column_name': 'analysis_number_affordable_housing', + 'display_name': 'Number of Affordable Housing Locations in Tract', + 'description': 'Number of affordable housing locations (both public housing developments and multi-family assisted housing) identified by HUD in census tract' + } + ] - # make sure we have the extra data columns we need, don't set the - # displayname and description if the column already exists because - # the user might have changed them which would re-create new columns - # here. - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_dac', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Disadvantaged Community' - column.column_description = 'Property located in a Disadvantaged Community as defined by CEJST' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_energy_burden_low_income', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Energy Burden and low Income?' - column.column_description = 'Is this property located in an energy burdened census tract. Energy Burden defined by CEJST as greater than or equal to the 90th percentile for energy burden and is low income.' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_energy_burden_percentile', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Energy Burden Percentile' - column.column_description = 'Energy Burden Percentile as identified by CEJST' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_low_income', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Low Income?' - column.column_description = 'Is this property located in a census tract identified as Low Income by CEJST?' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_share_neighbors_disadvantaged', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Share of Neighboring Tracts Identified as Disadvantaged' - column.column_description = 'The percentage of neighboring census tracts that have been identified as disadvantaged by CEJST' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_number_affordable_housing', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Number of Affordable Housing Locations in Tract' - column.column_description = 'Number of affordable housing locations (both public housing developments and multi-family assisted housing) identified by HUD in census tract' - column.save() + for col in column_meta: + try: + Column.objects.get( + column_name=col["column_name"], + organization=analysis.organization, + table_name='PropertyState', + ) + except Exception: + if analysis.can_create(): + column = Column.objects.create( + is_extra_data=True, + column_name=col["column_name"], + organization=analysis.organization, + table_name='PropertyState', + ) + column.display_name = col["display_name"] + column.column_description = col["description"] + column.save() + else: + missing_columns = True # fix the dict b/c celery messes with it when serializing analysis_property_view_ids = list(loc_data_by_analysis_property_view.keys()) @@ -505,17 +497,19 @@ def _run_analysis(self, loc_data_by_analysis_property_view, analysis_id): analysis_property_view.save() - # TODO: save each indicators back to property_view + # save each indicators back to property_view + # only if you can property_view = property_views_by_apv_id[analysis_property_view.id] - property_view.state.extra_data.update({ - 'analysis_census_tract': results[analysis_property_view.id]['census_tract'], - 'analysis_dac': results[analysis_property_view.id]['dac'], - 'analysis_energy_burden_low_income': results[analysis_property_view.id]['energy_burden_low_income'], - 'analysis_energy_burden_percentile': results[analysis_property_view.id]['energy_burden_percentile'], - 'analysis_low_income': results[analysis_property_view.id]['low_income'], - 'analysis_share_neighbors_disadvantaged': results[analysis_property_view.id]['share_neighbors_disadvantaged'], - 'analysis_number_affordable_housing': results[analysis_property_view.id]['number_affordable_housing'], - }) + if not missing_columns: + property_view.state.extra_data.update({ + 'analysis_census_tract': results[analysis_property_view.id]['census_tract'], + 'analysis_dac': results[analysis_property_view.id]['dac'], + 'analysis_energy_burden_low_income': results[analysis_property_view.id]['energy_burden_low_income'], + 'analysis_energy_burden_percentile': results[analysis_property_view.id]['energy_burden_percentile'], + 'analysis_low_income': results[analysis_property_view.id]['low_income'], + 'analysis_share_neighbors_disadvantaged': results[analysis_property_view.id]['share_neighbors_disadvantaged'], + 'analysis_number_affordable_housing': results[analysis_property_view.id]['number_affordable_housing'], + }) # store lat/lng (if blank) Census geocoder codes at the street address level (not Point level like mapquest) # store anyway but record as "Census Geocoder (L1AAA)" vs. mapquest "High (P1AAA)" diff --git a/seed/analysis_pipelines/eui.py b/seed/analysis_pipelines/eui.py index 051d779f5b..0bd9be5996 100644 --- a/seed/analysis_pipelines/eui.py +++ b/seed/analysis_pipelines/eui.py @@ -231,27 +231,43 @@ def _run_analysis(self, meter_readings_by_analysis_property_view, analysis_id): # displayname and description if the column already exists because # the user might have changed them which would re-create new columns # here. - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_eui', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'Fractional EUI (kBtu/sqft)' - column.column_description = 'Fractional EUI (kBtu/sqft)' - column.save() - - column, created = Column.objects.get_or_create( - is_extra_data=True, - column_name='analysis_eui_coverage', - organization=analysis.organization, - table_name='PropertyState', - ) - if created: - column.display_name = 'EUI Coverage (% of the year)' - column.column_description = 'EUI Coverage (% of the year)' - column.save() + + # if user is at root level and has role member or owner, columns can be created + # otherwise set the 'missing_columns' flag for later + missing_columns = False + + column_meta = [ + { + 'column_name': 'analysis_eui', + 'display_name': 'Fractional EUI (kBtu/sqft)', + 'description': 'Fractional EUI (kBtu/sqft)' + }, { + 'column_name': 'analysis_eui_coverage', + 'display_name': 'EUI Coverage (% of the year)', + 'description': 'EUI Coverage (% of the year)' + } + ] + + for col in column_meta: + try: + Column.objects.get( + column_name=col["column_name"], + organization=analysis.organization, + table_name='PropertyState', + ) + except Exception: + if analysis.can_create(): + column = Column.objects.create( + is_extra_data=True, + column_name=col["column_name"], + organization=analysis.organization, + table_name='PropertyState', + ) + column.display_name = col["display_name"] + column.column_description = col["description"] + column.save() + else: + missing_columns = True # fix the meter readings dict b/c celery messes with it when serializing meter_readings_by_analysis_property_view = { @@ -281,10 +297,12 @@ def _run_analysis(self, meter_readings_by_analysis_property_view, analysis_id): } analysis_property_view.save() - property_view = property_views_by_apv_id[analysis_property_view.id] - property_view.state.extra_data.update({'analysis_eui': eui['eui']}) - property_view.state.extra_data.update({'analysis_eui_coverage': eui['coverage']}) - property_view.state.save() + # only save to property view if columns exist + if not missing_columns: + property_view = property_views_by_apv_id[analysis_property_view.id] + property_view.state.extra_data.update({'analysis_eui': eui['eui']}) + property_view.state.extra_data.update({'analysis_eui_coverage': eui['coverage']}) + property_view.state.save() # all done! pipeline.set_analysis_status_to_completed() diff --git a/seed/lib/superperms/orgs/models.py b/seed/lib/superperms/orgs/models.py index 4792397171..29778f9fbe 100644 --- a/seed/lib/superperms/orgs/models.py +++ b/seed/lib/superperms/orgs/models.py @@ -344,6 +344,30 @@ def is_owner(self, user): user=user, role_level=ROLE_OWNER, organization=self, ).exists() + def has_role_member(self, user): + """ + Return True if the user has a relation to this org, with a role of + member. + """ + return OrganizationUser.objects.filter( + user=user, role_level=ROLE_MEMBER, organization=self, + ).exists() + + def is_user_ali_root(self, user): + """ + Return True if the user's ali is at the root of the organization + """ + is_root = False + + ou = OrganizationUser.objects.filter( + user=user, organization=self, + ) + if ou.count() > 0: + ou = ou.first() + if ou.access_level_instance == self.root: + is_root = True + return is_root + def get_exportable_fields(self): """Default to parent definition of exportable fields.""" if self.parent_org: diff --git a/seed/models/analyses.py b/seed/models/analyses.py index 0e2c4c3129..4d08488b92 100644 --- a/seed/models/analyses.py +++ b/seed/models/analyses.py @@ -196,3 +196,6 @@ def in_terminal_state(self): :returns: bool """ return self.status in [self.FAILED, self.STOPPED, self.COMPLETED] + + def can_create(self): + return self.organization.is_user_ali_root(self.user.id) and (self.organization.is_owner(self.user.id) or self.organization.has_role_member(self.user.id)) diff --git a/seed/static/seed/js/controllers/inventory_detail_analyses_modal_controller.js b/seed/static/seed/js/controllers/inventory_detail_analyses_modal_controller.js index a4647160b9..a69f4a2636 100644 --- a/seed/static/seed/js/controllers/inventory_detail_analyses_modal_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_analyses_modal_controller.js @@ -15,12 +15,14 @@ angular.module('BE.seed.controller.inventory_detail_analyses_modal', []).control 'inventory_ids', 'current_cycle', 'cycles', + 'user', // eslint-disable-next-line func-names - function ($scope, $log, $uibModalInstance, Notification, analyses_service, inventory_ids, current_cycle, cycles) { + function ($scope, $log, $uibModalInstance, Notification, analyses_service, inventory_ids, current_cycle, cycles, user) { $scope.inventory_count = inventory_ids.length; // used to disable buttons on submit $scope.waiting_for_server = false; $scope.cycles = cycles; + $scope.user = user; $scope.new_analysis = { name: null, @@ -76,8 +78,12 @@ angular.module('BE.seed.controller.inventory_detail_analyses_modal', []).control case 'CO2': $scope.new_analysis.configuration = { - save_co2_results: true + save_co2_results: false }; + // only root users can create columns + if (user.is_ali_root && ['member', 'owner'].includes(user.organization.user_role)) { + $scope.new_analysis.configuration.save_co2_results = true + } break; case 'BETTER': diff --git a/seed/static/seed/js/controllers/inventory_detail_controller.js b/seed/static/seed/js/controllers/inventory_detail_controller.js index 3527d90395..c4e986b626 100644 --- a/seed/static/seed/js/controllers/inventory_detail_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_controller.js @@ -551,7 +551,8 @@ angular.module('BE.seed.controller.inventory_detail', []).controller('inventory_ resolve: { inventory_ids: () => [$scope.inventory.view_id], current_cycle: () => $scope.cycle, - cycles: () => cycle_service.get_cycles().then((result) => result.cycles) + cycles: () => cycle_service.get_cycles().then((result) => result.cycles), + user: () => $scope.menu.user } }); }; diff --git a/seed/static/seed/js/controllers/inventory_list_controller.js b/seed/static/seed/js/controllers/inventory_list_controller.js index f55ecc4f3d..83870343a9 100644 --- a/seed/static/seed/js/controllers/inventory_list_controller.js +++ b/seed/static/seed/js/controllers/inventory_list_controller.js @@ -1599,7 +1599,8 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li resolve: { inventory_ids: () => ($scope.inventory_type === 'properties' ? selectedViewIds : []), cycles: () => cycles.cycles, - current_cycle: () => $scope.cycle.selected_cycle + current_cycle: () => $scope.cycle.selected_cycle, + user: () => $scope.menu.user } }); modalInstance.result.then( diff --git a/seed/tests/test_analysis_pipelines.py b/seed/tests/test_analysis_pipelines.py index f34587779e..f7659f244e 100644 --- a/seed/tests/test_analysis_pipelines.py +++ b/seed/tests/test_analysis_pipelines.py @@ -1153,7 +1153,7 @@ def test_get_location(self): def test_get_data_for_census_tract_fetch(self): pvids = [self.property_view.id] - loc_data_by_property_view, errors_by_property_view_id = _get_data_for_census_tract_fetch(pvids, self.org) + loc_data_by_property_view, errors_by_property_view_id = _get_data_for_census_tract_fetch(pvids, self.org, True) self.assertEqual(errors_by_property_view_id, {}) self.assertEqual(loc_data_by_property_view, {self.property_view.id: {'latitude': None, 'longitude': None, 'geocoding_confidence': None, 'tract': None, 'valid_coords': False, 'location': '730 Garcia Street, Boring, Oregon, 97080'}}) @@ -1170,7 +1170,7 @@ def test_get_eeej_indicators(self): apv_ids = [self.property_view_dac.id, self.property_view_not_dac.id] apvs = [self.property_view_dac, self.property_view_not_dac] - loc_data_by_property_view, errors_by_property_view_id = _get_data_for_census_tract_fetch(apv_ids, self.org) + loc_data_by_property_view, errors_by_property_view_id = _get_data_for_census_tract_fetch(apv_ids, self.org, True) results, errors_by_apv_id = _get_eeej_indicators(apvs, loc_data_by_property_view) self.assertEqual(len(results), 2) # DAC