From ebd4aa864c84d4af7dbba417597c126e8bc23181 Mon Sep 17 00:00:00 2001 From: Damon Haley Date: Fri, 3 Nov 2023 10:43:15 -0600 Subject: [PATCH 01/29] Provide environment variable to disable wait-for-it condition in deployments (#4377) * disable wait-for-it in Stratus - managed services are always running. * change var STRATUS_MANAGED_SERVICES_ENABLED to DISABLE_SERVICE_CHECKS_ON_START --------- Co-authored-by: Damon Haley --- docker/start_celery_docker.sh | 56 +++++++++++++++++------------------ docker/start_uwsgi_docker.sh | 37 +++++++++++++---------- 2 files changed, 49 insertions(+), 44 deletions(-) diff --git a/docker/start_celery_docker.sh b/docker/start_celery_docker.sh index 954145dec2..d970d29d51 100755 --- a/docker/start_celery_docker.sh +++ b/docker/start_celery_docker.sh @@ -1,35 +1,35 @@ #!/bin/bash -cd /seed - -echo "Waiting for postgres to start" -if [ -v POSTGRES_HOST ]; -then - POSTGRES_ACTUAL_HOST=$POSTGRES_HOST +# Check if 'DISABLE_SERVICE_CHECKS_ON_START' is not set or if its value is 'TRUE' +if [[ "${DISABLE_SERVICE_CHECKS_ON_START}" == "on" ]]; then + echo "'DISABLE_SERVICE_CHECKS_ON_START' is set and equal to 'on'. Skipping wait-for-it.sh execution." else - POSTGRES_ACTUAL_HOST=db-postgres + cd /seed + + echo "Waiting for postgres to start" + if [ -v POSTGRES_HOST ]; then + POSTGRES_ACTUAL_HOST=$POSTGRES_HOST + else + POSTGRES_ACTUAL_HOST=db-postgres + fi + /usr/local/wait-for-it.sh --strict -t 0 $POSTGRES_ACTUAL_HOST:$POSTGRES_PORT + + echo "Waiting for redis to start" + if [ -v REDIS_HOST ]; then + REDIS_ACTUAL_HOST=$REDIS_HOST + else + REDIS_ACTUAL_HOST=db-redis + fi + /usr/local/wait-for-it.sh --strict -t 0 $REDIS_ACTUAL_HOST:6379 + + echo "Waiting for web to start" + if [ -v WEB_HOST ]; then + WEB_ACTUAL_HOST=$WEB_HOST + else + WEB_ACTUAL_HOST=web + fi + /usr/local/wait-for-it.sh --strict -t 0 $WEB_ACTUAL_HOST:80 fi -/usr/local/wait-for-it.sh --strict -t 0 $POSTGRES_ACTUAL_HOST:$POSTGRES_PORT - -echo "Waiting for redis to start" -if [ -v REDIS_HOST ]; -then - REDIS_ACTUAL_HOST=$REDIS_HOST -else - REDIS_ACTUAL_HOST=db-redis -fi - -/usr/local/wait-for-it.sh --strict -t 0 $REDIS_ACTUAL_HOST:6379 - -echo "Waiting for web to start" -if [ -v WEB_HOST ]; -then - WEB_ACTUAL_HOST=$WEB_HOST -else - WEB_ACTUAL_HOST=web -fi - -/usr/local/wait-for-it.sh --strict -t 0 $WEB_ACTUAL_HOST:80 # check if the number of workers is set in the env if [ -z ${NUMBER_OF_WORKERS} ]; then diff --git a/docker/start_uwsgi_docker.sh b/docker/start_uwsgi_docker.sh index 5976041e67..5c30985d12 100755 --- a/docker/start_uwsgi_docker.sh +++ b/docker/start_uwsgi_docker.sh @@ -2,24 +2,29 @@ cd /seed -echo "Waiting for postgres to start" -if [ -v POSTGRES_HOST ]; -then - POSTGRES_ACTUAL_HOST=$POSTGRES_HOST +# Check if 'DISABLE_SERVICE_CHECKS_ON_START' is not set or if its value is 'TRUE' +if [[ "${DISABLE_SERVICE_CHECKS_ON_START}" == "on" ]]; then + echo "'DISABLE_SERVICE_CHECKS_ON_START' is set and equal to 'on'. Skipping wait-for-it.sh execution." else - POSTGRES_ACTUAL_HOST=db-postgres + cd /seed + + echo "Waiting for postgres to start" + if [ -v POSTGRES_HOST ]; then + POSTGRES_ACTUAL_HOST=$POSTGRES_HOST + else + POSTGRES_ACTUAL_HOST=db-postgres + fi + /usr/local/wait-for-it.sh --strict $POSTGRES_ACTUAL_HOST:$POSTGRES_PORT + + echo "Waiting for redis to start" + if [ -v REDIS_HOST ]; then + REDIS_ACTUAL_HOST=$REDIS_HOST + else + REDIS_ACTUAL_HOST=db-redis + fi + + /usr/local/wait-for-it.sh --strict $REDIS_ACTUAL_HOST:6379 fi -/usr/local/wait-for-it.sh --strict $POSTGRES_ACTUAL_HOST:$POSTGRES_PORT - -echo "Waiting for redis to start" -if [ -v REDIS_HOST ]; -then - REDIS_ACTUAL_HOST=$REDIS_HOST -else - REDIS_ACTUAL_HOST=db-redis -fi - -/usr/local/wait-for-it.sh --strict $REDIS_ACTUAL_HOST:6379 # collect static resources before starting and compress the assets ./manage.py collectstatic --no-input -i package.json -i npm-shrinkwrap.json -i node_modules/openlayers-ext/index.html From ea0a0e4fc6261866116769872f580c3825703e60 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Fri, 3 Nov 2023 15:03:29 -0600 Subject: [PATCH 02/29] Add healthcheck to docker web container (#4374) --- docker-compose.local.yml | 6 ++++++ docker-compose.yml | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 90b0c15b11..16fb93dd59 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -83,6 +83,12 @@ services: - seed_media:/seed/media ports: - "80:80" + healthcheck: + test: curl -f http://localhost/api/health_check/ || exit 1 + interval: 1m + timeout: 10s + retries: 1 + start_period: 45s logging: options: max-size: 50m diff --git a/docker-compose.yml b/docker-compose.yml index 904f690034..44fd16921b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,12 @@ services: - seed_media:/seed/media ports: - "80:80" + healthcheck: + test: curl -f http://localhost/api/health_check/ || exit 1 + interval: 1m + timeout: 10s + retries: 1 + start_period: 45s logging: options: max-size: 50m From 225858349e1d67fd81cc89f2d3008e21621dc8d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Nov 2023 15:26:36 -0600 Subject: [PATCH 03/29] Bump django from 3.2.20 to 3.2.23 in /requirements (#4379) Bumps [django](https://github.com/django/django) from 3.2.20 to 3.2.23. - [Commits](https://github.com/django/django/compare/3.2.20...3.2.23) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 9b9d678f33..1efc35f0d5 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,5 +1,5 @@ # Django -django==3.2.20 +django==3.2.23 django-autoslug==1.9.8 # Used by django-filter. See here: https://github.com/carltongibson/django-filter/blob/fe90e3a5fdeaff0983d1325a3e9dcf3458ef078f/docs/guide/rest_framework.txt#L210 From 21d9c9f3e2386bd62372bd4cd6ba4f4a53faa91e Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Mon, 6 Nov 2023 22:50:42 -0700 Subject: [PATCH 04/29] Updated salesforce pg_restore docs (#4382) --- docs/source/developer_resources.rst | 10 +++++++++- seed/utils/salesforce.py | 7 +------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/source/developer_resources.rst b/docs/source/developer_resources.rst index 49f631ad66..500105eded 100644 --- a/docs/source/developer_resources.rst +++ b/docs/source/developer_resources.rst @@ -341,8 +341,10 @@ Restoring a Database Dump --password=password \ --organization=testorg +If restoring a production backup to a different deployment update the site settings for password reset emails, and disable celerybeat Salesforce updates/emails: + +.. code-block:: bash - # if restoring a production backup to a different deployment update the site settings for password reset emails ./manage.py shell from django.contrib.sites.models import Site @@ -351,6 +353,12 @@ Restoring a Database Dump site.name = 'SEED Dev1' site.save() + from seed.models import Organization + Organization.objects.filter(salesforce_enabled=True).update(salesforce_enabled=False) + + from django_celery_beat.models import PeriodicTask + PeriodicTask.objects.filter(enabled=True, name__startswith='salesforce_sync_org-').update(enabled=False) + Migrating the Database ---------------------- diff --git a/seed/utils/salesforce.py b/seed/utils/salesforce.py index cb1c37cdb4..8ca7d50b66 100644 --- a/seed/utils/salesforce.py +++ b/seed/utils/salesforce.py @@ -86,12 +86,7 @@ def toggle_salesforce_sync(salesforce_enabled, org_id): tasks = PeriodicTask.objects.filter(name=AUTO_SYNC_NAME + str(org_id)) if tasks: task = tasks.first() - if salesforce_enabled: - # look for task and make sure it's enabled - task.enabled = True - else: - # look for task and make sure it's disabled - task.enabled = False + task.enabled = bool(salesforce_enabled) task.save() From 36af0ae5f967719cf6eeacf55aefce68a2986961 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Tue, 7 Nov 2023 13:37:06 -0700 Subject: [PATCH 05/29] Update documentation for bulk celerybeat task changes --- docs/source/developer_resources.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/developer_resources.rst b/docs/source/developer_resources.rst index 500105eded..4848464155 100644 --- a/docs/source/developer_resources.rst +++ b/docs/source/developer_resources.rst @@ -356,8 +356,9 @@ If restoring a production backup to a different deployment update the site setti from seed.models import Organization Organization.objects.filter(salesforce_enabled=True).update(salesforce_enabled=False) - from django_celery_beat.models import PeriodicTask + from django_celery_beat.models import PeriodicTask, PeriodicTasks PeriodicTask.objects.filter(enabled=True, name__startswith='salesforce_sync_org-').update(enabled=False) + PeriodicTasks.update_changed() Migrating the Database From a7ef1b1565ef62da018d01aff43523794e4b7e97 Mon Sep 17 00:00:00 2001 From: ebeers-png <53191386+ebeers-png@users.noreply.github.com> Date: Wed, 22 Nov 2023 23:32:11 -0500 Subject: [PATCH 06/29] Fix co2 analysis column naming (#4410) --- seed/analysis_pipelines/co2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seed/analysis_pipelines/co2.py b/seed/analysis_pipelines/co2.py index 77e25cf022..4193367357 100644 --- a/seed/analysis_pipelines/co2.py +++ b/seed/analysis_pipelines/co2.py @@ -357,8 +357,8 @@ def _run_analysis(self, meter_readings_by_analysis_property_view, analysis_id): table_name='PropertyState', ) if created: - column.display_name = 'Average Annual CO2 (kgCO2e)', - column.column_description = 'Average Annual CO2 (kgCO2e)', + column.display_name = 'Average Annual CO2 (kgCO2e)' + column.column_description = 'Average Annual CO2 (kgCO2e)' column.save() column, created = Column.objects.get_or_create( From bfe676540ce48e30047b28200b1189c897377de6 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Thu, 23 Nov 2023 06:41:40 -0800 Subject: [PATCH 07/29] Audit Template Report Submission Endpoint (#4411) * endpoint for AT submission pdf report download * fix upload at submission endpoint * add test * update swagger to support downloading AT pdf or xml. * fix typing * spelling --------- Co-authored-by: Nicholas Long Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> --- .cspell.json | 2 ++ seed/audit_template/audit_template.py | 38 ++++++++++++++++++++- seed/tests/test_audit_template.py | 16 +++++++++ seed/views/main.py | 2 +- seed/views/v3/audit_template.py | 48 +++++++++++++++++++++++++-- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/.cspell.json b/.cspell.json index 8b28b84137..398795018a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -203,6 +203,7 @@ "lokalize", "lookup", "lte", + "lxml", "MapItem", "mappable", "mapquest", @@ -235,6 +236,7 @@ "noqa", "npm", "nrows", + "nsmap", "num", "Octant", "officedocument", diff --git a/seed/audit_template/audit_template.py b/seed/audit_template/audit_template.py index 23f402344c..80a97cb96e 100644 --- a/seed/audit_template/audit_template.py +++ b/seed/audit_template/audit_template.py @@ -7,6 +7,7 @@ import json import logging from datetime import datetime +from typing import Any, Tuple import requests from celery import shared_task @@ -53,6 +54,41 @@ def get_building_xml(self, audit_template_building_id, token): return response, "" + def get_submission(self, audit_template_submission_id: int, report_format: str = 'pdf') -> Tuple[Any, str]: + """Download an Audit Template submission report. + + Args: + audit_template_submission_id (int): value of the "Submission ID" as seen on Audit Template + report_format (str, optional): Report format, either `xml` or `pdf`. Defaults to 'pdf'. + + Returns: + requests.response: Result from Audit Template website + """ + # supporting 'PDF' and 'XML' formats only for now + token, message = self.get_api_token() + if not token: + return None, message + + # validate format + if report_format.lower() not in ['xml', 'pdf']: + report_format = 'pdf' + + # set headers + headers = {'accept': 'application/pdf'} + if report_format.lower() == 'xml': + headers = {'accept': 'application/xml'} + + url = f'{self.API_URL}/rp/submissions/{audit_template_submission_id}.{report_format}?token={token}' + try: + response = requests.request("GET", url, headers=headers) + + if response.status_code != 200: + return None, f'Expected 200 response from Audit Template get_submission but got {response.status_code!r}: {response.content!r}' + except Exception as e: + return None, f'Unexpected error from Audit Template: {e}' + + return response, "" + def get_buildings(self, cycle_id): token, message = self.get_api_token() if not token: @@ -161,7 +197,7 @@ def build_xml(self, state, report_type, display_field): view = state.propertyview_set.first() gfa = state.gross_floor_area - if type(gfa) == int: + if isinstance(gfa, int): gross_floor_area = str(gfa) elif gfa.units != ureg.feet**2: gross_floor_area = str(gfa.to(ureg.feet ** 2).magnitude) diff --git a/seed/tests/test_audit_template.py b/seed/tests/test_audit_template.py index 4bce67b9ca..c7f1c1270c 100644 --- a/seed/tests/test_audit_template.py +++ b/seed/tests/test_audit_template.py @@ -44,6 +44,7 @@ def setUp(self): self.get_building_url = reverse('api:v3:audit_template-get-building-xml', args=['1']) self.get_buildings_url = reverse('api:v3:audit_template-get-buildings') + self.get_submission_url = reverse('api:v3:audit_template-get-submission', args=['1']) self.good_authenticate_response = mock.Mock() self.good_authenticate_response.status_code = 200 @@ -57,6 +58,11 @@ def setUp(self): self.good_get_building_response.status_code = 200 self.good_get_building_response.text = "building response" + self.good_get_submission_response = mock.Mock() + self.good_get_submission_response.status_code = 200 + self.good_get_submission_response.text = "submission response" + self.good_get_submission_response.content = "submission response" + self.bad_get_building_response = mock.Mock() self.bad_get_building_response.status_code = 400 self.bad_get_building_response.content = "bad building response" @@ -71,6 +77,16 @@ def test_get_building_xml_from_audit_template(self, mock_request): self.assertEqual(200, response.status_code, response.content) self.assertEqual(response.content, b"building response") + @mock.patch('requests.request') + def test_get_submission_from_audit_template(self, mock_request): + # -- Act + mock_request.side_effect = [self.good_authenticate_response, self.good_get_submission_response] + response = self.client.get(self.get_submission_url, data={"organization_id": self.org.id}) + + # -- Assert + self.assertEqual(200, response.status_code, response.content) + self.assertEqual(response.content, b"submission response") + @mock.patch('requests.request') def test_get_building_xml_from_audit_template_org_has_no_at_token(self, mock_request): # -- Setup diff --git a/seed/views/main.py b/seed/views/main.py index 93f30ab6c5..bcbadca90b 100644 --- a/seed/views/main.py +++ b/seed/views/main.py @@ -133,7 +133,7 @@ def health_check(request): celery_status = False try: - redis_status = not cache.has_key('redis-ping') + redis_status = 'redis-ping' not in cache except Exception: redis_status = False diff --git a/seed/views/v3/audit_template.py b/seed/views/v3/audit_template.py index 0cfecbf7d9..1dfb398d10 100644 --- a/seed/views/v3/audit_template.py +++ b/seed/views/v3/audit_template.py @@ -17,13 +17,57 @@ class AuditTemplateViewSet(viewsets.ViewSet, OrgMixin): + @swagger_auto_schema(manual_parameters=[ + AutoSchemaHelper.query_org_id_field(), + AutoSchemaHelper.base_field( + name='id', + location_attr='IN_PATH', + type='TYPE_INTEGER', + required=True, + description='Audit Template Submission ID.'), + AutoSchemaHelper.query_string_field('report_format', False, 'Report format Valid values are: xml, pdf. Defaults to pdf.') + ]) + @has_perm_class('can_view_data') + @action(detail=True, methods=['GET']) + def get_submission(self, request, pk): + """ + Fetches a Report Submission (XML or PDF) from Audit Template (only) + """ + # get report format or default to pdf + default_report_format = 'pdf' + report_format = request.query_params.get('report_format', default_report_format) + + valid_file_formats = ['xml', 'pdf'] + if report_format.lower() not in valid_file_formats: + message = f"The report_format specified is invalid. Must be one of: {valid_file_formats}." + return JsonResponse({ + 'success': False, + 'message': message + }, status=400) + + # retrieve report + at = AuditTemplate(self.get_organization(self.request)) + response, message = at.get_submission(pk, report_format) + + if response is None: + return JsonResponse({ + 'success': False, + 'message': message + }, status=400) + if report_format.lower() == 'xml': + return HttpResponse(response.text) + else: + response2 = HttpResponse(response.content) + response2.headers["Content-Type"] = 'application/pdf' + response2.headers["Content-Disposition"] = f'attachment; filename="at_submission_{pk}.pdf"' + return response2 @swagger_auto_schema(manual_parameters=[AutoSchemaHelper.query_org_id_field()]) @has_perm_class('can_view_data') @action(detail=True, methods=['GET']) def get_building_xml(self, request, pk): """ - Fetches a Building XML for an Audit Template property and updates the corresponding PropertyView + Fetches a Building XML for an Audit Template property (only) """ at = AuditTemplate(self.get_organization(self.request)) response, message = at.get_building(pk) @@ -145,7 +189,7 @@ def get_buildings(self, request): at = AuditTemplate(org) result = at.get_buildings(cycle_id) - if type(result) is tuple: + if isinstance(result, tuple): return JsonResponse({ 'success': False, 'message': result[1] From 748043e15c12a7cef4371b8fcb3f23d9bcf57135 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Tue, 28 Nov 2023 14:53:38 -0700 Subject: [PATCH 08/29] Salesforce scheduling issues (#4387) Fixes several Salesforce crontab issues Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> --- seed/static/seed/partials/organization_settings.html | 4 ++-- seed/utils/salesforce.py | 5 ++++- seed/views/v3/salesforce_configs.py | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/seed/static/seed/partials/organization_settings.html b/seed/static/seed/partials/organization_settings.html index 0a6dc5c9d8..dc8973c4a9 100644 --- a/seed/static/seed/partials/organization_settings.html +++ b/seed/static/seed/partials/organization_settings.html @@ -407,7 +407,7 @@

{$:: 'Scheduled Daily Update' | translate

Timezone: {$ timezone $}

- +
@@ -420,7 +420,7 @@

{$:: 'Scheduled Daily Update' | translate

- +
diff --git a/seed/utils/salesforce.py b/seed/utils/salesforce.py index 8ca7d50b66..d08333f27c 100644 --- a/seed/utils/salesforce.py +++ b/seed/utils/salesforce.py @@ -52,7 +52,7 @@ def schedule_sync(data, org_id): timezone = data.get('timezone', get_current_timezone()) - if 'update_at_hour' in data and data['update_at_hour'] and 'update_at_minute' in data and data['update_at_minute']: + if 'update_at_hour' in data and 'update_at_minute' in data: # create crontab schedule schedule, _ = CrontabSchedule.objects.get_or_create( minute=data['update_at_minute'], @@ -78,6 +78,9 @@ def schedule_sync(data, org_id): task.crontab = schedule task.save() + # Cleanup orphaned/unused crontab schedules + CrontabSchedule.objects.exclude(id__in=PeriodicTask.objects.values_list('crontab_id', flat=True)).delete() + def toggle_salesforce_sync(salesforce_enabled, org_id): """ when salesforce_enabled value is toggled, also toggle the auto sync diff --git a/seed/views/v3/salesforce_configs.py b/seed/views/v3/salesforce_configs.py index 0b5947a72f..0739d17505 100644 --- a/seed/views/v3/salesforce_configs.py +++ b/seed/views/v3/salesforce_configs.py @@ -405,7 +405,7 @@ def update(self, request, pk): }, status=status.HTTP_200_OK) except django.core.exceptions.ValidationError as e: message_dict = e.message_dict - # rename key __all__ to general to make it more user friendly + # rename key __all__ to general to make it more user-friendly if '__all__' in message_dict: message_dict['general'] = message_dict.pop('__all__') From 7f87ce85ba7c44a8eea78386883073fc199164d9 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Wed, 29 Nov 2023 14:21:57 -0800 Subject: [PATCH 09/29] Property Insights UI Enhancements (#4384) * update for nil case * update property insights ui * move export button for consistency * Pretty --------- Co-authored-by: Hannah Eslinger --- .../insights_property_controller.js | 8 +- seed/static/seed/locales/en_US.json | 6 + seed/static/seed/locales/fr_CA.json | 8 +- .../seed/partials/insights_property.html | 129 ++++++++++-------- seed/static/seed/scss/style.scss | 49 ++++++- 5 files changed, 135 insertions(+), 65 deletions(-) diff --git a/seed/static/seed/js/controllers/insights_property_controller.js b/seed/static/seed/js/controllers/insights_property_controller.js index b974844fe5..c01753461b 100644 --- a/seed/static/seed/js/controllers/insights_property_controller.js +++ b/seed/static/seed/js/controllers/insights_property_controller.js @@ -20,6 +20,12 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ $scope.organization = organization_payload.organization; $scope.auth = auth_payload.auth; + // toggle help + $scope.show_help = false; + $scope.toggle_help = () => { + $scope.show_help = !$scope.show_help; + } + // configs ($scope.configs set to saved_configs where still applies. // for example, if saved_configs.compliance_metric is 1, but 1 has been deleted, it does apply.) const saved_configs = JSON.parse(localStorage.getItem(`insights.property.configs.${$scope.organization.id}`)); @@ -468,7 +474,7 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ }); // update x axis ticks (for year) - if (x_axis_name.toLowerCase().includes('year')) { + if (x_axis_name && x_axis_name.toLowerCase().includes('year')) { $scope.insightsChart.options.scales.x.ticks = { callback(value) { return this.getLabelForValue(value).replace(',', ''); diff --git a/seed/static/seed/locales/en_US.json b/seed/static/seed/locales/en_US.json index 82a30236b3..478c54ecda 100644 --- a/seed/static/seed/locales/en_US.json +++ b/seed/static/seed/locales/en_US.json @@ -115,6 +115,7 @@ "Building Filters": "Building Filters", "Buildings": "Buildings", "By clicking the Log In button you accept the NREL Data Terms.": "By clicking the Log In button you accept the NREL Data Terms.", + "CLICK_LEGEND": "Click a label below to show/hide it on the chart", "COLUMN_NAME_DUPLICATE_ERROR": "Error: New column name cannot match previous name.", "COLUMN_NAME_EXISTS_WARNING": "Warning: Column name already exists.", "COL_CHANGE_DESCRIPTION": "Provides a description of the column to assist in remembering the meaning of the column. This defaults to the display name if available, otherwise the column name is utilized.", @@ -134,6 +135,7 @@ "COL_MATCHING_CRITERIA_TOGGLE": "Checking this box for a field will allow it to be used as a matching field.", "COL_MERGE_PROTECTION_TOGGLE": "Normally when an imported record is merged into another record the newest value overwrites an older one. Merge protection prevents this, and is particularly useful for columns where you have manually edited values that you want to persist even after importing and merging new data.", "COMPLETE_AND_REFRESH": "Complete and Refresh Page", + "CONFIGURE_PROGRAM":"Need to configure your Program?", "CONFIRMING_DELETE_PROFILE": "Are you sure you want to delete the profile", "CONFIRM_AND_START_MATCHING": "Confirm mappings & start matching", "CONTINUE": "Continue", @@ -152,6 +154,7 @@ "Change my password": "Change my password", "Changed By": "Changed By", "Chart Legend": "Chart Legend", + "Chart Options": "Chart Options", "Choose Existing Organization:": "Choose Existing Organization:", "Choose the year ending month for report period.": "Choose the year ending month for report period.", "City": "City", @@ -467,6 +470,7 @@ "INCLUDE_SHARED_TAXLOTS": "Include in your Tax Lot List all tax lots shared with you.", "INDIVIDUAL_MATCH_MERGE_ROUND_ALERT": "This action will kick off a match and merge round for the resulting record. This record's values will be given precedence in the event that any merges occur. Multiple matching records are merged beforehand with precedence given to more recently imported records.", "INTERNAL": "INTERNAL", + "INSIGHTS_HELP_TEXT": "Use the controls below to display a subset of properties on the graph. The chart legend can also be used to display or hide properties based on compliance status. The 'Update Property Labels' button can then be used to edit the labels applied to the properties visible on the graph.", "INVALID_CSV_EXTENSION_ALERT": "Sorry!<\/strong> SEED doesn't currently support that file format. Only .csv<\/strong> files are supported.", "INVALID_DOC_FILE_EXTENSION_ALERT": "Invalid document type selected. Accepted file types are .dxf, .pdf, .idf, and .osm", "INVALID_EXTENSION_ALERT": "Sorry!<\/strong> SEED doesn't currently support that file format. Only .csv<\/strong>, .xls<\/strong>, .xlsx<\/strong>, and .xml<\/strong> files are supported.", @@ -747,6 +751,7 @@ "Profile Information": "Profile Information", "Profile Name": "Profile Name", "Program": "Program", + "Program Configuration page": "Program Configuration page", "Program Definition": "Program Definition", "Program Metric": "Program Metric", "Program Metric Configuration": "Program Metric Configuration", @@ -1098,6 +1103,7 @@ "Until last date of": "Until last date of", "Update Charts": "Update Charts", "Update Filters": "Update Filters", + "Update Property Labels": "Update Property Labels", "Update Salesforce": "Update Salesforce", "Update UBID": "Update UBID", "Update with Audit Template": "Update with Audit Template", diff --git a/seed/static/seed/locales/fr_CA.json b/seed/static/seed/locales/fr_CA.json index 6ba56bcca2..dfbfedd277 100644 --- a/seed/static/seed/locales/fr_CA.json +++ b/seed/static/seed/locales/fr_CA.json @@ -115,6 +115,7 @@ "Building Filters": "Filtres de bâtiments", "Buildings": "Bâtiments", "By clicking the Log In button you accept the NREL Data Terms.": "En cliquant sur le bouton Connexion, vous acceptez les conditions d'utilisation des données NREL.", + "CLICK_LEGEND": "Cliquez sur une étiquette ci-dessous pour l'afficher/masquer sur le graphique", "COLUMN_NAME_DUPLICATE_ERROR": "Erreur: Le nom de la nouvelle colonne ne peut pas correspondre au nom précédent.", "COLUMN_NAME_EXISTS_WARNING": "Attention: le nom de la colonne existe déjà.", "COL_CHANGE_DESCRIPTION": "Fournit une description de la colonne pour aider à se souvenir de la signification de la colonne. Il s'agit par défaut du nom d'affichage s'il est disponible, sinon le nom de la colonne est utilisé.", @@ -134,6 +135,7 @@ "COL_MATCHING_CRITERIA_TOGGLE": "Pour les colonnes non extra_data, indiquez si la colonne correspond aux critères", "COL_MERGE_PROTECTION_TOGGLE": "Normalement, lorsqu'un enregistrement importé est fusionné dans un autre enregistrement, la valeur la plus récente remplace un enregistrement plus ancien. La protection de fusion empêche cela et est particulièrement utile pour les colonnes dans lesquelles vous avez manuellement modifié les valeurs que vous souhaitez conserver même après l'importation et la fusion de nouvelles données.", "COMPLETE_AND_REFRESH": "Complétez et Actualisez la Page", + "CONFIGURE_PROGRAM":"Besoin de configurer votre programme?", "CONFIRMING_DELETE_PROFILE": "Êtes-vous sûr de vouloir supprimer le profil", "CONFIRM_AND_START_MATCHING": "Confirmer et commencer l'appariement", "CONTINUE": "Continuer", @@ -151,7 +153,8 @@ "Change Your Password": "Changez votre mot de passe", "Change my password": "Changer mon mot de passe", "Changed By": "Changé par", - "Chart Legend": "Légende du graphique", + "Chart Legend": "Légende du Graphique", + "Chart Options": "Options du Graphique", "Choose Existing Organization:": "Choisissez Organisation Existante:", "Choose the year ending month for report period.": "Choisissez le mois de fin de l'année pour la période du rapport.", "City": "Ville", @@ -466,6 +469,7 @@ "INCLUDE_SHARED_PROPERTIES": "Inclure dans vos Liste de Propriétés toutes les propriétés partagées avec vous.", "INCLUDE_SHARED_TAXLOTS": "Inclure dans votre Liste de Lots d'Impôt tous les lots d'impôt partagé avec vous.", "INDIVIDUAL_MATCH_MERGE_ROUND_ALERT": "Cette action lancera un match et fusionnera pour l’enregistrement résultant. Les valeurs de cet enregistrement auront la priorité dans l'éventualité d'une fusion. Plusieurs enregistrements correspondants sont préalablement fusionnés, la priorité étant donnée aux enregistrements récemment importés.", + "INSIGHTS_HELP_TEXT": "Utilisez les commandes ci-dessous pour afficher un sous-ensemble de propriétés sur le graphique. La légende du graphique peut également être utilisée pour afficher ou masquer des propriétés en fonction de l'état de conformité. Le bouton 'Mettre à jour les étiquettes des propriétés' peut ensuite être utilisé pour modifier les étiquettes appliquées aux propriétés visibles sur le graphique.", "INTERNAL": "INTERNE", "INVALID_CSV_EXTENSION_ALERT": "Désolé!<\/strong> SEED ne prend actuellement pas en charge ce format de fichier. Seuls les fichiers .csv<\/strong> sont pris en charge.", "INVALID_DOC_FILE_EXTENSION_ALERT": "Type de document sélectionné non valide. Les types de fichiers acceptés sont .dxf, .pdf, .idf et .osm", @@ -747,6 +751,7 @@ "Profile Information": "Informations sur le profil", "Profile Name": "Nom de profil", "Program": "Programme", + "Program Configuration page": "Page de configuration du programme", "Program Definition": "Définition du programme", "Program Metric": "Métrique du programme", "Program Metric Configuration": "Configuration de la métrique du programme", @@ -1098,6 +1103,7 @@ "Until last date of": "Jusqu'à la dernière date de", "Update Charts": "Mettre à jour les graphiques", "Update Filters": "Mise à jour les filtres", + "Update Property Labels": "Mettre à jour les étiquettes de propriété", "Update Salesforce": "Mettre à jour Salesforce", "Update UBID": "Mettre à jour UBID", "Update with Audit Template": "Mise à jour avec Audit Template", diff --git a/seed/static/seed/partials/insights_property.html b/seed/static/seed/partials/insights_property.html index 76f5d66cac..9e45cd744c 100644 --- a/seed/static/seed/partials/insights_property.html +++ b/seed/static/seed/partials/insights_property.html @@ -12,82 +12,59 @@
-
-
-
-
-

{$:: 'Property Insights' | translate $}

-
+
+

If you are not seeing a chart on this page, visit the Program page to configure your program's metrics.

-
-
-
-
-

{$:: 'Property Insights' | translate $}

-
+
-

Need to configure your Program? Program Configuration page.

+

CONFIGURE_PROGRAM Program Configuration page.

-
-
Chart Legend
-

Click a series to show/hide on the chart

-
-
blue circle marker
-
Compliant
-
Compliant
-
-
-
red triangle marker
-
Not Compliant
-
Not Compliant
+
+
+
+
+
+
+
Chart Options
+
+

INSIGHTS_HELP_TEXT

+
-
-
gray square marker
-
Unknown
-
Unknown
+ +
+ +
-
-
distance to target marker
-
Distance to Target
-
Distance to Target
+ +
+ +
-
-
- - -
- -
- - -
- -
- - -
-
- - -
+ +
+ + +
+
+ + +
-
-
- +
+
-
-
-
+
+
-
-
+
+
@@ -164,6 +141,38 @@

{$:: 'Property Insights' | translate $}

+
+
+
+ +
+
+
+
Chart Legend
+

CLICK_LEGEND

+
+
blue circle marker
+
Compliant
+
Compliant
+
+
+
+ red triangle marker
+
Not Compliant
+
Not Compliant
+
+
+
gray square marker
+
Unknown
+
Unknown
+
+
+
distance to target marker
+
Distance to Target
+
Distance to Target
+
+
+
diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index f0ed4aa475..473431333b 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -4302,11 +4302,12 @@ iframe.analysis-results { // program overview / compliance-setup .compliance-setup { - background-color: #eee; - padding: 1.5em; + /*background-color: #eee;*/ + padding: 0.5em; p { margin: 0; + font-size: 1.25em !important; } } @@ -4330,12 +4331,22 @@ iframe.analysis-results { margin-right: 10px; } +.chart-buttons { + margin-top: 10px; + margin-right: 5px; + text-align: right; +} + .chart-legend { margin-top: 15px; margin-left: 0; + margin-right: 15px; + padding: 15px; + border: 1px solid #ddd; .title { font-size: 1.4em; + font-weight: bold; } .legend-icon { @@ -4384,6 +4395,30 @@ iframe.analysis-results { } } +.chart-options { + margin-top: 25px; + padding-top: 10px; + margin-left: 0; + + .title { + font-size: 1.4em; + font-weight: bold; + } + + .form-group { + margin-bottom: 5px; + } + + .small-text { + font-size: 1em !important; + } +} + +.help-text-icon { + margin: 0px 5px; + color: #337ab7; +} + .title-row { padding: 0 0 10px; @@ -4597,7 +4632,15 @@ ul.r-list { } .less_pad { - padding-left: 30px !important; + padding: 0px 10px 10px 10px !important; +} + +.insights-header { + padding: 10px 10px 0px 10px !important; +} + +.insights-table { + margin-top: 0px !important; } .rp-data-view { From 687117e9c81a1d94afea2c847b42d46f9b8655fd Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:40:16 -0800 Subject: [PATCH 10/29] Custom Report Name Field Location (#4385) * update for nil case * update property insights ui * move export button for consistency * update custom report name location and display bug * cleanup --- .../seed/js/controllers/data_view_controller.js | 6 +++++- seed/static/seed/partials/data_view.html | 15 +++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/seed/static/seed/js/controllers/data_view_controller.js b/seed/static/seed/js/controllers/data_view_controller.js index ee2ce91e53..c11991ecf7 100644 --- a/seed/static/seed/js/controllers/data_view_controller.js +++ b/seed/static/seed/js/controllers/data_view_controller.js @@ -100,6 +100,7 @@ angular.module('BE.seed.controller.data_view', []).controller('data_view_control if ($scope.selected_data_view) { $scope.selected_data_view.first_axis_aggregations = []; $scope.selected_data_view.second_axis_aggregations = []; + $scope.fields.name = $scope.selected_data_view.name; } else if ($scope.id) { $scope.data_views_error = `Could not find Data View with id #${$scope.id}!`; } @@ -262,9 +263,12 @@ angular.module('BE.seed.controller.data_view', []).controller('data_view_control $scope.selected_data_view = { name: 'New Custom Report', first_axis_aggregations: [], - second_axis_aggregations: [] + second_axis_aggregations: [], + filter_groups: [], + cycles: [] }; $scope.editing = true; + $scope.fields.name = $scope.selected_data_view.name; spinner_utility.hide(); }; diff --git a/seed/static/seed/partials/data_view.html b/seed/static/seed/partials/data_view.html index a08ea7aa87..0860ea6ce1 100644 --- a/seed/static/seed/partials/data_view.html +++ b/seed/static/seed/partials/data_view.html @@ -48,6 +48,17 @@
+
    +
  • Name
  • +
+
    +
  • + +
  • +
  • + +
  • +
  • Filter Groups
@@ -215,10 +226,6 @@
-
- - -
  • {$ error $}
  • From e3f93a507925c7ac9b7f929a3a880ef247f48f28 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Fri, 1 Dec 2023 13:23:38 -0800 Subject: [PATCH 11/29] Add distance to target in table under property insights graph (#4386) * update for nil case * update property insights ui * move export button for consistency * update custom report name location and display bug * adding distance to target column in table under property insights graph * prettier --- seed/static/seed/js/controllers/insights_property_controller.js | 2 ++ seed/static/seed/partials/insights_property.html | 2 ++ 2 files changed, 4 insertions(+) diff --git a/seed/static/seed/js/controllers/insights_property_controller.js b/seed/static/seed/js/controllers/insights_property_controller.js index c01753461b..576d4bbe01 100644 --- a/seed/static/seed/js/controllers/insights_property_controller.js +++ b/seed/static/seed/js/controllers/insights_property_controller.js @@ -290,11 +290,13 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ non_compliant.data.forEach((item) => { // only when we are displaying the non-compliant metric (energy or emission) // don't add whisker if data is in range for that metric or it looks bad + item.distance = null; let add = false; const metric_type = $scope.configs.chart_metric === 0 ? $scope.data.metric.energy_metric_type : $scope.data.metric.emission_metric_type; if (item.x && item.y && item.target) { if ((metric_type === 1 && item.target < item.y) || (metric_type === 2 && item.target > item.y)) { add = true; + item.distance = Math.abs(item.target - item.y) } } diff --git a/seed/static/seed/partials/insights_property.html b/seed/static/seed/partials/insights_property.html index 9e45cd744c..809934507c 100644 --- a/seed/static/seed/partials/insights_property.html +++ b/seed/static/seed/partials/insights_property.html @@ -106,11 +106,13 @@
+ +
Name X: {$ insightsChart.options.scales.x.title.text $} Y: {$ insightsChart.options.scales.y.title.text $}Distance to Target
Property - {$ item.name ? item.name: item.id $} {$ item.x $} {$ item.y $}{$ item.distance $}
From b6106759cd7f27f3ab1780947be1f4d7ecd54534 Mon Sep 17 00:00:00 2001 From: Hannah Eslinger Date: Fri, 1 Dec 2023 16:45:34 -0700 Subject: [PATCH 12/29] Remove chart (#4402) * Remove chart * Upgrade table (#4403) --------- Co-authored-by: Katherine Fleming <2205659+kflemin@users.noreply.github.com> --- .../inventory_summary_controller.js | 114 ++++++++---------- .../seed/partials/inventory_summary.html | 46 +------ 2 files changed, 50 insertions(+), 110 deletions(-) diff --git a/seed/static/seed/js/controllers/inventory_summary_controller.js b/seed/static/seed/js/controllers/inventory_summary_controller.js index a481a3ce95..e2ff441dfd 100644 --- a/seed/static/seed/js/controllers/inventory_summary_controller.js +++ b/seed/static/seed/js/controllers/inventory_summary_controller.js @@ -6,12 +6,14 @@ angular.module('BE.seed.controller.inventory_summary', []).controller('inventory '$scope', '$stateParams', '$uibModal', + '$window', 'urls', 'analyses_service', 'inventory_service', 'cycles', + 'uiGridConstants', // eslint-disable-next-line func-names - function ($scope, $stateParams, $uibModal, urls, analyses_service, inventory_service, cycles_payload) { + function ($scope, $stateParams, $uibModal, $window, urls, analyses_service, inventory_service, cycles_payload, uiGridConstants) { $scope.inventory_type = $stateParams.inventory_type; const lastCycleId = inventory_service.get_last_cycle(); @@ -20,72 +22,49 @@ angular.module('BE.seed.controller.inventory_summary', []).controller('inventory cycles: cycles_payload.cycles }; - $scope.charts = [ - { - name: 'property_types', - chart: null, - x: 'extra_data__Largest Property Use Type', - y: 'count', - xLabel: 'Property Types' + $scope.summaryGridOptions = { + data: [], + columnDefs: [ + { field: 'Summary'}, + { field: 'Count'}, + ], + onRegisterApi: function( gridApi ) { + $scope.summaryGridOptions = gridApi; }, - { - name: 'year_built', - chart: null, - x: 'year_built', - y: 'percentage', - xLabel: 'Year Built' - }, - { - name: 'energy', - chart: null, - x: 'site_eui', - y: 'percentage', - xLabel: 'Site EUI' - }, - { - name: 'square_footage', - chart: null, - x: 'gross_floor_area', - y: 'percentage', - xLabel: 'Gross Floor Area' - } - ]; - let charts_loaded = false; + minRowsToShow: 2, + }; + + $scope.countGridOptions = { + data: [], + enableSorting: true, + enableFiltering: true, + columnDefs: [ + { field: 'Field'}, + { field: 'Count'}, + ], - const load_charts = () => { - if (!charts_loaded) { - charts_loaded = true; - $scope.charts.forEach((config) => { - const svg = dimple.newSvg(`#chart-${config.name}`, '100%', 500); - // eslint-disable-next-line new-cap - const chart = new dimple.chart(svg, []); - const xaxis = chart.addCategoryAxis('x', config.x); - xaxis.title = config.xLabel; - chart.addMeasureAxis('y', config.y); - chart.addSeries(null, dimple.plot.bar); - $scope.charts[config.name] = chart; - }); + onRegisterApi: function( gridApi ) { + $scope.countGridOptions = gridApi; } + }; - $scope.charts.forEach((config) => { - const chart = $scope.charts[config.name]; - if ($scope.summary_data[config.name].length < 1) { - return; - } - chart.data = $scope.summary_data[config.name]; - chart.svg.select('.missing-data').remove(); - $scope.draw_chart(config.name, false); + $scope.updateHeight = () => { + let height = 0; + _.forEach(['.header', '.page_header_container', '.section_nav_container'], (selector) => { + const element = angular.element(selector)[0]; + if (element) height += element.offsetHeight; }); + angular.element('#count-grid').css('height', `calc(100vh - ${height - 1}px)`); + $scope.countGridOptions.core.handleWindowResize(); }; - $scope.draw_chart = (chart_name, no_data_change = true) => { - if ($scope.summary_data[chart_name].length < 1) { - return; - } - setTimeout(() => { - $scope.charts[chart_name].draw(0, no_data_change); - }, 50); - }; + const debouncedHeightUpdate = _.debounce($scope.updateHeight, 150); + angular.element($window).on('resize', debouncedHeightUpdate); + $scope.$on('$destroy', () => { + angular.element($window).off('resize', debouncedHeightUpdate); + }); + + _.delay($scope.updateHeight, 150); const refresh_data = () => { $scope.progress = {}; @@ -100,22 +79,23 @@ angular.module('BE.seed.controller.inventory_summary', []).controller('inventory $scope.summary_data = data; $scope.table_data = [ { - text: 'Total Records', - count: data.total_records + Summary: 'Total Records', + Count: data.total_records }, { - text: 'Number of Extra Data Fields', - count: data.number_extra_data_fields + Summary: 'Number of Extra Data Fields', + Count: data.number_extra_data_fields } ]; + $scope.summaryGridOptions.data = $scope.table_data; const column_settings_count = data['column_settings fields and counts']; $scope.column_settings_count = Object.entries(column_settings_count).map(([key, value]) => ({ - column_settings: key, - count: value + Field: key, + Count: value })); - load_charts(); + $scope.countGridOptions.data = $scope.column_settings_count; modalInstance.close(); }); }; diff --git a/seed/static/seed/partials/inventory_summary.html b/seed/static/seed/partials/inventory_summary.html index 7e74a83167..bfb0fd465a 100644 --- a/seed/static/seed/partials/inventory_summary.html +++ b/seed/static/seed/partials/inventory_summary.html @@ -12,7 +12,7 @@

{$:: (inventory_type === 'taxlots' ? 'Tax Lots' : 'Properties') | translate

-
+
@@ -30,47 +30,7 @@

{$:: (inventory_type === 'taxlots' ? 'Tax Lots' : 'Properties') | translate
-
- - - - - - - - - - - - - -
SummaryCount
{$item.text$}{$item.count$}
-
-
- - - - - - - - - - - - - -
FieldCount
{$item.column_settings$}{$item.count$}
-
-
-
-
- - -
-
Insufficient number of properties to summarize!
-
-
-
+
+
From ffd0c515555ad8412bcaface09763939b7718479 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Mon, 4 Dec 2023 08:35:12 -0800 Subject: [PATCH 13/29] Order cycles by start date on program setup (#4428) order cycles cby start date on program setup Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> --- .../seed/js/controllers/program_setup_controller.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/seed/static/seed/js/controllers/program_setup_controller.js b/seed/static/seed/js/controllers/program_setup_controller.js index 666ed7bdbc..f292b4679d 100644 --- a/seed/static/seed/js/controllers/program_setup_controller.js +++ b/seed/static/seed/js/controllers/program_setup_controller.js @@ -35,6 +35,9 @@ angular.module('BE.seed.controller.program_setup', []).controller('program_setup $scope.id = $stateParams.id; $scope.org = organization_payload.organization; $scope.cycles = cycles_payload.cycles; + // order cycles by start date + $scope.cycles = _.orderBy($scope.cycles, ['start'], + ['asc']); $scope.compliance_metrics_error = []; $scope.program_settings_not_changed = true; $scope.program_settings_changed = () => { @@ -75,6 +78,13 @@ angular.module('BE.seed.controller.program_setup', []).controller('program_setup $scope.selected_compliance_metric.cycles = []; } $scope.selected_compliance_metric.cycles.push(selection); + $scope.order_selected_cycles(); + + }; + + $scope.order_selected_cycles = () => { + // keep chronological order of displayed cycles + $scope.selected_compliance_metric.cycles = _.map($scope.cycles.filter(({ id }) => $scope.selected_compliance_metric?.cycles.includes(id)), 'id'); }; $scope.click_remove_cycle = (id) => { @@ -163,13 +173,11 @@ angular.module('BE.seed.controller.program_setup', []).controller('program_setup $scope.compliance_metrics_error.push('The actual energy or emission columns must be included when the target column is selected!'); } if ($scope.compliance_metrics_error.length > 0) { - console.log('exited due to compliance_metrics_error'); spinner_utility.hide(); return; } // update the compliance metric - console.log('about to update the metric'); compliance_metric_service.update_compliance_metric($scope.selected_compliance_metric.id, $scope.selected_compliance_metric, $scope.org.id).then((data) => { if ('status' in data && data.status === 'error') { for (const [key, error] of Object.entries(data.compliance_metrics_error)) { From 25c48666ddeba6a0869ae93cc6bce0effb6775ce Mon Sep 17 00:00:00 2001 From: Hannah Eslinger Date: Mon, 4 Dec 2023 15:57:53 -0700 Subject: [PATCH 14/29] Populate default report using cycles (#4420) * Populate default report using cycles * Remove console.logs * Fix api * update css a bit --------- Co-authored-by: kflemin <2205659+kflemin@users.noreply.github.com> --- .../export_report_modal_controller.js | 7 +- .../inventory_reports_controller.js | 79 +++---- .../js/services/inventory_reports_service.js | 21 +- .../seed/partials/inventory_reports.html | 56 ++--- seed/static/seed/scss/style.scss | 10 + seed/tests/test_reports.py | 76 +++++-- seed/views/v3/organizations.py | 211 +++++++----------- 7 files changed, 218 insertions(+), 242 deletions(-) diff --git a/seed/static/seed/js/controllers/export_report_modal_controller.js b/seed/static/seed/js/controllers/export_report_modal_controller.js index 630a351621..dda327f45e 100644 --- a/seed/static/seed/js/controllers/export_report_modal_controller.js +++ b/seed/static/seed/js/controllers/export_report_modal_controller.js @@ -6,11 +6,10 @@ angular.module('BE.seed.controller.export_report_modal', []).controller('export_ '$scope', '$uibModalInstance', 'axes_data', - 'cycle_start', - 'cycle_end', + 'cycles', 'inventory_reports_service', // eslint-disable-next-line func-names - function ($scope, $uibModalInstance, axes_data, cycle_start, cycle_end, inventory_reports_service) { + function ($scope, $uibModalInstance, axes_data, cycles, inventory_reports_service) { $scope.export_name = ''; $scope.export_selected = () => { @@ -21,7 +20,7 @@ angular.module('BE.seed.controller.export_report_modal', []).controller('export_ const ext = '.xlsx'; if (!filename.endsWith(ext)) filename += ext; - inventory_reports_service.export_reports_data(axes_data, cycle_start, cycle_end).then((response) => { + inventory_reports_service.export_reports_data(axes_data, cycles).then((response) => { const blob_type = response.headers()['content-type']; const blob = new Blob([response.data], { type: blob_type }); diff --git a/seed/static/seed/js/controllers/inventory_reports_controller.js b/seed/static/seed/js/controllers/inventory_reports_controller.js index ebb6f1af07..744460a36c 100644 --- a/seed/static/seed/js/controllers/inventory_reports_controller.js +++ b/seed/static/seed/js/controllers/inventory_reports_controller.js @@ -54,10 +54,6 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory /* Setup models from "From" and "To" selectors */ $scope.cycles = cycles.cycles; - /* Model for pulldowns, initialized in init below */ - $scope.fromCycle = {}; - $scope.toCycle = {}; - const translateAxisLabel = (label, units) => { let str = ''; str += $translate.instant(label); @@ -123,15 +119,6 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory $scope.chart1Title = ''; $scope.chart2Title = ''; - // Datepickers - const initStartDate = new Date(); - initStartDate.setYear(initStartDate.getFullYear() - 1); - $scope.startDate = initStartDate; - $scope.startDatePickerOpen = false; - $scope.endDate = new Date(); - $scope.endDatePickerOpen = false; - $scope.invalidDates = false; // set this to true when startDate >= endDate; - // Series // the following variable keeps track of which // series will be sent to the graphs when data is updated @@ -252,34 +239,33 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory // specific styling for scatter chart $scope.scatterChart.options.scales.x.suggestedMin = 0; - /* END NEW CHART STUFF */ - - /* UI HANDLERS */ - /* ~~~~~~~~~~~ */ - - // Handle datepicker open/close events - $scope.openStartDatePicker = ($event) => { - $event.preventDefault(); - $event.stopPropagation(); - $scope.startDatePickerOpen = !$scope.startDatePickerOpen; + $scope.cycle_selection = ''; + $scope.selected_cycles = []; + $scope.available_cycles = () => $scope.cycles.filter(({ id }) => !$scope.selected_cycles.includes(id)); + $scope.select_cycle = () => { + const selection = $scope.cycle_selection; + $scope.cycle_selection = ''; + if (!$scope.selected_cycles) { + $scope.selected_cycles = []; + } + $scope.selected_cycles.push(selection); }; - $scope.openEndDatePicker = ($event) => { - $event.preventDefault(); - $event.stopPropagation(); - $scope.endDatePickerOpen = !$scope.endDatePickerOpen; + + $scope.get_cycle_display = (id) => { + const record = _.find($scope.cycles, { id }); + if (record) { + return record.name; + } }; - $scope.$watch('startDate', () => { - $scope.checkInvalidDate(); - }); + $scope.click_remove_cycle = (id) => { + $scope.selected_cycles = $scope.selected_cycles.filter((item) => item !== id); + }; - $scope.$watch('endDate', () => { - $scope.checkInvalidDate(); - }); + /* END NEW CHART STUFF */ - $scope.checkInvalidDate = () => { - $scope.invalidDates = $scope.endDate < $scope.startDate; - }; + /* UI HANDLERS */ + /* ~~~~~~~~~~~ */ /* Update data used by the chart. This will force the charts to re-render */ $scope.updateChartData = () => { @@ -374,8 +360,7 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory yVar: $scope.chartData.yAxisVarName, yLabel: $scope.chartData.yAxisTitle }), - cycle_start: () => $scope.fromCycle.selected_cycle.start, - cycle_end: () => $scope.toCycle.selected_cycle.end + cycles: () => $scope.selected_cycles, } }); }; @@ -398,7 +383,7 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory $scope.chartIsLoading = true; inventory_reports_service - .get_report_data(xVar, yVar, $scope.fromCycle.selected_cycle.start, $scope.toCycle.selected_cycle.end) + .get_report_data(xVar, yVar, $scope.selected_cycles) .then( (data) => { data = data.data; @@ -462,7 +447,7 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory const yVar = $scope.yAxisSelectedItem.varName; $scope.aggChartIsLoading = true; inventory_reports_service - .get_aggregated_report_data(xVar, yVar, $scope.fromCycle.selected_cycle.start, $scope.toCycle.selected_cycle.end) + .get_aggregated_report_data(xVar, yVar, $scope.selected_cycles) .then( (data) => { data = data.aggregated_data; @@ -506,9 +491,7 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory // Save axis and cycle selections localStorage.setItem(localStorageXAxisKey, JSON.stringify($scope.xAxisSelectedItem)); localStorage.setItem(localStorageYAxisKey, JSON.stringify($scope.yAxisSelectedItem)); - - localStorage.setItem(localStorageFromCycleKey, JSON.stringify($scope.fromCycle.selected_cycle)); - localStorage.setItem(localStorageToCycleKey, JSON.stringify($scope.toCycle.selected_cycle)); + localStorage.setItem(localStorageSelectedCycles, JSON.stringify($scope.selected_cycles)); } /* Generate an array of color objects to be used as part of chart configuration @@ -536,19 +519,13 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory return colorsArr; } - var localStorageFromCycleKey = `${base_storage_key}.fromcycle`; - var localStorageToCycleKey = `${base_storage_key}.tocycle`; + var localStorageSelectedCycles = `${base_storage_key}.SelectedCycles`; /* Call the update method so the page initializes with the values set in the scope */ function init() { // Initialize pulldowns - $scope.fromCycle = { - selected_cycle: JSON.parse(localStorage.getItem(localStorageFromCycleKey)) || _.head($scope.cycles) - }; - $scope.toCycle = { - selected_cycle: JSON.parse(localStorage.getItem(localStorageToCycleKey)) || _.last($scope.cycles) - }; + $scope.selected_cycles = JSON.parse(localStorage.getItem(localStorageSelectedCycles)) || [] // Attempt to load selections $scope.updateChartData(); diff --git a/seed/static/seed/js/services/inventory_reports_service.js b/seed/static/seed/js/services/inventory_reports_service.js index 242b597d7e..9f6d436f72 100644 --- a/seed/static/seed/js/services/inventory_reports_service.js +++ b/seed/static/seed/js/services/inventory_reports_service.js @@ -34,9 +34,9 @@ angular.module('BE.seed.service.inventory_reports', []).factory('inventory_repor ] } */ - const get_report_data = (xVar, yVar, start, end) => { + const get_report_data = (xVar, yVar, cycle_ids) => { // Error checks - if (_.some([xVar, yVar, start, end], _.isNil)) { + if (_.some([xVar, yVar, cycle_ids], _.isNil)) { $log.error('#inventory_reports_service.get_report_data(): null parameter'); throw new Error('Invalid Parameter'); } @@ -47,8 +47,7 @@ angular.module('BE.seed.service.inventory_reports', []).factory('inventory_repor params: { x_var: xVar, y_var: yVar, - start, - end + cycle_ids, } }) .then((response) => response.data) @@ -83,9 +82,9 @@ angular.module('BE.seed.service.inventory_reports', []).factory('inventory_repor } } */ - const get_aggregated_report_data = (xVar, yVar, start, end) => { + const get_aggregated_report_data = (xVar, yVar, cycle_ids) => { // Error checks - if (_.some([xVar, yVar, start, end], _.isNil)) { + if (_.some([xVar, yVar, cycle_ids], _.isNil)) { $log.error('#inventory_reports_service.get_aggregated_report_data(): null parameter'); throw new Error('Invalid Parameter'); } @@ -96,21 +95,20 @@ angular.module('BE.seed.service.inventory_reports', []).factory('inventory_repor params: { x_var: xVar, y_var: yVar, - start, - end + cycle_ids, } }) .then((response) => response.data) .catch(() => {}); }; - const export_reports_data = (axes_data, start, end) => { + const export_reports_data = (axes_data, cycle_ids) => { const { xVar } = axes_data; const { xLabel } = axes_data; const { yVar } = axes_data; const { yLabel } = axes_data; // Error checks - if (_.some([xVar, xLabel, yVar, yLabel, start, end], _.isNil)) { + if (_.some([xVar, xLabel, yVar, yLabel, cycle_ids], _.isNil)) { $log.error('#inventory_reports_service.get_aggregated_report_data(): null parameter'); throw new Error('Invalid Parameter'); } @@ -123,8 +121,7 @@ angular.module('BE.seed.service.inventory_reports', []).factory('inventory_repor x_label: xLabel, y_var: yVar, y_label: yLabel, - start, - end + cycle_ids, }, responseType: 'arraybuffer' }) diff --git a/seed/static/seed/partials/inventory_reports.html b/seed/static/seed/partials/inventory_reports.html index 30d09564ce..e140b5c2fa 100644 --- a/seed/static/seed/partials/inventory_reports.html +++ b/seed/static/seed/partials/inventory_reports.html @@ -19,42 +19,34 @@

Default Reports

{$:: 'Property Reports' | translate $}

-
-
-
- - -
- -
- - +
+ +
+
+ +
  • + {$:: get_cycle_display(item) $} + +
  • +
  • + +
  • +
    -
    - - -
    +
    +
    + + +
    -
    - - +
    + + +
    -
    diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index 473431333b..3a10d3ee77 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -3891,6 +3891,16 @@ $pairedCellWidth: 60px; padding-left: 5px; } +.pad-bottom-10 { + padding-bottom: 10px; +} + +.right-bar { + padding-left: 10px; + padding-right: 25px; + border-right: 1px solid #999; +} + .matching-column-header .ui-grid-header-cell-label { font-weight: bolder; } diff --git a/seed/tests/test_reports.py b/seed/tests/test_reports.py index 9eb8c1bef3..d9f202bf97 100644 --- a/seed/tests/test_reports.py +++ b/seed/tests/test_reports.py @@ -33,12 +33,9 @@ def setUp(self): 'email': 'test_user@demo.com' } self.client.login(**user_details) - - def test_report_export_excel_workbook(self): - cycle_factory = FakeCycleFactory(organization=self.org, user=self.user) + self.cycle_factory = FakeCycleFactory(organization=self.org, user=self.user) start = datetime.datetime(2016, 1, 1, tzinfo=timezone.get_current_timezone()) - # import pdb; pdb.set_trace() - self.cycle_2 = cycle_factory.get_cycle(name="Cycle 2", start=start) + self.cycle_2 = self.cycle_factory.get_cycle(name="Cycle 2", start=start) # create 5 records with site_eui and gross_floor_area in each cycle for i in range(1, 6): @@ -66,17 +63,17 @@ def test_report_export_excel_workbook(self): cycle_id=self.cycle_2.id ) + def test_report_export_excel_workbook(self): url = reverse('api:v3:organizations-report-export', args=[self.org.pk]) # needs to be turned into post? - response = self.client.get(url + '?{}={}&{}={}&{}={}&{}={}&{}={}&{}={}'.format( - 'start', '2014-12-31T00:00:00-07:53', - 'end', '2017-12-31T00:00:00-07:53', - 'x_var', 'site_eui', - 'x_label', 'Site EUI', - 'y_var', 'gross_floor_area', - 'y_label', 'Gross Floor Area' - )) + response = self.client.get(url, data={ + 'cycle_ids': [self.cycle.id, self.cycle_2.id], + 'x_var': 'site_eui', + 'x_label': 'Site EUI', + 'y_var': 'gross_floor_area', + 'y_label': 'Gross Floor Area', + }) self.assertEqual(200, response.status_code) @@ -110,3 +107,56 @@ def test_report_export_excel_workbook(self): self.assertEqual('Gross Floor Area', agg_sheet.cell(0, 1).value) self.assertEqual('0-99k', agg_sheet.cell(1, 1).value) self.assertEqual('0-99k', agg_sheet.cell(2, 1).value) + + def test_report(self): + url = reverse('api:v3:organizations-report', args=[self.org.pk]) + data = { + 'cycle_ids': [self.cycle.id, self.cycle_2.id], + 'x_var': 'site_eui', + 'y_var': 'gross_floor_area', + } + response = self.client.get(url, data) + + assert response.json()["data"]["property_counts"] == [ + {'yr_e': '2016', 'num_properties': 5, 'num_properties_w-data': 5}, + {'yr_e': '2015', 'num_properties': 5, 'num_properties_w-data': 5} + ] + + data["cycle_ids"] = [self.cycle.id] + response = self.client.get(url, data) + + assert response.json()["data"]["property_counts"] == [ + {'yr_e': '2015', 'num_properties': 5, 'num_properties_w-data': 5} + ] + + def test_report_aggregated(self): + url = reverse('api:v3:organizations-report-aggregated', args=[self.org.pk]) + data = { + 'cycle_ids': [self.cycle.id, self.cycle_2.id], + 'x_var': 'site_eui', + 'y_var': 'gross_floor_area', + } + response = self.client.get(url, data) + + assert response.json()["aggregated_data"]["property_counts"] == [ + {'yr_e': '2016', 'num_properties': 5, 'num_properties_w-data': 5}, + {'yr_e': '2015', 'num_properties': 5, 'num_properties_w-data': 5} + ] + + data["cycle_ids"] = [self.cycle.id] + response = self.client.get(url, data) + + assert response.json()["aggregated_data"]["property_counts"] == [ + {'yr_e': '2015', 'num_properties': 5, 'num_properties_w-data': 5} + ] + + def test_report_missing_arg(self): + url = reverse('api:v3:organizations-report-aggregated', args=[self.org.pk]) + data = { + 'cycle_ids': [self.cycle.id, self.cycle_2.id], + 'x_var': 'site_eui', + # 'y_var': 'gross_floor_area', it's missing! + } + response = self.client.get(url, data) + + assert response.status_code == 400 diff --git a/seed/views/v3/organizations.py b/seed/views/v3/organizations.py index 493ecedae9..ec8422858d 100644 --- a/seed/views/v3/organizations.py +++ b/seed/views/v3/organizations.py @@ -9,7 +9,6 @@ from io import BytesIO from pathlib import Path -import dateutil from django.conf import settings from django.contrib.auth.decorators import permission_required from django.contrib.auth.forms import PasswordResetForm @@ -20,7 +19,6 @@ from django.utils.decorators import method_decorator from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from past.builtins import basestring from rest_framework import status, viewsets from rest_framework.decorators import action from rest_framework.response import Response @@ -785,35 +783,6 @@ def geocoding_columns(self, request, pk=None): return JsonResponse(geocoding_columns) - def get_cycles(self, start, end, organization_id): - if not isinstance(start, type(end)): - raise TypeError('start and end not same types') - # if of type int or convertable assume they are cycle ids - try: - start = int(start) - end = int(end) - except ValueError: - # assume string is JS date - if isinstance(start, basestring): - start_datetime = dateutil.parser.parse(start) - end_datetime = dateutil.parser.parse(end) - else: - raise Exception('Date is not a string') - # get date times from cycles - if isinstance(start, int): - cycle = Cycle.objects.get(pk=start, organization_id=organization_id) - start_datetime = cycle.start - if start == end: - end_datetime = cycle.end - else: - end_datetime = Cycle.objects.get( - pk=end, organization_id=organization_id - ).end - return Cycle.objects.filter( - start__gte=start_datetime, end__lte=end_datetime, - organization_id=organization_id - ).order_by('start') - def get_data(self, property_view, x_var, y_var, matching_columns): result = {} state = property_view.state @@ -896,42 +865,31 @@ def get_raw_report_data(self, organization_id, cycles, x_var, y_var, addtional_c def report(self, request, pk=None): """Retrieve a summary report for charting x vs y """ - params = {} - missing_params = [] - error = '' - for param in ['x_var', 'y_var', 'start', 'end']: - val = request.query_params.get(param, None) - if not val: - missing_params.append(param) - else: - params[param] = val + params = { + "x_var": request.query_params.get("x_var", None), + "y_var": request.query_params.get("y_var", None), + "cycle_ids": request.query_params.getlist("cycle_ids", None), + } + + excepted_params = ["x_var", "y_var", "cycle_ids"] + missing_params = [p for p in excepted_params if p not in params] if missing_params: - error = "{} Missing params: {}".format( - error, ", ".join(missing_params) + return Response( + {'status': 'error', 'message': "Missing params: {}".format(", ".join(missing_params))}, + status=status.HTTP_400_BAD_REQUEST ) - if error: - status_code = status.HTTP_400_BAD_REQUEST - result = {'status': 'error', 'message': error} - else: - cycles = self.get_cycles(params['start'], params['end'], pk) - data = self.get_raw_report_data( - pk, cycles, params['x_var'], params['y_var'] - ) - for datum in data: - if datum['property_counts']['num_properties_w-data'] != 0: - break - property_counts = [] - chart_data = [] - for datum in data: - property_counts.append(datum['property_counts']) - chart_data.extend(datum['chart_data']) - data = { - 'property_counts': property_counts, - 'chart_data': chart_data, - } - result = {'status': 'success', 'data': data} - status_code = status.HTTP_200_OK - return Response(result, status=status_code) + + cycles = Cycle.objects.filter(id__in=params["cycle_ids"]) + data = self.get_raw_report_data(pk, cycles, params['x_var'], params['y_var']) + data = { + "chart_data": sum([d["chart_data"] for d in data], []), + "property_counts": [d["property_counts"] for d in data] + } + + return Response( + {'status': 'success', 'data': data}, + status=status.HTTP_200_OK + ) @swagger_auto_schema( manual_parameters=[ @@ -964,59 +922,52 @@ def report(self, request, pk=None): def report_aggregated(self, request, pk=None): """Retrieve a summary report for charting x vs y aggregated by y_var """ - valid_y_values = ['gross_floor_area', 'property_type', 'year_built'] - params = {} - missing_params = [] - empty = True - error = '' - for param in ['x_var', 'y_var', 'start', 'end']: - val = request.query_params.get(param, None) - if not val: - missing_params.append(param) - elif param == 'y_var' and val not in valid_y_values: - error = "{} {} is not a valid value for {}.".format( - error, val, param - ) - else: - params[param] = val + # get params + params = { + "x_var": request.query_params.get("x_var", None), + "y_var": request.query_params.get("y_var", None), + "cycle_ids": request.query_params.getlist("cycle_ids", None) + } + + # error if missing + excepted_params = ["x_var", "y_var", "cycle_ids"] + missing_params = [p for p in excepted_params if p not in params] if missing_params: - error = "{} Missing params: {}".format( - error, ", ".join(missing_params) + return Response( + {'status': 'error', 'message': "Missing params: {}".format(", ".join(missing_params))}, + status=status.HTTP_400_BAD_REQUEST ) - if error: - status_code = status.HTTP_400_BAD_REQUEST - result = {'status': 'error', 'message': error} - else: - cycles = self.get_cycles(params['start'], params['end'], pk) - x_var = params['x_var'] - y_var = params['y_var'] - data = self.get_raw_report_data(pk, cycles, x_var, y_var) - for datum in data: - if datum['property_counts']['num_properties_w-data'] != 0: - empty = False - break - if empty: - result = {'status': 'error', 'message': 'No data found'} - status_code = status.HTTP_404_NOT_FOUND - if not empty or not error: - chart_data = [] - property_counts = [] - for datum in data: - buildings = datum['chart_data'] - yr_e = datum['property_counts']['yr_e'] - chart_data.extend(self.aggregate_data(yr_e, y_var, buildings)), - property_counts.append(datum['property_counts']) - # Send back to client - aggregated_data = { + + # error if y_var invalid + valid_y_values = ['gross_floor_area', 'property_type', 'year_built'] + if params["y_var"] not in valid_y_values: + return Response( + {'status': 'error', 'message': f"{params['y_var']} is not a valid value for y_var"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # get data + cycles = Cycle.objects.filter(id__in=params["cycle_ids"]) + data = self.get_raw_report_data(pk, cycles, params["x_var"], params["y_var"]) + + chart_data = [] + property_counts = [] + for datum in data: + buildings = datum['chart_data'] + yr_e = datum['property_counts']['yr_e'] + chart_data.extend(self.aggregate_data(yr_e, params["y_var"], buildings)), + property_counts.append(datum['property_counts']) + + # Send back to client + result = { + 'status': 'success', + 'aggregated_data': { 'chart_data': chart_data, 'property_counts': property_counts - } - result = { - 'status': 'success', - 'aggregated_data': aggregated_data, - } - status_code = status.HTTP_200_OK - return Response(result, status=status_code) + }, + } + + return Response(result, status=status.HTTP_200_OK) def aggregate_data(self, yr_e, y_var, buildings): aggregation_method = { @@ -1141,23 +1092,23 @@ def report_export(self, request, pk=None): """ Export a report as a spreadsheet """ - params = {} - missing_params = [] - error = '' - for param in ['x_var', 'x_label', 'y_var', 'y_label', 'start', 'end']: - val = request.query_params.get(param, None) - if not val: - missing_params.append(param) - else: - params[param] = val + # get params + params = { + "x_var": request.query_params.get("x_var", None), + "x_label": request.query_params.get("x_label", None), + "y_var": request.query_params.get("y_var", None), + "y_label": request.query_params.get("y_label", None), + "cycle_ids": request.query_params.getlist("cycle_ids", None) + } + + # error if missing + excepted_params = ["x_var", "x_label", "y_var", "y_label", "cycle_ids"] + missing_params = [p for p in excepted_params if p not in params] if missing_params: - error = "{} Missing params: {}".format( - error, ", ".join(missing_params) + return Response( + {'status': 'error', 'message': "Missing params: {}".format(", ".join(missing_params))}, + status=status.HTTP_400_BAD_REQUEST ) - if error: - status_code = status.HTTP_400_BAD_REQUEST - result = {'status': 'error', 'message': error} - return Response(result, status=status_code) response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') response['Content-Disposition'] = 'attachment; filename="report-data"' @@ -1186,7 +1137,7 @@ def report_export(self, request, pk=None): agg_sheet.write(data_row_start, data_col_start + 2, 'Year Ending', bold) # Gather base data - cycles = self.get_cycles(params['start'], params['end'], pk) + cycles = Cycle.objects.filter(id__in=params["cycle_ids"]) matching_columns = Column.objects.filter(organization_id=pk, is_matching_criteria=True, table_name="PropertyState") data = self.get_raw_report_data( pk, cycles, params['x_var'], params['y_var'], matching_columns From 06c2389e150876fe3440bd06556199f603e7e317 Mon Sep 17 00:00:00 2001 From: Hannah Eslinger Date: Mon, 4 Dec 2023 17:02:36 -0700 Subject: [PATCH 15/29] Add count to default reports (#4423) --- .../inventory_reports_controller.js | 12 +++- seed/views/v3/organizations.py | 55 ++++++++++++------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/seed/static/seed/js/controllers/inventory_reports_controller.js b/seed/static/seed/js/controllers/inventory_reports_controller.js index 744460a36c..8b9d7bbddc 100644 --- a/seed/static/seed/js/controllers/inventory_reports_controller.js +++ b/seed/static/seed/js/controllers/inventory_reports_controller.js @@ -94,14 +94,22 @@ angular.module('BE.seed.controller.inventory_reports', []).controller('inventory const filtered_columns = _.filter(columns, (column) => _.includes(acceptable_column_types, column.data_type)); - $scope.xAxisVars = _.map(filtered_columns, (column) => ({ + $scope.xAxisVars =[ + { + name: "Count", + label: "Count", + varName: "Count", + axisLabel: "Count", + }, + ... _.map(filtered_columns, (column) => ({ name: $translate.instant(column.displayName), // short name for variable, used in pulldown label: $translate.instant(column.displayName), // full name for variable varName: column.column_name, // name of variable, to be sent to server axisLabel: parse_axis_label(column) // label to be used in charts, should include units // axisType: 'Measure', //DimpleJS property for axis type // axisTickFormat: ',.0f' //DimpleJS property for axis tick format - })); + })) + ]; const acceptable_y_column_names = ['gross_floor_area', 'property_type', 'year_built']; const filtered_y_columns = _.filter(columns, (column) => _.includes(acceptable_y_column_names, column.column_name)); diff --git a/seed/views/v3/organizations.py b/seed/views/v3/organizations.py index ec8422858d..6017b99f52 100644 --- a/seed/views/v3/organizations.py +++ b/seed/views/v3/organizations.py @@ -786,15 +786,29 @@ def geocoding_columns(self, request, pk=None): def get_data(self, property_view, x_var, y_var, matching_columns): result = {} state = property_view.state - if getattr(state, x_var, None) and getattr(state, y_var, None): - for matching_column in matching_columns: - name = matching_column.column_name - if matching_column.is_extra_data: - result[name] = state.extra_data.get(name) - else: - result[name] = getattr(state, name) + # set matching columns + for matching_column in matching_columns: + name = matching_column.column_name + if matching_column.is_extra_data: + result[name] = state.extra_data.get(name) + else: + result[name] = getattr(state, name) + + # set x + if x_var == "Count": + result["x"] = 1 + + elif not getattr(state, x_var): + return {} + + else: result["x"] = getattr(state, x_var) + + # set y + if not getattr(state, y_var, None): + return {} + else: result["y"] = getattr(state, y_var) return result @@ -955,7 +969,7 @@ def report_aggregated(self, request, pk=None): for datum in data: buildings = datum['chart_data'] yr_e = datum['property_counts']['yr_e'] - chart_data.extend(self.aggregate_data(yr_e, params["y_var"], buildings)), + chart_data.extend(self.aggregate_data(yr_e, params["x_var"], params["y_var"], buildings)), property_counts.append(datum['property_counts']) # Send back to client @@ -969,7 +983,7 @@ def report_aggregated(self, request, pk=None): return Response(result, status=status.HTTP_200_OK) - def aggregate_data(self, yr_e, y_var, buildings): + def aggregate_data(self, yr_e, x_var, y_var, buildings): aggregation_method = { 'property_type': self.aggregate_property_type, 'year_built': self.aggregate_year_built, @@ -977,9 +991,9 @@ def aggregate_data(self, yr_e, y_var, buildings): } - return aggregation_method[y_var](yr_e, buildings) + return aggregation_method[y_var](yr_e, x_var, buildings) - def aggregate_property_type(self, yr_e, buildings): + def aggregate_property_type(self, yr_e, x_var, buildings): # Group buildings in this year_ending group into uses chart_data = [] grouped_uses = defaultdict(list) @@ -988,14 +1002,15 @@ def aggregate_property_type(self, yr_e, buildings): # Now iterate over use groups to make each chart item for use, buildings_in_uses in grouped_uses.items(): + x = [b['x'] for b in buildings_in_uses] chart_data.append({ - 'x': median([b['x'] for b in buildings_in_uses]), + 'x': sum(x) if x_var == "Count" else median(x), 'y': use.capitalize(), 'yr_e': yr_e }) return chart_data - def aggregate_year_built(self, yr_e, buildings): + def aggregate_year_built(self, yr_e, x_var, buildings): # Group buildings in this year_ending group into decades chart_data = [] grouped_decades = defaultdict(list) @@ -1004,16 +1019,15 @@ def aggregate_year_built(self, yr_e, buildings): # Now iterate over decade groups to make each chart item for decade, buildings_in_decade in grouped_decades.items(): + x = [b['x'] for b in buildings_in_decade] chart_data.append({ - 'x': median( - [b['x'] for b in buildings_in_decade] - ), + 'x': sum(x) if x_var == "Count" else median(x), 'y': '%s-%s' % (decade, '%s9' % str(decade)[:-1]), # 1990-1999 'yr_e': yr_e }) return chart_data - def aggregate_gross_floor_area(self, yr_e, buildings): + def aggregate_gross_floor_area(self, yr_e, x_var, buildings): chart_data = [] y_display_map = { 0: '0-99k', @@ -1041,10 +1055,9 @@ def aggregate_gross_floor_area(self, yr_e, buildings): # Now iterate over range groups to make each chart item for range_floor, buildings_in_range in grouped_ranges.items(): + x = [b['x'] for b in buildings_in_range] chart_data.append({ - 'x': median( - [b['x'] for b in buildings_in_range] - ), + 'x': sum(x) if x_var == "Count" else median(x), 'y': y_display_map[range_floor], 'yr_e': yr_e }) @@ -1176,7 +1189,7 @@ def report_export(self, request, pk=None): base_row += 1 # Gather and write Agg data - for agg_datum in self.aggregate_data(yr_e, params['y_var'], data_rows): + for agg_datum in self.aggregate_data(yr_e, params['x_var'], params['y_var'], data_rows): agg_sheet.write(agg_row, data_col_start, agg_datum.get('x')) agg_sheet.write(agg_row, data_col_start + 1, agg_datum.get('y')) agg_sheet.write(agg_row, data_col_start + 2, agg_datum.get('yr_e')) From d06600eef616a937fd3c27ae0ae971f571003d9e Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Tue, 5 Dec 2023 10:08:49 -0800 Subject: [PATCH 16/29] Property Insights Tooltip Refinement (#4425) update tooltip text --- .../insights_property_controller.js | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/seed/static/seed/js/controllers/insights_property_controller.js b/seed/static/seed/js/controllers/insights_property_controller.js index 576d4bbe01..786f73dfc7 100644 --- a/seed/static/seed/js/controllers/insights_property_controller.js +++ b/seed/static/seed/js/controllers/insights_property_controller.js @@ -317,20 +317,6 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ // CHARTS const colors = { compliant: '#77CCCB', 'non-compliant': '#A94455', unknown: '#DDDDDD' }; - const tooltip_footer = (tooltipItems) => { - let text = ''; - tooltipItems.forEach((tooltipItem) => { - if (tooltipItem.raw.name) { - text = `Property: ${tooltipItem.raw.name}`; - } else { - // revise this in future - text = `Property ID: ${tooltipItem.raw.id}`; - } - }); - - return text; - }; - const _build_chart = () => { if (!$scope.chart_datasets) { return; @@ -369,7 +355,30 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ }, tooltip: { callbacks: { - footer: tooltip_footer + label: function(context) { + let text = []; + // property ID / default display field + if (context.raw.name) { + text.push(`Property: ${context.raw.name}`); + } else { + text.push(`Property ID: ${context.raw.id}`); + } + + // x and y axis names and values + const x_index = _.findIndex($scope.data.metric.x_axis_columns, { id: $scope.configs.chart_xaxis }); + const x_axis_name = $scope.data.metric.x_axis_columns[x_index]?.display_name; + + let y_axis_name = null; + if ($scope.configs.chart_metric === 0) { + y_axis_name = $scope.data.metric.actual_energy_column_name; + } else if ($scope.configs.chart_metric === 1) { + y_axis_name = $scope.data.metric.actual_emission_column_name; + } + + text.push(`${x_axis_name}: ${context.parsed.x}`); + text.push(`${y_axis_name}: ${context.parsed.y}`); + return text; + } } }, zoom: { From ae982975f58df67b2e23dda315e94901875e39b2 Mon Sep 17 00:00:00 2001 From: Hannah Eslinger Date: Wed, 6 Dec 2023 11:12:24 -0700 Subject: [PATCH 17/29] Forgo evil evil serializer (#4434) * Forgo evil evil serializer * Remove parsed results --- seed/views/v3/analyses.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/seed/views/v3/analyses.py b/seed/views/v3/analyses.py index 9341f30a27..8527ac73ce 100644 --- a/seed/views/v3/analyses.py +++ b/seed/views/v3/analyses.py @@ -34,9 +34,6 @@ PropertyView ) from seed.serializers.analyses import AnalysisSerializer -from seed.serializers.analysis_property_views import ( - AnalysisPropertyViewSerializer -) from seed.utils.api import OrgMixin, api_endpoint_class from seed.utils.api_schema import AutoSchemaHelper @@ -159,7 +156,25 @@ def list(self, request): views_queryset = views_queryset.annotate(display_name=F(f'property_state__{display_column_field}')).prefetch_related("analysisoutputfile_set") property_views_by_apv_id = AnalysisPropertyView.get_property_views(views_queryset) - results["views"] = AnalysisPropertyViewSerializer(list(views_queryset), many=True).data + results["views"] = [ + { + "id": view.id, + "display_name": view.display_name, + "analysis": view.analysis_id, + "property": view.property_id, + "cycle": view.cycle_id, + "property_state": view.property_state_id, + "output_files": [ + { + "id": output_file.id, + "content_type": output_file.content_type, + "file": output_file.file.path + } + for output_file in view.analysisoutputfile_set.all() + ], + } + for view in views_queryset + ] results["original_views"] = { apv_id: property_view.id if property_view is not None else None for apv_id, property_view in property_views_by_apv_id.items() From 794bb0ad399551526c45b6bee2879398003e8e64 Mon Sep 17 00:00:00 2001 From: Hannah Eslinger Date: Fri, 8 Dec 2023 10:04:17 -0700 Subject: [PATCH 18/29] Additional analyses performance improvement (#4438) * Faster get_property_views * Fix test * Fixed type annotations --------- Co-authored-by: Alex Swindler --- seed/models/analysis_property_views.py | 19 +++++++++---------- seed/views/v3/analysis_views.py | 5 ++--- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/seed/models/analysis_property_views.py b/seed/models/analysis_property_views.py index d847db9b6c..73ab1061da 100644 --- a/seed/models/analysis_property_views.py +++ b/seed/models/analysis_property_views.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError from django.db import models, transaction +from django.db.models import QuerySet from seed.models import Analysis, Cycle, Property, PropertyState, PropertyView @@ -84,21 +85,19 @@ def batch_create(cls, analysis_id, property_view_ids): return analysis_property_view_ids, failures @classmethod - def get_property_views(cls, analysis_property_views): + def get_property_views(cls, analysis_property_views: QuerySet): """Get PropertyViews related to the AnalysisPropertyViews. If no PropertyView is found for an AnalysisPropertyView, the value will be None for that key. - :param analysis_property_views: list[AnalysisPropertyView] + :param analysis_property_views: QuerySet[AnalysisPropertyView] :return: dict{int: PropertyView}, PropertyViews keyed by the related AnalysisPropertyView id """ - # build a query to find PropertyViews linked to the canonical property and cycles we're interested in - property_view_query = models.Q() - for analysis_property_view in analysis_property_views: - property_view_query |= ( - models.Q(property_id=analysis_property_view.property_id) - & models.Q(cycle_id=analysis_property_view.cycle_id) - ) - property_views = PropertyView.objects.filter(property_view_query) + # Fast query to find all potentially-necessary propertyViews + views = analysis_property_views.values('property_id', 'cycle_id') + property_views = PropertyView.objects.filter( + property_id__in=set(v['property_id'] for v in views), + cycle_id__in=set(v['cycle_id'] for v in views), + ) # get original property views keyed by canonical property id and cycle property_views_by_property_cycle_id = { diff --git a/seed/views/v3/analysis_views.py b/seed/views/v3/analysis_views.py index e3bf39e21f..35913fac97 100644 --- a/seed/views/v3/analysis_views.py +++ b/seed/views/v3/analysis_views.py @@ -11,7 +11,7 @@ from seed.decorators import ajax_request_class, require_organization_id_class from seed.lib.superperms.orgs.decorators import has_perm_class -from seed.models import AnalysisPropertyView +from seed.models import AnalysisPropertyView, PropertyView from seed.serializers.analysis_property_views import ( AnalysisPropertyViewSerializer ) @@ -68,8 +68,7 @@ def retrieve(self, request, analysis_pk, pk): 'message': "Requested analysis property view doesn't exist in this organization and/or analysis." }, status=HTTP_409_CONFLICT) - property_view_by_apv_id = AnalysisPropertyView.get_property_views([view]) - original_view = property_view_by_apv_id[view.id] + original_view = PropertyView.objects.filter(property=view.property, cycle=view.cycle).first() return JsonResponse({ 'status': 'success', From 1dc901360e499898385fa3f940fdce4284bc55f1 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Fri, 8 Dec 2023 12:19:02 -0700 Subject: [PATCH 19/29] Release 2.21.0 (#4439) Updates for v2.21.0 --- CHANGELOG.md | 31 +++++++++++++++ docs/source/migrations.rst | 4 ++ locale/en_US/LC_MESSAGES/django.mo | Bin 117060 -> 117706 bytes locale/en_US/LC_MESSAGES/django.po | 18 +++++++++ locale/fr_CA/LC_MESSAGES/django.mo | Bin 127964 -> 128804 bytes locale/fr_CA/LC_MESSAGES/django.po | 18 +++++++++ package-lock.json | 4 +- package.json | 2 +- seed/static/seed/locales/en_US.json | 6 +-- seed/static/seed/locales/fr_CA.json | 8 ++-- vendors/package-lock.json | 56 ++++++++++++++-------------- 11 files changed, 109 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3281e510ca..895a48b755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +# SEED Version 2.21.0 + + + +## What's Changed +### New Features 🎉 +* Provide environment variable to disable wait-for-it condition in deployments by @dhaley in https://github.com/SEED-platform/seed/pull/4377 +* Add healthcheck to docker web container by @axelstudios in https://github.com/SEED-platform/seed/pull/4374 +* Audit Template report submission endpoint by @kflemin in https://github.com/SEED-platform/seed/pull/4411 +* Remove Summary charts by @haneslinger in https://github.com/SEED-platform/seed/pull/4402 +* Add count to default reports by @haneslinger in https://github.com/SEED-platform/seed/pull/4423 +### Improvements 📈 +* Property Insights UI enhancements by @kflemin in https://github.com/SEED-platform/seed/pull/4384 +* Add distance to target in table under property insights graph by @kflemin in https://github.com/SEED-platform/seed/pull/4386 +* Populate default report using cycles by @haneslinger in https://github.com/SEED-platform/seed/pull/4420 +* Analyses performance improvement by @haneslinger in https://github.com/SEED-platform/seed/pull/4434 +* Additional analyses performance improvement by @haneslinger in https://github.com/SEED-platform/seed/pull/4438 +### Maintenance 🧹 +* Updated developer resources documentation by @axelstudios in https://github.com/SEED-platform/seed/pull/4373 +* Bump django from 3.2.20 to 3.2.23 in /requirements by @dependabot in https://github.com/SEED-platform/seed/pull/4379 +* Updated salesforce pg_restore docs by @axelstudios in https://github.com/SEED-platform/seed/pull/4382 +### Bug Fixes 🐛 +* Fix CO2 analysis column naming by @ebeers-png in https://github.com/SEED-platform/seed/pull/4410 +* Salesforce scheduling issues by @axelstudios in https://github.com/SEED-platform/seed/pull/4387 +* Custom report name field location by @kflemin in https://github.com/SEED-platform/seed/pull/4385 +* Order cycles by start date on program setup by @kflemin in https://github.com/SEED-platform/seed/pull/4428 +* Property Insights tooltip refinement by @kflemin in https://github.com/SEED-platform/seed/pull/4425 + + +**Full Changelog**: https://github.com/SEED-platform/seed/compare/v2.20.1...v2.21.0 + # SEED Version 2.20.1 diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index d6f8db45a8..cd321bd2ba 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -47,6 +47,10 @@ local_untracked.py file ), ) +Version 2.21.0 +-------------- +- There are no special migrations needed for this version. Simply run `./manage.py migrate`. + Version 2.20.1 -------------- - There are no special migrations needed for this version. Simply run `./manage.py migrate`. diff --git a/locale/en_US/LC_MESSAGES/django.mo b/locale/en_US/LC_MESSAGES/django.mo index efae7836a1868e1ecc6edd73f45db66e040ab9a6..fab5a46cbf621caf49af3a80efc66caf4a7eaee1 100644 GIT binary patch delta 28386 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

    Kre8;xl^BaU?XysaFdlVS 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�EzE;8 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 PMZLtH9k+LRo+SJqDwgM% diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po index f58dc25388..d84446e63a 100644 --- a/locale/en_US/LC_MESSAGES/django.po +++ b/locale/en_US/LC_MESSAGES/django.po @@ -367,6 +367,9 @@ msgstr "Buildings" msgid "By clicking the Log In button you accept the NREL Data Terms." msgstr "By clicking the Log In button you accept the NREL Data Terms." +msgid "CLICK_LEGEND" +msgstr "Click a label below to show/hide it on the chart" + msgid "COLUMN_NAME_DUPLICATE_ERROR" msgstr "Error: New column name cannot match previous name." @@ -424,6 +427,9 @@ msgstr "Normally when an imported record is merged into another record the newes msgid "COMPLETE_AND_REFRESH" msgstr "Complete and Refresh Page" +msgid "CONFIGURE_PROGRAM" +msgstr "Need to configure your Program?" + msgid "CONFIRMING_DELETE_PROFILE" msgstr "Are you sure you want to delete the profile" @@ -484,6 +490,9 @@ msgstr "Changed By" msgid "Chart Legend" msgstr "Chart Legend" +msgid "Chart Options" +msgstr "Chart Options" + msgid "Choose Existing Organization:" msgstr "Choose Existing Organization:" @@ -1440,6 +1449,9 @@ msgstr "Include in your Tax Lot List all tax lots shared with you." msgid "INDIVIDUAL_MATCH_MERGE_ROUND_ALERT" msgstr "This action will kick off a match and merge round for the resulting record. This record's values will be given precedence in the event that any merges occur. Multiple matching records are merged beforehand with precedence given to more recently imported records." +msgid "INSIGHTS_HELP_TEXT" +msgstr "Use the controls below to display a subset of properties on the graph. The chart legend can also be used to display or hide properties based on compliance status. The 'Update Property Labels' button can then be used to edit the labels applied to the properties visible on the graph." + msgid "INTERNAL" msgstr "INTERNAL" @@ -2304,6 +2316,9 @@ msgstr "Profile Name" msgid "Program" msgstr "Program" +msgid "Program Configuration page" +msgstr "Program Configuration page" + msgid "Program Definition" msgstr "Program Definition" @@ -3385,6 +3400,9 @@ msgstr "Update Charts" msgid "Update Filters" msgstr "Update Filters" +msgid "Update Property Labels" +msgstr "Update Property Labels" + msgid "Update Salesforce" msgstr "Update Salesforce" diff --git a/locale/fr_CA/LC_MESSAGES/django.mo b/locale/fr_CA/LC_MESSAGES/django.mo index 4c15135cba1f3b98440cd1fff21eee00ba629760..e73f3137595000718ffde5feb463a9cd8d9a4ceb 100644 GIT binary patch delta 27860 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 diff --git a/locale/fr_CA/LC_MESSAGES/django.po b/locale/fr_CA/LC_MESSAGES/django.po index 2b5df06c66..817d0acab1 100644 --- a/locale/fr_CA/LC_MESSAGES/django.po +++ b/locale/fr_CA/LC_MESSAGES/django.po @@ -370,6 +370,9 @@ msgstr "Bâtiments" msgid "By clicking the Log In button you accept the NREL Data Terms." msgstr "En cliquant sur le bouton Connexion, vous acceptez les conditions d'utilisation des données NREL." +msgid "CLICK_LEGEND" +msgstr "Cliquez sur une étiquette ci-dessous pour l'afficher/masquer sur le graphique" + msgid "COLUMN_NAME_DUPLICATE_ERROR" msgstr "Erreur: Le nom de la nouvelle colonne ne peut pas correspondre au nom précédent." @@ -430,6 +433,9 @@ msgstr "Normalement, lorsqu'un enregistrement importé est fusionné dans un aut msgid "COMPLETE_AND_REFRESH" msgstr "Complétez et Actualisez la Page" +msgid "CONFIGURE_PROGRAM" +msgstr "Besoin de configurer votre programme?" + msgid "CONFIRMING_DELETE_PROFILE" msgstr "Êtes-vous sûr de vouloir supprimer le profil" @@ -491,6 +497,9 @@ msgstr "Changé par" msgid "Chart Legend" msgstr "Légende du graphique" +msgid "Chart Options" +msgstr "Options du graphique" + msgid "Choose Existing Organization:" msgstr "Choisissez Organisation Existante:" @@ -1453,6 +1462,9 @@ msgstr "Inclure dans votre Liste de Lots d'Impôt tous les lots d'impôt partag msgid "INDIVIDUAL_MATCH_MERGE_ROUND_ALERT" msgstr "Cette action lancera un match et fusionnera pour l’enregistrement résultant. Les valeurs de cet enregistrement auront la priorité dans l'éventualité d'une fusion. Plusieurs enregistrements correspondants sont préalablement fusionnés, la priorité étant donnée aux enregistrements récemment importés." +msgid "INSIGHTS_HELP_TEXT" +msgstr "Utilisez les commandes ci-dessous pour afficher un sous-ensemble de propriétés sur le graphique. La légende du graphique peut également être utilisée pour afficher ou masquer des propriétés en fonction de l'état de conformité. Le bouton 'Mettre à jour les étiquettes des propriétés' peut ensuite être utilisé pour modifier les étiquettes appliquées aux propriétés visibles sur le graphique." + msgid "INTERNAL" msgstr "INTERNE" @@ -2320,6 +2332,9 @@ msgstr "Nom de profil" msgid "Program" msgstr "Programme" +msgid "Program Configuration page" +msgstr "Page de configuration du programme" + msgid "Program Definition" msgstr "Définition du programme" @@ -3412,6 +3427,9 @@ msgstr "Mettre à jour les graphiques" msgid "Update Filters" msgstr "Mise à jour les filtres" +msgid "Update Property Labels" +msgstr "Mettre à jour les étiquettes de propriété" + msgid "Update Salesforce" msgstr "Mettre à jour Salesforce" diff --git a/package-lock.json b/package-lock.json index 0fc5fe31f5..977e2de4ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "seed", - "version": "2.20.1", + "version": "2.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seed", - "version": "2.20.1", + "version": "2.21.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.md", "devDependencies": { diff --git a/package.json b/package.json index b3d0b5bd1b..2e7f509ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "seed", - "version": "2.20.1", + "version": "2.21.0", "description": "Standard Energy Efficiency Data (SEED) Platform™", "license": "SEE LICENSE IN LICENSE.md", "directories": { diff --git a/seed/static/seed/locales/en_US.json b/seed/static/seed/locales/en_US.json index 478c54ecda..3c9a46b919 100644 --- a/seed/static/seed/locales/en_US.json +++ b/seed/static/seed/locales/en_US.json @@ -115,7 +115,7 @@ "Building Filters": "Building Filters", "Buildings": "Buildings", "By clicking the Log In button you accept the NREL Data Terms.": "By clicking the Log In button you accept the NREL Data Terms.", - "CLICK_LEGEND": "Click a label below to show/hide it on the chart", + "CLICK_LEGEND": "Click a label below to show\/hide it on the chart", "COLUMN_NAME_DUPLICATE_ERROR": "Error: New column name cannot match previous name.", "COLUMN_NAME_EXISTS_WARNING": "Warning: Column name already exists.", "COL_CHANGE_DESCRIPTION": "Provides a description of the column to assist in remembering the meaning of the column. This defaults to the display name if available, otherwise the column name is utilized.", @@ -135,7 +135,7 @@ "COL_MATCHING_CRITERIA_TOGGLE": "Checking this box for a field will allow it to be used as a matching field.", "COL_MERGE_PROTECTION_TOGGLE": "Normally when an imported record is merged into another record the newest value overwrites an older one. Merge protection prevents this, and is particularly useful for columns where you have manually edited values that you want to persist even after importing and merging new data.", "COMPLETE_AND_REFRESH": "Complete and Refresh Page", - "CONFIGURE_PROGRAM":"Need to configure your Program?", + "CONFIGURE_PROGRAM": "Need to configure your Program?", "CONFIRMING_DELETE_PROFILE": "Are you sure you want to delete the profile", "CONFIRM_AND_START_MATCHING": "Confirm mappings & start matching", "CONTINUE": "Continue", @@ -469,8 +469,8 @@ "INCLUDE_SHARED_PROPERTIES": "Include in your Properties List all properties shared with you.", "INCLUDE_SHARED_TAXLOTS": "Include in your Tax Lot List all tax lots shared with you.", "INDIVIDUAL_MATCH_MERGE_ROUND_ALERT": "This action will kick off a match and merge round for the resulting record. This record's values will be given precedence in the event that any merges occur. Multiple matching records are merged beforehand with precedence given to more recently imported records.", - "INTERNAL": "INTERNAL", "INSIGHTS_HELP_TEXT": "Use the controls below to display a subset of properties on the graph. The chart legend can also be used to display or hide properties based on compliance status. The 'Update Property Labels' button can then be used to edit the labels applied to the properties visible on the graph.", + "INTERNAL": "INTERNAL", "INVALID_CSV_EXTENSION_ALERT": "Sorry!<\/strong> SEED doesn't currently support that file format. Only .csv<\/strong> files are supported.", "INVALID_DOC_FILE_EXTENSION_ALERT": "Invalid document type selected. Accepted file types are .dxf, .pdf, .idf, and .osm", "INVALID_EXTENSION_ALERT": "Sorry!<\/strong> SEED doesn't currently support that file format. Only .csv<\/strong>, .xls<\/strong>, .xlsx<\/strong>, and .xml<\/strong> files are supported.", diff --git a/seed/static/seed/locales/fr_CA.json b/seed/static/seed/locales/fr_CA.json index dfbfedd277..fac5f1c5c1 100644 --- a/seed/static/seed/locales/fr_CA.json +++ b/seed/static/seed/locales/fr_CA.json @@ -115,7 +115,7 @@ "Building Filters": "Filtres de bâtiments", "Buildings": "Bâtiments", "By clicking the Log In button you accept the NREL Data Terms.": "En cliquant sur le bouton Connexion, vous acceptez les conditions d'utilisation des données NREL.", - "CLICK_LEGEND": "Cliquez sur une étiquette ci-dessous pour l'afficher/masquer sur le graphique", + "CLICK_LEGEND": "Cliquez sur une étiquette ci-dessous pour l'afficher\/masquer sur le graphique", "COLUMN_NAME_DUPLICATE_ERROR": "Erreur: Le nom de la nouvelle colonne ne peut pas correspondre au nom précédent.", "COLUMN_NAME_EXISTS_WARNING": "Attention: le nom de la colonne existe déjà.", "COL_CHANGE_DESCRIPTION": "Fournit une description de la colonne pour aider à se souvenir de la signification de la colonne. Il s'agit par défaut du nom d'affichage s'il est disponible, sinon le nom de la colonne est utilisé.", @@ -135,7 +135,7 @@ "COL_MATCHING_CRITERIA_TOGGLE": "Pour les colonnes non extra_data, indiquez si la colonne correspond aux critères", "COL_MERGE_PROTECTION_TOGGLE": "Normalement, lorsqu'un enregistrement importé est fusionné dans un autre enregistrement, la valeur la plus récente remplace un enregistrement plus ancien. La protection de fusion empêche cela et est particulièrement utile pour les colonnes dans lesquelles vous avez manuellement modifié les valeurs que vous souhaitez conserver même après l'importation et la fusion de nouvelles données.", "COMPLETE_AND_REFRESH": "Complétez et Actualisez la Page", - "CONFIGURE_PROGRAM":"Besoin de configurer votre programme?", + "CONFIGURE_PROGRAM": "Besoin de configurer votre programme?", "CONFIRMING_DELETE_PROFILE": "Êtes-vous sûr de vouloir supprimer le profil", "CONFIRM_AND_START_MATCHING": "Confirmer et commencer l'appariement", "CONTINUE": "Continuer", @@ -153,8 +153,8 @@ "Change Your Password": "Changez votre mot de passe", "Change my password": "Changer mon mot de passe", "Changed By": "Changé par", - "Chart Legend": "Légende du Graphique", - "Chart Options": "Options du Graphique", + "Chart Legend": "Légende du graphique", + "Chart Options": "Options du graphique", "Choose Existing Organization:": "Choisissez Organisation Existante:", "Choose the year ending month for report period.": "Choisissez le mois de fin de l'année pour la période du rapport.", "City": "Ville", diff --git a/vendors/package-lock.json b/vendors/package-lock.json index d59f815d16..32765ce618 100644 --- a/vendors/package-lock.json +++ b/vendors/package-lock.json @@ -73,11 +73,11 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", "dependencies": { - "@babel/highlight": "^7.22.13", + "@babel/highlight": "^7.23.4", "chalk": "^2.4.2" }, "engines": { @@ -143,11 +143,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.5.tgz", + "integrity": "sha512-BPssCHrBD+0YrxviOa3QzpqwhNIXKEtOa2jQrm4FlmkC2apYgRnQcmPWiGZDlGxiNtltnUFolMe8497Esry+jA==", "dependencies": { - "@babel/types": "^7.23.0", + "@babel/types": "^7.23.5", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -254,9 +254,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", "engines": { "node": ">=6.9.0" } @@ -291,9 +291,9 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", @@ -304,9 +304,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.5.tgz", + "integrity": "sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ==", "bin": { "parser": "bin/babel-parser.js" }, @@ -328,18 +328,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz", - "integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.5.tgz", + "integrity": "sha512-czx7Xy5a6sapWWRx61m1Ke1Ra4vczu1mCTtJam5zRTBOonfdJ+S/B6HYmGYu3fJtr8GGET3si6IhgWVBhJ/m8w==", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", + "@babel/parser": "^7.23.5", + "@babel/types": "^7.23.5", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -369,11 +369,11 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.5.tgz", + "integrity": "sha512-ON5kSOJwVO6xXVRTvOI0eOnWe7VdUcIpsovGo9U/Br4Ie4UVFQTboO2cYnDhAGU6Fp+UxSiT+pMft0SMHfuq6w==", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", "to-fast-properties": "^2.0.0" }, From 3d334cf1fbb1028314c42f159a38f022cf93ca59 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Wed, 13 Dec 2023 09:13:38 -0800 Subject: [PATCH 20/29] sort cycles by start_date on property insights page (#4441) * sort cycles by start_date on property insights page * implement this better! --- seed/models/compliance_metrics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/seed/models/compliance_metrics.py b/seed/models/compliance_metrics.py index b2a55f6c0e..223a3cf7a2 100644 --- a/seed/models/compliance_metrics.py +++ b/seed/models/compliance_metrics.py @@ -104,6 +104,7 @@ def evaluate(self): results_by_cycles = {} # property_datasets = {} # figure out what kind of metric it is (energy? emission? combo? bool?) + metric = { 'energy_metric': False, 'emission_metric': False, @@ -118,7 +119,7 @@ def evaluate(self): 'target_emission_column': None, 'emission_metric_type': self.emission_metric_type, 'filter_group': None, - 'cycles': list(self.cycles.all().values('id', 'name')), + 'cycles': list(self.cycles.all().order_by('start').values('id', 'name')), 'x_axis_columns': list(self.x_axis_columns.all().values('id', 'display_name'))} if self.actual_energy_column is not None: From 462a320495358011ccea08c2ce22efc338fb4c39 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Thu, 14 Dec 2023 10:31:01 -0800 Subject: [PATCH 21/29] display x axis label for ranked distance to compliance (#4442) display x axis label for ranked Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> --- .../js/controllers/insights_property_controller.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/seed/static/seed/js/controllers/insights_property_controller.js b/seed/static/seed/js/controllers/insights_property_controller.js index 786f73dfc7..c2de5e8680 100644 --- a/seed/static/seed/js/controllers/insights_property_controller.js +++ b/seed/static/seed/js/controllers/insights_property_controller.js @@ -121,8 +121,8 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ } // x axis - $scope.x_axis_options = [...$scope.data.metric.x_axis_columns, { display_name: 'Ranked', id: 'Ranked' }]; + $scope.x_axis_options = [...$scope.data.metric.x_axis_columns, { display_name: 'Ranked Distance to Compliance', id: 'Ranked' }]; if (_.size($scope.x_axis_options) > 0) { // used saved chart_xaxis if (saved_configs?.chart_xaxis) { @@ -365,9 +365,8 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ } // x and y axis names and values - const x_index = _.findIndex($scope.data.metric.x_axis_columns, { id: $scope.configs.chart_xaxis }); - const x_axis_name = $scope.data.metric.x_axis_columns[x_index]?.display_name; - + const x_index = _.findIndex($scope.x_axis_options, { id: $scope.configs.chart_xaxis }); + const x_axis_name = $scope.x_axis_options[x_index]?.display_name; let y_axis_name = null; if ($scope.configs.chart_metric === 0) { y_axis_name = $scope.data.metric.actual_energy_column_name; @@ -458,8 +457,8 @@ angular.module('BE.seed.controller.insights_property', []).controller('insights_ }; const _update_chart = () => { - const x_index = _.findIndex($scope.data.metric.x_axis_columns, { id: $scope.configs.chart_xaxis }); - const x_axis_name = $scope.data.metric.x_axis_columns[x_index]?.display_name; + const x_index = _.findIndex($scope.x_axis_options, { id: $scope.configs.chart_xaxis }); + const x_axis_name = $scope.x_axis_options[x_index]?.display_name; let y_axis_name = null; if ($scope.configs.chart_metric === 0) { From acd8337466fa975eeed2e3c3e94479bd8172bea3 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Fri, 15 Dec 2023 13:40:11 -0700 Subject: [PATCH 22/29] Fixed FontAwesome v6.5 alignment (#4444) Fixed FontAwesome v6.5 issues Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> --- seed/static/seed/partials/home.html | 24 +++------ seed/static/seed/scss/style.scss | 80 +++++++++++------------------ seed/templates/seed/_sidebar.html | 24 ++++----- vendors/package-lock.json | 8 +-- vendors/package.json | 2 +- 5 files changed, 52 insertions(+), 86 deletions(-) diff --git a/seed/static/seed/partials/home.html b/seed/static/seed/partials/home.html index c0a1cea23f..55b54efbae 100644 --- a/seed/static/seed/partials/home.html +++ b/seed/static/seed/partials/home.html @@ -14,36 +14,24 @@

    The SEED Platform™

    -
    - -
    -
    -

    Upload your data

    -
    + +

    Upload your data

    MARKETING_BULLET_1
    -
    - -
    -
    -

    Match your data

    -
    + +

    Match your data

    MARKETING_BULLET_2
    -
    - -
    -
    -

    Manage compliance

    -
    + +

    Manage compliance

    MARKETING_BULLET_3
    diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index 3a10d3ee77..e866693c99 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -202,6 +202,7 @@ a:not([href]) { } .sidebar { + font-size: 14px; margin-left: 0; position: fixed; float: none; @@ -220,15 +221,15 @@ a:not([href]) { } .item { - position: relative; - overflow: hidden; - display: block; - padding: 16px; - width: 100%; - height: 53px; + height: 54px; color: $white; cursor: pointer; + display: flex; text-decoration: none; + justify-content: start; + overflow: hidden; + align-items: center; + position: relative; &:hover, &.active { @@ -258,15 +259,8 @@ a:not([href]) { .icon { display: flex; - align-items: center; - position: relative; - float: left; - width: 20px; - text-align: center; - - i { - width: 100%; - } + justify-content: center; + min-width: 55px; .toggle { display: flex; @@ -274,19 +268,13 @@ a:not([href]) { } } - i:not(.fa-sm) { - font-size: 18px; - } - .item_name { - position: relative; - top: 1px; - margin-top: 0; - margin-left: 16px; - line-height: 0; text-transform: uppercase; font-weight: bold; letter-spacing: 2px; + overflow: hidden; + text-overflow: ellipsis; + max-width: 204px; } .badge { @@ -859,8 +847,6 @@ a:not([href]) { h3 { font-size: 14px; font-weight: bold; - margin-top: 26px; - margin-bottom: 6px; } .content_block { @@ -909,42 +895,34 @@ a:not([href]) { .content_col { .content_col_header { - position: relative; - overflow: auto; - - .content_col_header_left { - position: relative; - float: left; + display: flex; + flex-direction: row; - i { - padding-top: 7px; - padding-right: 10px; - font-size: 48px; + i { + padding-top: 7px; + padding-right: 10px; + font-size: 48px; - &.fa-cloud-arrow-up { - color: $lightBlue; - } + &.fa-cloud-arrow-up { + color: $lightBlue; + } - &.fa-arrow-right-arrow-left { - color: lighten($purple, 25%); - } + &.fa-arrow-right-arrow-left { + color: lighten($purple, 25%); + } - &.fa-square-check { - color: lighten($green, 20%); - } + &.fa-square-check { + color: lighten($green, 20%); } } - .content_col_header_right { - h3 { - font-size: 20px; - font-weight: bold; - } + h3 { + font-size: 20px; + font-weight: bold; } } .copy { - clear: both; padding: 5px 30px 0 0; font-size: 16px; } diff --git a/seed/templates/seed/_sidebar.html b/seed/templates/seed/_sidebar.html index bcf292ab29..7fa196e4fa 100644 --- a/seed/templates/seed/_sidebar.html +++ b/seed/templates/seed/_sidebar.html @@ -3,7 +3,7 @@ diff --git a/vendors/package-lock.json b/vendors/package-lock.json index 32765ce618..70c26d79d7 100644 --- a/vendors/package-lock.json +++ b/vendors/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@fontsource/pt-sans": "^5.0.8", "@fontsource/pt-sans-narrow": "^5.0.15", - "@fortawesome/fontawesome-free": "^6.4.2", + "@fortawesome/fontawesome-free": "^6.5.1", "angular": "^1.8.3", "angular-animate": "^1.8.3", "angular-aria": "^1.8.3", @@ -400,9 +400,9 @@ "integrity": "sha512-jsECczOyWn+r4QqzLGY8FxVhecjbt+8mHk77VxOR/0IWWhO6BtQqJRPPirtn+E5nx6y71HDJaZac3ufXCzth+g==" }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.4.2.tgz", - "integrity": "sha512-m5cPn3e2+FDCOgi1mz0RexTUvvQibBebOUlUlW0+YrMjDTPkiJ6VTKukA1GRsvRw+12KyJndNjj0O4AgTxm2Pg==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz", + "integrity": "sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==", "hasInstallScript": true, "engines": { "node": ">=6" diff --git a/vendors/package.json b/vendors/package.json index ec271db37f..22c18dc050 100644 --- a/vendors/package.json +++ b/vendors/package.json @@ -7,7 +7,7 @@ "dependencies": { "@fontsource/pt-sans": "^5.0.8", "@fontsource/pt-sans-narrow": "^5.0.15", - "@fortawesome/fontawesome-free": "^6.4.2", + "@fortawesome/fontawesome-free": "^6.5.1", "angular": "^1.8.3", "angular-animate": "^1.8.3", "angular-aria": "^1.8.3", From d7adb2b9d2f8a90b95467715cae9a006d8776443 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Wed, 3 Jan 2024 17:10:58 -0700 Subject: [PATCH 23/29] Bug multi cycle test new year update (#4459) * eeej small files * change date to avoid test error --- seed/data_importer/tests/test_match_incoming.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed/data_importer/tests/test_match_incoming.py b/seed/data_importer/tests/test_match_incoming.py index c9208f2a07..abe7841958 100644 --- a/seed/data_importer/tests/test_match_incoming.py +++ b/seed/data_importer/tests/test_match_incoming.py @@ -1358,7 +1358,7 @@ def setUp(self): self.property_state_factory.get_property_state(**base_details) base_details['property_name'] = 'p_default_b' - base_details['year_ending'] = date(2023, 4, 10) + base_details['year_ending'] = date(2000, 4, 10) self.property_state_factory.get_property_state(**base_details) # Properties with missing year_ending will be placed in default cycle From 2f24661c589d43d6c25a2065f068b09ac20a3b76 Mon Sep 17 00:00:00 2001 From: Hannah Eslinger Date: Fri, 5 Jan 2024 11:38:18 -0700 Subject: [PATCH 24/29] Fix and optimize sensors (#4461) * Fix sensor import * Optimize sensor usage * Performance improvement * Add comment --------- Co-authored-by: Alex Swindler --- .../seed/partials/sensors_upload_modal.html | 2 +- seed/utils/sensors.py | 26 +++++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/seed/static/seed/partials/sensors_upload_modal.html b/seed/static/seed/partials/sensors_upload_modal.html index 2ab51d6143..9e0460d459 100644 --- a/seed/static/seed/partials/sensors_upload_modal.html +++ b/seed/static/seed/partials/sensors_upload_modal.html @@ -49,7 +49,7 @@ class="btn btn-primary col-sm-6 center-block" sd-uploader organization-id="organization_id" - sourcetype="SensorMetaData" + sourcetype="SensorMetadata" sourceprog="" sourcever="" importrecord="selectedDataset.id" diff --git a/seed/utils/sensors.py b/seed/utils/sensors.py index eb1273e1fc..1ef0afddcd 100644 --- a/seed/utils/sensors.py +++ b/seed/utils/sensors.py @@ -50,7 +50,9 @@ def _usages_by_exact_times(self, page, per_page): sensor_readings = SensorReading.objects.filter(sensor__in=self.sensors) if self.showOnlyOccupiedReadings: sensor_readings = sensor_readings.filter(is_occupied=True) - timestamps = sensor_readings.distinct('timestamp').order_by("timestamp").values_list("timestamp", flat=True) + + # order by id **greatly** speeds this up (cause of indexing, I think + timestamps = sensor_readings.distinct('timestamp').order_by("timestamp", "id").values_list("timestamp", flat=True) paginator = Paginator(timestamps, per_page) timestamps_in_page = paginator.page(page) @@ -71,19 +73,21 @@ def _usages_by_exact_times(self, page, per_page): time_format = "%Y-%m-%d %H:%M:%S" - for sensor in self.sensors: - field_name = self._build_column_def(sensor, column_defs) + field_name_by_sensor_id = { + sensor.id: self._build_column_def(sensor, column_defs) + for sensor in self.sensors + } - sensor_readings = sensor.sensor_readings.filter(timestamp__range=[earliest_time, latest_time]) - if self.showOnlyOccupiedReadings: - sensor_readings = sensor_readings.filter(is_occupied=True) + sensor_readings = SensorReading.objects.filter(timestamp__range=[earliest_time, latest_time], sensor__in=self.sensors) + if self.showOnlyOccupiedReadings: + sensor_readings = sensor_readings.filter(is_occupied=True) - for sensor_reading in sensor_readings.all(): - timestamp = sensor_reading.timestamp.astimezone(tz=self.tz).strftime(time_format) - times_key = str(timestamp) + for sensor_reading in sensor_readings.all(): + timestamp = sensor_reading.timestamp.astimezone(tz=self.tz).strftime(time_format) + times_key = str(timestamp) - timestamps[times_key]["timestamp"] = timestamp - timestamps[times_key][field_name] = sensor_reading.reading + timestamps[times_key]["timestamp"] = timestamp + timestamps[times_key][field_name_by_sensor_id[sensor_reading.sensor_id]] = sensor_reading.reading return { 'pagination': { From e500512cf0c837dd1f24f81d32e411efe25cb95d Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Tue, 9 Jan 2024 19:33:48 -0700 Subject: [PATCH 25/29] Restore column filters when unpinning (#4473) Fixes column filters when unpinning --- .../controllers/inventory_list_controller.js | 24 +++++++++++++++++-- vendors/package-lock.json | 8 +++---- vendors/package.json | 2 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/seed/static/seed/js/controllers/inventory_list_controller.js b/seed/static/seed/js/controllers/inventory_list_controller.js index 89557faf79..02382d338a 100644 --- a/seed/static/seed/js/controllers/inventory_list_controller.js +++ b/seed/static/seed/js/controllers/inventory_list_controller.js @@ -11,6 +11,7 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li '$state', '$stateParams', '$q', + '$timeout', 'inventory_service', 'label_service', 'data_quality_service', @@ -45,6 +46,7 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li $state, $stateParams, $q, + $timeout, inventory_service, label_service, data_quality_service, @@ -1829,7 +1831,7 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li fastWatch: true, flatEntityAccess: true, gridMenuShowHideColumns: false, - showTreeExpandNoChildren: false, + hidePinRight: true, saveFocus: false, saveGrouping: false, saveGroupingExpandedStates: false, @@ -1840,6 +1842,7 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li saveTreeView: false, saveVisible: false, saveWidths: false, + showTreeExpandNoChildren: false, useExternalFiltering: true, useExternalSorting: true, columnDefs: $scope.columns, @@ -1903,7 +1906,24 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li } }, 1000) ); - gridApi.pinning.on.columnPinned($scope, saveSettings); + gridApi.pinning.on.columnPinned($scope, (colDef, container) => { + if (container) { + saveSettings(); + } else { + // Hack to fix disappearing filter after unpinning a column + const gridCol = gridApi.grid.columns.find(({ colDef: { name } }) => name === colDef.name); + if (gridCol) { + gridCol.colDef.visible = false; + gridApi.grid.refresh(); + + $timeout(() => { + gridCol.colDef.visible = true; + gridApi.grid.refresh(); + saveSettings(); + }, 0); + } + } + }); const selectionChanged = () => { const selected = gridApi.selection.getSelectedRows(); diff --git a/vendors/package-lock.json b/vendors/package-lock.json index 70c26d79d7..e522dafae2 100644 --- a/vendors/package-lock.json +++ b/vendors/package-lock.json @@ -24,7 +24,7 @@ "angular-translate-interpolation-messageformat": "^2.19.0", "angular-translate-loader-static-files": "^2.19.0", "angular-ui-bootstrap": "^2.5.6", - "angular-ui-grid": "^4.12.2", + "angular-ui-grid": "^4.12.4", "angular-ui-notification": "^0.3.6", "angular-ui-router": "^1.0.30", "angular-ui-router.statehelper": "^1.3.1", @@ -1985,9 +1985,9 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", diff --git a/vendors/package.json b/vendors/package.json index 22c18dc050..1924f0ee7d 100644 --- a/vendors/package.json +++ b/vendors/package.json @@ -20,7 +20,7 @@ "angular-translate-interpolation-messageformat": "^2.19.0", "angular-translate-loader-static-files": "^2.19.0", "angular-ui-bootstrap": "^2.5.6", - "angular-ui-grid": "^4.12.2", + "angular-ui-grid": "^4.12.4", "angular-ui-notification": "^0.3.6", "angular-ui-router": "^1.0.30", "angular-ui-router.statehelper": "^1.3.1", From 02f3f85780146f7566dba5bf82f94fc8e9edf468 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Wed, 10 Jan 2024 19:50:27 -0700 Subject: [PATCH 26/29] Update copyright to 2024 (#4470) --- LICENSE.md | 2 +- docs/source/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE.md b/LICENSE.md index e03bc2187b..45d140df16 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -SEED Platform™, Copyright (c) 2017, 2023 Alliance for Sustainable Energy, LLC, and other contributors. +SEED Platform™, Copyright (c) 2017, 2024 Alliance for Sustainable Energy, LLC, and other contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted diff --git a/docs/source/conf.py b/docs/source/conf.py index 5c77244de8..ac5d5c046f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -71,7 +71,7 @@ # General information about the project. project = 'SEED Platform' -copyright = '2017, 2023, Alliance for Sustainable Energy, LLC, and other contributors.' +copyright = '2017, 2024, Alliance for Sustainable Energy, LLC, and other contributors.' author = 'Alliance for Sustainable Energy, LLC, and other contributors.' # The version info for the project you're documenting, acts as replacement for From 13a83cc59fe8b2ebfed0d585496f93dc801fdc12 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Thu, 11 Jan 2024 10:24:48 -0700 Subject: [PATCH 27/29] Fixes text alignment following FontAwesome changes (#4465) --- seed/static/seed/scss/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index e866693c99..e4fcbb0a11 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -3745,7 +3745,7 @@ $pairedCellWidth: 60px; div { position: relative; text-align: right; - top: -2px; + top: -6px; width: 20px; } } From 036122d7a5f590d642e5c167c35fa05ebad8eae2 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Thu, 11 Jan 2024 10:25:14 -0700 Subject: [PATCH 28/29] Sensor reading performance improvement (#4464) Less queries, 40% faster --- seed/utils/sensors.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/seed/utils/sensors.py b/seed/utils/sensors.py index 1ef0afddcd..0ffa6f4df7 100644 --- a/seed/utils/sensors.py +++ b/seed/utils/sensors.py @@ -51,8 +51,7 @@ def _usages_by_exact_times(self, page, per_page): if self.showOnlyOccupiedReadings: sensor_readings = sensor_readings.filter(is_occupied=True) - # order by id **greatly** speeds this up (cause of indexing, I think - timestamps = sensor_readings.distinct('timestamp').order_by("timestamp", "id").values_list("timestamp", flat=True) + timestamps = list(sensor_readings.distinct('timestamp').order_by('timestamp').values_list("timestamp", flat=True)) paginator = Paginator(timestamps, per_page) timestamps_in_page = paginator.page(page) @@ -78,9 +77,7 @@ def _usages_by_exact_times(self, page, per_page): for sensor in self.sensors } - sensor_readings = SensorReading.objects.filter(timestamp__range=[earliest_time, latest_time], sensor__in=self.sensors) - if self.showOnlyOccupiedReadings: - sensor_readings = sensor_readings.filter(is_occupied=True) + sensor_readings = sensor_readings.filter(timestamp__range=[earliest_time, latest_time]) for sensor_reading in sensor_readings.all(): timestamp = sensor_reading.timestamp.astimezone(tz=self.tz).strftime(time_format) From 3e1cb89e3661954240bffd089bda899e9da77c79 Mon Sep 17 00:00:00 2001 From: Alex Swindler Date: Thu, 11 Jan 2024 16:52:47 -0700 Subject: [PATCH 29/29] Fix importing diesel meter readings (#4476) --- seed/data_importer/meters_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seed/data_importer/meters_parser.py b/seed/data_importer/meters_parser.py index f7f701d5dc..743646aac8 100644 --- a/seed/data_importer/meters_parser.py +++ b/seed/data_importer/meters_parser.py @@ -461,11 +461,11 @@ class with the data. # check which (if any) meter readings are provided # there can be more than one reading type per row (e.g., both electricity # and natural gas in the same row) - provided_reading_types = [] + provided_reading_types = set() for field in raw_data[0].keys(): for header_string in Meter.ENERGY_TYPE_BY_HEADER_STRING.keys(): if field.startswith(header_string): - provided_reading_types.append(field) + provided_reading_types.add(field) continue if not provided_reading_types: