From af1ac6abd092c96df7ab5face25206ec33169c01 Mon Sep 17 00:00:00 2001
From: Ross Perry
Date: Fri, 9 Aug 2024 15:22:11 -0600
Subject: [PATCH] Add cross cycle data quality checks (#4695)
* Initial cross-cycle data quality changes
* create default cross cycle rules
* update propertyview label name
* rule span
* merge develop
* add target to rule
* change cross-cycle to goal and add action
* order
* update goalnote passed_check based on rule
* update goalnote passed_check based on rule
* add and remove labels based on cross cycle data quality checks
* specify goal and baseline_view when applying cc labels
* add default labels, update rule model fields
* cross cycle rules functional
* labels limited to goal, or none goal
* update test
* checking default single cycle
* results for range and not null
* frontend results
* precommit
* precommit
* fix eui inverse
* add help text
* prevent new goal rules
* precommit
* lint
* test hard coded goal rules
* fix frontend test
* update migration to remove old constraint
* fix tests
* precommit
* remove consoles
* label width bugfix
* help text if not passing checks
* clarifying text
* default goal labels to show in list
* lint
* modify migration
* show all labels
* qaqc
* precommit
* lint
* temp disable wui rules
* lint
* remove WUI data type - indroduced in different pr
* precommit
* update rule count
* migration order
* update migration
update migration
precommit
* move spinner utility earlier
---------
Co-authored-by: Alex Swindler
Co-authored-by: Ross Perry
Co-authored-by: Ross Perry
Co-authored-by: Katherine Fleming <2205659+kflemin@users.noreply.github.com>
---
seed/api/v3/urls.py | 2 +
seed/data_importer/tasks.py | 45 ++-
.../0222_cross_cycle_data_quality.py | 214 +++++++++++++
seed/models/columns.py | 26 +-
seed/models/data_quality.py | 291 +++++++++++++++++-
seed/models/models.py | 10 +-
seed/models/properties.py | 20 +-
seed/serializers/property_view_labels.py | 21 ++
seed/serializers/rules.py | 6 +-
.../data_quality_admin_controller.js | 86 +++---
.../data_quality_modal_controller.js | 5 +
.../js/controllers/delete_modal_controller.js | 1 -
.../controllers/inventory_list_controller.js | 2 +-
.../portfolio_summary_controller.js | 74 +++--
seed/static/seed/js/seed.js | 18 +-
.../seed/js/services/data_quality_service.js | 9 +-
seed/static/seed/js/services/label_service.js | 7 +
seed/static/seed/partials/accounts.html | 2 +-
seed/static/seed/partials/accounts_nav.html | 2 +-
.../seed/partials/data_quality_admin.html | 127 +++++++-
.../seed/partials/portfolio_summary.html | 22 +-
seed/static/seed/scss/style.scss | 6 +-
.../data_quality_admin_controller.spec.js | 2 +-
seed/templates/seed/_header.html | 2 +-
seed/test_helpers/fake.py | 55 ++++
.../test_data_quality_check_rules_views.py | 11 +-
seed/tests/test_data_quality_checks.py | 186 ++++++++++-
seed/tests/test_labels_api_views.py | 1 +
seed/tests/test_property_view_labels.py | 95 ++++++
seed/utils/goals.py | 48 ++-
seed/utils/labels.py | 5 +-
seed/utils/merge.py | 2 +-
seed/views/v3/data_quality_checks.py | 7 +-
seed/views/v3/label_inventories.py | 2 +-
seed/views/v3/property_view_labels.py | 46 +++
35 files changed, 1315 insertions(+), 143 deletions(-)
create mode 100644 seed/migrations/0222_cross_cycle_data_quality.py
create mode 100644 seed/serializers/property_view_labels.py
create mode 100644 seed/tests/test_property_view_labels.py
create mode 100644 seed/views/v3/property_view_labels.py
diff --git a/seed/api/v3/urls.py b/seed/api/v3/urls.py
index 0f584807c8..3c8b554ebf 100644
--- a/seed/api/v3/urls.py
+++ b/seed/api/v3/urls.py
@@ -55,6 +55,7 @@
from seed.views.v3.properties import PropertyViewSet
from seed.views.v3.property_measures import PropertyMeasureViewSet
from seed.views.v3.property_scenarios import PropertyScenarioViewSet
+from seed.views.v3.property_view_labels import PropertyViewLabelViewSet
from seed.views.v3.property_views import PropertyViewViewSet
from seed.views.v3.public import PublicCycleViewSet, PublicOrganizationViewSet
from seed.views.v3.salesforce_configs import SalesforceConfigViewSet
@@ -102,6 +103,7 @@
api_v3_router.register(r"postoffice_email", PostOfficeEmailViewSet, basename="postoffice_email")
api_v3_router.register(r"progress", ProgressViewSet, basename="progress")
api_v3_router.register(r"properties", PropertyViewSet, basename="properties")
+api_v3_router.register(r"property_view_labels", PropertyViewLabelViewSet, basename="property_view_labels")
api_v3_router.register(r"property_views", PropertyViewViewSet, basename="property_views")
api_v3_router.register(r"salesforce_configs", SalesforceConfigViewSet, basename="salesforce_configs")
api_v3_router.register(r"salesforce_mappings", SalesforceMappingViewSet, basename="salesforce_mappings")
diff --git a/seed/data_importer/tasks.py b/seed/data_importer/tasks.py
index 1ff3cc5369..38ce8a5f46 100644
--- a/seed/data_importer/tasks.py
+++ b/seed/data_importer/tasks.py
@@ -72,6 +72,7 @@
ColumnMapping,
Cycle,
DataLogger,
+ Goal,
Meter,
PropertyAuditLog,
PropertyState,
@@ -86,6 +87,7 @@
from seed.models.data_quality import DataQualityCheck, Rule
from seed.utils.buildings import get_source_type
from seed.utils.geocode import MapQuestAPIKeyError, create_geocoded_additional_columns, geocode_buildings
+from seed.utils.goals import get_state_pairs
from seed.utils.match import update_sub_progress_total
from seed.utils.ubid import decode_unique_ids
@@ -95,17 +97,26 @@
@shared_task(ignore_result=True)
-def check_data_chunk(model, ids, dq_id):
+def check_data_chunk(org_id, model, ids, dq_id, goal_id=None):
+ try:
+ organization = Organization.objects.get(id=org_id)
+ super_organization = organization.get_parent()
+ except Organization.DoesNotExist:
+ return
+
if model == "PropertyState":
qs = PropertyState.objects.filter(id__in=ids)
elif model == "TaxLotState":
qs = TaxLotState.objects.filter(id__in=ids)
- else:
- qs = None
- organization = qs.first().organization
- super_organization = organization.get_parent()
+ elif model == "Property" and goal_id:
+ # return a list of dicts with property, basseline_state, and current_state
+ state_pairs = get_state_pairs(ids, goal_id)
+
d = DataQualityCheck.retrieve(super_organization.id)
- d.check_data(model, qs.iterator())
+ if not goal_id:
+ d.check_data(model, qs.iterator())
+ else:
+ d.check_data_cross_cycle(goal_id, state_pairs)
d.save_to_cache(dq_id, organization.id)
@@ -122,13 +133,14 @@ def finish_checking(progress_key):
return progress_data.result()
-def do_checks(org_id, propertystate_ids, taxlotstate_ids, import_file_id=None):
+def do_checks(org_id, propertystate_ids, taxlotstate_ids, goal_id, import_file_id=None):
"""
Run the dq checks on the data
:param org_id:
:param propertystate_ids:
:param taxlotstate_ids:
+ :param goal_id:
:param import_file_id: int, if present, find the data to check by the import file id
:return:
"""
@@ -151,7 +163,7 @@ def do_checks(org_id, propertystate_ids, taxlotstate_ids, import_file_id=None):
.values_list("id", flat=True)
)
- tasks = _data_quality_check_create_tasks(org_id, propertystate_ids, taxlotstate_ids, dq_id)
+ tasks = _data_quality_check_create_tasks(org_id, propertystate_ids, taxlotstate_ids, goal_id, dq_id)
progress_data.total = len(tasks)
progress_data.save()
if tasks:
@@ -589,7 +601,7 @@ def _map_data_create_tasks(import_file_id, progress_key):
return tasks
-def _data_quality_check_create_tasks(org_id, property_state_ids, taxlot_state_ids, dq_id):
+def _data_quality_check_create_tasks(org_id, property_state_ids, taxlot_state_ids, goal_id, dq_id):
"""
Entry point into running data quality checks.
@@ -612,12 +624,23 @@ def _data_quality_check_create_tasks(org_id, property_state_ids, taxlot_state_id
if property_state_ids:
id_chunks = [list(chunk) for chunk in batch(property_state_ids, 100)]
for ids in id_chunks:
- tasks.append(check_data_chunk.s("PropertyState", ids, dq_id))
+ tasks.append(check_data_chunk.s(org_id, "PropertyState", ids, dq_id))
if taxlot_state_ids:
id_chunks_tl = [list(chunk) for chunk in batch(taxlot_state_ids, 100)]
for ids in id_chunks_tl:
- tasks.append(check_data_chunk.s("TaxLotState", ids, dq_id))
+ tasks.append(check_data_chunk.s(org_id, "TaxLotState", ids, dq_id))
+
+ if goal_id:
+ # If goal_id is passed, treat as a cross cycle data quality check.
+ try:
+ goal = Goal.objects.get(id=goal_id)
+ property_ids = goal.properties().values_list("id", flat=True)
+ id_chunks = [list(chunk) for chunk in batch(property_ids, 100)]
+ for ids in id_chunks:
+ tasks.append(check_data_chunk.s(org_id, "Property", ids, dq_id, goal.id))
+ except Goal.DoesNotExist:
+ pass
return tasks
diff --git a/seed/migrations/0222_cross_cycle_data_quality.py b/seed/migrations/0222_cross_cycle_data_quality.py
new file mode 100644
index 0000000000..de62e890ce
--- /dev/null
+++ b/seed/migrations/0222_cross_cycle_data_quality.py
@@ -0,0 +1,214 @@
+# Generated by Django 3.2.20 on 2023-10-24 21:54
+
+import django.db.models.deletion
+from django.db import connection, migrations, models
+
+
+def forwards(apps, schema_editor):
+ Organization = apps.get_model("orgs", "Organization")
+ Rule = apps.get_model("seed", "Rule")
+ DataQualityCheck = apps.get_model("seed", "DataQualityCheck")
+ Label = apps.get_model("seed", "StatusLabel")
+
+ # Populate the default labels for goal rules.
+ NEW_DEFAULT_LABELS = [
+ "High EUI % Change",
+ "Low EUI % Change",
+ "High Area",
+ "Low Area",
+ "High Area % Change",
+ "Low Area % Change",
+ ]
+
+ # Populate the default data quality goal rules if they do not already exist.
+ TYPE_AREA = 4
+ TYPE_EUI = 5
+ RULE_TYPE_DEFAULT = 0
+ SEVERITY_ERROR = 0
+ RULE_NOT_NULL = "not_null"
+ RULE_RANGE = "range"
+
+ NEW_DEFAULT_RULES = [
+ {
+ "table_name": "Goal",
+ "name": "High EUI % Change",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 40,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low EUI % Change",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": -40,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "High Area % Change",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 5,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low Area % Change",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": -5,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "High EUI",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 1000,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low EUI",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": 40,
+ },
+ {
+ "table_name": "Goal",
+ "name": "High Area",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 1000000,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low Area",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": 1000,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Missing EUI",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_NOT_NULL,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Missing Area",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_NOT_NULL,
+ },
+ ]
+
+ for org in Organization.objects.all():
+ for label in NEW_DEFAULT_LABELS:
+ Label.objects.get_or_create(name=label, super_organization=org, defaults={"color": "blue"})
+ for dqc in DataQualityCheck.objects.all():
+ for rule in NEW_DEFAULT_RULES:
+ Rule.objects.get_or_create(**rule, data_quality_check=dqc)
+
+
+def remove_unique_constraint(apps, schema_editor):
+ # The auto generated unique constraint for PropertyViewLabels is PropertyView_id and StatusLabel_id.
+ # The constraint needs to be updated to include PropertyView_id, StatusLabel_id, and Goal.id
+ PropertyViewLabel = apps.get_model("seed", "PropertyViewLabel")
+ table_name = PropertyViewLabel._meta.db_table
+
+ # Get the original unique constraint
+ constraints = connection.introspection.get_constraints(connection.cursor(), table_name)
+ constraint_names = [
+ name
+ for name, details in constraints.items()
+ if details.get("unique") and set(details.get("columns")) == {"propertyview_id", "statuslabel_id"}
+ ]
+ # Remove the constraint
+ if constraint_names:
+ with connection.cursor() as cursor:
+ cursor.execute(f"ALTER TABLE {table_name} DROP CONSTRAINT {constraint_names[0]};")
+ # A new unique constraint will be created in the following operation
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("seed", "0221_audittemplateconfig"),
+ ]
+
+ operations = [
+ migrations.SeparateDatabaseAndState(
+ database_operations=[
+ migrations.RunSQL(
+ sql="ALTER TABLE seed_propertyview_labels RENAME TO seed_propertyviewlabel",
+ reverse_sql="ALTER TABLE seed_propertyviewlabel RENAME TO seed_propertyview_labels",
+ ),
+ ],
+ state_operations=[
+ migrations.CreateModel(
+ name="PropertyViewLabel",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("propertyview", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="seed.propertyview")),
+ ("statuslabel", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="seed.statuslabel")),
+ ],
+ ),
+ migrations.AlterField(
+ model_name="propertyview",
+ name="labels",
+ field=models.ManyToManyField(through="seed.PropertyViewLabel", to="seed.StatusLabel"),
+ ),
+ ],
+ ),
+ migrations.AddField(
+ model_name="propertyviewlabel",
+ name="goal",
+ field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="seed.goal"),
+ ),
+ migrations.AlterField(
+ model_name="rule",
+ name="field",
+ field=models.CharField(blank=True, max_length=200, null=True),
+ ),
+ migrations.AddField(
+ model_name="rule",
+ name="cross_cycle",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.RunPython(remove_unique_constraint),
+ migrations.AddConstraint(
+ model_name="propertyviewlabel",
+ constraint=models.UniqueConstraint(fields=("propertyview", "statuslabel", "goal"), name="unique_propertyview_statuslabel_goal"),
+ ),
+ migrations.RunPython(forwards),
+ ]
diff --git a/seed/models/columns.py b/seed/models/columns.py
index ca2ed13241..0bb76220e4 100644
--- a/seed/models/columns.py
+++ b/seed/models/columns.py
@@ -458,7 +458,7 @@ class Column(models.Model):
"display_name": "Gross Floor Area",
"column_description": "Gross Floor Area",
"data_type": "area",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "use_description",
@@ -473,7 +473,7 @@ class Column(models.Model):
"display_name": "ENERGY STAR Score",
"column_description": "ENERGY STAR Score",
"data_type": "integer",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "property_notes",
@@ -523,7 +523,7 @@ class Column(models.Model):
"display_name": "Building Count",
"column_description": "Building Count",
"data_type": "integer",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "year_built",
@@ -531,7 +531,7 @@ class Column(models.Model):
"display_name": "Year Built",
"column_description": "Year Built",
"data_type": "integer",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "recent_sale_date",
@@ -548,7 +548,7 @@ class Column(models.Model):
"display_name": "Conditioned Floor Area",
"column_description": "Conditioned Floor Area",
"data_type": "area",
- # 'type': 'number',
+ # "type": "number",
# 'dbField': True,
},
{
@@ -557,7 +557,7 @@ class Column(models.Model):
"display_name": "Occupied Floor Area",
"column_description": "Occupied Floor Area",
"data_type": "area",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "owner_address",
@@ -611,7 +611,7 @@ class Column(models.Model):
"display_name": "Site EUI",
"column_description": "Site EUI",
"data_type": "eui",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "site_eui_weather_normalized",
@@ -619,7 +619,7 @@ class Column(models.Model):
"display_name": "Site EUI Weather Normalized",
"column_description": "Site EUI Weather Normalized",
"data_type": "eui",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "site_eui_modeled",
@@ -627,7 +627,7 @@ class Column(models.Model):
"display_name": "Site EUI Modeled",
"column_description": "Site EUI Modeled",
"data_type": "eui",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "source_eui",
@@ -635,7 +635,7 @@ class Column(models.Model):
"display_name": "Source EUI",
"column_description": "Source EUI",
"data_type": "eui",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "source_eui_weather_normalized",
@@ -643,7 +643,7 @@ class Column(models.Model):
"display_name": "Source EUI Weather Normalized",
"column_description": "Source EUI Weather Normalized",
"data_type": "eui",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "source_eui_modeled",
@@ -651,7 +651,7 @@ class Column(models.Model):
"display_name": "Source EUI Modeled",
"column_description": "Source EUI Modeled",
"data_type": "eui",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "energy_alerts",
@@ -680,7 +680,7 @@ class Column(models.Model):
"display_name": "Number Properties",
"column_description": "Number Properties",
"data_type": "integer",
- # 'type': 'number',
+ # "type": "number",
},
{
"column_name": "block_number",
diff --git a/seed/models/data_quality.py b/seed/models/data_quality.py
index 68c85ad0eb..a761cbf47e 100644
--- a/seed/models/data_quality.py
+++ b/seed/models/data_quality.py
@@ -7,6 +7,7 @@
import json
import logging
import re
+from collections import defaultdict
from datetime import date, datetime
from random import randint
@@ -19,9 +20,10 @@
from quantityfield.units import ureg
from seed.lib.superperms.orgs.models import Organization
-from seed.models import Column, DerivedColumn, PropertyView, StatusLabel, TaxLotView, obj_to_dict
+from seed.models import Column, DerivedColumn, GoalNote, PropertyView, PropertyViewLabel, StatusLabel, TaxLotView, obj_to_dict
from seed.serializers.pint import pretty_units
from seed.utils.cache import get_cache_raw, set_cache_raw
+from seed.utils.goals import get_area_value, get_eui_value, percentage_difference
from seed.utils.time import convert_datestr
_log = logging.getLogger(__name__)
@@ -332,14 +334,117 @@ class Rule(models.Model):
"severity": SEVERITY_ERROR,
"condition": RULE_RANGE,
},
+ {
+ "table_name": "Goal",
+ "name": "High EUI % Change",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 40,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low EUI % Change",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": -40,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "High Area % Change",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 5,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low Area % Change",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": -5,
+ "cross_cycle": True,
+ },
+ {
+ "table_name": "Goal",
+ "name": "High EUI",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 1000,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low EUI",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": 40,
+ },
+ {
+ "table_name": "Goal",
+ "name": "High Area",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "max": 1000000,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Low Area",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_RANGE,
+ "min": 1000,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Missing EUI",
+ "field": "eui",
+ "data_type": TYPE_EUI,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_NOT_NULL,
+ },
+ {
+ "table_name": "Goal",
+ "name": "Missing Area",
+ "field": "area",
+ "data_type": TYPE_AREA,
+ "rule_type": RULE_TYPE_DEFAULT,
+ "severity": SEVERITY_ERROR,
+ "condition": RULE_NOT_NULL,
+ },
]
name = models.CharField(max_length=255, blank=True)
+ cross_cycle = models.BooleanField(default=False)
description = models.CharField(max_length=1000, blank=True)
data_quality_check = models.ForeignKey("DataQualityCheck", on_delete=models.CASCADE, related_name="rules", null=True)
status_label = models.ForeignKey(StatusLabel, on_delete=models.DO_NOTHING, null=True)
table_name = models.CharField(max_length=200, default="PropertyState", blank=True)
- field = models.CharField(max_length=200)
+ field = models.CharField(max_length=200, null=True, blank=True)
# If for_derived_column is True, `Rule.field` is a derived column name
# If False, `Rule.field` is a *State field or extra_data key
for_derived_column = models.BooleanField(default=False)
@@ -646,16 +751,51 @@ def check_data(self, record_type, rows):
for row in rows:
# Initialize the ID if it does not exist yet. Add in the other
# fields that are of interest to the GUI
- if row.id not in self.results:
- self.results[row.id] = {}
- for field in fields:
- self.results[row.id][field] = getattr(row, field)
- self.results[row.id]["data_quality_results"] = []
+ self.init_result(record_type, row, fields)
# Run the checks
self._check(rules, row, derived_columns_by_name)
# Prune the results will remove any entries that have zero data_quality_results
+ self.prune_results()
+
+ def check_data_cross_cycle(self, goal_id, state_pairs):
+ """
+ Send in data as properties and their goal state pairs. Update GoalNote.passed_checks with data quality check return
+ :param goal:
+ :param state_pairs: [{property: Property, baseline: PropertyState, current: PropertyState}, {}, ...]
+ """
+ rules = self.rules.filter(enabled=True, table_name="Goal")
+ goal_notes = GoalNote.objects.filter(goal=goal_id)
+ goal_notes = {note.property.id: note for note in goal_notes}
+ goal_notes_to_update = []
+ fields = self.get_fieldnames("PropertyState")
+ for row in state_pairs:
+ for cycle_key in ["baseline", "current"]:
+ self.init_result("PropertyState", row[cycle_key], fields)
+
+ goal_note = self._check_cross_cycle(rules, row, goal_notes)
+ if goal_note:
+ goal_notes_to_update.append(goal_note)
+
+ self.prune_results()
+
+ GoalNote.objects.bulk_update(goal_notes_to_update, ["passed_checks"])
+
+ def init_result(self, record_type, row, fields):
+ # Initialize the ID if it does not exist yet. Add in the other
+ # fields that are of interest to the GUI
+ if row and row.id not in self.results:
+ self.results[row.id] = {}
+ for field in fields:
+ self.results[row.id][field] = getattr(row, field)
+ view = row.taxlotview_set.first() if record_type == "TaxLotState" else row.propertyview_set.first()
+ if view:
+ self.results[row.id]["cycle"] = view.cycle.name
+ self.results[row.id]["data_quality_results"] = []
+
+ def prune_results(self):
+ # prune_results will remove any entries that have zero data_quality_results
for k, v in self.results.copy().items():
if not v["data_quality_results"]:
del self.results[k]
@@ -681,7 +821,7 @@ def _check(self, rules, row, derived_columns_by_name):
# check if the row has any rules applied to it
model_labels = {"linked_id": None, "label_ids": []}
if row.__class__.__name__ == "PropertyState":
- label = apps.get_model("seed", "PropertyView_labels")
+ label = apps.get_model("seed", "PropertyViewLabel")
if PropertyView.objects.filter(state=row).exists():
model_labels["linked_id"] = PropertyView.objects.get(state=row).id
model_labels["label_ids"] = list(
@@ -793,6 +933,108 @@ def _check(self, rules, row, derived_columns_by_name):
if not label_applied and rule.status_label_id in model_labels["label_ids"]:
self.remove_status_label(label, rule, linked_id)
+ def _check_cross_cycle(self, rules, row, goal_notes):
+ """
+ Check if property percent changes are within tolerance. Update GoalNote.passed_checks with result
+ :param rules: list, rules to run from database objects
+ :param row: { property: Property, baseline: PropertyState, current PropertyState }
+ :goal_notes: dictionary of { property_id: GoalNote, ... }
+ """
+ apply_labels = {"baseline": defaultdict(list), "current": defaultdict(list)}
+ results = []
+ property_id = row["property"].id
+ goal_note = goal_notes.get(property_id)
+ if not goal_note or not row["current"]:
+ # NEED TO MAKE SURE MISSING DATA IS APPLIED
+ return
+ goal = goal_note.goal
+ baseline_view = row["baseline"].propertyview_set.first() if row["baseline"] else None
+ current_view = row["current"].propertyview_set.first() if row["current"] else None
+
+ def check_range():
+ return (rule.min is None or value > rule.min) and (rule.max is None or value < rule.max)
+
+ def append_to_apply_labels():
+ if rule.status_label:
+ apply_labels[cycle_key][rule.status_label.id].append(result)
+
+ for rule in rules:
+ result = None
+ data_type = rule.DATA_TYPES[rule.data_type][1]
+ baseline = self.get_value(row, data_type, goal, "baseline")
+ current = self.get_value(row, data_type, goal, "current")
+ # EUI is inverese as a drop in EUI is an improvement
+ cycle_values = [baseline, current] if data_type == "eui" else [current, baseline]
+
+ if rule.cross_cycle:
+ cycle_key = "current"
+ value = percentage_difference(*cycle_values)
+ if value is None:
+ continue
+ if rule.condition == Rule.RULE_RANGE:
+ result = check_range()
+ results.append(result)
+ append_to_apply_labels()
+ if not result:
+ self.add_result_range_error(row["current"].id, rule, data_type, value)
+ self.update_status_label(PropertyViewLabel, rule, current_view.id, row["current"].id)
+
+ # other rule condition types
+ else:
+ logging.error(">>> OTHER")
+
+ else: # Within Cycle
+ for cycle_key in ["baseline", "current"]:
+ state = row["baseline"] if cycle_key == "baseline" else row["current"]
+ view = baseline_view if cycle_key == "baseline" else current_view
+ if not state:
+ return
+ value = baseline if cycle_key == "baseline" else current
+ if rule.condition == rule.RULE_RANGE:
+ if value:
+ result = check_range()
+ results.append(result)
+ append_to_apply_labels()
+ if not result:
+ self.add_result_range_error(state.id, rule, data_type, value)
+ self.update_status_label(PropertyViewLabel, rule, view.id, state.id)
+
+ elif rule.condition == rule.RULE_NOT_NULL:
+ result = value is not None
+ results.append(result)
+ append_to_apply_labels()
+ if not result:
+ self.add_result_is_null(state.id, rule, data_type, value)
+ self.update_status_label(PropertyViewLabel, rule, view.id, state.id)
+
+ # other rule condition types.
+ else:
+ logging.error(">>> OTHER")
+
+ goal_note.passed_checks = all(results)
+
+ # if there are multiple rules with the same label, determine if they are all passing to add or remove the label
+ for cycle_key in ["baseline", "current"]:
+ view = baseline_view if cycle_key == "baseline" else current_view
+
+ for id, results in apply_labels[cycle_key].items():
+ label = StatusLabel.objects.get(id=id)
+ property_view_label = PropertyViewLabel.objects.filter(propertyview=view, statuslabel=label, goal=goal)
+ if all(results):
+ property_view_label.delete()
+ elif not property_view_label:
+ PropertyViewLabel.objects.create(propertyview=view, statuslabel=label, goal=goal)
+
+ return goal_note
+
+ def get_value(self, property_obj, data_type, goal, cycle_key):
+ if not property_obj[cycle_key]:
+ return None
+ if data_type == "area":
+ return get_area_value(property_obj[cycle_key], goal)
+ elif data_type == "eui":
+ return get_eui_value(property_obj[cycle_key], goal)
+
def save_to_cache(self, identifier, organization_id):
"""
Save the results to the cache database. The data in the cache are
@@ -823,7 +1065,22 @@ def initialize_rules(self):
:return: None
"""
for rule in Rule.DEFAULT_RULES:
- self.rules.add(Rule.objects.create(**rule))
+ rule_instance = Rule.objects.create(**rule)
+ self.rules.add(rule_instance)
+ self.add_goal_rule_labels(rule_instance)
+
+ def add_goal_rule_labels(self, rule):
+ # Add labels to default portfolio/goal rules
+ if rule:
+ org = rule.data_quality_check.organization
+ try:
+ if "Missing" in rule.name:
+ rule.status_label = StatusLabel.objects.get(name="Missing Data", super_organization=org)
+ else:
+ rule.status_label = StatusLabel.objects.get(name=rule.name, super_organization=org)
+ rule.save()
+ except StatusLabel.DoesNotExist:
+ pass
def remove_all_rules(self):
"""
@@ -928,6 +1185,20 @@ def add_result_max_error(self, row_id, rule, display_name, value, rule_max):
}
)
+ def add_result_range_error(self, row_id, rule, display_name, value):
+ self.results[row_id]["data_quality_results"].append(
+ {
+ "field": rule.field,
+ "formatted_field": display_name,
+ "value": value,
+ "table_name": rule.table_name,
+ "message": f"{display_name} out of range",
+ "detailed_message": f"{display_name} [ {value} ] outside range {rule.min} to {rule.max}",
+ "severity": rule.get_severity_display(),
+ "condition": rule.condition,
+ }
+ )
+
def add_result_comparison_error(self, row_id, rule, display_name, value, rule_check):
self.results[row_id]["data_quality_results"].append(
{
@@ -1054,7 +1325,7 @@ def update_status_label(self, label_class, rule, linked_id, row_id, add_to_resul
f"Label with super_organization_id={label_org_id} cannot be applied to a record with parent "
f"organization_id={property_parent_org_id}."
)
- else:
+ elif rule.table_name == "TaxLotState":
taxlot_parent_org_id = TaxLotView.objects.get(pk=linked_id).taxlot.organization.get_parent().id
if taxlot_parent_org_id == label_org_id:
label_class.objects.get_or_create(taxlotview_id=linked_id, statuslabel_id=rule.status_label_id)
diff --git a/seed/models/models.py b/seed/models/models.py
index f745f76831..9b836d028a 100644
--- a/seed/models/models.py
+++ b/seed/models/models.py
@@ -118,11 +118,17 @@ class StatusLabel(TimeStampedModel):
"Update Bldg Info",
"Call",
"Email",
- "High EUI",
- "Low EUI",
"Exempted",
"Extension",
"Change of Ownership",
+ "High EUI",
+ "Low EUI",
+ "High EUI % Change",
+ "Low EUI % Change",
+ "High Area",
+ "Low Area",
+ "High Area % Change",
+ "Low Area % Change",
]
class Meta:
diff --git a/seed/models/properties.py b/seed/models/properties.py
index 1d2a7396e8..978744352c 100644
--- a/seed/models/properties.py
+++ b/seed/models/properties.py
@@ -13,7 +13,7 @@
from django.contrib.gis.db import models as geomodels
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models, transaction
-from django.db.models import Case, Value, When
+from django.db.models import Case, UniqueConstraint, Value, When
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.forms.models import model_to_dict
@@ -22,8 +22,6 @@
from quantityfield.units import ureg
from seed.data_importer.models import ImportFile
-
-# from seed.utils.cprofile import cprofile
from seed.lib.mcm.cleaners import date_cleaner
from seed.lib.superperms.orgs.models import AccessLevelInstance, Organization
from seed.models.cycles import Cycle
@@ -78,7 +76,7 @@ class Meta:
verbose_name_plural = "properties"
def __str__(self):
- return "Property - %s" % (self.pk)
+ return f"Property - {self.pk}"
def copy_meters(self, source_property_id, source_persists=True):
"""
@@ -881,7 +879,6 @@ class PropertyView(models.Model):
A PropertyView contains a reference to a property (which should not change) and to a
cycle (time period), and a state (characteristics).
-
"""
# different property views can be associated with each other (2012, 2013)
@@ -889,12 +886,12 @@ class PropertyView(models.Model):
cycle = models.ForeignKey(Cycle, on_delete=models.PROTECT)
state = models.ForeignKey(PropertyState, on_delete=models.CASCADE)
- labels = models.ManyToManyField(StatusLabel)
+ labels = models.ManyToManyField(StatusLabel, through="PropertyViewLabel", through_fields=("propertyview", "statuslabel"))
# notes has a relationship here -- PropertyViews have notes, not the state, and not the property.
def __str__(self):
- return "Property View - %s" % self.pk
+ return f"Property View - {self.pk}"
class Meta:
unique_together = (
@@ -958,6 +955,15 @@ def post_save_property_view(sender, **kwargs):
kwargs["instance"].property.save()
+class PropertyViewLabel(models.Model):
+ propertyview = models.ForeignKey(PropertyView, on_delete=models.CASCADE)
+ statuslabel = models.ForeignKey(StatusLabel, on_delete=models.CASCADE)
+ goal = models.ForeignKey("seed.Goal", on_delete=models.CASCADE, null=True)
+
+ class Meta:
+ constraints = [UniqueConstraint(fields=["propertyview", "statuslabel", "goal"], name="unique_propertyview_statuslabel_goal")]
+
+
class PropertyAuditLog(models.Model):
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
parent1 = models.ForeignKey(
diff --git a/seed/serializers/property_view_labels.py b/seed/serializers/property_view_labels.py
new file mode 100644
index 0000000000..d84abf1554
--- /dev/null
+++ b/seed/serializers/property_view_labels.py
@@ -0,0 +1,21 @@
+"""
+SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors.
+See also https://github.com/SEED-platform/seed/blob/main/LICENSE.md
+"""
+
+from rest_framework import serializers
+
+from seed.models import PropertyViewLabel
+
+
+class PropertyViewLabelSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = PropertyViewLabel
+ fields = "__all__"
+
+ def to_representation(self, obj):
+ result = super().to_representation(obj)
+ result["show_in_list"] = obj.statuslabel.show_in_list
+ result["color"] = obj.statuslabel.color
+ result["name"] = obj.statuslabel.name
+ return result
diff --git a/seed/serializers/rules.py b/seed/serializers/rules.py
index 701a93d8fd..eabce8ae9a 100644
--- a/seed/serializers/rules.py
+++ b/seed/serializers/rules.py
@@ -17,9 +17,11 @@ class Meta:
model = Rule
fields = [
"condition",
+ "cross_cycle",
"data_type",
"enabled",
"field",
+ "for_derived_column",
"id",
"max",
"min",
@@ -31,7 +33,6 @@ class Meta:
"table_name",
"text_match",
"units",
- "for_derived_column",
]
def create(self, validated_data):
@@ -78,6 +79,9 @@ def validate(self, data):
# Rule is new
severity_is_valid = False
label_is_not_associated = False
+ if data.get("table_name") == "Goal":
+ # prevent new Goal type rules from being created
+ raise serializers.ValidationError({"message": "Creating new Goal Rules has been disabled"})
else:
severity_is_valid = self.instance.severity == Rule.SEVERITY_VALID
label_is_not_associated = self.instance.status_label is None
diff --git a/seed/static/seed/js/controllers/data_quality_admin_controller.js b/seed/static/seed/js/controllers/data_quality_admin_controller.js
index eec74f4d04..e88bebd69a 100644
--- a/seed/static/seed/js/controllers/data_quality_admin_controller.js
+++ b/seed/static/seed/js/controllers/data_quality_admin_controller.js
@@ -48,7 +48,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
naturalSort,
$translate
) {
- $scope.inventory_type = $stateParams.inventory_type;
+ $scope.rule_type = $stateParams.rule_type;
$scope.org = organization_payload.organization;
$scope.auth = auth_payload.auth;
$scope.ruleGroups = {};
@@ -56,6 +56,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
$scope.state = $state.current;
$scope.rule_count_property = 0;
$scope.rule_count_taxlot = 0;
+ $scope.rule_count_cross_cycle = 0;
$scope.conditions = [
{ id: 'required', label: 'Required' },
@@ -117,12 +118,18 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
{ id: 'ft**2', label: 'square feet' },
{ id: 'm**2', label: 'square metres' },
{ id: 'kBtu/ft**2/year', label: 'kBtu/sq. ft./year' },
+ { id: 'gal/ft**2/year', label: 'gal/sq. ft./year' },
{ id: 'GJ/m**2/year', label: 'GJ/m²/year' },
{ id: 'MJ/m**2/year', label: 'MJ/m²/year' },
{ id: 'kWh/m**2/year', label: 'kWh/m²/year' },
{ id: 'kBtu/m**2/year', label: 'kBtu/m²/year' }
];
+ $scope.cross_cycle_options = [
+ { bool: true, label: '% Change aross cycle' },
+ { bool: false, label: 'Within cycle' }
+ ];
+
$scope.columns = _.map(angular.copy(columns.filter((col) => !col.derived_column)), (col) => {
if (!_.find(used_columns, ['id', col.id])) {
col.group = 'Not Mapped';
@@ -147,19 +154,20 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
const loadRules = (rules_payload) => {
const ruleGroups = {
properties: {},
- taxlots: {}
+ taxlots: {},
+ goals: {}
};
- let inventory_type;
+ let rule_type;
_.forEach(rules_payload, (rule) => {
if (rule.table_name === 'PropertyState') {
- inventory_type = 'properties';
+ rule_type = 'properties';
} else if (rule.table_name === 'TaxLotState') {
- inventory_type = 'taxlots';
+ rule_type = 'taxlots';
} else {
- inventory_type = rule.table_name;
+ rule_type = 'goals';
}
- if (!_.has(ruleGroups[inventory_type], rule.field)) ruleGroups[inventory_type][rule.field] = [];
+ if (!_.has(ruleGroups[rule_type], rule.field)) ruleGroups[rule_type][rule.field] = [];
const row = rule;
if (row.data_type === $scope.data_type_keys.date) {
if (row.min) row.min = moment(row.min, 'YYYYMMDD').toDate();
@@ -171,18 +179,22 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
row.label = match;
}
}
- ruleGroups[inventory_type][rule.field].push(row);
+ ruleGroups[rule_type][rule.field].push(row);
});
$scope.ruleGroups = ruleGroups;
$scope.rule_count_property = 0;
$scope.rule_count_taxlot = 0;
+ $scope.rule_count_cross_cycle = 0;
_.map($scope.ruleGroups.properties, (rule) => {
$scope.rule_count_property += rule.length;
});
_.map($scope.ruleGroups.taxlots, (rule) => {
$scope.rule_count_taxlot += rule.length;
});
+ _.map($scope.ruleGroups.goals, (rule) => {
+ $scope.rule_count_cross_cycle += rule.length;
+ });
};
loadRules(data_quality_rules_payload);
@@ -224,7 +236,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
const rules = [];
const misconfigured_rules = [];
$scope.duplicate_rule_keys = [];
- _.forEach($scope.ruleGroups, (ruleGroups, inventory_type) => {
+ _.forEach($scope.ruleGroups, (ruleGroups, rule_type) => {
_.forEach(ruleGroups, (ruleGroup) => {
const duplicate_rules = _.groupBy(
ruleGroup,
@@ -244,6 +256,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
if (rule.field === null) return;
const column = _.find($scope.columns, { column_name: rule.field }) || {};
+ const rule_type_lookup = { properties: 'PropertyState', taxlots: 'TaxLotState', goals: 'Goal' };
const r = {
enabled: rule.enabled,
condition: rule.condition,
@@ -255,10 +268,11 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
not_null: rule.not_null,
min: rule.min,
max: rule.max,
- table_name: inventory_type === 'properties' ? 'PropertyState' : 'TaxLotState',
+ table_name: rule_type_lookup[rule_type],
text_match: rule.text_match,
severity: rule.severity,
units: rule.units,
+ target: rule.target,
status_label: null,
for_derived_column: !!column.is_derived
};
@@ -451,7 +465,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
};
// perform checks on load
- _.forEach($scope.ruleGroups[$scope.inventory_type], (group, group_name) => {
+ _.forEach($scope.ruleGroups[$scope.rule_type], (group, group_name) => {
$scope.validate_data_types(group, group_name);
$scope.validate_conditions(group, group_name);
});
@@ -475,7 +489,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
}
const group_name = rule.field;
- const group = $scope.ruleGroups[$scope.inventory_type][group_name];
+ const group = $scope.ruleGroups[$scope.rule_type][group_name];
$scope.validate_conditions(group, group_name);
$scope.validate_data_types(group, group_name);
};
@@ -509,33 +523,33 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
rule.data_type = newDataType;
// move rule to appropriate spot in ruleGroups.
- if (!_.has($scope.ruleGroups[$scope.inventory_type], rule.field)) {
- $scope.ruleGroups[$scope.inventory_type][rule.field] = [];
+ if (!_.has($scope.ruleGroups[$scope.rule_type], rule.field)) {
+ $scope.ruleGroups[$scope.rule_type][rule.field] = [];
} else {
// Rules already exist for the new field name, so match the data_type, required, and not_null columns
- const existingRule = _.first($scope.ruleGroups[$scope.inventory_type][rule.field]);
+ const existingRule = _.first($scope.ruleGroups[$scope.rule_type][rule.field]);
rule.data_type = existingRule.data_type;
rule.required = existingRule.required;
rule.not_null = existingRule.not_null;
}
- $scope.ruleGroups[$scope.inventory_type][rule.field].push(rule);
+ $scope.ruleGroups[$scope.rule_type][rule.field].push(rule);
// remove old rule.
- if ($scope.ruleGroups[$scope.inventory_type][oldField].length === 1) delete $scope.ruleGroups[$scope.inventory_type][oldField];
- else $scope.ruleGroups[$scope.inventory_type][oldField].splice(index, 1);
+ if ($scope.ruleGroups[$scope.rule_type][oldField].length === 1) delete $scope.ruleGroups[$scope.rule_type][oldField];
+ else $scope.ruleGroups[$scope.rule_type][oldField].splice(index, 1);
rule.autofocus = true;
const group_name = rule.field;
- const group = $scope.ruleGroups[$scope.inventory_type][group_name];
+ const group = $scope.ruleGroups[$scope.rule_type][group_name];
$scope.validate_data_types(group, group_name);
$scope.validate_conditions(group, group_name);
- $scope.validate_data_types($scope.ruleGroups[$scope.inventory_type][oldField], oldField);
- $scope.validate_conditions($scope.ruleGroups[$scope.inventory_type][oldField], oldField);
+ $scope.validate_data_types($scope.ruleGroups[$scope.rule_type][oldField], oldField);
+ $scope.validate_conditions($scope.ruleGroups[$scope.rule_type][oldField], oldField);
};
// Keep field types consistent for identical fields
$scope.change_data_type = (rule, oldValue) => {
const { data_type } = rule;
- const rule_group = $scope.ruleGroups[$scope.inventory_type][rule.field];
+ const rule_group = $scope.ruleGroups[$scope.rule_type][rule.field];
_.forEach(rule_group, (currentRule) => {
currentRule.text_match = null;
@@ -558,10 +572,10 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
// create a new rule.
$scope.create_new_rule = () => {
const field = null;
- if (!_.has($scope.ruleGroups[$scope.inventory_type], field)) {
- $scope.ruleGroups[$scope.inventory_type][field] = [];
+ if (!_.has($scope.ruleGroups[$scope.rule_type], field)) {
+ $scope.ruleGroups[$scope.rule_type][field] = [];
}
- $scope.ruleGroups[$scope.inventory_type][field].push({
+ $scope.ruleGroups[$scope.rule_type][field].push({
enabled: true,
condition: '',
field,
@@ -581,8 +595,9 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
autofocus: true
});
$scope.rules_changed();
- if ($scope.inventory_type === 'properties') $scope.rule_count_property += 1;
- else $scope.rule_count_taxlot += 1;
+ if ($scope.rule_type === 'properties') $scope.rule_count_property += 1;
+ else if ($scope.rule_type === 'taxlots') $scope.rule_count_taxlot += 1;
+ else $scope.rule_count_cross_cycle += 1;
};
// create label and assign to that rule
@@ -608,12 +623,13 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
// set rule as deleted.
$scope.delete_rule = (rule, index) => {
- if ($scope.ruleGroups[$scope.inventory_type][rule.field].length === 1) {
- delete $scope.ruleGroups[$scope.inventory_type][rule.field];
- } else $scope.ruleGroups[$scope.inventory_type][rule.field].splice(index, 1);
+ if ($scope.ruleGroups[$scope.rule_type][rule.field].length === 1) {
+ delete $scope.ruleGroups[$scope.rule_type][rule.field];
+ } else $scope.ruleGroups[$scope.rule_type][rule.field].splice(index, 1);
$scope.rules_changed();
- if ($scope.inventory_type === 'properties') $scope.rule_count_property -= 1;
- else $scope.rule_count_taxlot -= 1;
+ if ($scope.rule_type === 'properties') $scope.rule_count_property -= 1;
+ else if ($scope.rule_type === 'taxlots') $scope.rule_count_taxlot -= 1;
+ else $scope.rule_count_cross_cycle -= 1;
};
const displayNames = {};
@@ -623,7 +639,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
});
$scope.sortedRuleGroups = () => {
- const sortedKeys = _.keys($scope.ruleGroups[$scope.inventory_type]).sort((a, b) => naturalSort(displayNames[a], displayNames[b]));
+ const sortedKeys = _.keys($scope.ruleGroups[$scope.rule_type]).sort((a, b) => naturalSort(displayNames[a], displayNames[b]));
const nullKey = _.remove(sortedKeys, (key) => key === 'null');
// Put created unassigned rows first
@@ -634,7 +650,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
$scope.rules_changed();
const allEnabled = $scope.allEnabled();
- _.forEach($scope.ruleGroups[$scope.inventory_type], (ruleGroup) => {
+ _.forEach($scope.ruleGroups[$scope.rule_type], (ruleGroup) => {
_.forEach(ruleGroup, (rule) => {
rule.enabled = !allEnabled;
});
@@ -644,7 +660,7 @@ angular.module('SEED.controller.data_quality_admin', []).controller('data_qualit
$scope.allEnabled = () => {
let total = 0;
const enabled = _.reduce(
- $scope.ruleGroups[$scope.inventory_type],
+ $scope.ruleGroups[$scope.rule_type],
(result, ruleGroup) => {
total += ruleGroup.length;
return result + _.filter(ruleGroup, 'enabled').length;
diff --git a/seed/static/seed/js/controllers/data_quality_modal_controller.js b/seed/static/seed/js/controllers/data_quality_modal_controller.js
index aff55f10a7..1eab6a6c00 100644
--- a/seed/static/seed/js/controllers/data_quality_modal_controller.js
+++ b/seed/static/seed/js/controllers/data_quality_modal_controller.js
@@ -39,6 +39,11 @@ angular.module('SEED.controller.data_quality_modal', []).controller('data_qualit
sortable: false,
title: 'Table'
},
+ {
+ sort_column: 'cycle',
+ sortable: true,
+ title: 'Cycle'
+ },
{
sort_column: 'address_line_1',
sortable: true,
diff --git a/seed/static/seed/js/controllers/delete_modal_controller.js b/seed/static/seed/js/controllers/delete_modal_controller.js
index 6e54becbfe..8cb08d4899 100644
--- a/seed/static/seed/js/controllers/delete_modal_controller.js
+++ b/seed/static/seed/js/controllers/delete_modal_controller.js
@@ -43,7 +43,6 @@ angular.module('SEED.controller.delete_modal', []).controller('delete_modal_cont
$scope.delete_state = 'success';
})
.catch((resp) => {
- console.log('resp', resp);
$scope.delete_state = 'fail';
if (resp.status === 422) {
$scope.error = resp.data.message;
diff --git a/seed/static/seed/js/controllers/inventory_list_controller.js b/seed/static/seed/js/controllers/inventory_list_controller.js
index eef75728b7..8d2090c22e 100644
--- a/seed/static/seed/js/controllers/inventory_list_controller.js
+++ b/seed/static/seed/js/controllers/inventory_list_controller.js
@@ -749,7 +749,7 @@ angular.module('SEED.controller.inventory_list', []).controller('inventory_list_
const property_view_ids = $scope.inventory_type === 'properties' ? selectedViewIds : [];
const taxlot_view_ids = $scope.inventory_type === 'taxlots' ? selectedViewIds : [];
- data_quality_service.start_data_quality_checks(property_view_ids, taxlot_view_ids).then((response) => {
+ data_quality_service.start_data_quality_checks(property_view_ids, taxlot_view_ids, null).then((response) => {
data_quality_service
.data_quality_checks_status(response.progress_key)
.then((result) => {
diff --git a/seed/static/seed/js/controllers/portfolio_summary_controller.js b/seed/static/seed/js/controllers/portfolio_summary_controller.js
index 1e06e8b45b..9b6740af16 100644
--- a/seed/static/seed/js/controllers/portfolio_summary_controller.js
+++ b/seed/static/seed/js/controllers/portfolio_summary_controller.js
@@ -11,9 +11,11 @@ angular.module('SEED.controller.portfolio_summary', [])
'$window',
'urls',
'ah_service',
+ 'data_quality_service',
'inventory_service',
'label_service',
'goal_service',
+ 'Notification',
'cycles',
'organization_payload',
'access_level_tree',
@@ -31,9 +33,11 @@ angular.module('SEED.controller.portfolio_summary', [])
$window,
urls,
ah_service,
+ data_quality_service,
inventory_service,
label_service,
goal_service,
+ Notification,
cycles,
organization_payload,
access_level_tree,
@@ -142,8 +146,15 @@ angular.module('SEED.controller.portfolio_summary', [])
}
};
+ $scope.toggle_help = (bool) => {
+ $scope.show_help = bool;
+ _.delay($scope.updateHeight, 150);
+ };
+
const get_goal_stats = (summary) => {
const passing_sqft = summary.current ? summary.current.total_sqft : null;
+ // show help text if less than {50}% of properties are passing checks
+ $scope.show_help = summary.total_passing <= summary.total_properties * 0.5;
$scope.goal_stats = [
{ name: 'Commitment (Sq. Ft)', value: $scope.goal.commitment_sqft },
{ name: 'Shared (Sq. Ft)', value: summary.shared_sqft },
@@ -284,11 +295,11 @@ angular.module('SEED.controller.portfolio_summary', [])
$scope.max_label_width = 750;
$scope.get_label_column_width = (labels_col, key) => {
- if (!$scope.show_full_labels[key]) {
+ const renderContainer = document.body.getElementsByClassName('ui-grid-render-container-body')[1];
+ if (!$scope.show_full_labels[key] || !renderContainer) {
return 31;
}
let maxWidth = 0;
- const renderContainer = document.body.getElementsByClassName('ui-grid-render-container-body')[1];
const col = $scope.gridApi.grid.getColumn(labels_col);
const cells = renderContainer.querySelectorAll(`.${uiGridConstants.COL_CLASS_PREFIX}${col.uid} .ui-grid-cell-contents`);
Array.prototype.forEach.call(cells, (cell) => {
@@ -318,17 +329,9 @@ angular.module('SEED.controller.portfolio_summary', [])
}, 0);
};
- // retrieve labels for cycle
+ // retrieve labels, key = 'baseline' or 'current'
const get_labels = (key) => {
- const cycle = key === 'baseline' ? $scope.goal.baseline_cycle : $scope.goal.current_cycle;
-
- label_service.get_labels('properties', undefined, cycle).then((current_labels) => {
- const labels = _.filter(current_labels, (label) => !_.isEmpty(label.is_applied));
-
- // load saved label filter
- // const ids = inventory_service.loadSelectedLabels('grid.properties.labels');
- // $scope.selected_labels = _.filter(labels, (label) => _.includes(ids, label.id));
-
+ label_service.get_property_view_labels_by_goal($scope.organization.id, $scope.goal.id, key).then((labels) => {
if (key === 'baseline') {
$scope.baseline_labels = labels;
$scope.build_labels(key, $scope.baseline_labels);
@@ -338,6 +341,7 @@ angular.module('SEED.controller.portfolio_summary', [])
}
});
};
+
const get_all_labels = () => {
get_labels('baseline');
get_labels('current');
@@ -349,16 +353,11 @@ angular.module('SEED.controller.portfolio_summary', [])
$scope.show_labels_by_inventory_id[key] = {};
for (const n in labels) {
const label = labels[n];
- if (label.show_in_list) {
- for (const m in label.is_applied) {
- const id = label.is_applied[m];
- const property_id = $scope.property_lookup[id];
- if (!$scope.show_labels_by_inventory_id[key][property_id]) {
- $scope.show_labels_by_inventory_id[key][property_id] = [];
- }
- $scope.show_labels_by_inventory_id[key][property_id].push(label);
- }
+ const property_id = $scope.property_lookup[label.propertyview];
+ if (!$scope.show_labels_by_inventory_id[key][property_id]) {
+ $scope.show_labels_by_inventory_id[key][property_id] = [];
}
+ $scope.show_labels_by_inventory_id[key][property_id].push(label);
}
};
@@ -897,6 +896,7 @@ angular.module('SEED.controller.portfolio_summary', [])
};
const set_grid_options = (result) => {
+ $scope.show_full_labels = { baseline: false, current: false };
$scope.selected_ids = [];
$scope.data = format_properties(result);
spinner_utility.hide();
@@ -1113,4 +1113,36 @@ angular.module('SEED.controller.portfolio_summary', [])
}
};
};
+
+ $scope.run_data_quality_check = () => {
+ spinner_utility.show();
+ data_quality_service.start_data_quality_checks([], [], $scope.goal.id)
+ .then((response) => {
+ data_quality_service.data_quality_checks_status(response.progress_key)
+ .then((result) => {
+ data_quality_service.get_data_quality_results($scope.organization.id, result.unique_id)
+ .then((dq_result) => {
+ $uibModal.open({
+ templateUrl: `${urls.static_url}seed/partials/data_quality_modal.html`,
+ controller: 'data_quality_modal_controller',
+ size: 'lg',
+ resolve: {
+ dataQualityResults: () => dq_result,
+ name: () => null,
+ uploaded: () => null,
+ run_id: () => result.unique_id,
+ orgId: () => $scope.organization.id
+ }
+ });
+ spinner_utility.hide();
+ load_summary();
+ load_inventory();
+ });
+ });
+ })
+ .catch(() => {
+ spinner_utility.hide();
+ Notification.erorr('Unexpected Error');
+ });
+ };
}]);
diff --git a/seed/static/seed/js/seed.js b/seed/static/seed/js/seed.js
index f5e7923fbd..58d583c780 100644
--- a/seed/static/seed/js/seed.js
+++ b/seed/static/seed/js/seed.js
@@ -1488,7 +1488,7 @@
})
.state({
name: 'organization_data_quality',
- url: '/accounts/{organization_id:int}/data_quality/{inventory_type:properties|taxlots}',
+ url: '/accounts/{organization_id:int}/data_quality/{rule_type:properties|taxlots|goals}',
templateUrl: `${static_url}seed/partials/data_quality_admin.html`,
controller: 'data_quality_admin_controller',
resolve: {
@@ -1498,7 +1498,7 @@
'naturalSort',
($stateParams, inventory_service, naturalSort) => {
const { organization_id } = $stateParams;
- if ($stateParams.inventory_type === 'properties') {
+ if ($stateParams.rule_type === 'properties' || $stateParams.rule_type === 'goals') {
return inventory_service.get_property_columns_for_org(organization_id).then((columns) => {
columns = _.reject(columns, 'related');
columns = _.map(columns, (col) => _.omit(col, ['pinnedLeft', 'related']));
@@ -1506,12 +1506,14 @@
return columns;
});
}
- return inventory_service.get_taxlot_columns_for_org(organization_id).then((columns) => {
- columns = _.reject(columns, 'related');
- columns = _.map(columns, (col) => _.omit(col, ['pinnedLeft', 'related']));
- columns.sort((a, b) => naturalSort(a.displayName, b.displayName));
- return columns;
- });
+ if ($stateParams.rule_type === 'taxlots') {
+ return inventory_service.get_taxlot_columns_for_org(organization_id).then((columns) => {
+ columns = _.reject(columns, 'related');
+ columns = _.map(columns, (col) => _.omit(col, ['pinnedLeft', 'related']));
+ columns.sort((a, b) => naturalSort(a.displayName, b.displayName));
+ return columns;
+ });
+ }
}
],
used_columns: [
diff --git a/seed/static/seed/js/services/data_quality_service.js b/seed/static/seed/js/services/data_quality_service.js
index 8328ee656e..1c5a9bb262 100644
--- a/seed/static/seed/js/services/data_quality_service.js
+++ b/seed/static/seed/js/services/data_quality_service.js
@@ -61,13 +61,14 @@ angular.module('SEED.service.data_quality', []).factory('data_quality_service',
data_quality_factory.start_data_quality_checks_for_import_file = (org_id, import_file_id) => $http.post(`/api/v3/import_files/${import_file_id}/start_data_quality_checks/?organization_id=${org_id}`).then((response) => response.data);
- data_quality_factory.start_data_quality_checks = (property_view_ids, taxlot_view_ids) => data_quality_factory
- .start_data_quality_checks_for_org(user_service.get_organization().id, property_view_ids, taxlot_view_ids);
+ data_quality_factory.start_data_quality_checks = (property_view_ids, taxlot_view_ids, goal_id) => data_quality_factory
+ .start_data_quality_checks_for_org(user_service.get_organization().id, property_view_ids, taxlot_view_ids, goal_id);
- data_quality_factory.start_data_quality_checks_for_org = (org_id, property_view_ids, taxlot_view_ids) => $http
+ data_quality_factory.start_data_quality_checks_for_org = (org_id, property_view_ids, taxlot_view_ids, goal_id) => $http
.post(`/api/v3/data_quality_checks/${org_id}/start/`, {
property_view_ids,
- taxlot_view_ids
+ taxlot_view_ids,
+ goal_id
})
.then((response) => response.data);
diff --git a/seed/static/seed/js/services/label_service.js b/seed/static/seed/js/services/label_service.js
index b8ac112038..8cbd5a2041 100644
--- a/seed/static/seed/js/services/label_service.js
+++ b/seed/static/seed/js/services/label_service.js
@@ -281,6 +281,12 @@ angular.module('SEED.service.label', []).factory('label_service', [
}
];
+ const get_property_view_labels_by_goal = (org_id, goal_id, cycle) => $http.get(
+ '/api/v3/property_view_labels/list_by_goal/',
+ { params: { organization_id: org_id, goal_id, cycle } }
+ )
+ .then(map_labels);
+
return {
get_labels,
get_labels_for_org,
@@ -293,6 +299,7 @@ angular.module('SEED.service.label', []).factory('label_service', [
update_property_labels,
update_taxlot_labels,
get_available_colors,
+ get_property_view_labels_by_goal,
lookup_label
};
}
diff --git a/seed/static/seed/partials/accounts.html b/seed/static/seed/partials/accounts.html
index 16504708c6..20fb94ead8 100644
--- a/seed/static/seed/partials/accounts.html
+++ b/seed/static/seed/partials/accounts.html
@@ -36,7 +36,7 @@
- Create a new rule
+ Create a new rule
Fields that have a "Must Contain" or "Must Not Contain" Condition Check rule cannot have a "Range" Condition Check rule.
@@ -50,16 +50,17 @@
Modifying Data Quality Rules
-
+
@@ -78,7 +79,7 @@ Modifying Data Quality Rules
-
+
@@ -87,7 +88,7 @@ Modifying Data Quality Rules
class="form-control input-sm"
ng-model="rule.condition"
ng-options="condition.id as condition.label for condition in ::conditions"
- ng-change="rules_changed(rule); change_condition(rule, ruleGroups[inventory_type][field], field)"
+ ng-change="rules_changed(rule); change_condition(rule, ruleGroups[rule_type][field], field)"
ng-class="{'border-red red': invalid_conditions.includes(field), 'error-bg': misconfigured_fields_ref.condition.includes(rule.$$hashKey)}"
>
@@ -209,6 +210,116 @@ Modifying Data Quality Rules
+
diff --git a/seed/static/seed/partials/portfolio_summary.html b/seed/static/seed/partials/portfolio_summary.html
index 9f1939909c..9bad2e4abf 100644
--- a/seed/static/seed/partials/portfolio_summary.html
+++ b/seed/static/seed/partials/portfolio_summary.html
@@ -56,7 +56,24 @@
Portfolio Summary
-
Portfolio Summary
+
+
Portfolio Summary
+
+
+
+
Unexpected Portfolio Summary Calculations?
+
+ Run "
Data Quality Check " from the Actions dropdown to auto populate the "Passed Checks" column.
+ Portfolio Summary calculations only include properties that have "Passed Checks" and are not "New Build or Acquired" (see far right columns below).
+
+
+
Hide
+
{$:: 'Loading Summary Data...' | translate $}
@@ -93,6 +110,9 @@
{$:: 'Loading Summary Data...' | translate $}
Edit Fields for Selected
Edit Fields for Selected
+