diff --git a/config/settings/common.py b/config/settings/common.py index 4588a4455f..fcbfe20137 100644 --- a/config/settings/common.py +++ b/config/settings/common.py @@ -76,7 +76,6 @@ 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.BrokenLinkEmailsMiddleware', 'seed.utils.api.APIBypassCSRFMiddleware', - 'seed.utils.nosniff.DisableMIMESniffingMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', diff --git a/docker/nginx-seed.conf b/docker/nginx-seed.conf index 2e5af7db90..b409dc30b6 100644 --- a/docker/nginx-seed.conf +++ b/docker/nginx-seed.conf @@ -1,3 +1,34 @@ +# https://gist.github.com/plentz/6737338 +# config to disallow the browser to render the page inside a frame or iframe +# and avoid clickjacking http://en.wikipedia.org/wiki/Clickjacking +# if you need to allow [i]frames, you can use SAMEORIGIN or even set an uri with ALLOW-FROM uri +# https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options +add_header X-Frame-Options SAMEORIGIN; + +# when serving user-supplied content, include a X-Content-Type-Options: nosniff header along with the Content-Type: header, +# to disable content-type sniffing on some browsers. +# https://www.owasp.org/index.php/List_of_useful_HTTP_headers +# currently supported in IE > 8 http://blogs.msdn.com/b/ie/archive/2008/09/02/ie8-security-part-vi-beta-2-update.aspx +# https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/compatibility/gg622941(v=vs.85) +# 'soon' on Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=471020 +add_header X-Content-Type-Options nosniff; + +# This header enables the Cross-site scripting (XSS) filter built into most recent web browsers. +# It's usually enabled by default anyway, so the role of this header is to re-enable the filter for +# this particular website if it was disabled by the user. +# https://www.owasp.org/index.php/List_of_useful_HTTP_headers +add_header X-XSS-Protection "1; mode=block"; + +# with Content Security Policy (CSP) enabled (and a browser that supports it (http://caniuse.com/#feat=contentsecuritypolicy)), +# you can tell the browser that it can only download content from the domains you explicitly allow +# https://www.html5rocks.com/en/tutorials/security/content-security-policy/ +# https://www.owasp.org/index.php/Content_Security_Policy +# https://www.html5rocks.com/en/tutorials/security/content-security-policy/#inline-code-considered-harmful +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https://stamen-tiles-a.a.ssl.fastly.net https://stamen-tiles-b.a.ssl.fastly.net https://stamen-tiles-c.a.ssl.fastly.net https://stamen-tiles-d.a.ssl.fastly.net; style-src 'self' 'unsafe-inline'; frame-src 'self'; object-src 'none'"; + +# HSTS +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + # the upstream component nginx needs to connect to upstream seed_upsteam { server unix:///tmp/uwsgi-seed.sock; @@ -9,7 +40,7 @@ server { server_name localhost; charset utf-8; - # increase the timeouts (large files can take awhile to upload) + # increase the timeouts (large files can take a while to upload) # These are probably not needed, but increasing anyway proxy_connect_timeout 600; proxy_send_timeout 600; diff --git a/docs/source/docker.rst b/docs/source/docker.rst index e2aa676334..697955f697 100644 --- a/docs/source/docker.rst +++ b/docs/source/docker.rst @@ -79,7 +79,7 @@ Ubuntu server 18.04 or newer with a m5ad.xlarge (if using in Production instance # The admin user is only valid only until the database is restored export SEED_ADMIN_USER=user@seed-platform.org - export SEED_ADMIN_PASSWORD=7FeBWal38*&k3jlfa92lakj8ih4 + export SEED_ADMIN_PASSWORD="7FeBWal38*&k3jlfa92lakj8ih4" export SEED_ADMIN_ORG=default # For SES diff --git a/requirements/base.txt b/requirements/base.txt index be1837d9d2..318532423a 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # Django -django==2.2.10 +django==2.2.13 # NL 12/30/2019 - DJ database url is not longer used? #dj-database-url==0.5.0 diff --git a/seed/api/v2_1/views.py b/seed/api/v2_1/views.py index e20f6266ee..694741da29 100644 --- a/seed/api/v2_1/views.py +++ b/seed/api/v2_1/views.py @@ -24,6 +24,7 @@ PropertyState, BuildingFile, Cycle, + ColumnMappingPreset, ) from seed.serializers.properties import ( PropertyViewAsStateSerializer, @@ -169,6 +170,23 @@ def building_sync(self, request, pk): required: true paramType: query """ + preset_pk = request.GET.get('preset_id') + try: + preset_pk = int(preset_pk) + column_mapping_preset = ColumnMappingPreset.objects.get( + pk=preset_pk, + preset_type__in=[ColumnMappingPreset.BUILDINGSYNC_DEFAULT, ColumnMappingPreset.BUILDINGSYNC_CUSTOM]) + except TypeError: + return JsonResponse({ + 'success': False, + 'message': 'Query param `preset_id` is either missing or invalid' + }, status=status.HTTP_400_BAD_REQUEST) + except ColumnMappingPreset.DoesNotExist: + return JsonResponse({ + 'success': False, + 'message': f'Cannot find a BuildingSync ColumnMappingPreset with pk={preset_pk}' + }, status=status.HTTP_400_BAD_REQUEST) + try: # TODO: not checking organization? Is that right? # TODO: this needs to call _get_property_view and use the property pk, not the property_view pk. @@ -178,19 +196,22 @@ def building_sync(self, request, pk): return JsonResponse({ 'success': False, 'message': 'Cannot match a PropertyView with pk=%s' % pk - }) + }, status=status.HTTP_400_BAD_REQUEST) bs = BuildingSync() # Check if there is an existing BuildingSync XML file to merge bs_file = property_view.state.building_files.order_by('created').last() if bs_file is not None and os.path.exists(bs_file.file.path): bs.import_file(bs_file.file.path) - xml = bs.export(property_view.state) - return HttpResponse(xml, content_type='application/xml') - else: - # create a new XML from the record, do not import existing XML - xml = bs.export(property_view.state) + + try: + xml = bs.export_using_preset(property_view.state, column_mapping_preset.mappings) return HttpResponse(xml, content_type='application/xml') + except Exception as e: + return JsonResponse({ + 'success': False, + 'message': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @action(detail=True, methods=['GET']) def hpxml(self, request, pk): diff --git a/seed/api/v3/urls.py b/seed/api/v3/urls.py index 1882f228c8..81020182fa 100644 --- a/seed/api/v3/urls.py +++ b/seed/api/v3/urls.py @@ -3,13 +3,28 @@ from django.conf.urls import url, include from rest_framework import routers +from seed.views.v3.columns import ColumnViewSet +from seed.views.v3.cycles import CycleViewSet from seed.views.v3.data_quality import DataQualityViews from seed.views.v3.datasets import DatasetViewSet +from seed.views.v3.labels import LabelViewSet +from seed.views.v3.import_files import ImportFileViewSet +from seed.views.v3.organizations import OrganizationViewSet +from seed.views.v3.properties import PropertyViewSet +from seed.views.v3.taxlots import TaxlotViewSet +from seed.views.v3.users import UserViewSet api_v3_router = routers.DefaultRouter() -api_v3_router.register(r'data_quality_checks', DataQualityViews, base_name='data_quality_checks') +api_v3_router.register(r'columns', ColumnViewSet, base_name='columns') +api_v3_router.register(r'cycles', CycleViewSet, base_name='cycles') api_v3_router.register(r'datasets', DatasetViewSet, base_name='datasets') - +api_v3_router.register(r'labels', LabelViewSet, base_name='labels') +api_v3_router.register(r'data_quality_checks', DataQualityViews, base_name='data_quality_checks') +api_v3_router.register(r'import_files', ImportFileViewSet, base_name='import_files') +api_v3_router.register(r'organizations', OrganizationViewSet, base_name='organizations') +api_v3_router.register(r'properties', PropertyViewSet, base_name='properties') +api_v3_router.register(r'taxlots', TaxlotViewSet, base_name='taxlots') +api_v3_router.register(r'users', UserViewSet, base_name='user') urlpatterns = [ url(r'^', include(api_v3_router.urls)), diff --git a/seed/building_sync/building_sync.py b/seed/building_sync/building_sync.py index f5cbeb9826..5f5417fdfe 100644 --- a/seed/building_sync/building_sync.py +++ b/seed/building_sync/building_sync.py @@ -117,12 +117,21 @@ def init_tree(self, version=BUILDINGSYNC_V2_0): self.element_tree = etree.parse(StringIO(xml_string)) self.version = version - def export(self, property_state, custom_mapping=None): + def export_using_preset(self, property_state, column_mapping_preset=None): """Export BuildingSync file from an existing BuildingSync file (from import), property_state and a custom mapping. + expected column_mapping_preset structure + [ + {from_field: , from_field_value: 'text' | @ | ..., to_field: }, + {from_field: , from_field_value: 'text' | @ | ..., to_field: }, + . + . + . + ] + :param property_state: object, PropertyState to merge into BuildingSync - :param custom_mapping: dict, user-defined mapping (used with higher priority over the default mapping) + :param column_mapping_preset: list, mappings from ColumnMappingPreset :return: string, as XML """ if not property_state: @@ -131,39 +140,33 @@ def export(self, property_state, custom_mapping=None): if not self.element_tree: self.init_tree(version=BuildingSync.BUILDINGSYNC_V2_0) - merged_mappings = merge_mappings(self.VERSION_MAPPINGS_DICT[self.version], custom_mapping) schema = self.get_schema(self.version) - # iterate through the 'property' field mappings doing the following + # iterate through the mappings doing the following # - if the property_state has the field, update the xml with that value # - else, ignore it - base_path = merged_mappings['property']['xpath'] - field_mappings = merged_mappings['property']['properties'] - for field, mapping in field_mappings.items(): - value = None + for mapping in column_mapping_preset: + field = mapping['to_field'] + xml_element_xpath = mapping['from_field'] + xml_element_value = mapping['from_field_value'] + seed_value = None try: property_state._meta.get_field(field) - value = getattr(property_state, field) + seed_value = getattr(property_state, field) except FieldDoesNotExist: _log.debug("Field {} is not a db field, trying read from extra data".format(field)) - value = property_state.extra_data.get(field, None) + seed_value = property_state.extra_data.get(field, None) - if value is None: + if seed_value is None: continue - if isinstance(value, ureg.Quantity): - value = value.magnitude - - if mapping['xpath'].startswith('./'): - mapping_path = mapping['xpath'][2:] - else: - mapping_path = mapping['xpath'] - absolute_xpath = os.path.join(base_path, mapping_path) + if isinstance(seed_value, ureg.Quantity): + seed_value = seed_value.magnitude - update_tree(schema, self.element_tree, absolute_xpath, - mapping['value'], str(value), NAMESPACES) + update_tree(schema, self.element_tree, xml_element_xpath, + xml_element_value, str(seed_value), NAMESPACES) # Not sure why, but lxml was not pretty printing if the tree was updated - # a hack to fix this, we just export the tree, parse it, then export again + # As a hack to fix this, we just export the tree, parse it, then export again xml_bytes = etree.tostring(self.element_tree, pretty_print=True) tree = etree.parse(BytesIO(xml_bytes)) return etree.tostring(tree, pretty_print=True).decode() diff --git a/seed/building_sync/mappings.py b/seed/building_sync/mappings.py index 0557349a87..23d2268785 100644 --- a/seed/building_sync/mappings.py +++ b/seed/building_sync/mappings.py @@ -507,6 +507,7 @@ def update_tree(schema, tree, xpath, target, value, namespaces): 'type': 'value', 'value': 'text', 'formatter': to_float, + 'units': 'ft**2', }, 'net_floor_area': { 'xpath': './auc:Buildings/auc:Building/auc:FloorAreas/auc:FloorArea[auc:FloorAreaType="Net"]/auc:FloorAreaValue', diff --git a/seed/building_sync/tests/data/buildingsync_v2_0_bricr_workflow.xml b/seed/building_sync/tests/data/buildingsync_v2_0_bricr_workflow.xml index bf07ba1827..6364088d14 100644 --- a/seed/building_sync/tests/data/buildingsync_v2_0_bricr_workflow.xml +++ b/seed/building_sync/tests/data/buildingsync_v2_0_bricr_workflow.xml @@ -1915,6 +1915,7 @@ 0.0 + 0.0 0 @@ -1936,6 +1937,7 @@ kBtu 2235075.9859244428 2235.076 + 110.75706 Natural gas @@ -2166,6 +2168,9 @@ All end uses 3902.655949232491 + 52.8533358512101 + 2051986.1527934822 + 132.24988549512202 @@ -2183,6 +2188,7 @@ 758.5569977291548 + 1772.1336698497769 15183 @@ -2204,6 +2210,7 @@ kBtu 3206417.9271639367 2064.179 + 110.75706 Natural gas @@ -2434,6 +2441,9 @@ All end uses 3144.098951503336 + 52.8533358512101 + 2051986.1527934822 + 132.24988549512202 @@ -2456,6 +2466,7 @@ 284.11765998512055 + 1772.1336698497769 5194 @@ -2477,6 +2488,7 @@ kBtu 2913836.260294419 2913.836 + 110.75706 Natural gas @@ -2707,6 +2719,9 @@ All end uses 3618.5382892473704 + 52.8533358512101 + 2051986.1527934822 + 132.24988549512202 diff --git a/seed/building_sync/tests/test_buildingsync_views.py b/seed/building_sync/tests/test_buildingsync_views.py index e98444d767..9d9ee80aa7 100644 --- a/seed/building_sync/tests/test_buildingsync_views.py +++ b/seed/building_sync/tests/test_buildingsync_views.py @@ -16,6 +16,7 @@ from seed.models import ( PropertyView, StatusLabel, + ColumnMappingPreset, ) from seed.test_helpers.fake import ( FakeCycleFactory, FakeColumnFactory, @@ -50,6 +51,8 @@ def setUp(self): start=datetime(2010, 10, 10, tzinfo=timezone.get_current_timezone()) ) + self.default_bsync_preset = ColumnMappingPreset.objects.get(preset_type=ColumnMappingPreset.BUILDINGSYNC_DEFAULT) + self.client.login(**user_details) def test_get_building_sync(self): @@ -61,7 +64,8 @@ def test_get_building_sync(self): # go to buildingsync endpoint params = { - 'organization_id': self.org.pk + 'organization_id': self.org.pk, + 'preset_id': self.default_bsync_preset.id } url = reverse('api:v2.1:properties-building-sync', args=[pv.id]) response = self.client.get(url, params) @@ -90,7 +94,7 @@ def test_upload_and_get_building_sync(self): # now get the building sync that was just uploaded property_id = result['data']['property_view']['id'] url = reverse('api:v2.1:properties-building-sync', args=[property_id]) - response = self.client.get(url) + response = self.client.get(url, {'organization_id': self.org.pk, 'preset_id': self.default_bsync_preset.id}) self.assertIn('1967', response.content.decode("utf-8")) @@ -182,6 +186,6 @@ def test_upload_and_get_building_sync_diff_ns(self): # now get the building sync that was just uploaded property_id = result['data']['property_view']['id'] url = reverse('api:v2.1:properties-building-sync', args=[property_id]) - response = self.client.get(url) + response = self.client.get(url, {'organization_id': self.org.pk, 'preset_id': self.default_bsync_preset.id}) self.assertIn('1889', response.content.decode('utf-8')) diff --git a/seed/building_sync/tests/test_validation_client.py b/seed/building_sync/tests/test_validation_client.py new file mode 100644 index 0000000000..17118f8b18 --- /dev/null +++ b/seed/building_sync/tests/test_validation_client.py @@ -0,0 +1,226 @@ +# !/usr/bin/env python +# encoding: utf-8 +""" +:copyright (c) 2014 - 2019, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Department of Energy) and contributors. All rights reserved. # NOQA +:author +""" +import os +from unittest.mock import patch + +from django.test import TestCase +from requests.models import Response +import json + +from config.settings.common import BASE_DIR +from seed.building_sync.validation_client import validate_use_case, DEFAULT_USE_CASE + + +def responseFactory(status_code, body_dict): + the_response = Response() + the_response.status_code = status_code + the_response._content = json.dumps(body_dict).encode() + return the_response + + +class TestValidationClient(TestCase): + def setUp(self): + # NOTE: the contents of these files are not actually used, it's just convenient + # to use these files so we don't have to create tmp ones and clean them up + self.single_file = open(os.path.join(BASE_DIR, 'seed', 'building_sync', 'tests', 'data', 'buildingsync_v2_0_bricr_workflow.xml')) + self.zip_file = open(os.path.join(BASE_DIR, 'seed', 'building_sync', 'tests', 'data', 'ex_1_and_buildingsync_ex01_measures.zip')) + + def test_validation_single_file_ok(self): + good_body = { + 'success': True, + 'validation_results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + 'warnings': [], + } + } + } + } + + with patch('seed.building_sync.validation_client._validation_api_post', return_value=responseFactory(200, good_body)): + all_files_valid, file_summaries = validate_use_case(self.single_file) + + self.assertTrue(all_files_valid) + self.assertEqual([], file_summaries) + + def test_validation_zip_file_ok(self): + good_body = { + 'success': True, + 'validation_results': [ + { + 'file': 'file1.xml', + 'results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + 'warnings': [], + } + } + } + }, { + 'file': 'file2.xml', + 'results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + 'warnings': [], + } + } + } + } + ] + } + + with patch('seed.building_sync.validation_client._validation_api_post', return_value=responseFactory(200, good_body)): + all_files_valid, file_summaries = validate_use_case(self.zip_file) + + self.assertTrue(all_files_valid) + self.assertEqual([], file_summaries) + + def test_validation_fails_when_one_file_has_bad_schema(self): + bad_file_result = { + 'file': 'bad.xml', + 'results': { + 'schema': { + # Set the schema as NOT valid + 'valid': False, + 'errors': ['schema was bad'] + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + 'warnings': [], + } + } + } + } + + body = { + 'success': True, + 'validation_results': [ + { + 'file': 'file1.xml', + 'results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + 'warnings': [], + } + } + } + }, + bad_file_result, + ] + } + + with patch('seed.building_sync.validation_client._validation_api_post', return_value=responseFactory(200, body)): + all_files_valid, file_summaries = validate_use_case(self.zip_file) + + self.assertFalse(all_files_valid) + bad_file_names = [f['file'] for f in file_summaries] + self.assertEqual([bad_file_result['file']], bad_file_names) + + def test_validation_fails_when_one_file_fails_use_case(self): + bad_file_result = { + 'file': 'bad.xml', + 'results': { + 'schema': { + 'valid': True, + }, + 'use_cases': { + DEFAULT_USE_CASE: { + # Include a use case error + 'errors': ['something was wrong'], + 'warnings': [], + } + } + } + } + + body = { + 'success': True, + 'validation_results': [ + { + 'file': 'file1.xml', + 'results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + 'warnings': [], + } + } + } + }, + bad_file_result, + ] + } + + with patch('seed.building_sync.validation_client._validation_api_post', return_value=responseFactory(200, body)): + all_files_valid, file_summaries = validate_use_case(self.zip_file) + + self.assertFalse(all_files_valid) + bad_file_names = [f['file'] for f in file_summaries] + self.assertEqual([bad_file_result['file']], bad_file_names) + + def test_validation_zip_file_ok_when_warnings(self): + good_body = { + 'success': True, + 'validation_results': [ + { + 'file': 'file1.xml', + 'results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + # Include a warning + 'warnings': ['This is a warning!'], + } + } + } + }, { + 'file': 'file2.xml', + 'results': { + 'schema': { + 'valid': True + }, + 'use_cases': { + DEFAULT_USE_CASE: { + 'errors': [], + # Include a warning + 'warnings': ['This is another warning!'], + } + } + } + } + ] + } + + with patch('seed.building_sync.validation_client._validation_api_post', return_value=responseFactory(200, good_body)): + all_files_valid, file_summaries = validate_use_case(self.zip_file) + + self.assertTrue(all_files_valid) + file_names = [f['file'] for f in file_summaries] + self.assertEqual(['file1.xml', 'file2.xml'], file_names) diff --git a/seed/building_sync/validation_client.py b/seed/building_sync/validation_client.py new file mode 100644 index 0000000000..23ddf91cc5 --- /dev/null +++ b/seed/building_sync/validation_client.py @@ -0,0 +1,134 @@ +import os + +import requests + + +VALIDATION_API_URL = "https://selectiontool.buildingsync.net/api/validate" +DEFAULT_SCHEMA_VERSION = '2.0.0' +DEFAULT_USE_CASE = 'SEED' + + +class ValidationClientException(Exception): + pass + + +def _validation_api_post(file_, schema_version, use_case_name): + payload = {'schema_version': schema_version} + files = [ + ('file', file_) + ] + + return requests.request( + "POST", + VALIDATION_API_URL, + data=payload, + files=files, + timeout=60 * 2, # timeout after two minutes (it can take a long time for zips) + ) + + +def validate_use_case(file_, filename=None, schema_version=DEFAULT_SCHEMA_VERSION, use_case_name=DEFAULT_USE_CASE): + """calls Selection Tool's validation API + + :param file_: File, the file to validate; can be single xml or zip + :param filename: string, (optional) name of the file, useful if file_.name is not user friendly (e.g. a Django SimpleUploadedFile). Not used if file_ is a zip + :param schema_version: string + :param use_case_name: string + :return: tuple, (bool, list), bool indicates if the file passes validation, + the list is a collection of files and their errors, warnings, and infos + """ + + try: + response = _validation_api_post(file_, schema_version, use_case_name) + except requests.exceptions.Timeout: + raise ValidationClientException( + "Request to Selection Tool timed out. SEED may need to increase the timeout", + ) + except Exception as e: + raise ValidationClientException( + f"Failed to make request to selection tool: {e}", + ) + + if response.status_code != 200: + raise ValidationClientException( + f"Received bad response from Selection Tool: {response.text}", + ) + + try: + response_body = response.json() + except ValueError: + raise ValidationClientException( + f"Expected JSON response from Selection Tool: {response.text}", + ) + + if response_body.get('success', False) is not True: + ValidationClientException( + f"Selection Tool request was not successful: {response.text}", + ) + + response_schema_version = response_body.get('schema_version') + if response_schema_version != schema_version: + ValidationClientException( + f"Expected schema_version to be '{schema_version}' but it was '{response_schema_version}'", + ) + + _, file_extension = os.path.splitext(file_.name) + validation_results = response_body.get('validation_results') + # check the returned type and make validation_results a list if it's not already + if file_extension == '.zip': + if type(validation_results) is not list: + raise ValidationClientException( + f"Expected response validation_results to be list for zip file: {response.text}", + ) + else: + if type(validation_results) is not dict: + raise ValidationClientException( + f"Expected response validation_results to be dict for single xml file: {response.text}", + ) + # turn the single file result into the same structure as zip file result + if filename is None: + filename = os.path.basename(file_.name) + validation_results = [{ + 'file': filename, + 'results': validation_results, + }] + + # check that the schema and use case is valid for every file + file_summaries = [] + all_files_valid = True + for validation_result in validation_results: + results = validation_result['results'] + filename = validation_result['file'] + + # it's possible there's no use_cases key - occurs when schema validation fails + if results['schema']['valid'] is False: + use_case_result = {} + else: + if use_case_name not in results.get('use_cases', []): + raise ValidationClientException( + f'Expected use case "{use_case_name}" to exist in result\'s uses cases.', + ) + use_case_result = results['use_cases'][use_case_name] + + file_summary = { + 'file': filename, + 'schema_errors': results['schema'].get('errors', []), + 'use_case_errors': use_case_result.get('errors', []), + 'use_case_warnings': use_case_result.get('warnings', []), + } + + file_has_errors = ( + len(file_summary['schema_errors']) > 0 + or len(file_summary['use_case_errors']) > 0 + ) + if file_has_errors: + all_files_valid = False + + file_has_errors_or_warnings = ( + file_has_errors + or len(file_summary['use_case_warnings']) > 0 + ) + if file_has_errors_or_warnings: + file_summaries.append(file_summary) + + return all_files_valid, file_summaries diff --git a/seed/data_importer/tasks.py b/seed/data_importer/tasks.py index bfe849b8d0..1b7d116835 100644 --- a/seed/data_importer/tasks.py +++ b/seed/data_importer/tasks.py @@ -33,6 +33,7 @@ from past.builtins import basestring from unidecode import unidecode +from seed.building_sync import validation_client from seed.data_importer.equivalence_partitioner import EquivalencePartitioner from seed.data_importer.match import ( match_and_link_incoming_properties_and_taxlots, @@ -1527,3 +1528,53 @@ def pair_new_states(merged_property_views, merged_taxlot_views): m2m_join.save() return + + +@shared_task +def _validate_use_cases(file_pk, progress_key): + import_file = ImportFile.objects.get(pk=file_pk) + progress_data = ProgressData.from_key(progress_key) + + progress_data.step('validating data with Selection Tool') + try: + all_files_valid, file_summaries = validation_client.validate_use_case( + import_file.file, + filename=import_file.uploaded_filename + ) + if all_files_valid is False: + import_file.delete() + progress_data.finish_with_success( + message=json.dumps({ + 'valid': all_files_valid, + 'issues': file_summaries, + }), + ) + except validation_client.ValidationClientException as e: + _log.debug(f'ValidationClientException while validating import_file `{file_pk}`: {e}') + progress_data.finish_with_error(message=str(e)) + progress_data.save() + import_file.delete() + except Exception as e: + _log.debug(f'Unexpected Exception while validating import_file `{file_pk}`: {e}') + progress_data.finish_with_error(message=str(e)) + progress_data.save() + import_file.delete() + + +def validate_use_cases(file_pk): + """ + Kicks off task for validating BuildingSync files for use cases + + :param file_pk: ImportFile Primary Key + :return: + """ + progress_data = ProgressData(func_name='validate_use_cases', unique_id=file_pk) + # break progress into two steps: + # 1. started job + # 2. finished request + progress_data.total = 2 + progress_data.save() + + _validate_use_cases.s(file_pk, progress_data.key).apply_async() + _log.debug(progress_data.result()) + return progress_data.result() diff --git a/seed/data_importer/tests/integration/test_data_import.py b/seed/data_importer/tests/integration/test_data_import.py index 29d336dd13..5b24f266b2 100644 --- a/seed/data_importer/tests/integration/test_data_import.py +++ b/seed/data_importer/tests/integration/test_data_import.py @@ -26,7 +26,6 @@ FAKE_EXTRA_DATA, FAKE_MAPPINGS, FAKE_ROW, - mock_buildingsync_mapping ) from seed.models import ( ASSESSED_RAW, @@ -43,6 +42,7 @@ BuildingFile, ) from seed.tests.util import DataMappingBaseTestCase +from seed.lib.xml_mapping.mapper import default_buildingsync_preset_mappings _log = logging.getLogger(__name__) @@ -240,7 +240,7 @@ def test_map_data_zip(self): self.assertEqual(PropertyState.objects.filter(import_file=self.import_file).count(), 2) # make the column mappings - self.fake_mappings = mock_buildingsync_mapping() + self.fake_mappings = default_buildingsync_preset_mappings() Column.create_mappings(self.fake_mappings, self.org, self.user, self.import_file.pk) # -- Act @@ -260,7 +260,7 @@ def test_map_all_models_zip(self): self.assertEqual(PropertyState.objects.filter(import_file=self.import_file).count(), 2) # make the column mappings - self.fake_mappings = mock_buildingsync_mapping() + self.fake_mappings = default_buildingsync_preset_mappings() Column.create_mappings(self.fake_mappings, self.org, self.user, self.import_file.pk) # map the data @@ -327,7 +327,7 @@ def test_map_all_models_xml(self): self.assertEqual(PropertyState.objects.filter(import_file=self.import_file).count(), 1) # make the column mappings - self.fake_mappings = mock_buildingsync_mapping() + self.fake_mappings = default_buildingsync_preset_mappings() Column.create_mappings(self.fake_mappings, self.org, self.user, self.import_file.pk) # map the data @@ -386,7 +386,7 @@ def test_map_all_models_xml(self): self.assertEqual(PropertyState.objects.filter(import_file=self.import_file).count(), 1) # make the column mappings - self.fake_mappings = mock_buildingsync_mapping() + self.fake_mappings = default_buildingsync_preset_mappings() Column.create_mappings(self.fake_mappings, self.org, self.user, self.import_file.pk) # map the data diff --git a/seed/data_importer/tests/test_mapping.py b/seed/data_importer/tests/test_mapping.py index 070818401c..3f4f1f7754 100644 --- a/seed/data_importer/tests/test_mapping.py +++ b/seed/data_importer/tests/test_mapping.py @@ -94,6 +94,105 @@ def test_mapping(self): # for p in props: # pp(p) + def test_remapping_with_and_without_unit_aware_columns_doesnt_lose_data(self): + """ + During import, when the initial -State objects are created from the extra_data values, + ColumnMapping objects are used to take the extra_data dictionary values and create the + -State objects, setting the DB-level values as necessary - e.g. taking a raw + "Site EUI (kBtu/ft2)" value and inserting it into the DB field "site_eui". + + Previously, remapping could cause extra Column objects to be created, and subsequently, + this created extra ColumnMapping objects. These extra ColumnMapping objects could cause + raw values to be inserted into the wrong DB field on -State creation. + """ + # Just as in the previous test, build extra_data PropertyState + state = self.property_state_factory.get_property_state_as_extra_data( + import_file_id=self.import_file.id, + source_type=ASSESSED_RAW, + data_state=DATA_STATE_IMPORT, + random_extra=42, + ) + + # Replace the site_eui key-value that gets autogenerated by get_property_state_as_extra_data + del state.extra_data['site_eui'] + state.extra_data['Site EUI (kBtu/ft2)'] = 123 + state.save() + + self.import_file.raw_save_done = True + self.import_file.save() + + # Build 2 sets of mappings - with and without a unit-aware destination site_eui data + suggested_mappings = mapper.build_column_mapping( + list(state.extra_data.keys()), + Column.retrieve_all_by_tuple(self.org), + previous_mapping=get_column_mapping, + map_args=[self.org], + thresh=80 + ) + + ed_site_eui_mappings = [] + unit_aware_site_eui_mappings = [] + for raw_column, suggestion in suggested_mappings.items(): + if raw_column == 'Site EUI (kBtu/ft2)': + # Make this an extra_data field (without from_units) + ed_site_eui_mappings.append({ + "from_field": raw_column, + "from_units": None, + "to_table_name": 'PropertyState', + "to_field": raw_column, + "to_field_display_name": raw_column, + }) + + unit_aware_site_eui_mappings.append({ + "from_field": raw_column, + "from_units": 'kBtu/ft**2/year', + "to_table_name": 'PropertyState', + "to_field": 'site_eui', + "to_field_display_name": 'Site EUI', + }) + else: + other_mapping = { + "from_field": raw_column, + "from_units": None, + "to_table_name": suggestion[0], + "to_field": suggestion[1], + "to_field_display_name": suggestion[1], + } + ed_site_eui_mappings.append(other_mapping) + unit_aware_site_eui_mappings.append(other_mapping) + + # Map and remap the file multiple times with different mappings each time. + # Round 1 - Map site_eui data into Extra Data + Column.create_mappings(ed_site_eui_mappings, self.org, self.user, self.import_file.id) + tasks.map_data(self.import_file.id) + + # There should only be one raw 'Site EUI (kBtu/ft2)' Column object + self.assertEqual(1, self.org.column_set.filter(column_name='Site EUI (kBtu/ft2)', table_name='').count()) + # The one propertystate should have site eui info in extra_data + prop = self.import_file.find_unmatched_property_states().get() + self.assertIsNone(prop.site_eui) + self.assertIsNotNone(prop.extra_data.get('Site EUI (kBtu/ft2)')) + + # Round 2 - Map site_eui data into the PropertyState attribute "site_eui" + Column.create_mappings(unit_aware_site_eui_mappings, self.org, self.user, self.import_file.id) + tasks.map_data(self.import_file.id, remap=True) + + self.assertEqual(1, self.org.column_set.filter(column_name='Site EUI (kBtu/ft2)', table_name='').count()) + # The one propertystate should have site eui info in site_eui + prop = self.import_file.find_unmatched_property_states().get() + self.assertIsNotNone(prop.site_eui) + self.assertIsNone(prop.extra_data.get('Site EUI (kBtu/ft2)')) + + # Round 3 - Map site_eui data into Extra Data + Column.create_mappings(ed_site_eui_mappings, self.org, self.user, self.import_file.id) + tasks.map_data(self.import_file.id, remap=True) + + self.assertEqual(1, self.org.column_set.filter(column_name='Site EUI (kBtu/ft2)', table_name='').count()) + # The one propertystate should have site eui info in extra_data + prop = self.import_file.find_unmatched_property_states().get() + self.assertIsNone(prop.site_eui) + self.assertIsNotNone(prop.extra_data.get('Site EUI (kBtu/ft2)')) + class TestDuplicateFileHeaders(DataMappingBaseTestCase): def setUp(self): diff --git a/seed/data_importer/tests/util.py b/seed/data_importer/tests/util.py index e741260550..25ec0c4735 100644 --- a/seed/data_importer/tests/util.py +++ b/seed/data_importer/tests/util.py @@ -7,7 +7,6 @@ import logging -from seed.building_sync.mappings import BASE_MAPPING_V2_0, xpath_to_column_map logger = logging.getLogger(__name__) @@ -269,18 +268,3 @@ "to_table_name": 'PropertyState', "to_field": 'property_footprint', } - - -def mock_buildingsync_mapping(): - # returns a column mapping for bsync files - xpath_to_col = xpath_to_column_map(BASE_MAPPING_V2_0) - result = [] - for xpath, col in xpath_to_col.items(): - result.append( - { - 'from_field': xpath, - 'to_field': col, - 'to_table_name': 'PropertyState' - } - ) - return result diff --git a/seed/data_importer/views.py b/seed/data_importer/views.py index bba10c134f..075da67fe6 100644 --- a/seed/data_importer/views.py +++ b/seed/data_importer/views.py @@ -34,6 +34,7 @@ map_additional_models as task_map_additional_models, match_buildings as task_match_buildings, save_raw_data as task_save_raw, + validate_use_cases as task_validate_use_cases, ) from seed.decorators import ajax_request, ajax_request_class from seed.decorators import get_prog_key @@ -901,6 +902,43 @@ def data_quality_progress(self, request, pk=None): cache = get_cache(prog_key) return HttpResponse(cache['progress']) + @api_endpoint_class + @ajax_request_class + @has_perm_class('can_modify_data') + @action(detail=True, methods=['POST']) + def validate_use_cases(self, request, pk=None): + """ + Starts a background task to call BuildingSync's use case validation + tool. + --- + type: + status: + required: true + type: string + description: either success or error + message: + required: false + type: string + description: error message, if any + progress_key: + type: integer + description: ID of background job, for retrieving job progress + parameter_strategy: replace + parameters: + - name: pk + description: Import file ID + required: true + paramType: path + """ + import_file_id = pk + if not import_file_id: + return JsonResponse({ + 'status': 'error', + 'message': 'must include pk of import_file to validate' + }, status=status.HTTP_400_BAD_REQUEST) + + return task_validate_use_cases(import_file_id) + @api_endpoint_class @ajax_request_class @has_perm_class('can_modify_data') diff --git a/seed/landing/forms.py b/seed/landing/forms.py index 4a07f54de6..3fbde8cc24 100644 --- a/seed/landing/forms.py +++ b/seed/landing/forms.py @@ -19,7 +19,7 @@ class LoginForm(forms.Form): password = forms.CharField( label=_("Password"), widget=forms.PasswordInput( - attrs={'class': 'field', 'placeholder': _('Password')} + attrs={'class': 'field', 'placeholder': _('Password'), 'autocomplete': 'off'} ), required=True ) diff --git a/seed/landing/models.py b/seed/landing/models.py index be5d2b9dc2..7cfa94c298 100644 --- a/seed/landing/models.py +++ b/seed/landing/models.py @@ -124,6 +124,10 @@ def process_header_request(cls, request): def get_absolute_url(self): return "/users/%s/" % urlquote(self.username) + def deactivate_user(self): + self.is_active = False + self.save() + def get_full_name(self): """ Returns the first_name plus the last_name, with a space in between. diff --git a/seed/lib/xml_mapping/mapper.py b/seed/lib/xml_mapping/mapper.py index 32cfc94203..51733df26b 100644 --- a/seed/lib/xml_mapping/mapper.py +++ b/seed/lib/xml_mapping/mapper.py @@ -9,7 +9,7 @@ import logging from seed.building_sync.building_sync import BuildingSync -from seed.building_sync.mappings import merge_mappings, xpath_to_column_map +from seed.building_sync.mappings import merge_mappings, xpath_to_column_map, BASE_MAPPING_V2_0 _log = logging.getLogger(__name__) @@ -23,3 +23,41 @@ def build_column_mapping(base_mapping=None, custom_mapping=None): xpath: ('PropertyState', db_column, 100) for xpath, db_column in column_mapping.items() } + + +def default_buildingsync_preset_mappings(): + """Returns the default ColumnMappingPreset mappings for BuildingSync + :return: list + """ + # taken from mapping partial (./static/seed/partials/mapping.html) + valid_units = [ + # area units + "ft**2", + "m**2", + # eui_units + "kBtu/ft**2/year", + "kWh/m**2/year", + "GJ/m**2/year", + "MJ/m**2/year", + "kBtu/m**2/year", + ] + + mapping = BASE_MAPPING_V2_0.copy() + base_path = mapping['property']['xpath'].rstrip('/') + result = [] + for col_name, col_info in mapping['property']['properties'].items(): + from_units = col_info.get('units') + if from_units not in valid_units: + from_units = None + + sub_path = col_info['xpath'].replace('./', '') + absolute_xpath = f'{base_path}/{sub_path}' + result.append({ + 'from_field': absolute_xpath, + 'from_field_value': col_info['value'], + 'from_units': from_units, + 'to_field': col_name, + 'to_table_name': 'PropertyState' + }) + + return result diff --git a/seed/migrations/0126_columnmappingpreset_preset_type.py b/seed/migrations/0126_columnmappingpreset_preset_type.py new file mode 100644 index 0000000000..471196d9b7 --- /dev/null +++ b/seed/migrations/0126_columnmappingpreset_preset_type.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.10 on 2020-05-01 21:24 + +from django.db import migrations, models + +from seed.lib.xml_mapping.mapper import default_buildingsync_preset_mappings + + +def create_default_bsync_presets(apps, schema_editor): + """create a default BuildingSync column mapping preset for each organization""" + Organization = apps.get_model("orgs", "Organization") + + for org in Organization.objects.all(): + bsync_mapping_name = 'BuildingSync v2.0 Defaults' + org.columnmappingpreset_set.create( + name=bsync_mapping_name, + mappings=default_buildingsync_preset_mappings(), + preset_type=1 + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('seed', '0125_dq_refactor'), + ] + + operations = [ + migrations.AddField( + model_name='columnmappingpreset', + name='preset_type', + field=models.IntegerField(choices=[(0, 'Normal'), (1, 'BuildingSync Default'), (2, 'BuildingSync Custom')], default=0), + ), + migrations.RunPython(create_default_bsync_presets), + ] diff --git a/seed/models/column_mapping_presets.py b/seed/models/column_mapping_presets.py index 6badf1dec5..a65b04d249 100644 --- a/seed/models/column_mapping_presets.py +++ b/seed/models/column_mapping_presets.py @@ -8,6 +8,16 @@ class ColumnMappingPreset(models.Model): + NORMAL = 0 + BUILDINGSYNC_DEFAULT = 1 + BUILDINGSYNC_CUSTOM = 2 + + COLUMN_MAPPING_PRESET_TYPES = ( + (NORMAL, 'Normal'), + (BUILDINGSYNC_DEFAULT, 'BuildingSync Default'), + (BUILDINGSYNC_CUSTOM, 'BuildingSync Custom') + ) + name = models.CharField(max_length=255, blank=False) mappings = JSONField(default=list, blank=True) @@ -15,3 +25,20 @@ class ColumnMappingPreset(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + + preset_type = models.IntegerField(choices=COLUMN_MAPPING_PRESET_TYPES, default=NORMAL) + + @classmethod + def get_preset_type(cls, preset_type): + """Returns the integer value for a preset type. Raises exception when + preset_type is invalid. + + :param preset_type: int | str + :return: str + """ + if isinstance(preset_type, int): + return preset_type + types_dict = dict((v, k) for k, v in cls.COLUMN_MAPPING_PRESET_TYPES) + if preset_type in types_dict: + return types_dict[preset_type] + raise Exception(f'Invalid preset type "{preset_type}"') diff --git a/seed/models/columns.py b/seed/models/columns.py index aa41fe9be1..611c60c004 100644 --- a/seed/models/columns.py +++ b/seed/models/columns.py @@ -982,22 +982,32 @@ def select_col_obj(column_name, table_name, organization_column): organization=organization, table_name__in=[None, ''], column_name=field['from_field'], - units_pint=field.get('from_units'), # might be None - is_extra_data=False # data from header rows in the files are NEVER extra data + is_extra_data=False # Column objects representing raw/header rows are NEVER extra data ) except Column.MultipleObjectsReturned: + # We want to avoid the ambiguity of having multiple Column objects for a specific raw column. + # To do that, delete all multiples along with any associated ColumnMapping objects. _log.debug( "More than one from_column found for {}.{}".format(field['to_table_name'], field['to_field'])) - # TODO: write something to remove the duplicate columns - from_org_col = Column.objects.filter(organization=organization, - table_name__in=[None, ''], - column_name=field['from_field'], - units_pint=field.get('from_units'), - # might be None - is_extra_data=is_extra_data).first() - _log.debug("Grabbing the first from_column") + all_from_cols = Column.objects.filter( + organization=organization, + table_name__in=[None, ''], + column_name=field['from_field'], + is_extra_data=False + ) + + ColumnMapping.objects.filter(column_raw__id__in=models.Subquery(all_from_cols.values('id'))).delete() + all_from_cols.delete() + + from_org_col = Column.objects.create( + organization=organization, + table_name__in=[None, ''], + column_name=field['from_field'], + is_extra_data=False # Column objects representing raw/header rows are NEVER extra data + ) + _log.debug("Creating a new from_column") new_field['to_column_object'] = select_col_obj(field['to_field'], field['to_table_name'], to_org_col) diff --git a/seed/models/data_quality.py b/seed/models/data_quality.py index ebbcc4a0e7..d22652df65 100644 --- a/seed/models/data_quality.py +++ b/seed/models/data_quality.py @@ -161,6 +161,7 @@ class Rule(models.Model): }, { 'table_name': 'TaxLotState', 'field': 'address_line_1', + 'data_type': TYPE_STRING, 'not_null': True, 'rule_type': RULE_TYPE_DEFAULT, 'severity': SEVERITY_ERROR, diff --git a/seed/serializers/column_mapping_presets.py b/seed/serializers/column_mapping_presets.py index 9d14aaa584..b9b466ab93 100644 --- a/seed/serializers/column_mapping_presets.py +++ b/seed/serializers/column_mapping_presets.py @@ -3,10 +3,30 @@ from rest_framework import serializers +from seed.serializers.base import ChoiceField from seed.models import ColumnMappingPreset class ColumnMappingPresetSerializer(serializers.ModelSerializer): + preset_type = ChoiceField(choices=ColumnMappingPreset.COLUMN_MAPPING_PRESET_TYPES, default=ColumnMappingPreset.NORMAL) + class Meta: model = ColumnMappingPreset fields = '__all__' + + def validate_mappings(self, mappings): + """if the preset is for BuildingSync, make sure it has valid mappings""" + preset_types_dict = dict(ColumnMappingPreset.COLUMN_MAPPING_PRESET_TYPES) + bsync_presets = [ + preset_types_dict[ColumnMappingPreset.BUILDINGSYNC_DEFAULT], + preset_types_dict[ColumnMappingPreset.BUILDINGSYNC_CUSTOM] + ] + preset_type = self.initial_data.get('preset_type') + if preset_type is None or preset_type not in bsync_presets: + return mappings + + for mapping in mappings: + if mapping.get('from_field_value') is None: + raise serializers.ValidationError(f'All BuildingSync mappings must include "from_field_value": for mapping {mapping["from_field"]}') + + return mappings diff --git a/seed/serializers/column_mappings.py b/seed/serializers/column_mappings.py index 0dfb5c9104..cbee425c22 100644 --- a/seed/serializers/column_mappings.py +++ b/seed/serializers/column_mappings.py @@ -33,3 +33,38 @@ def to_representation(self, obj): result['column_mapped'] = ColumnSerializer(obj.column_mapped.first()).data return result + + +class ImportMappingSerializer(serializers.Serializer): + from_field = serializers.CharField() + from_units = serializers.CharField() + to_field = serializers.CharField() + to_field_display_name = serializers.CharField() + to_table_name = serializers.CharField() + + +class SaveColumnMappingsRequestPayloadSerializer(serializers.Serializer): + """ + Note that this is _not_ a model serializer, but used only for saving mappings + + Example: + { + "mappings": [ + { + 'from_field': 'eui', # raw field in import file + 'from_units': 'kBtu/ft**2/year', # pint-parsable units, optional + 'to_field': 'energy_use_intensity', + 'to_field_display_name': 'Energy Use Intensity', + 'to_table_name': 'PropertyState', + }, + { + 'from_field': 'gfa', + 'from_units': 'ft**2', # pint-parsable units, optional + 'to_field': 'gross_floor_area', + 'to_field_display_name': 'Gross Floor Area', + 'to_table_name': 'PropertyState', + } + ] + } + """ + mappings = serializers.ListField(child=ImportMappingSerializer()) diff --git a/seed/static/seed/js/constants.js b/seed/static/seed/js/constants.js new file mode 100644 index 0000000000..de19976095 --- /dev/null +++ b/seed/static/seed/js/constants.js @@ -0,0 +1,8 @@ +/** + * :copyright (c) 2014 - 2020, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Department of Energy) and contributors. All rights reserved. + * :author + */ +angular.module('BE.seed.constants', []) + .constant('COLUMN_MAPPING_PRESET_TYPE_NORMAL', 'Normal') + .constant('COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT', 'BuildingSync Default') + .constant('COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM', 'BuildingSync Custom'); diff --git a/seed/static/seed/js/controllers/admin_controller.js b/seed/static/seed/js/controllers/admin_controller.js index 0c1d1e7bc2..d70e1ed84a 100644 --- a/seed/static/seed/js/controllers/admin_controller.js +++ b/seed/static/seed/js/controllers/admin_controller.js @@ -188,7 +188,7 @@ angular.module('BE.seed.controller.admin', []) } }; - function confirm_remove_user(user, org_id) { + function confirm_remove_user (user, org_id) { organization_service.remove_user(user.user_id, org_id).then(function () { $scope.get_organizations_users($scope.org_user.organization); get_users(); @@ -239,12 +239,12 @@ angular.module('BE.seed.controller.admin', []) .then(function (data) { // resolve promise uploader_service.check_progress_loop(data.progress_key, 0, 1, function () { - org.remove_message = 'success'; - get_organizations(); - }, function () { - // Do nothing - }, - org); + org.remove_message = 'success'; + get_organizations(); + }, function () { + // Do nothing + }, + org); }); }; diff --git a/seed/static/seed/js/controllers/column_mapping_preset_modal_controller.js b/seed/static/seed/js/controllers/column_mapping_preset_modal_controller.js index 36dfbff8cb..97c8fd1733 100644 --- a/seed/static/seed/js/controllers/column_mapping_preset_modal_controller.js +++ b/seed/static/seed/js/controllers/column_mapping_preset_modal_controller.js @@ -18,7 +18,7 @@ angular.module('BE.seed.controller.column_mapping_preset_modal', []) $scope.rename_preset = function () { if (!$scope.disabled()) { var preset_id = $scope.data.id; - var updated_data = {name: $scope.newName} + var updated_data = {name: $scope.newName}; column_mappings_service.update_column_mapping_preset($scope.org_id, preset_id, updated_data).then(function (result) { $uibModalInstance.close(result.data.name); }); @@ -36,6 +36,7 @@ angular.module('BE.seed.controller.column_mapping_preset_modal', []) column_mappings_service.new_column_mapping_preset_for_org($scope.org_id, { name: $scope.newName, mappings: $scope.data.mappings, + preset_type: $scope.data.preset_type }).then(function (result) { $uibModalInstance.close(result.data); }); diff --git a/seed/static/seed/js/controllers/column_mappings_controller.js b/seed/static/seed/js/controllers/column_mappings_controller.js index ff9aaac5fa..e289e74e1a 100644 --- a/seed/static/seed/js/controllers/column_mappings_controller.js +++ b/seed/static/seed/js/controllers/column_mappings_controller.js @@ -6,6 +6,7 @@ angular.module('BE.seed.controller.column_mappings', []) .controller('column_mappings_controller', [ '$scope', '$state', + '$log', '$uibModal', 'Notification', 'auth_payload', @@ -16,9 +17,13 @@ angular.module('BE.seed.controller.column_mappings', []) 'mappable_taxlot_columns_payload', 'organization_payload', 'urls', + 'COLUMN_MAPPING_PRESET_TYPE_NORMAL', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM', function ( $scope, $state, + $log, $uibModal, Notification, auth_payload, @@ -28,7 +33,10 @@ angular.module('BE.seed.controller.column_mappings', []) mappable_property_columns_payload, mappable_taxlot_columns_payload, organization_payload, - urls + urls, + COLUMN_MAPPING_PRESET_TYPE_NORMAL, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM ) { $scope.org = organization_payload.organization; $scope.auth = auth_payload.auth; @@ -39,12 +47,12 @@ angular.module('BE.seed.controller.column_mappings', []) $scope.mappable_taxlot_columns = mappable_taxlot_columns_payload; // Helpers to convert to and from DB column names and column display names - var mapping_db_to_display = function(mapping) { + var mapping_db_to_display = function (mapping) { var mappable_column; - if (mapping.to_table_name === "PropertyState") { + if (mapping.to_table_name === 'PropertyState') { mappable_column = _.find($scope.mappable_property_columns, {column_name: mapping.to_field}); - } else if (mapping.to_table_name === "TaxLotState") { + } else if (mapping.to_table_name === 'TaxLotState') { mappable_column = _.find($scope.mappable_taxlot_columns, {column_name: mapping.to_field}); } @@ -53,16 +61,16 @@ angular.module('BE.seed.controller.column_mappings', []) } }; - var mapping_display_to_db = function(mapping) { + var mapping_display_to_db = function (mapping) { // Also, clear from_units if mapping is not for units col if (!$scope.is_eui_column(mapping) && !$scope.is_area_column(mapping)) { mapping.from_units = null; } var mappable_column; - if (mapping.to_table_name === "PropertyState") { + if (mapping.to_table_name === 'PropertyState') { mappable_column = _.find($scope.mappable_property_columns, {displayName: mapping.to_field}); - } else if (mapping.to_table_name === "TaxLotState") { + } else if (mapping.to_table_name === 'TaxLotState') { mappable_column = _.find($scope.mappable_taxlot_columns, {displayName: mapping.to_field}); } @@ -72,7 +80,7 @@ angular.module('BE.seed.controller.column_mappings', []) }; // On page load, convert DB field names to display names - _.forEach(column_mapping_presets_payload, function(preset) { + _.forEach(column_mapping_presets_payload, function (preset) { _.forEach(preset.mappings, mapping_db_to_display); }); @@ -101,7 +109,7 @@ angular.module('BE.seed.controller.column_mappings', []) }; // On load... - analyze_chosen_inventory_types() + analyze_chosen_inventory_types(); $scope.updateSingleInventoryTypeDropdown = function () { analyze_chosen_inventory_types(); @@ -120,13 +128,19 @@ angular.module('BE.seed.controller.column_mappings', []) // Preset-level CRUD modal-rending actions $scope.new_preset = function () { + var presetData = JSON.parse(JSON.stringify($scope.current_preset)); + // change the preset type to custom if we've edited a default preset + if ($scope.current_preset.preset_type === COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT) { + presetData.preset_type = COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM; + } + var modalInstance = $uibModal.open({ templateUrl: urls.static_url + 'seed/partials/column_mapping_preset_modal.html', controller: 'column_mapping_preset_modal_controller', resolve: { action: _.constant('new'), - data: _.constant($scope.current_preset), - org_id: _.constant($scope.org.id), + data: _.constant(presetData), + org_id: _.constant($scope.org.id) } }); @@ -148,7 +162,7 @@ angular.module('BE.seed.controller.column_mappings', []) resolve: { action: _.constant('rename'), data: _.constant($scope.current_preset), - org_id: _.constant($scope.org.id), + org_id: _.constant($scope.org.id) } }); @@ -169,7 +183,7 @@ angular.module('BE.seed.controller.column_mappings', []) resolve: { action: _.constant('remove'), data: _.constant($scope.current_preset), - org_id: _.constant($scope.org.id), + org_id: _.constant($scope.org.id) } }); @@ -236,7 +250,7 @@ angular.module('BE.seed.controller.column_mappings', []) // Add and remove column methods $scope.add_new_column = function () { - var empty_row = {from_field: "", from_units: null, to_field: "", to_table_name: ""}; + var empty_row = {from_field: '', from_units: null, to_field: '', to_table_name: ''}; if ($scope.current_preset.mappings[0]) { $scope.current_preset.mappings.push(empty_row); @@ -257,9 +271,9 @@ angular.module('BE.seed.controller.column_mappings', []) }; // Copy Comma-delimited list into headers - $scope.csv_headers = ""; + $scope.csv_headers = ''; - $scope.copy_csv_headers = function() { + $scope.copy_csv_headers = function () { $uibModal.open({ template: '', + '' }, { field: 'data_type', - displayName: 'Data Type Change', + displayName: 'Data Type Change' }, { field: 'merge_protection', displayName: 'Merge Protection Change', cellTemplate: '
' + '' + - '
', + '' }, { field: 'recognize_empty', displayName: 'Recognize Empty', cellTemplate: '
' + '' + - '
', + '' }, { field: 'is_matching_criteria', displayName: 'Matching Criteria Change', cellTemplate: '
' + '' + - '
', - }, - ] + '' + } + ]; var unique_summary_columns = _.uniq(all_changed_settings); $scope.change_summary_column_defs = _.filter(base_summary_column_defs, function (column_def) { @@ -115,25 +115,25 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) columnDefs: $scope.change_summary_column_defs, enableColumnResizing: true, rowHeight: 40, - minRowsToShow: Math.min($scope.change_summary_data.length, 5), + minRowsToShow: Math.min($scope.change_summary_data.length, 5) }; // By default, assume matching criteria isn't being updated to exclude PM Property ID // And since warning wouldn't be shown in that case, set "acknowledged" to true. $scope.checks = { matching_criteria_excludes_pm_property_id: false, - warnings_acknowledged: true, - } + warnings_acknowledged: true + }; // Check if PM Property ID is actually being removed from matching criteria - if (_.find($scope.change_summary_data, {column_name: "pm_property_id", is_matching_criteria: false})) { + if (_.find($scope.change_summary_data, {column_name: 'pm_property_id', is_matching_criteria: false})) { $scope.checks.matching_criteria_excludes_pm_property_id = true; $scope.checks.warnings_acknowledged = false; } // Preview // Agg function returning last value of matching criteria field (all should be the same if they match) - $scope.matching_field_value = function(aggregation, fieldValue) { + $scope.matching_field_value = function (aggregation, fieldValue) { aggregation.value = fieldValue; }; @@ -143,7 +143,7 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) // Lastly, non-matching columns are given next priority so that users can sort within a grouped set. if (sortColumns.length > 1) { var matching_cols = _.filter(sortColumns, function (col) { - return col.colDef.is_matching_criteria; + return col.colDef.is_matching_criteria; }); var linking_id_col = _.find(sortColumns, ['name', 'id']); var remaining_cols = _.filter(sortColumns, function (col) { @@ -152,20 +152,20 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) sortColumns = matching_cols.concat(linking_id_col).concat(remaining_cols); _.forEach(sortColumns, function (col, index) { col.sort.priority = index; - }) + }); } }; // Takes raw cycle-partitioned records and returns array of cycle-aware records - var format_preview_records = function(raw_inventory) { - return _.reduce(raw_inventory, function(all_records, records, cycle_id) { + var format_preview_records = function (raw_inventory) { + return _.reduce(raw_inventory, function (all_records, records, cycle_id) { var cycle = _.find($scope.cycles, { id: parseInt(cycle_id) }); - _.forEach(records, function(record) { + _.forEach(records, function (record) { record.cycle_name = cycle.name; record.cycle_start = cycle.start; - all_records.push(record) - }) - return all_records + all_records.push(record); + }); + return all_records; }, []); }; @@ -179,7 +179,7 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) headerCellFilter: 'translate', minWidth: default_min_width, width: 125, - groupingShowAggregationMenu: false, + groupingShowAggregationMenu: false }; _.map(preview_column_defs, function (col) { @@ -197,7 +197,7 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) // Help indicate matching columns are given preferred sort priority col.displayName = col.displayName + '*'; - options.headerCellClass = "matching-column-header"; + options.headerCellClass = 'matching-column-header'; options.customTreeAggregationFn = $scope.matching_field_value; options.width = autopin_width; @@ -216,23 +216,23 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) visible: false, suppressRemoveSort: true, // since grouping relies on sorting minWidth: default_min_width, - width: autopin_width, + width: autopin_width }, { - name: "cycle_name", - displayName: "Cycle", + name: 'cycle_name', + displayName: 'Cycle', pinnedLeft: true, treeAggregationType: uiGridGroupingConstants.aggregation.COUNT, customTreeAggregationFinalizerFn: function (aggregation) { - aggregation.rendered = "total cycles: " + aggregation.value; + aggregation.rendered = 'total cycles: ' + aggregation.value; }, minWidth: default_min_width, width: autopin_width, - groupingShowAggregationMenu: false, + groupingShowAggregationMenu: false }, { - name: "cycle_start", - displayName: "Cycle Start", + name: 'cycle_start', + displayName: 'Cycle Start', cellFilter: 'date:\'yyyy-MM-dd\'', filter: inventory_service.dateFilter(), type: 'date', @@ -240,11 +240,11 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) pinnedLeft: true, minWidth: default_min_width, width: autopin_width, - groupingShowAggregationMenu: false, - }, - ) + groupingShowAggregationMenu: false + } + ); - return preview_column_defs; + return preview_column_defs; }; // Initialize preview table as empty for now. @@ -260,31 +260,31 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) $scope.gridApi.core.on.filterChanged($scope, function () { // This is a workaround for losing the state of expanded rows during filtering. - _.delay($scope.gridApi.treeBase.expandAllRows, 500); - }) + _.delay($scope.gridApi.treeBase.expandAllRows, 500); + }); // Prioritized to maintain grouping. $scope.gridApi.core.on.sortChanged($scope, prioritize_sort); - }, + } }; // Preview Loading Helpers var build_proposed_matching_columns = function (result) { // Summarize proposed matching_criteria_columns for pinning and to create preview var criteria_additions = _.filter($scope.change_summary_data, function (change) { - return change.is_matching_criteria + return change.is_matching_criteria; }); var criteria_removals = _.filter($scope.change_summary_data, function (change) { - return change.is_matching_criteria === false; + return change.is_matching_criteria === false; }); $scope.criteria_changes = { add: _.map(criteria_additions, 'column_name'), - remove: _.map(criteria_removals, 'column_name'), - } + remove: _.map(criteria_removals, 'column_name') + }; var base_and_add; - if ($scope.inventory_type == "properties") { + if ($scope.inventory_type == 'properties') { base_and_add = _.union(result.PropertyState, $scope.criteria_changes.add); } else { base_and_add = _.union(result.TaxLotState, $scope.criteria_changes.add); @@ -307,24 +307,24 @@ angular.module('BE.seed.controller.confirm_column_settings_modal', []) // Use new proposed matching_criteria_columns to request a preview then render this preview. var spinner_options = { scale: 0.40, - position: "relative", - left: "100%", - } + position: 'relative', + left: '100%' + }; spinner_utility.show(spinner_options, $('#spinner_placeholder')[0]); organization_service.match_merge_link_preview($scope.org_id, $scope.inventory_type, $scope.criteria_changes) .then(function (response) { organization_service.check_match_merge_link_status(response.progress_key) - .then(function (completion_notice) { - organization_service.get_match_merge_link_result($scope.org_id, completion_notice.unique_id) - .then(build_preview) - .then(preview_loading_complete); - }); + .then(function (completion_notice) { + organization_service.get_match_merge_link_result($scope.org_id, completion_notice.unique_id) + .then(build_preview) + .then(preview_loading_complete); + }); }); }; // Get and Show Preview (If matching criteria changes exist.) - $scope.matching_criteria_exists = _.find(_.values($scope.change_summary_data), function(delta) { + $scope.matching_criteria_exists = _.find(_.values($scope.change_summary_data), function (delta) { return _.has(delta, 'is_matching_criteria'); }); diff --git a/seed/static/seed/js/controllers/data_quality_admin_controller.js b/seed/static/seed/js/controllers/data_quality_admin_controller.js index 63f873aa49..47ad29f0d7 100644 --- a/seed/static/seed/js/controllers/data_quality_admin_controller.js +++ b/seed/static/seed/js/controllers/data_quality_admin_controller.js @@ -141,10 +141,10 @@ angular.module('BE.seed.controller.data_quality_admin', []) $scope.ruleGroups = ruleGroups; $scope.rule_count_property = 0; $scope.rule_count_taxlot = 0; - _.map($scope.ruleGroups['properties'], function (rule) { + _.map($scope.ruleGroups.properties, function (rule) { $scope.rule_count_property += rule.length; }); - _.map($scope.ruleGroups['taxlots'], function (rule) { + _.map($scope.ruleGroups.taxlots, function (rule) { $scope.rule_count_taxlot += rule.length; }); }; @@ -422,8 +422,7 @@ angular.module('BE.seed.controller.data_quality_admin', []) $scope.delete_rule = function (rule, index) { if ($scope.ruleGroups[$scope.inventory_type][rule.field].length === 1) { delete $scope.ruleGroups[$scope.inventory_type][rule.field]; - } - else $scope.ruleGroups[$scope.inventory_type][rule.field].splice(index, 1); + } else $scope.ruleGroups[$scope.inventory_type][rule.field].splice(index, 1); $scope.change_rules(); if ($scope.inventory_type === 'properties') $scope.rule_count_property -= 1; else $scope.rule_count_taxlot -= 1; diff --git a/seed/static/seed/js/controllers/data_upload_modal_controller.js b/seed/static/seed/js/controllers/data_upload_modal_controller.js index aa8bd5440d..1b50afb83b 100644 --- a/seed/static/seed/js/controllers/data_upload_modal_controller.js +++ b/seed/static/seed/js/controllers/data_upload_modal_controller.js @@ -21,7 +21,6 @@ * ng-switch-when="11" == Confirm Save Mappings? * ng-switch-when="12" == Error Processing Data * ng-switch-when="13" == Portfolio Manager Import - * ng-switch-when="14" == Successful upload! [BuildingSync] */ angular.module('BE.seed.controller.data_upload_modal', []) .controller('data_upload_modal_controller', [ @@ -318,34 +317,20 @@ angular.module('BE.seed.controller.data_upload_modal', []) case 'upload_error': $scope.step_12_error_message = file.error; $scope.step.number = 12; - // add variables to identify buildingsync bulk uploads - $scope.building_sync_files = (file.source_type === 'BuildingSync'); - $scope.bulk_upload = (_.last(file.filename.split('.')) === 'zip'); break; case 'upload_in_progress': $scope.uploader.in_progress = true; - if (file.source_type === 'BuildingSync') { - $scope.uploader.progress = 100 * progress.loaded / progress.total; - } else { - $scope.uploader.progress = 25 * progress.loaded / progress.total; - } + $scope.uploader.progress = 25 * progress.loaded / progress.total; break; case 'upload_complete': var current_step = $scope.step.number; $scope.uploader.status_message = 'upload complete'; $scope.dataset.filename = file.filename; - $scope.step_14_message = null; $scope.source_type = file.source_type; - if (file.source_type === 'BuildingSync') { - $scope.uploader.complete = true; - $scope.uploader.in_progress = false; - $scope.uploader.progress = 100; - $scope.step.number = 14; - $scope.step_14_message = (_.size(file.message.warnings) > 0) ? file.message.warnings : null; - } else if (file.source_type === 'PM Meter Usage') { + if (file.source_type === 'PM Meter Usage') { $scope.cycle_id = file.cycle_id; $scope.file_id = file.file_id; @@ -361,7 +346,12 @@ angular.module('BE.seed.controller.data_upload_modal', []) // Assessed Data; upload is step 2; PM import is currently treated as such, and is step 13 if (current_step === 2 || current_step === 13) { - save_raw_assessed_data(file.file_id, file.cycle_id, false); + // if importing BuildingSync, validate then save, otherwise just save + if (file.source_type === "BuildingSync Raw") { + validate_use_cases_then_save(file.file_id, file.cycle_id) + } else { + save_raw_assessed_data(file.file_id, file.cycle_id, false) + } } // Portfolio Data if (current_step === 4) { @@ -496,6 +486,58 @@ angular.module('BE.seed.controller.data_upload_modal', []) }; }; + /** + * validate_use_cases_then_save: validates BuildingSync files for use cases + * before saving the data + * + * @param {string} file_id: the id of the import file + * @param cycle_id + */ + var validate_use_cases_then_save = function (file_id, cycle_id) { + $scope.uploader.status_message = 'validating data'; + $scope.uploader.progress = 0; + + const successHandler = (progress_data) => { + $scope.uploader.complete = false + $scope.uploader.in_progress = true + $scope.uploader.status_message = 'validation complete; starting to save data'; + $scope.uploader.progress = 100; + + const result = JSON.parse(progress_data.message) + $scope.buildingsync_valid = result.valid + $scope.buildingsync_issues = result.issues + + // if validation failed, end the import flow here; otherwise continue + if ($scope.buildingsync_valid !== true) { + $scope.step_12_error_message = 'Failed to validate uploaded BuildingSync file(s)' + $scope.step_12_buildingsync_validation_error = true + $scope.step.number = 12 + } else { + // successfully passed validation, save the data + save_raw_assessed_data(file_id, cycle_id, false) + } + } + + const errorHandler = (data) => { + $log.error(data.message); + if (data.hasOwnProperty('stacktrace')) $log.error(data.stacktrace); + $scope.step_12_error_message = data.data ? data.data.message : data.message; + $scope.step.number = 12; + } + + uploader_service.validate_use_cases(file_id) + .then(data => { + const progress = _.clamp(data.progress, 0, 100); + uploader_service.check_progress_loop( + data.progress_key, + progress, 1 - (progress / 100), + successHandler, + errorHandler, + $scope.uploader, + ) + }) + }; + /** * save_raw_assessed_data: saves Assessed data * @@ -578,7 +620,7 @@ angular.module('BE.seed.controller.data_upload_modal', []) // this only occurs in buildingsync, where we are not actually merging properties // thus we will always end up at step 10 $scope.step_10_style = 'danger'; - $scope.step_10_file_message = 'Warning(s)/Error(s) occurred while processing the file(s):\n' + JSON.stringify(progress_data.file_info, null, 2) + $scope.step_10_file_message = 'Warning(s)/Error(s) occurred while processing the file(s):\n' + JSON.stringify(progress_data.file_info, null, 2); } // If merges against existing exist, provide slightly different feedback diff --git a/seed/static/seed/js/controllers/export_buildingsync_modal_controller.js b/seed/static/seed/js/controllers/export_buildingsync_modal_controller.js new file mode 100644 index 0000000000..fb02d8d1f4 --- /dev/null +++ b/seed/static/seed/js/controllers/export_buildingsync_modal_controller.js @@ -0,0 +1,49 @@ +/** + * :copyright (c) 2014 - 2020, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Department of Energy) and contributors. All rights reserved. + * :author + */ +angular.module('BE.seed.controller.export_buildingsync_modal', []) + .controller('export_buildingsync_modal_controller', [ + '$http', + '$window', + '$scope', + '$uibModalInstance', + 'property_view_id', + 'column_mapping_presets', + function ( + $http, + $window, + $scope, + $uibModalInstance, + property_view_id, + column_mapping_presets + ) { + $scope.column_mapping_presets = column_mapping_presets; + $scope.current_column_mapping_preset = column_mapping_presets[0]; + + $scope.download_file = function () { + var the_url = '/api/v2_1/properties/' + property_view_id + '/building_sync/'; + $http.get( + the_url, + { params: { preset_id: $scope.current_column_mapping_preset.id } } + ).then(function (response) { + var blob = new Blob([response.data], {type: 'application/xml;charset=utf-8;'}); + var downloadLink = angular.element(''); + var filename = 'buildingsync_property_' + property_view_id + '.xml'; + downloadLink.attr('href', $window.URL.createObjectURL(blob)); + downloadLink.attr('download', filename); + downloadLink[0].click(); + $uibModalInstance.close(); + }, function (err) { + $scope.download_error_message = err.data ? err.data : err.toString(); + }); + }; + + $scope.close = function () { + $uibModalInstance.close(); + }; + + $scope.cancel = function () { + $uibModalInstance.dismiss(); + }; + }]); diff --git a/seed/static/seed/js/controllers/inventory_cycles_controller.js b/seed/static/seed/js/controllers/inventory_cycles_controller.js index 8642ce7b91..706ca429f9 100644 --- a/seed/static/seed/js/controllers/inventory_cycles_controller.js +++ b/seed/static/seed/js/controllers/inventory_cycles_controller.js @@ -44,7 +44,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) $scope.currentProfile = current_profile; // Scope columns/data to only those of the given inventory_type - var state_type = $scope.inventory_type == "properties" ? "PropertyState" : "TaxLotState"; + var state_type = $scope.inventory_type == 'properties' ? 'PropertyState' : 'TaxLotState'; $scope.all_columns = _.filter(all_columns, {table_name: state_type}); // set up i18n @@ -61,13 +61,13 @@ angular.module('BE.seed.controller.inventory_cycles', []) $scope.included_cycle_ids = _.map(_.keys(inventory), function (cycle_id) { return parseInt(cycle_id); }); - $scope.cycle_options = _.map(cycles.cycles, function(cycle) { + $scope.cycle_options = _.map(cycles.cycles, function (cycle) { var selected = $scope.included_cycle_ids.includes(cycle.id); return { selected: selected, label: cycle.name, value: cycle.id, - start: cycle.start, + start: cycle.start }; }); @@ -84,15 +84,15 @@ angular.module('BE.seed.controller.inventory_cycles', []) }; // Takes raw cycle-partitioned records and returns array of cycle-aware records - $scope.format_records = function(raw_inventory) { - return _.reduce(raw_inventory, function(all_records, records, cycle_id) { + $scope.format_records = function (raw_inventory) { + return _.reduce(raw_inventory, function (all_records, records, cycle_id) { var cycle = _.find($scope.cycle_options, { value: parseInt(cycle_id) }); - _.forEach(records, function(record) { + _.forEach(records, function (record) { record.cycle_name = cycle.label; record.cycle_start = cycle.start; - all_records.push(record) - }) - return all_records + all_records.push(record); + }); + return all_records; }, []); }; @@ -100,16 +100,16 @@ angular.module('BE.seed.controller.inventory_cycles', []) $scope.data = $scope.format_records(inventory); // Refreshes inventory by making API call - $scope.refresh_objects = function() { + $scope.refresh_objects = function () { spinner_utility.show(); var profile_id = _.has($scope.currentProfile, 'id') ? $scope.currentProfile.id : undefined; - if ($scope.inventory_type == "properties") { - inventory_service.properties_cycle(profile_id, $scope.included_cycle_ids).then(function(refreshed_inventory) { + if ($scope.inventory_type == 'properties') { + inventory_service.properties_cycle(profile_id, $scope.included_cycle_ids).then(function (refreshed_inventory) { $scope.data = $scope.format_records(refreshed_inventory); spinner_utility.hide(); }); } else { - inventory_service.taxlots_cycle(profile_id, $scope.included_cycle_ids).then(function(refreshed_inventory) { + inventory_service.taxlots_cycle(profile_id, $scope.included_cycle_ids).then(function (refreshed_inventory) { $scope.data = $scope.format_records(refreshed_inventory); spinner_utility.hide(); }); @@ -117,34 +117,34 @@ angular.module('BE.seed.controller.inventory_cycles', []) }; // On profile change, refreshes objects and rebuild columns - $scope.profile_change = function() { + $scope.profile_change = function () { inventory_service.save_last_profile($scope.currentProfile.id, $scope.inventory_type); $scope.refresh_objects(); // uiGrid doesn't recognize complete columnDefs swap unless it's removed and refreshed and notified for each $scope.gridOptions.columnDefs = []; - $scope.gridApi.core.notifyDataChange(uiGridConstants.dataChange.COLUMN) + $scope.gridApi.core.notifyDataChange(uiGridConstants.dataChange.COLUMN); $scope.build_columns(); $scope.gridOptions.columnDefs = $scope.columns; - $scope.gridApi.core.notifyDataChange(uiGridConstants.dataChange.COLUMN) + $scope.gridApi.core.notifyDataChange(uiGridConstants.dataChange.COLUMN); }; // Agg function returning last value of matching criteria field (all should be the same if they match) - $scope.matching_field_value = function(aggregation, fieldValue) { + $scope.matching_field_value = function (aggregation, fieldValue) { aggregation.value = fieldValue; }; // matching_criteria_columns identified here to pin left on table - if ($scope.inventory_type == "properties") { + if ($scope.inventory_type == 'properties') { $scope.matching_criteria_columns = matching_criteria_columns.PropertyState; } else { $scope.matching_criteria_columns = matching_criteria_columns.TaxLotState; } // Builds columns with profile, default, and grouping settings - $scope.build_columns = function() { + $scope.build_columns = function () { $scope.columns = []; // Profile Settings @@ -167,7 +167,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) var column_def_defaults = { headerCellFilter: 'translate', minWidth: default_min_width, - width: 150, + width: 150 }; _.map($scope.columns, function (col) { @@ -185,7 +185,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) // Help indicate matching columns are given preferred sort priority col.displayName = col.displayName + '*'; - options.headerCellClass = "matching-column-header"; + options.headerCellClass = 'matching-column-header'; options.customTreeAggregationFn = $scope.matching_field_value; options.width = autopin_width; @@ -204,7 +204,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) visible: false, suppressRemoveSort: true, // since grouping relies on sorting minWidth: default_min_width, - width: autopin_width, + width: autopin_width }, { name: 'inventory detail link icon', @@ -222,31 +222,31 @@ angular.module('BE.seed.controller.inventory_cycles', []) enableSorting: false, pinnedLeft: true, visible: true, - width: 30, + width: 30 }, { - name: "cycle_name", - displayName: "Cycle", + name: 'cycle_name', + displayName: 'Cycle', pinnedLeft: true, treeAggregationType: uiGridGroupingConstants.aggregation.COUNT, customTreeAggregationFinalizerFn: function (aggregation) { - aggregation.rendered = "total cycles: " + aggregation.value; + aggregation.rendered = 'total cycles: ' + aggregation.value; }, minWidth: default_min_width, - width: autopin_width, + width: autopin_width }, { - name: "cycle_start", - displayName: "Cycle Start", + name: 'cycle_start', + displayName: 'Cycle Start', cellFilter: 'date:\'yyyy-MM-dd\'', filter: inventory_service.dateFilter(), type: 'date', sort: { priority: 1, direction: 'asc' }, pinnedLeft: true, minWidth: default_min_width, - width: autopin_width, - }, - ) + width: autopin_width + } + ); }; $scope.build_columns(); @@ -257,7 +257,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) // Lastly, non-matching columns are given next priority so that users can sort within a grouped set. if (sortColumns.length > 1) { var matching_cols = _.filter(sortColumns, function (col) { - return col.colDef.is_matching_criteria; + return col.colDef.is_matching_criteria; }); var linking_id_col = _.find(sortColumns, ['name', 'id']); var remaining_cols = _.filter(sortColumns, function (col) { @@ -266,7 +266,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) sortColumns = matching_cols.concat(linking_id_col).concat(remaining_cols); _.forEach(sortColumns, function (col, index) { col.sort.priority = index; - }) + }); } }; @@ -283,8 +283,8 @@ angular.module('BE.seed.controller.inventory_cycles', []) $scope.gridApi.core.on.filterChanged($scope, function () { // This is a workaround for losing the state of expanded rows during filtering. - _.delay($scope.gridApi.treeBase.expandAllRows, 500); - }) + _.delay($scope.gridApi.treeBase.expandAllRows, 500); + }); // Prioritized to maintain grouping. $scope.gridApi.core.on.sortChanged($scope, prioritize_sort); @@ -296,7 +296,7 @@ angular.module('BE.seed.controller.inventory_cycles', []) $scope.$on('$destroy', function () { angular.element($window).off('resize', debouncedHeightUpdate); }); - }, + } }; $scope.updateHeight = function () { diff --git a/seed/static/seed/js/controllers/inventory_detail_controller.js b/seed/static/seed/js/controllers/inventory_detail_controller.js index d53c4d7e47..da3e906bb6 100644 --- a/seed/static/seed/js/controllers/inventory_detail_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_controller.js @@ -115,7 +115,7 @@ angular.module('BE.seed.controller.inventory_detail', []) } }; - function populated_columns_modal () { + function populated_columns_modal() { $uibModal.open({ backdrop: 'static', templateUrl: urls.static_url + 'seed/partials/show_populated_columns_modal.html', @@ -127,9 +127,7 @@ angular.module('BE.seed.controller.inventory_detail', []) currentProfile: function () { return $scope.currentProfile; }, - cycle: function () { - return null; - }, + cycle: _.constant(null), inventory_type: function () { return $stateParams.inventory_type; }, @@ -144,7 +142,7 @@ angular.module('BE.seed.controller.inventory_detail', []) }); // add "master" copy - item_copy = angular.copy($scope.item_state); + var item_copy = angular.copy($scope.item_state); _.defaults(item_copy, $scope.item_state.extra_data); return provided_inventory; @@ -500,16 +498,37 @@ angular.module('BE.seed.controller.inventory_detail', []) }; $scope.export_building_sync = function () { - var the_url = '/api/v2_1/properties/' + $stateParams.view_id + '/building_sync/'; - $http.get(the_url, {}) - .then(function (response) { - var blob = new Blob([response.data], {type: 'application/xml;charset=utf-8;'}); - var downloadLink = angular.element(''); - var filename = 'buildingsync_property_' + $stateParams.view_id + '.xml'; - downloadLink.attr('href', $window.URL.createObjectURL(blob)); - downloadLink.attr('download', filename); - downloadLink[0].click(); - }); + var modalInstance = $uibModal.open({ + templateUrl: urls.static_url + 'seed/partials/export_buildingsync_modal.html', + controller: 'export_buildingsync_modal_controller', + resolve: { + property_view_id: function () { + return $stateParams.view_id; + }, + column_mapping_presets: [ + 'column_mappings_service', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM', + function ( + column_mappings_service, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM + ) { + var filter_preset_types = [ + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM + ]; + return column_mappings_service.get_column_mapping_presets_for_org( + $scope.organization.id, + filter_preset_types + ).then(function (response) { + return response.data; + }); + }] + } + }); + modalInstance.result.then(function () { + }); }; $scope.export_building_sync_xlsx = function () { diff --git a/seed/static/seed/js/controllers/inventory_detail_cycles_controller.js b/seed/static/seed/js/controllers/inventory_detail_cycles_controller.js index fbe850b55c..e45b479abc 100644 --- a/seed/static/seed/js/controllers/inventory_detail_cycles_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_cycles_controller.js @@ -30,19 +30,19 @@ angular.module('BE.seed.controller.inventory_detail_cycles', []) ) { $scope.inventory_type = $stateParams.inventory_type; $scope.inventory = { - view_id: $stateParams.view_id, + view_id: $stateParams.view_id }; $scope.states = inventory_payload.data; $scope.base_state = _.find(inventory_payload.data, {view_id: $stateParams.view_id}); - $scope.cycles = _.reduce(cycles.cycles, function(cycles_by_id, cycle) { + $scope.cycles = _.reduce(cycles.cycles, function (cycles_by_id, cycle) { cycles_by_id[cycle.id] = cycle; return cycles_by_id; }, {}); // Flag columns whose values have changed between cycles. - var changes_check = function(column) { + var changes_check = function (column) { var uniq_column_values; if (column.is_extra_data) { @@ -53,7 +53,7 @@ angular.module('BE.seed.controller.inventory_detail_cycles', []) uniq_column_values = _.uniqBy($scope.states, column.column_name); } - column['changed'] = uniq_column_values.length > 1; + column.changed = uniq_column_values.length > 1; return column; }; @@ -69,7 +69,7 @@ angular.module('BE.seed.controller.inventory_detail_cycles', []) }); } else { // No profiles exist - $scope.columns = _.map(_.reject(columns, 'is_extra_data'), function(col) { + $scope.columns = _.map(_.reject(columns, 'is_extra_data'), function (col) { return changes_check(col); }); } @@ -89,7 +89,7 @@ angular.module('BE.seed.controller.inventory_detail_cycles', []) // Horizontal scroll for "2 tables" that scroll together for fixed header effect. var table_container = $('.table-xscroll-fixed-header-container'); - table_container.scroll(function() { + table_container.scroll(function () { $('.table-xscroll-fixed-header-container > .table-body-x-scroll').width( table_container.width() + table_container.scrollLeft() ); diff --git a/seed/static/seed/js/controllers/inventory_detail_meters_controller.js b/seed/static/seed/js/controllers/inventory_detail_meters_controller.js index ae086ffc7d..00ec61db36 100644 --- a/seed/static/seed/js/controllers/inventory_detail_meters_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_meters_controller.js @@ -32,30 +32,32 @@ angular.module('BE.seed.controller.inventory_detail_meters', []) spinner_utility, urls, user_service, - $log, + $log ) { spinner_utility.show(); $scope.item_state = inventory_payload.state; $scope.inventory_type = $stateParams.inventory_type; $scope.organization = user_service.get_organization(); $scope.filler_cycle = cycles.cycles[0].id; - $scope.scenarios = _.uniqBy(_.map(meters, function(meter) { + $scope.scenarios = _.uniqBy(_.map(meters, function (meter) { return { id: meter.scenario_id, name: meter.scenario_name - } - }), 'id').filter(scenario => scenario.id !== undefined && scenario.id !== null) + }; + }), 'id').filter(function (scenario) { + return !_.isNil(scenario.id); + }); $scope.inventory = { view_id: $stateParams.view_id }; - const getMeterLabel = (meter) => { - return meter.type + ' - ' + meter.source + ' - ' + meter.source_id - } + var getMeterLabel = function (meter) { + return meter.type + ' - ' + meter.source + ' - ' + meter.source_id; + }; - const resetSelections = () => { - $scope.meter_selections = _.map(sorted_meters, function(meter) { + var resetSelections = function () { + $scope.meter_selections = _.map(sorted_meters, function (meter) { return { selected: true, label: getMeterLabel(meter), @@ -63,14 +65,14 @@ angular.module('BE.seed.controller.inventory_detail_meters', []) }; }); - $scope.scenario_selections = _.map($scope.scenarios, function(scenario) { + $scope.scenario_selections = _.map($scope.scenarios, function (scenario) { return { selected: true, label: scenario.name, value: scenario.id - } + }; }); - } + }; // On page load, all meters and readings $scope.data = property_meter_usage.readings; @@ -85,9 +87,9 @@ angular.module('BE.seed.controller.inventory_detail_meters', []) } }; - $scope.scenario_selection_toggled = (is_open) => { + $scope.scenario_selection_toggled = function (is_open) { if (!is_open) { - $scope.applyFilters() + $scope.applyFilters(); } }; @@ -150,70 +152,85 @@ angular.module('BE.seed.controller.inventory_detail_meters', []) }; // remove option to filter by scenario if there are no scenarios if ($scope.scenarios.length === 0) { - $scope.filterMethod.options = ['meter'] + $scope.filterMethod.options = ['meter']; } // given a list of meter labels, it returns the filtered readings and column defs // This is used by the primary filterBy... functions - const filterByMeterLabels = (readings, columnDefs, meterLabels) => { - const timeColumns = ['start_time', 'end_time', 'month', 'year'] - const selectedColumns = meterLabels.concat(timeColumns) + var filterByMeterLabels = function filterByMeterLabels (readings, columnDefs, meterLabels) { + var timeColumns = ['start_time', 'end_time', 'month', 'year']; + var selectedColumns = meterLabels.concat(timeColumns); + var filteredReadings = readings.map(function (reading) { + return Object.entries(reading).reduce(function (newReading, _ref) { + var key = _ref[0], + value = _ref[1]; - const filteredReadings = readings.map(reading => Object.entries(reading).reduce((newReading, [key, value]) => { - if (selectedColumns.includes(key)) { - newReading[key] = value; - } - return newReading; - }, {})); + if (selectedColumns.includes(key)) { + newReading[key] = value; + } - const filteredColumnDefs = columnDefs.filter(columnDef => selectedColumns.includes(columnDef.field)) - return { readings: filteredReadings, columnDefs: filteredColumnDefs} - } + return newReading; + }, {}); + }); + var filteredColumnDefs = columnDefs.filter(function (columnDef) { + return selectedColumns.includes(columnDef.field); + }); + return { + readings: filteredReadings, + columnDefs: filteredColumnDefs + }; + }; // given the meter selections, it returns the filtered readings and column defs - const filterByMeterSelections = (readings, columnDefs, meterSelections) => { + var filterByMeterSelections = function (readings, columnDefs, meterSelections) { // filter according to meter selections - const selectedMeterLabels = meterSelections.filter(selection => selection.selected) - .map(selection => selection.label); + var selectedMeterLabels = meterSelections.filter(function (selection) { + return selection.selected; + }) + .map(function (selection) { + return selection.label; + }); - return filterByMeterLabels(readings, columnDefs, selectedMeterLabels) - } + return filterByMeterLabels(readings, columnDefs, selectedMeterLabels); + }; // given the scenario selections, it returns the filtered readings and column defs - const filterByScenarioSelections = (readings, columnDefs, meters, scenarioSelections) => { - const selectedScenarioIds = scenarioSelections.filter(selection => selection.selected).map(selection => selection.value); - const selectedMeterLabels = meters.filter(meter => selectedScenarioIds.includes(meter.scenario_id)) - .map(meter => getMeterLabel(meter)) + var filterByScenarioSelections = function (readings, columnDefs, meters, scenarioSelections) { + var selectedScenarioIds = scenarioSelections.filter(function (selection) { + return selection.selected; + }).map(function (selection) { + return selection.value; + }); + var selectedMeterLabels = meters.filter(function (meter) { + return selectedScenarioIds.includes(meter.scenario_id); + }).map(function (meter) { + return getMeterLabel(meter); + }); - return filterByMeterLabels(readings, columnDefs, selectedMeterLabels) - } + return filterByMeterLabels(readings, columnDefs, selectedMeterLabels); + }; // filters the meter readings by selected meters or scenarios and updates the table - $scope.applyFilters = () => { - let readings, columnDefs; + $scope.applyFilters = function () { + var results, readings, columnDefs; if ($scope.filterMethod.selected === 'meter') { - ({readings, columnDefs} = filterByMeterSelections( - property_meter_usage.readings, - property_meter_usage.column_defs, - $scope.meter_selections - )) + results = filterByMeterSelections(property_meter_usage.readings, property_meter_usage.column_defs, $scope.meter_selections); + readings = results.readings; + columnDefs = results.columnDefs; } else if ($scope.filterMethod.selected === 'scenario') { - ({readings, columnDefs} = filterByScenarioSelections( - property_meter_usage.readings, - property_meter_usage.column_defs, - sorted_meters, - $scope.scenario_selections - )) + results = filterByScenarioSelections(property_meter_usage.readings, property_meter_usage.column_defs, sorted_meters, $scope.scenario_selections); + readings = results.readings; + columnDefs = results.columnDefs; } else { - $log.error("Invalid filter method selected: ", $scope.filterMethod) - return + $log.error('Invalid filter method selected: ', $scope.filterMethod); + return; } $scope.data = readings; $scope.gridOptions.columnDefs = columnDefs; $scope.has_readings = $scope.data.length > 0; $scope.apply_column_settings(); - } + }; // refresh_readings make an API call to refresh the base readings data // according to the selected interval diff --git a/seed/static/seed/js/controllers/inventory_detail_notes_controller.js b/seed/static/seed/js/controllers/inventory_detail_notes_controller.js index ae3e9d204d..5211861534 100644 --- a/seed/static/seed/js/controllers/inventory_detail_notes_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_notes_controller.js @@ -70,7 +70,7 @@ angular.module('BE.seed.controller.inventory_detail_notes', []) return $scope.org_id; }, note: _.constant({text: ''}), - action: _.constant('new'), + action: _.constant('new') } }); @@ -97,7 +97,7 @@ angular.module('BE.seed.controller.inventory_detail_notes', []) note: function () { return note; }, - action: _.constant('update'), + action: _.constant('update') } }); @@ -124,7 +124,7 @@ angular.module('BE.seed.controller.inventory_detail_notes', []) note: function () { return note; }, - action: _.constant('delete'), + action: _.constant('delete') } }); diff --git a/seed/static/seed/js/controllers/inventory_detail_notes_modal_controller.js b/seed/static/seed/js/controllers/inventory_detail_notes_modal_controller.js index 4a1a549ee4..1fa720e4b8 100644 --- a/seed/static/seed/js/controllers/inventory_detail_notes_modal_controller.js +++ b/seed/static/seed/js/controllers/inventory_detail_notes_modal_controller.js @@ -36,7 +36,7 @@ angular.module('BE.seed.controller.inventory_detail_notes_modal', []) var data = { name: 'Manually Created', note_type: 'Note', - text: note.text, + text: note.text }; note_service.create_note($scope.orgId, $scope.inventoryType, $scope.viewId, data).then(function () { $uibModalInstance.close(); @@ -47,7 +47,7 @@ angular.module('BE.seed.controller.inventory_detail_notes_modal', []) var data = { name: note.name, note_type: note.note_type, - text: note.text, + text: note.text }; note_service.update_note($scope.orgId, $scope.inventoryType, $scope.viewId, note.id, data).then(function () { $uibModalInstance.close(); diff --git a/seed/static/seed/js/controllers/inventory_list_controller.js b/seed/static/seed/js/controllers/inventory_list_controller.js index 3271a5c485..aefe8d6218 100644 --- a/seed/static/seed/js/controllers/inventory_list_controller.js +++ b/seed/static/seed/js/controllers/inventory_list_controller.js @@ -59,7 +59,7 @@ angular.module('BE.seed.controller.inventory_list', []) $scope.data = []; var lastCycleId = inventory_service.get_last_cycle(); $scope.cycle = { - selected_cycle: _.find(cycles.cycles, {id: lastCycleId}) || _.first(cycles.cycles), + selected_cycle: _.find(cycles.cycles, {id: lastCycleId}) || _.first(cycles.cycles), cycles: cycles.cycles }; @@ -155,7 +155,7 @@ angular.module('BE.seed.controller.inventory_list', []) } }; - function populated_columns_modal() { + function populated_columns_modal () { $uibModal.open({ backdrop: 'static', templateUrl: urls.static_url + 'seed/partials/show_populated_columns_modal.html', @@ -173,9 +173,7 @@ angular.module('BE.seed.controller.inventory_list', []) inventory_type: function () { return $stateParams.inventory_type; }, - provided_inventory: function () { - return null; - } + provided_inventory: _.constant(null) } }); } @@ -192,7 +190,7 @@ angular.module('BE.seed.controller.inventory_list', []) }); }; - function updateApplicableLabels(current_labels) { + function updateApplicableLabels (current_labels) { var inventoryIds; if ($scope.inventory_type === 'properties') { inventoryIds = _.map($scope.data, 'property_view_id').sort(); @@ -829,7 +827,7 @@ angular.module('BE.seed.controller.inventory_list', []) }); }; - function currentColumns() { + function currentColumns () { // Save all columns except first 3 var gridCols = _.filter($scope.gridApi.grid.columns, function (col) { return !_.includes(['treeBaseRowHeaderCol', 'selectionRowHeaderCol', 'notes_count', 'merged_indicator', 'id'], col.name) && col.visible; diff --git a/seed/static/seed/js/controllers/inventory_map_controller.js b/seed/static/seed/js/controllers/inventory_map_controller.js index 724e5f8ff5..8d13575d21 100644 --- a/seed/static/seed/js/controllers/inventory_map_controller.js +++ b/seed/static/seed/js/controllers/inventory_map_controller.js @@ -541,7 +541,7 @@ angular.module('BE.seed.controller.inventory_map', []) }); }; - function updateApplicableLabels() { + function updateApplicableLabels () { var inventoryIds; if ($scope.inventory_type === 'properties') { inventoryIds = _.map($scope.data, 'property_view_id').sort(); diff --git a/seed/static/seed/js/controllers/inventory_reports_controller.js b/seed/static/seed/js/controllers/inventory_reports_controller.js index 2ab0c5d0ac..44329d4093 100644 --- a/seed/static/seed/js/controllers/inventory_reports_controller.js +++ b/seed/static/seed/js/controllers/inventory_reports_controller.js @@ -318,7 +318,7 @@ angular.module('BE.seed.controller.inventory_reports', []) xVar: $scope.chartData.xAxisVarName, xLabel: $scope.chartData.xAxisTitle, yVar: $scope.chartData.yAxisVarName, - yLabel: $scope.chartData.yAxisTitle, + yLabel: $scope.chartData.yAxisTitle }; }, cycle_start: function () { @@ -326,7 +326,7 @@ angular.module('BE.seed.controller.inventory_reports', []) }, cycle_end: function () { return $scope.toCycle.selected_cycle.end; - }, + } } }); }; @@ -405,7 +405,7 @@ angular.module('BE.seed.controller.inventory_reports', []) $scope.toCycle.selected_cycle.end ).then(function (data) { data = data.aggregated_data; - console.log(data); + $log.log(data); $scope.aggPropertyCounts = data.property_counts; var propertyCounts = data.property_counts; var colorsArr = mapColors(propertyCounts); @@ -440,7 +440,7 @@ angular.module('BE.seed.controller.inventory_reports', []) localStorage.setItem(localStorageFromCycleKey, JSON.stringify($scope.fromCycle.selected_cycle)); localStorage.setItem(localStorageToCycleKey, JSON.stringify($scope.toCycle.selected_cycle)); - }; + } /* Generate an array of color objects to be used as part of chart configuration Each color object should have the following properties: diff --git a/seed/static/seed/js/controllers/mapping_controller.js b/seed/static/seed/js/controllers/mapping_controller.js index 0fdcfb7783..86dea1a800 100644 --- a/seed/static/seed/js/controllers/mapping_controller.js +++ b/seed/static/seed/js/controllers/mapping_controller.js @@ -30,6 +30,9 @@ angular.module('BE.seed.controller.mapping', []) 'i18nService', // from ui-grid 'simple_modal_service', 'Notification', + 'COLUMN_MAPPING_PRESET_TYPE_NORMAL', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM', function ( $scope, $log, @@ -56,10 +59,13 @@ angular.module('BE.seed.controller.mapping', []) $translate, i18nService, simple_modal_service, - Notification + Notification, + COLUMN_MAPPING_PRESET_TYPE_NORMAL, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM ) { $scope.presets = [ - {id: 0, mappings: [], name: ""} + {id: 0, mappings: [], name: ''} ].concat(column_mapping_presets_payload); // $scope.selected_preset = $scope.applied_preset = $scope.mock_presets[0]; @@ -76,6 +82,16 @@ angular.module('BE.seed.controller.mapping', []) $scope.mappings_change_possible = true; }; + $scope.is_buildingsync_and_preset_not_ok = function () { + if (!$scope.mappingBuildingSync) { + return false; + } + // BuildingSync requires a saved preset to be applied + return $scope.current_preset.id === 0 + || $scope.preset_change_possible + || $scope.mappings_change_possible; + }; + var analyze_chosen_inventory_types = function () { var chosenTypes = _.uniq(_.map($scope.mappings, 'suggestion_table_name')); var all_cols_have_table_name = !_.find($scope.mappings, {suggestion_table_name: undefined}); @@ -113,13 +129,22 @@ angular.module('BE.seed.controller.mapping', []) var preset_mappings_from_working_mappings = function () { // for to_field, try DB col name, if not use col display name return _.reduce($scope.mappings, function (preset_mapping_data, mapping) { - preset_mapping_data.push({ + var this_mapping = { from_field: mapping.name, from_units: mapping.from_units, to_field: mapping.suggestion_column_name || mapping.suggestion || '', to_table_name: mapping.suggestion_table_name - }); - + }; + var isBuildingSyncPreset = $scope.current_preset.preset_type !== undefined + && [ + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM + ].includes($scope.current_preset.preset_type); + + if (isBuildingSyncPreset) { + this_mapping.from_field_value = mapping.from_field_value; + } + preset_mapping_data.push(this_mapping); return preset_mapping_data; }, []); }; @@ -127,12 +152,43 @@ angular.module('BE.seed.controller.mapping', []) $scope.new_preset = function () { var preset_mapping_data = preset_mappings_from_working_mappings(); + var presetType; + if (!$scope.mappingBuildingSync) { + presetType = COLUMN_MAPPING_PRESET_TYPE_NORMAL; + } else { + presetType = COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM; + + // make sure the new preset mapping data has the required data + var currentPresetForBuildingSync = + $scope.current_preset.preset_type !== undefined + && [ + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM + ].includes($scope.current_preset.preset_type); + + if (!currentPresetForBuildingSync) { + // we need to add mapping data, from_field_value, using the default mapping + var defaultPreset = $scope.presets.find(function (preset) { + return preset.preset_type === COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT; + }); + preset_mapping_data = preset_mapping_data.map(function (mapping) { + // find the corresponding mapping in the default preset + var defaultMapping = defaultPreset.mappings.find(function (defaultMapping) { + return defaultMapping.from_field === mapping.from_field; + }); + return _.merge({}, mapping, { + from_field_value: defaultMapping.from_field_value + }); + }); + } + } + var modalInstance = $uibModal.open({ templateUrl: urls.static_url + 'seed/partials/column_mapping_preset_modal.html', controller: 'column_mapping_preset_modal_controller', resolve: { action: _.constant('new'), - data: _.constant({mappings: preset_mapping_data}), + data: _.constant({mappings: preset_mapping_data, preset_type: presetType}), org_id: _.constant(user_service.get_organization().id) } }); @@ -197,6 +253,8 @@ angular.module('BE.seed.controller.mapping', []) $scope.isValidCycle = Boolean(_.find(cycles.cycles, {id: $scope.import_file.cycle})); + $scope.mappingBuildingSync = $scope.import_file.source_type === 'BuildingSync Raw'; + matching_criteria_columns_payload.PropertyState = _.map(matching_criteria_columns_payload.PropertyState, function (column_name) { var display_name = _.find($scope.mappable_property_columns, {column_name: column_name}).display_name; return { @@ -219,10 +277,13 @@ angular.module('BE.seed.controller.mapping', []) $scope.setAllFieldsOptions = [{ name: 'Property', value: 'PropertyState' - }, { - name: 'Tax Lot', - value: 'TaxLotState' }]; + if (!$scope.mappingBuildingSync) { + $scope.setAllFieldsOptions.push({ + name: 'Tax Lot', + value: 'TaxLotState' + }); + } var eui_columns = _.filter($scope.mappable_property_columns, {data_type: 'eui'}); $scope.is_eui_column = function (col) { @@ -331,29 +392,20 @@ angular.module('BE.seed.controller.mapping', []) $scope.duplicates_present = duplicates_present; }; - const get_col_from_suggestion = (name) => { - if ($scope.import_file.source_type === "BuildingSync Raw") { - const suggestion = $scope.suggested_mappings[name]; + var get_col_from_suggestion = function (name) { - return { - name: name, - suggestion_column_name: suggestion[1], - suggestion_table_name: suggestion[0], - raw_data: _.map(first_five_rows_payload.first_five_rows, name) - }; - } - - const suggestion = _.find($scope.current_preset.mappings, {from_field: name}) || {}; + var suggestion = _.find($scope.current_preset.mappings, {from_field: name}) || {}; return { from_units: suggestion.from_units, name: name, + from_field_value: suggestion.from_field_value, raw_data: _.map(first_five_rows_payload.first_five_rows, name), suggestion: suggestion.to_field, suggestion_column_name: suggestion.to_field, suggestion_table_name: suggestion.to_table_name }; - } + }; /** * initialize_mappings: prototypical inheritance for all the raw columns @@ -362,7 +414,7 @@ angular.module('BE.seed.controller.mapping', []) $scope.initialize_mappings = function () { $scope.mappings = []; _.forEach($scope.raw_columns, function (name) { - let col = get_col_from_suggestion(name); + var col = get_col_from_suggestion(name); var match; if (col.suggestion_table_name === 'PropertyState') { @@ -378,7 +430,7 @@ angular.module('BE.seed.controller.mapping', []) } if (match) { col.suggestion = match.display_name; - } else if ($scope.import_file.source_type === "BuildingSync Raw") { + } else if ($scope.mappingBuildingSync) { col.suggestion = $filter('titleCase')(col.suggestion_column_name); } @@ -759,7 +811,9 @@ angular.module('BE.seed.controller.mapping', []) var init = function () { $scope.initialize_mappings(); - if ($scope.import_file.matching_done) { display_cached_column_mappings(); } + if ($scope.import_file.matching_done) { + display_cached_column_mappings(); + } $scope.updateColDuplicateStatus(); $scope.updateInventoryTypeDropdown(); diff --git a/seed/static/seed/js/controllers/members_controller.js b/seed/static/seed/js/controllers/members_controller.js index 2a79bf20ce..63680fe0a5 100644 --- a/seed/static/seed/js/controllers/members_controller.js +++ b/seed/static/seed/js/controllers/members_controller.js @@ -63,7 +63,7 @@ angular.module('BE.seed.controller.members', []) } }; - function confirm_remove_user(user) { + function confirm_remove_user (user) { organization_service.remove_user(user.user_id, $scope.org.id).then(function () { organization_service.get_organization_users({org_id: $scope.org.id}).then(function (data) { $scope.users = data.users; diff --git a/seed/static/seed/js/controllers/menu_controller.js b/seed/static/seed/js/controllers/menu_controller.js index d3e668eeb6..9d1c7d4037 100644 --- a/seed/static/seed/js/controllers/menu_controller.js +++ b/seed/static/seed/js/controllers/menu_controller.js @@ -88,14 +88,14 @@ angular.module('BE.seed.controller.menu', []) }; //Sets initial expanded/collapse state of sidebar menu - function init_menu() { + function init_menu () { //Default to false but use cookie value if one has been set var isNavExpanded = $cookies.seed_nav_is_expanded === 'true'; $scope.expanded_controller = isNavExpanded; $scope.collapsed_controller = !isNavExpanded; $scope.narrow_controller = isNavExpanded; $scope.wide_controller = !isNavExpanded; - }; + } // returns true if menu toggle has never been clicked, i.e. first run, else returns false $scope.menu_toggle_has_never_been_clicked = function () { diff --git a/seed/static/seed/js/controllers/merge_modal_controller.js b/seed/static/seed/js/controllers/merge_modal_controller.js index eaf90f9fcf..e874c77123 100644 --- a/seed/static/seed/js/controllers/merge_modal_controller.js +++ b/seed/static/seed/js/controllers/merge_modal_controller.js @@ -111,15 +111,15 @@ angular.module('BE.seed.controller.merge_modal', []) var plural = ($scope.inventory_type === 'properties' ? ' properties' : ' tax lots'); // The term "subsequent" below implies not including itself var merged_count = Math.max(result.match_merged_count - 1, 0); - var link_count = result.match_link_count; + var link_count = result.match_link_count; Notification.info({ message: (merged_count + ' subsequent ' + (merged_count === 1 ? singular : plural) + ' merged'), - delay: 10000, + delay: 10000 }); Notification.info({ message: ('Resulting ' + singular + ' has ' + link_count + ' cross-cycle link' + (link_count === 1 ? '' : 's')), - delay: 10000, + delay: 10000 }); }; @@ -136,8 +136,8 @@ angular.module('BE.seed.controller.merge_modal', []) }, headers: function () { return { - properties: "The resulting property will be further merged & linked with any matching properties.", - taxlots: "The resulting tax lot will be further merged & linked with any matching tax lots.", + properties: 'The resulting property will be further merged & linked with any matching properties.', + taxlots: 'The resulting tax lot will be further merged & linked with any matching tax lots.' }; } } @@ -155,7 +155,7 @@ angular.module('BE.seed.controller.merge_modal', []) state_ids = _.map($scope.data, 'property_state_id').reverse(); return matching_service.mergeProperties(state_ids).then(function (data) { Notification.success('Successfully merged ' + state_ids.length + ' properties'); - notify_merges_and_links(data) + notify_merges_and_links(data); $scope.close(); }, function (err) { $log.error(err); @@ -167,7 +167,7 @@ angular.module('BE.seed.controller.merge_modal', []) state_ids = _.map($scope.data, 'taxlot_state_id').reverse(); return matching_service.mergeTaxlots(state_ids).then(function (data) { Notification.success('Successfully merged ' + state_ids.length + ' tax lots'); - notify_merges_and_links(data) + notify_merges_and_links(data); $scope.close(); }, function (err) { $log.error(err); diff --git a/seed/static/seed/js/controllers/record_match_merge_link_modal_controller.js b/seed/static/seed/js/controllers/record_match_merge_link_modal_controller.js index 455cb86119..acd72b28e1 100644 --- a/seed/static/seed/js/controllers/record_match_merge_link_modal_controller.js +++ b/seed/static/seed/js/controllers/record_match_merge_link_modal_controller.js @@ -33,33 +33,33 @@ angular.module('BE.seed.controller.record_match_merge_link_modal', []) $scope.helpBtnText = 'Expand Help'; - $scope.changeHelpBtnText = function(helpBtnText) { - if(helpBtnText === 'Collapse Help'){ - $scope.helpBtnText = 'Expand Help' ; + $scope.changeHelpBtnText = function (helpBtnText) { + if (helpBtnText === 'Collapse Help') { + $scope.helpBtnText = 'Expand Help'; } else { $scope.helpBtnText = 'Collapse Help'; } }; - promises = [ - organization_service.matching_criteria_columns(organization_id), + var promises = [ + organization_service.matching_criteria_columns(organization_id) ]; if (inventory_type === 'properties') { - promises.unshift(inventory_service.get_property_columns()) + promises.unshift(inventory_service.get_property_columns()); } else if (inventory_type === 'taxlots') { - promises.unshift(inventory_service.get_taxlot_columns()) + promises.unshift(inventory_service.get_taxlot_columns()); } - $scope.matching_criteria_columns = ["Loading..."]; + $scope.matching_criteria_columns = ['Loading...']; - $q.all(promises).then(function(results) { - var inventory_columns = _.filter(results[0], { table_name: $scope.table_name }); + $q.all(promises).then(function (results) { + var inventory_columns = _.filter(results[0], {table_name: $scope.table_name}); var raw_column_names = results[1][$scope.table_name]; // Use display names to identify matching criteria columns. - $scope.matching_criteria_columns = _.map(raw_column_names, function(col_name) { - return _.find(inventory_columns, { column_name: col_name }).displayName; + $scope.matching_criteria_columns = _.map(raw_column_names, function (col_name) { + return _.find(inventory_columns, {column_name: col_name}).displayName; }); }); diff --git a/seed/static/seed/js/controllers/show_populated_columns_modal_controller.js b/seed/static/seed/js/controllers/show_populated_columns_modal_controller.js index 0198b76717..e6b87b7990 100644 --- a/seed/static/seed/js/controllers/show_populated_columns_modal_controller.js +++ b/seed/static/seed/js/controllers/show_populated_columns_modal_controller.js @@ -58,7 +58,7 @@ angular.module('BE.seed.controller.show_populated_columns_modal', []) var relatedCols = _.filter($scope.columns, 'related'); // console.log('relatedCols', relatedCols); - var col_key = provided_inventory ? "column_name" : "name"; + var col_key = provided_inventory ? 'column_name' : 'name'; _.forEach(inventory, function (record, index) { // console.log(cols.length + ' remaining cols to check'); diff --git a/seed/static/seed/js/seed.js b/seed/static/seed/js/seed.js index 4f41f331eb..78d5ffa60e 100644 --- a/seed/static/seed/js/seed.js +++ b/seed/static/seed/js/seed.js @@ -55,6 +55,7 @@ angular.module('BE.seed.controllers', [ 'BE.seed.controller.delete_modal', 'BE.seed.controller.delete_user_modal', 'BE.seed.controller.developer', + 'BE.seed.controller.export_buildingsync_modal', 'BE.seed.controller.export_report_modal', 'BE.seed.controller.export_inventory_modal', 'BE.seed.controller.geocode_modal', @@ -152,7 +153,8 @@ var SEED_app = angular.module('BE.seed', [ 'BE.seed.directives', 'BE.seed.services', 'BE.seed.controllers', - 'BE.seed.utilities' + 'BE.seed.utilities', + 'BE.seed.constants' ], ['$interpolateProvider', '$qProvider', function ($interpolateProvider, $qProvider) { $interpolateProvider.startSymbol('{$'); $interpolateProvider.endSymbol('$}'); @@ -239,7 +241,6 @@ SEED_app.run([ // Revert the url when the transition was triggered by a sidebar link (options.source === 'url') if (transition.options().source === 'url') { - var $state = transition.router.stateService; var $urlRouter = transition.router.urlRouter; $urlRouter.push($state.$current.navigable.url, $state.params, {replace: true}); @@ -529,12 +530,37 @@ SEED_app.config(['stateHelperProvider', '$urlRouterProvider', '$locationProvider templateUrl: static_url + 'seed/partials/mapping.html', controller: 'mapping_controller', resolve: { - column_mapping_presets_payload: ['column_mappings_service', 'user_service', function (column_mappings_service, user_service) { - var organization_id = user_service.get_organization().id; - return column_mappings_service.get_column_mapping_presets_for_org(organization_id).then(function (response) { - return response.data; - }); - }], + column_mapping_presets_payload: [ + 'column_mappings_service', + 'user_service', + 'COLUMN_MAPPING_PRESET_TYPE_NORMAL', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT', + 'COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM', + 'import_file_payload', + function ( + column_mappings_service, + user_service, + COLUMN_MAPPING_PRESET_TYPE_NORMAL, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM, + import_file_payload) { + var filter_preset_types; + if (import_file_payload.import_file.source_type === 'BuildingSync Raw') { + filter_preset_types = [ + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_DEFAULT, + COLUMN_MAPPING_PRESET_TYPE_BUILDINGSYNC_CUSTOM + ]; + } else { + filter_preset_types = [COLUMN_MAPPING_PRESET_TYPE_NORMAL]; + } + var organization_id = user_service.get_organization().id; + return column_mappings_service.get_column_mapping_presets_for_org( + organization_id, + filter_preset_types + ).then(function (response) { + return response.data; + }); + }], import_file_payload: ['dataset_service', '$stateParams', function (dataset_service, $stateParams) { var importfile_id = $stateParams.importfile_id; return dataset_service.get_import_file(importfile_id); diff --git a/seed/static/seed/js/services/column_mappings_service.js b/seed/static/seed/js/services/column_mappings_service.js index f67bbd1890..d41ba178a7 100644 --- a/seed/static/seed/js/services/column_mappings_service.js +++ b/seed/static/seed/js/services/column_mappings_service.js @@ -28,11 +28,15 @@ angular.module('BE.seed.service.column_mappings', []).factory('column_mappings_s }); }; - column_mappings_factory.get_column_mapping_presets_for_org = function (org_id) { + column_mappings_factory.get_column_mapping_presets_for_org = function (org_id, filter_preset_types) { + var params = { + organization_id: org_id + }; + if (filter_preset_types != null) { + params.preset_type = filter_preset_types; + } return $http.get('/api/v2/column_mapping_presets/', { - params: { - organization_id: org_id - } + params: params }).then(function (response) { return response.data; }); @@ -54,10 +58,10 @@ angular.module('BE.seed.service.column_mappings', []).factory('column_mappings_s column_mappings_factory.get_header_suggestions_for_org = function (org_id, headers) { return $http.post('/api/v2/column_mapping_presets/suggestions/', { - headers: headers, + headers: headers }, { params: { - organization_id: org_id, + organization_id: org_id } }).then(function (response) { return response.data; diff --git a/seed/static/seed/js/services/inventory_reports_service.js b/seed/static/seed/js/services/inventory_reports_service.js index a82b3c65f6..f659ce091d 100644 --- a/seed/static/seed/js/services/inventory_reports_service.js +++ b/seed/static/seed/js/services/inventory_reports_service.js @@ -44,7 +44,7 @@ angular.module('BE.seed.service.inventory_reports', function get_report_data (xVar, yVar, start, end) { // Error checks - if (_.isNil(xVar) || _.isNil(yVar) || _.isNil(start) || _.isNil(end)) { + if (_.some([xVar, yVar, start, end], _.isNil)) { $log.error('#inventory_reports_service.get_report_data(): null parameter'); throw new Error('Invalid Parameter'); } @@ -97,7 +97,7 @@ angular.module('BE.seed.service.inventory_reports', function get_aggregated_report_data (xVar, yVar, start, end) { // Error checks - if (_.isNil(xVar) || _.isNil(yVar) || _.isNil(start) || _.isNil(end)) { + if (_.some([xVar, yVar, start, end], _.isNil)) { $log.error('#inventory_reports_service.get_aggregated_report_data(): null parameter'); throw new Error('Invalid Parameter'); } @@ -123,8 +123,8 @@ angular.module('BE.seed.service.inventory_reports', var xLabel = axes_data.xLabel; var yVar = axes_data.yVar; var yLabel = axes_data.yLabel; - // Error checks - if (_.isNil(xVar) || _.isNil(xLabel) || _.isNil(yVar) || _.isNil(yLabel) || _.isNil(start) || _.isNil(end)) { + // Error checks + if (_.some([xVar, xLabel, yVar, yLabel, start, end], _.isNil)) { $log.error('#inventory_reports_service.get_aggregated_report_data(): null parameter'); throw new Error('Invalid Parameter'); } @@ -137,11 +137,11 @@ angular.module('BE.seed.service.inventory_reports', y_var: yVar, y_label: yLabel, start: start, - end: end, + end: end }, responseType: 'arraybuffer' }).then(function (response) { - return response + return response; }); } @@ -158,7 +158,7 @@ angular.module('BE.seed.service.inventory_reports', //get_summary_data : get_summary_data, get_report_data: get_report_data, get_aggregated_report_data: get_aggregated_report_data, - export_reports_data: export_reports_data, + export_reports_data: export_reports_data }; diff --git a/seed/static/seed/js/services/inventory_service.js b/seed/static/seed/js/services/inventory_service.js index b03953cb45..165a77414d 100644 --- a/seed/static/seed/js/services/inventory_service.js +++ b/seed/static/seed/js/services/inventory_service.js @@ -54,7 +54,7 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ return $http.post('/api/v2/properties/cycles/', { organization_id: user_service.get_organization().id, profile_id: profile_id, - cycle_ids: cycle_ids, + cycle_ids: cycle_ids }).then(function (response) { return response.data; }); @@ -163,7 +163,7 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ spinner_utility.show(); return $http.post('/api/v2/properties/' + view_id + '/links/', { - organization_id: user_service.get_organization().id, + organization_id: user_service.get_organization().id }).then(function (response) { return response.data; }).finally(function () { @@ -288,7 +288,7 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ return $http.post('/api/v2/taxlots/cycles/', { organization_id: user_service.get_organization().id, profile_id: profile_id, - cycle_ids: cycle_ids, + cycle_ids: cycle_ids }).then(function (response) { return response.data; }); @@ -392,7 +392,7 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ spinner_utility.show(); return $http.post('/api/v2/taxlots/' + view_id + '/links/', { - organization_id: user_service.get_organization().id, + organization_id: user_service.get_organization().id }).then(function (response) { return response.data; }).finally(function () { diff --git a/seed/static/seed/js/services/label_service.js b/seed/static/seed/js/services/label_service.js index f05655b0d4..62cc8283e1 100644 --- a/seed/static/seed/js/services/label_service.js +++ b/seed/static/seed/js/services/label_service.js @@ -61,7 +61,7 @@ angular.module('BE.seed.service.label', []) return get_labels_for_org(user_service.get_organization().id, inventory_type, filter_ids); } - function get_labels_for_org (org_id, inventory_type, filter_ids) { + function get_labels_for_org(org_id, inventory_type, filter_ids) { var params = { organization_id: org_id }; @@ -78,7 +78,6 @@ angular.module('BE.seed.service.label', []) }).then(map_labels); } - /* Add a label to an organization's list of labels @param {object} label Label object to use for creating label on server. @@ -105,7 +104,6 @@ angular.module('BE.seed.service.label', []) }); } - /* Update an existing a label in an organization @param {object} label A label object with changed properties to update on server. diff --git a/seed/static/seed/js/services/main_service.js b/seed/static/seed/js/services/main_service.js index 241b6c5835..57ae150b4f 100644 --- a/seed/static/seed/js/services/main_service.js +++ b/seed/static/seed/js/services/main_service.js @@ -9,7 +9,7 @@ angular.module('BE.seed.service.main', []).factory('main_service', [ var main_factory = {}; main_factory.version = function () { - return $http.get('/api/v2/version').then(function (response) { + return $http.get('/api/v2/version/').then(function (response) { return response.data; }); }; diff --git a/seed/static/seed/js/services/organization_service.js b/seed/static/seed/js/services/organization_service.js index 30981cd76e..fe5315c24a 100644 --- a/seed/static/seed/js/services/organization_service.js +++ b/seed/static/seed/js/services/organization_service.js @@ -165,7 +165,7 @@ angular.module('BE.seed.service.organization', []).factory('organization_service organization_factory.match_merge_link = function (org_id, inventory_type) { return $http.post('/api/v2/organizations/' + org_id + '/match_merge_link/', { - inventory_type: inventory_type, + inventory_type: inventory_type }).then(function (response) { return response.data; }); @@ -181,7 +181,7 @@ angular.module('BE.seed.service.organization', []).factory('organization_service return $http.post('/api/v2/organizations/' + org_id + '/match_merge_link_preview/', { inventory_type: inventory_type, add: criteria_change_columns.add, - remove: criteria_change_columns.remove, + remove: criteria_change_columns.remove }).then(function (response) { return response.data; }); diff --git a/seed/static/seed/js/services/uploader_service.js b/seed/static/seed/js/services/uploader_service.js index 8df8013c1e..bc02e58d4e 100644 --- a/seed/static/seed/js/services/uploader_service.js +++ b/seed/static/seed/js/services/uploader_service.js @@ -39,6 +39,26 @@ angular.module('BE.seed.service.uploader', []).factory('uploader_service', [ }); }; + /** + * validate_use_cases + * This service call will simply call a view on the backend to validate + * BuildingSync files with use cases + * @param file_id: the pk of a ImportFile object we're going to save raw. + */ + uploader_factory.validate_use_cases = function (file_id) { + return $http.post('/api/v2/import_files/' + file_id + '/validate_use_cases/') + .then(response => { + return response.data + }) + .catch(err => { + if (err.data.status === 'error') { + return err.data + } + // something unexpected happened... throw it + throw err + }) + }; + /** * save_raw_data * This service call will simply call a view on the backend to save raw diff --git a/seed/static/seed/partials/column_mappings.html b/seed/static/seed/partials/column_mappings.html index 5cb6d4eee9..a9c8dc90b6 100644 --- a/seed/static/seed/partials/column_mappings.html +++ b/seed/static/seed/partials/column_mappings.html @@ -31,13 +31,13 @@

Column Mappings

{$:: 'Column Mapping Preset' | translate $}: - - - -
+
@@ -95,8 +95,10 @@

Column Mappings

+ {$:: 'Set all fields to' | translate $}: +

Mapped Fields

@@ -110,7 +112,7 @@

Column Mappings

Measurement Units Data File Header - + @@ -119,7 +121,7 @@

Column Mappings

@@ -130,14 +132,15 @@

Column Mappings

class="form-control input-sm tcm_field" ng-change="flag_change(col)" typeahead-on-select="seed_header_change(col)" - ng-attr-id="mapped-row-input-box-{$ $index $}"> + ng-attr-id="mapped-row-input-box-{$ $index $}" + ng-disabled="!preset_action_ok('change_to_field')"> - - @@ -146,13 +149,13 @@

Column Mappings

- + {$:: cell_value $} - @@ -160,7 +163,7 @@

Column Mappings

- diff --git a/seed/static/seed/partials/data_upload_modal.html b/seed/static/seed/partials/data_upload_modal.html index f0af0ca658..3964b8fbc0 100644 --- a/seed/static/seed/partials/data_upload_modal.html +++ b/seed/static/seed/partials/data_upload_modal.html @@ -12,7 +12,6 @@ - @@ -100,6 +99,18 @@