diff --git a/locale/en_US/LC_MESSAGES/django.mo b/locale/en_US/LC_MESSAGES/django.mo index 9eb9a401a1..1c4e1a1f77 100644 Binary files a/locale/en_US/LC_MESSAGES/django.mo and b/locale/en_US/LC_MESSAGES/django.mo differ diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po index 47dcfe1b68..133c6e3fda 100644 --- a/locale/en_US/LC_MESSAGES/django.po +++ b/locale/en_US/LC_MESSAGES/django.po @@ -89,6 +89,9 @@ msgstr "Upload your Organizational Structure in spreadsheet form. The file shoul msgid "ACCESS_LEVEL_TREE_HELP_1" msgstr "This page allows you to view the structure of your organization's content. The structure is made up of hierarchical levels that define a tree; these are called access levels. Each level contains one or more nodes, called access level instances. Permissions are managed by associating content and users with a particular access level instance, thereby grouping and restricting access to the content. The number of access levels in your structure, the names of the levels, and the names of the access level instances are customizable." +msgid "ACCESS_LEVEL_UPLOAD_PROGRESS_MSG" +msgstr "Saving access level instances in progress... This process depends on the size of your file and may take several minutes." + msgid "ADD_FILES_TO" msgstr "Add files to {dataset_name}." @@ -155,6 +158,15 @@ msgstr "Accept" msgid "Accept Terms of Service?" msgstr "Accept Terms of Service?" +msgid "Access Level" +msgstr "Access Level" + +msgid "Access Level Instance" +msgstr "Access Level Instance" + +msgid "Access Level Instance Information" +msgstr "Access Level Instance Information" + msgid "Access Level Instances Errors" msgstr "Access Level Instances Errors" @@ -327,6 +339,12 @@ msgstr "Are you sure you want to unmerge these tax lots and then merge with the msgid "Area" msgstr "Area" +msgid "Area Column" +msgstr "Area Column" + +msgid "Area Target Column" +msgstr "Area Target Column" + msgid "As the admin of your SEED instance you can control what data is shared throughout your organization and between your sub-organizations as well as what data is shared externally with the public-at-large. The subset of data you choose to share with the public can be different than the subset shared between your sub-organizations." msgstr "As the admin of your SEED instance you can control what data is shared throughout your organization and between your sub-organizations as well as what data is shared externally with the public-at-large. The subset of data you choose to share with the public can be different than the subset shared between your sub-organizations." @@ -387,6 +405,9 @@ msgstr "Back to List" msgid "Back to Mapping" msgstr "Back to Mapping" +msgid "Baseline Cycle" +msgstr "Baseline Cycle" + #: seed/models/models.py:135 msgid "Benchmarking" msgstr "Benchmarking" @@ -652,6 +673,9 @@ msgstr "Conditioned Floor Area" msgid "Configuration" msgstr "Configuration" +msgid "Configure Goals" +msgstr "Configure Goals" + #: seed/landing/templates/landing/password_reset_confirm.html:64 #: seed/landing/templates/landing/signup.html:63 msgid "Confirm" @@ -791,6 +815,12 @@ msgstr "Cross-Cycles" msgid "Current Column Mapping Profile" msgstr "Current Column Mapping Profile" +msgid "Current Cycle" +msgstr "Current Cycle" + +msgid "Current Cycle will be measured against Baseline Cycle" +msgstr "Current Cycle will be measured against Baseline Cycle" + msgid "Current Filters" msgstr "Current Filters" @@ -824,6 +854,9 @@ msgstr "Cycle" msgid "Cycle Name" msgstr "Cycle Name" +msgid "Cycle Selection" +msgstr "Cycle Selection" + msgid "Cycle updated." msgstr "Cycle updated." @@ -1105,6 +1138,9 @@ msgstr "Choose an EnergyStar Portfolio Manager (ESPM) data importing method belo msgid "EUI" msgstr "EUI" +msgid "EUI Target Columns" +msgstr "EUI Target Columns" + msgid "EXCLUDE" msgstr "EXCLUDE" @@ -1420,6 +1456,12 @@ msgstr "Geocoding now..." msgid "GJ/m²/year" msgstr "GJ/m²/year" +msgid "GOAL" +msgstr "GOAL" + +msgid "GOAL_SETUP_TEXT" +msgstr "Configure one or more portfolio Energy Use Intensity (EUI) reduction goals below. Select a baseline cycle and a current cycle for comparison, indicate the level in your access level tree that this goal applies to, specify a percentage EUI improvement target, and indicate which fields in your data should be used for EUI and square footage information." + msgid "GREENBUTTON_CONTENTS_TITLE" msgstr "Confirm GreenButton File Contents" @@ -1466,6 +1508,9 @@ msgstr "Go to Meters" msgid "Go to Notes" msgstr "Go to Notes" +msgid "Goal Setup" +msgstr "Goal Setup" + msgid "Gross Floor Area" msgstr "Gross Floor Area" @@ -1484,6 +1529,12 @@ msgstr "HTTP Error! Status Code: 404. The requested URL was not found." msgid "HTTP Error! Status Code: 500. Internal Server Error." msgstr "HTTP Error! Status Code: 500. Internal Server Error." +msgid "Have your organization owner update the column's data type to \"Area\"" +msgstr "Have your organization owner update the column's data type to \"Area\"" + +msgid "Have your organization owner update the data type of the column to \"EUI\"" +msgstr "Have your organization owner update the data type of the column to \"EUI\"" + #: seed/templates/seed/account_create_email.html:2 msgid "Hello %(first_name)s, " msgstr "Hello %(first_name)s, " @@ -1705,6 +1756,12 @@ msgstr "Left Half" msgid "Level" msgstr "Level" +msgid "Level Instance" +msgstr "Level Instance" + +msgid "Loading Summary Data..." +msgstr "Loading Summary Data..." + msgid "Loading data..." msgstr "Loading data..." @@ -2050,6 +2107,9 @@ msgstr "National Renewable Energy Laboratory" msgid "New Analysis" msgstr "New Analysis" +msgid "New Goal" +msgstr "New Goal" + msgid "New Note" msgstr "New Note" @@ -2115,6 +2175,9 @@ msgstr "Not Null" msgid "Not all inventory items were successfully deleted" msgstr "Not all inventory items were successfully deleted" +msgid "Not seeing your column?" +msgstr "Not seeing your column?" + msgid "Note:" msgstr "Note:" @@ -2255,6 +2318,9 @@ msgstr "Portfolio Manager Meter Import Results" msgid "PM_PROPERTY_ID_MATCHING_CRITERIA_WARNING" msgstr "Removing PM Property ID from matching criteria can cause unexpected issues for Portfolio Manager Meter imports." +msgid "PORTFOLIO_SUMMARY_HEADER_TEXT" +msgstr "The portfolio summary page compares 2 cycles to calculate progress toward an Energy Use Intensity reduction goal. Cycle selection and goal details can be customized by clicking the Configure Goals button below." + msgid "POST_GEOCODING_COUNTS" msgstr "Updated counts after geocoding" @@ -2346,6 +2412,15 @@ msgstr "Please wait while your data is loaded..." msgid "Populate SEED Headers with best known matches" msgstr "Populate SEED Headers with best known matches" +msgid "Portfolio Summary" +msgstr "Portfolio Summary" + +msgid "Portfolio Summary will only include properties belonging to this Access Level Instance." +msgstr "Portfolio Summary will only include properties belonging to this Access Level Instance." + +msgid "Portfolio Target" +msgstr "Portfolio Target" + msgid "Postal Code" msgstr "Postal Code" @@ -2373,6 +2448,9 @@ msgstr "Preview Loading" msgid "Previous" msgstr "Previous" +msgid "Primary Column" +msgstr "Primary Column" + msgid "Primary Tax Lot ID" msgstr "Primary Tax Lot ID" @@ -2916,6 +2994,9 @@ msgstr "Search field name" msgid "Search table name" msgstr "Search table name" +msgid "Secondary (optional)" +msgstr "Secondary (optional)" + msgid "Security" msgstr "Security" @@ -3169,6 +3250,9 @@ msgstr "Target Column" msgid "Target Field" msgstr "Target Field" +msgid "Target to quantify Portfolio EUI improvement. Must be between 0 and 100." +msgstr "Target to quantify Portfolio EUI improvement. Must be between 0 and 100." + msgid "Tax Lot" msgstr "Tax Lot" @@ -3218,6 +3302,9 @@ msgstr "Terms of Service" msgid "Terms of Service as of %(tos.created|date:\"SHORT_DATE_FORMAT\")s" msgstr "Terms of Service as of %(tos.created|date:\"SHORT_DATE_FORMAT\")s" +msgid "Tertiary (optional)" +msgstr "Tertiary (optional)" + msgid "Test Connection" msgstr "Test Connection" @@ -3499,6 +3586,12 @@ msgstr "Update Salesforce" msgid "Update UBID" msgstr "Update UBID" +msgid "Update the data type of the column to \"Area\" in" +msgstr "Update the column's data type to \"Area\" in" + +msgid "Update the data type of the column to \"EUI\" in" +msgstr "Update the data type of the column to \"EUI\" in" + msgid "Update with Audit Template" msgstr "Update with Audit Template" @@ -3939,6 +4032,9 @@ msgstr "first name" msgid "for your SEED Platform user account" msgstr "for your SEED Platform user account" +msgid "goal" +msgstr "Goal" + #: seed/models/models.py:183 msgid "gray" msgstr "gray" diff --git a/locale/fr_CA/LC_MESSAGES/django.mo b/locale/fr_CA/LC_MESSAGES/django.mo index 45b3766ecf..a036cef191 100644 Binary files a/locale/fr_CA/LC_MESSAGES/django.mo and b/locale/fr_CA/LC_MESSAGES/django.mo differ diff --git a/locale/fr_CA/LC_MESSAGES/django.po b/locale/fr_CA/LC_MESSAGES/django.po index 94b23dae39..ce6233ad78 100644 --- a/locale/fr_CA/LC_MESSAGES/django.po +++ b/locale/fr_CA/LC_MESSAGES/django.po @@ -90,6 +90,9 @@ msgstr "Téléchargez votre structure organisationnelle sous forme de feuille de msgid "ACCESS_LEVEL_TREE_HELP_1" msgstr "Cette page vous permet de visualiser la structure du contenu de votre organisation. La structure est constituée de niveaux hiérarchiques qui définissent une arborescence ; c'est ce qu'on appelle les niveaux d'accès. Chaque niveau contient un ou plusieurs nœuds, appelés instances de niveau d'accès. Les autorisations sont gérées en associant le contenu et les utilisateurs à une instance de niveau d'accès particulière, regroupant et restreignant ainsi l'accès au contenu. Le nombre de niveaux d'accès dans votre structure, les noms des niveaux et les noms des instances de niveau d'accès sont personnalisables." +msgid "ACCESS_LEVEL_UPLOAD_PROGRESS_MSG" +msgstr "Enregistrement des instances de niveau d'accès en cours... Ce processus dépend de la taille de votre fichier et peut prendre plusieurs minutes." + msgid "ADD_FILES_TO" msgstr "Ajoutez des fichiers à {dataset_name}." @@ -157,6 +160,15 @@ msgstr "Acceptez" msgid "Accept Terms of Service?" msgstr "Accepter les conditions d'utilisation?" +msgid "Access Level" +msgstr "Niveau d'accès" + +msgid "Access Level Instance" +msgstr "Instance de niveau d'accès" + +msgid "Access Level Instance Information" +msgstr "Informations sur l'instance de niveau d'accès" + msgid "Access Level Instances Errors" msgstr "Erreurs d’instances de niveau d’accès" @@ -329,6 +341,12 @@ msgstr "Êtes-vous sûr de vouloir annuler la fusion de ces lots fiscaux pour en msgid "Area" msgstr "Superficie" +msgid "Area Column" +msgstr "Colonne de Superficie" + +msgid "Area Target Column" +msgstr "Colonne Cible de Superficie" + msgid "As the admin of your SEED instance you can control what data is shared throughout your organization and between your sub-organizations as well as what data is shared externally with the public-at-large. The subset of data you choose to share with the public can be different than the subset shared between your sub-organizations." msgstr "En tant qu'administrateur de votre instance SEED, vous pouvez contrôler les données partagées dans votre organisation et entre vos sous-organisations ainsi que les données partagées à l'externe avec le public. Le sous-ensemble de données que vous choisissez de partager avec le public peut être différent du sous-ensemble partagé entre vos sous-organisations." @@ -389,6 +407,9 @@ msgstr "Retour à la Liste" msgid "Back to Mapping" msgstr "Retournez à les mappages" +msgid "Baseline Cycle" +msgstr "Cycle de référence" + #: seed/models/models.py:135 #, fuzzy msgid "Benchmarking" @@ -660,6 +681,9 @@ msgstr "Surface climatisé" msgid "Configuration" msgstr "Configuration" +msgid "Configure Goals" +msgstr "Configurer les Objectifs" + #: seed/landing/templates/landing/password_reset_confirm.html:64 #: seed/landing/templates/landing/signup.html:63 msgid "Confirm" @@ -800,6 +824,12 @@ msgstr "Entre les Cycles" msgid "Current Column Mapping Profile" msgstr "Profil de mappage de colonne actuel" +msgid "Current Cycle" +msgstr "Cycle actuel" + +msgid "Current Cycle will be measured against Baseline Cycle" +msgstr "Le cycle actuel sera mesuré par rapport au cycle de référence" + msgid "Current Filters" msgstr "Filtres Actuel" @@ -833,6 +863,9 @@ msgstr "Cycle" msgid "Cycle Name" msgstr "Nom du cycle" +msgid "Cycle Selection" +msgstr "Sélection des cycles" + msgid "Cycle updated." msgstr "Cycle mis à jour." @@ -1115,6 +1148,9 @@ msgstr "Choisissez une méthode d'importation de données EnergyStar Portfolio M msgid "EUI" msgstr "IUE" +msgid "EUI Target Columns" +msgstr "Colonnes cibles EUI" + msgid "EXCLUDE" msgstr "EXCLURE" @@ -1433,6 +1469,12 @@ msgstr "Géocodage maintenant ..." msgid "GJ/m²/year" msgstr "GJ/m²/année" +msgid "GOAL" +msgstr "OBJECTIF" + +msgid "GOAL_SETUP_TEXT" +msgstr "Configurez un ou plusieurs objectifs de réduction de l'intensité de l'utilisation d'énergie (IUE) du portefeuille ci-dessous. Sélectionnez un cycle de référence et un cycle actuel à des fins de comparaison, identifiez le niveau dans votre arborescence de niveaux d'accès auquel cet objectif s'applique, spécifiez un objectif d'amélioration de l'IUE en pourcentage et indiquez quels champs de vos données doivent être utilisés pour les informations sur l'IUE et la superficie en pieds carrés." + msgid "GREENBUTTON_CONTENTS_TITLE" msgstr "Confirmer le contenu du fichier GreenButton" @@ -1479,6 +1521,9 @@ msgstr "Aller aux compteurs" msgid "Go to Notes" msgstr "Accéder aux Notes" +msgid "Goal Setup" +msgstr "Configuration des objectifs" + msgid "Gross Floor Area" msgstr "Surface brute" @@ -1497,6 +1542,12 @@ msgstr "Erreur HTTP! Code d'état : 404. L'URL demandée est introuvable." msgid "HTTP Error! Status Code: 500. Internal Server Error." msgstr "Erreur HTTP! Code d'état : 500. Erreur interne du serveur." +msgid "Have your organization owner update the column's data type to \"Area\"" +msgstr "Demandez au propriétaire de votre organisation d s'assurer que le type de données de la colonne est \"Area\" dans" + +msgid "Have your organization owner update the data type of the column to \"EUI\"" +msgstr "Demandez au propriétaire de votre organisation de mettre à jour le type de données de la colonne en \"EUI\"." + #: seed/templates/seed/account_create_email.html:2 msgid "Hello %(first_name)s, " msgstr "Bonjour% (first_name) s," @@ -1720,6 +1771,12 @@ msgstr "Moitié gauche" msgid "Level" msgstr "Niveau" +msgid "Level Instance" +msgstr "Instance de niveau" + +msgid "Loading Summary Data..." +msgstr "Chargement des données récapitulatives..." + msgid "Loading data..." msgstr "Chargeant les données ..." @@ -2066,6 +2123,9 @@ msgstr "Laboratoire National Des Énergies Renouvelables" msgid "New Analysis" msgstr "Nouvelle analyse" +msgid "New Goal" +msgstr "Nouvel Objectif" + msgid "New Note" msgstr "Nouvelle note" @@ -2131,6 +2191,9 @@ msgstr "Pas nul" msgid "Not all inventory items were successfully deleted" msgstr "Tous les éléments de l'inventaire n'ont pas été supprimés" +msgid "Not seeing your column?" +msgstr "Vous ne voyez pas votre colonne?" + msgid "Note:" msgstr "Remarque:" @@ -2271,6 +2334,9 @@ msgstr "Résultats d'importation de Portfolio Manager" msgid "PM_PROPERTY_ID_MATCHING_CRITERIA_WARNING" msgstr "La suppression de l'ID de propriété PM des critères de correspondance peut entraîner des problèmes inattendus pour les importations de compteurs Portfolio Manager." +msgid "PORTFOLIO_SUMMARY_HEADER_TEXT" +msgstr "La page de résumé du portefeuille compare 2 cycles pour calculer les progrès vers un objectif de réduction de l'intensité de l'utilisation d'énergie. La sélection du cycle et les détails des objectifs peuvent être personnalisés en cliquant sur le bouton Configurer les Objectifs ci-dessous." + msgid "POST_GEOCODING_COUNTS" msgstr "Nombre mis à jour après le géocodage" @@ -2362,6 +2428,15 @@ msgstr "Veuillez patienter pendant que vos données sont chargées ..." msgid "Populate SEED Headers with best known matches" msgstr "Remplissez les en-têtes SEED avec les correspondances les plus connues" +msgid "Portfolio Summary" +msgstr "Résumé du portefeuille" + +msgid "Portfolio Summary will only include properties belonging to this Access Level Instance." +msgstr "Le résumé du portefeuille inclura uniquement les propriétés appartenant à cette instance de niveau d'accès." + +msgid "Portfolio Target" +msgstr "Cible du portefeuille" + msgid "Postal Code" msgstr "Code postal" @@ -2389,6 +2464,9 @@ msgstr "Aperçu Chargement" msgid "Previous" msgstr "Précédente" +msgid "Primary Column" +msgstr "Colonne principale" + msgid "Primary Tax Lot ID" msgstr "ID de lot d'impôt primaire" @@ -2941,6 +3019,9 @@ msgstr "Rechercher le nom du champ" msgid "Search table name" msgstr "Rechercher le nom de la table" +msgid "Secondary (optional)" +msgstr "Secondaire (facultatif)" + msgid "Security" msgstr "Sécurité" @@ -3194,6 +3275,9 @@ msgstr "Colonne cible" msgid "Target Field" msgstr "Champ cible" +msgid "Target to quantify Portfolio EUI improvement. Must be between 0 and 100." +msgstr "Cible pour quantifier l’amélioration de l’IUE du portefeuille. Doit être compris entre 0 et 100." + msgid "Tax Lot" msgstr "Lot d'impôt" @@ -3244,6 +3328,9 @@ msgstr "Conditions d'utilisation" msgid "Terms of Service as of %(tos.created|date:\"SHORT_DATE_FORMAT\")s" msgstr "Conditions de service à partir de% (tos.created | date: \"SHORT_DATE_FORMAT\") s" +msgid "Tertiary (optional)" +msgstr "Tertiaire (facultatif)" + msgid "Test Connection" msgstr "Tester la Connexion" @@ -3526,6 +3613,12 @@ msgstr "Mettre à jour Salesforce" msgid "Update UBID" msgstr "Mettre à jour UBID" +msgid "Update the data type of the column to \"Area\" in" +msgstr "Assurez-vous que le type de données de la colonne est bien \"Area\" dans" + +msgid "Update the data type of the column to \"EUI\" in" +msgstr "Assurez-vous que le type de données de la colonne est \"EUI\" dans" + msgid "Update with Audit Template" msgstr "Mise à jour avec Audit Template" @@ -3967,6 +4060,9 @@ msgstr "Prénom" msgid "for your SEED Platform user account" msgstr "pour votre compte utilisateur SEED Platform" +msgid "goal" +msgstr "Objectif" + #: seed/models/models.py:183 msgid "gray" msgstr "gris" diff --git a/seed/api/v3/urls.py b/seed/api/v3/urls.py index 916327b9b6..da8ddc06eb 100644 --- a/seed/api/v3/urls.py +++ b/seed/api/v3/urls.py @@ -31,6 +31,7 @@ from seed.views.v3.filter_group import FilterGroupViewSet from seed.views.v3.gbr_properties import GBRPropertyViewSet from seed.views.v3.geocode import GeocodeViewSet +from seed.views.v3.goals import GoalViewSet from seed.views.v3.green_assessment_properties import ( GreenAssessmentPropertyViewSet ) @@ -80,6 +81,7 @@ api_v3_router.register(r'eeej', EEEJViewSet, basename='eeej') api_v3_router.register(r'filter_groups', FilterGroupViewSet, basename='filter_groups') api_v3_router.register(r'gbr_properties', GBRPropertyViewSet, basename='gbr_properties') +api_v3_router.register(r'goals', GoalViewSet, basename='goals') api_v3_router.register(r'geocode', GeocodeViewSet, basename='geocode') api_v3_router.register(r'green_assessment_properties', GreenAssessmentPropertyViewSet, basename='green_assessment_properties') api_v3_router.register(r'green_assessment_urls', GreenAssessmentURLViewSet, basename='green_assessment_urls') diff --git a/seed/lib/superperms/orgs/decorators.py b/seed/lib/superperms/orgs/decorators.py index 32f54b96c1..7bae6446c7 100644 --- a/seed/lib/superperms/orgs/decorators.py +++ b/seed/lib/superperms/orgs/decorators.py @@ -23,7 +23,14 @@ OrganizationUser ) from seed.lib.superperms.orgs.permissions import get_org_id -from seed.models import Analysis, Property, PropertyView, TaxLotView, UbidModel +from seed.models import ( + Analysis, + Goal, + Property, + PropertyView, + TaxLotView, + UbidModel +) # Allow Super Users to ignore permissions. ALLOW_SUPER_USER_PERMS = getattr(settings, 'ALLOW_SUPER_USER_PERMS', True) @@ -225,7 +232,7 @@ def _wrapped(request, *args, **kwargs): return decorator -def assert_hierarchy_access(request, property_id_kwarg=None, property_view_id_kwarg=None, param_property_view_id=None, taxlot_view_id_kwarg=None, import_file_id_kwarg=None, param_import_file_id=None, import_record_id_kwarg=None, body_ali_id=None, body_import_file_id=None, analysis_id_kwarg=None, ubid_id_kwarg=None, body_import_record_id=None, param_import_record_id=None, *args, **kwargs): +def assert_hierarchy_access(request, property_id_kwarg=None, property_view_id_kwarg=None, param_property_view_id=None, taxlot_view_id_kwarg=None, import_file_id_kwarg=None, param_import_file_id=None, import_record_id_kwarg=None, body_ali_id=None, body_import_file_id=None, analysis_id_kwarg=None, ubid_id_kwarg=None, body_import_record_id=None, param_import_record_id=None, goal_id_kwarg=None, *args, **kwargs): """Helper function to has_hierarchy_access""" body = request.data params = request.GET @@ -288,6 +295,15 @@ def assert_hierarchy_access(request, property_id_kwarg=None, property_view_id_kw else: requests_ali = ubid.taxlot.taxlotview_set.first().taxlot.access_level_instance + elif goal_id_kwarg and goal_id_kwarg in kwargs: + goal = Goal.objects.get(pk=kwargs[goal_id_kwarg]) + body_ali_id = body.get('access_level_instance') + if body_ali_id: + body_ali = AccessLevelInstance.objects.get(pk=body_ali_id) + requests_ali = body_ali if body_ali.depth < goal.access_level_instance.depth else goal.access_level_instance + else: + requests_ali = goal.access_level_instance + else: property_view = PropertyView.objects.get(pk=request.GET['property_view_id']) requests_ali = property_view.property.access_level_instance @@ -306,17 +322,17 @@ def assert_hierarchy_access(request, property_id_kwarg=None, property_view_id_kw }, status=status.HTTP_404_NOT_FOUND) -def has_hierarchy_access(property_id_kwarg=None, property_view_id_kwarg=None, param_property_view_id=None, taxlot_view_id_kwarg=None, import_file_id_kwarg=None, param_import_file_id=None, import_record_id_kwarg=None, body_ali_id=None, body_import_file_id=None, analysis_id_kwarg=None, ubid_id_kwarg=None, body_import_record_id=None, param_import_record_id=None): +def has_hierarchy_access(property_id_kwarg=None, property_view_id_kwarg=None, param_property_view_id=None, taxlot_view_id_kwarg=None, import_file_id_kwarg=None, param_import_file_id=None, import_record_id_kwarg=None, body_ali_id=None, body_import_file_id=None, analysis_id_kwarg=None, ubid_id_kwarg=None, body_import_record_id=None, param_import_record_id=None, goal_id_kwarg=None): """Must be called after has_perm_class""" def decorator(fn): if 'self' in signature(fn).parameters: @wraps(fn) def _wrapped(self, request, *args, **kwargs): - return assert_hierarchy_access(request, property_id_kwarg, property_view_id_kwarg, param_property_view_id, taxlot_view_id_kwarg, import_file_id_kwarg, param_import_file_id, import_record_id_kwarg, body_ali_id, body_import_file_id, analysis_id_kwarg, ubid_id_kwarg, body_import_record_id, param_import_record_id, *args, **kwargs) or fn(self, request, *args, **kwargs) + return assert_hierarchy_access(request, property_id_kwarg, property_view_id_kwarg, param_property_view_id, taxlot_view_id_kwarg, import_file_id_kwarg, param_import_file_id, import_record_id_kwarg, body_ali_id, body_import_file_id, analysis_id_kwarg, ubid_id_kwarg, body_import_record_id, param_import_record_id, goal_id_kwarg, *args, **kwargs) or fn(self, request, *args, **kwargs) else: @wraps(fn) def _wrapped(request, *args, **kwargs): - return assert_hierarchy_access(request, property_id_kwarg, property_view_id_kwarg, param_property_view_id, taxlot_view_id_kwarg, import_file_id_kwarg, param_import_file_id, import_record_id_kwarg, body_ali_id, body_import_file_id, analysis_id_kwarg, ubid_id_kwarg, body_import_record_id, param_import_record_id, *args, **kwargs) or fn(request, *args, **kwargs) + return assert_hierarchy_access(request, property_id_kwarg, property_view_id_kwarg, param_property_view_id, taxlot_view_id_kwarg, import_file_id_kwarg, param_import_file_id, import_record_id_kwarg, body_ali_id, body_import_file_id, analysis_id_kwarg, ubid_id_kwarg, body_import_record_id, param_import_record_id, goal_id_kwarg, *args, **kwargs) or fn(request, *args, **kwargs) return _wrapped diff --git a/seed/migrations/0213_goal.py b/seed/migrations/0213_goal.py new file mode 100644 index 0000000000..a8c3e73e83 --- /dev/null +++ b/seed/migrations/0213_goal.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.20 on 2024-01-16 17:27 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0029_auto_20230413_1250'), + ('seed', '0212_auto_add_props_to_tree_squashed_0213_analysis_access_level_instance'), + ] + + operations = [ + migrations.CreateModel( + name='Goal', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('target_percentage', models.IntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])), + ('name', models.CharField(max_length=255, unique=True)), + ('access_level_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orgs.accesslevelinstance')), + ('area_column', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goal_area_columns', to='seed.column')), + ('baseline_cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goal_baseline_cycles', to='seed.cycle')), + ('current_cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goal_current_cycles', to='seed.cycle')), + ('eui_column1', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='goal_eui_column1s', to='seed.column')), + ('eui_column2', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='goal_eui_column2s', to='seed.column')), + ('eui_column3', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='goal_eui_column3s', to='seed.column')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='orgs.organization')), + ], + ), + ] diff --git a/seed/models/__init__.py b/seed/models/__init__.py index f59de0b4c7..b1f52abc43 100644 --- a/seed/models/__init__.py +++ b/seed/models/__init__.py @@ -44,6 +44,7 @@ from .events import * # noqa from .ubid_models import * # noqa from .uniformat import * # noqa +from .goals import * # noqa from .certification import ( # noqa GreenAssessment, diff --git a/seed/models/goals.py b/seed/models/goals.py new file mode 100644 index 0000000000..14ccc8442f --- /dev/null +++ b/seed/models/goals.py @@ -0,0 +1,30 @@ +""" +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/seed/main/LICENSE.md +""" +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from seed.models import AccessLevelInstance, Column, Cycle, Organization + + +class Goal(models.Model): + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + baseline_cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name='goal_baseline_cycles') + current_cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name='goal_current_cycles') + access_level_instance = models.ForeignKey(AccessLevelInstance, on_delete=models.CASCADE) + eui_column1 = models.ForeignKey(Column, on_delete=models.CASCADE, related_name='goal_eui_column1s') + # eui column 2 and 3 optional + eui_column2 = models.ForeignKey(Column, on_delete=models.CASCADE, related_name='goal_eui_column2s', blank=True, null=True) + eui_column3 = models.ForeignKey(Column, on_delete=models.CASCADE, related_name='goal_eui_column3s', blank=True, null=True) + area_column = models.ForeignKey(Column, on_delete=models.CASCADE, related_name='goal_area_columns') + target_percentage = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(100)]) + name = models.CharField(max_length=255, unique=True) + + def __str__(self): + return f"Goal - {self.name}" + + def eui_columns(self): + """ Preferred column order """ + eui_columns = [self.eui_column1, self.eui_column2, self.eui_column3] + return [column for column in eui_columns if column] diff --git a/seed/serializers/goals.py b/seed/serializers/goals.py new file mode 100644 index 0000000000..d62938611f --- /dev/null +++ b/seed/serializers/goals.py @@ -0,0 +1,48 @@ +""" +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/seed/main/LICENSE.md +""" +from django.core.exceptions import ValidationError +from rest_framework import serializers + +from seed.models import Goal + + +class GoalSerializer(serializers.ModelSerializer): + + class Meta: + model = Goal + fields = '__all__' + + def to_representation(self, obj): + result = super().to_representation(obj) + result['level_name_index'] = obj.access_level_instance.depth - 1 + return result + + def validate(self, data): + # partial update allows a cycle or ali to be blank + baseline_cycle = data.get('baseline_cycle') or self.instance.baseline_cycle + current_cycle = data.get('current_cycle') or self.instance.current_cycle + organization = data.get('organization') or self.instance.organization + ali = data.get('access_level_instance') or self.instance.access_level_instance + + if baseline_cycle == current_cycle: + raise ValidationError('Cycles must be unique.') + + if baseline_cycle.end > current_cycle.end: + raise ValidationError('Baseline Cycle must preceed Current Cycle.') + + if not all([ + getattr(baseline_cycle, 'organization', None) == organization, + getattr(current_cycle, 'organization', None) == organization, + getattr(ali, 'organization', None) == organization + ]): + raise ValidationError('Organization mismatch.') + + # non Null columns must be uniuqe + eui_columns = [data.get('eui_column1'), data.get('eui_column2'), data.get('eui_column3')] + unique_columns = {column for column in eui_columns if column is not None} + if len(unique_columns) < len([column for column in eui_columns if column is not None]): + raise ValidationError('Columns must be unique.') + + return data diff --git a/seed/static/seed/js/controllers/goal_editor_modal_controller.js b/seed/static/seed/js/controllers/goal_editor_modal_controller.js new file mode 100644 index 0000000000..f3cddaab2d --- /dev/null +++ b/seed/static/seed/js/controllers/goal_editor_modal_controller.js @@ -0,0 +1,157 @@ +/** + * SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. + * See also https://github.com/seed-platform/seed/main/LICENSE.md + */ +angular.module('BE.seed.controller.goal_editor_modal', []) + .controller('goal_editor_modal_controller', [ + '$scope', + '$state', + '$stateParams', + '$uibModalInstance', + 'Notification', + 'goal_service', + 'auth_payload', + 'organization', + 'cycles', + 'area_columns', + 'eui_columns', + 'access_level_tree', + 'goal', + function ( + $scope, + $state, + $stateParams, + $uibModalInstance, + Notification, + goal_service, + auth_payload, + organization, + cycles, + area_columns, + eui_columns, + access_level_tree, + goal, + ) { + $scope.auth = auth_payload.auth; + $scope.organization = organization; + $scope.goal = goal || {}; + $scope.access_level_tree = access_level_tree.access_level_tree; + $scope.all_level_names = [] + access_level_tree.access_level_names.forEach((level, i) => $scope.all_level_names.push({index: i, name: level})) + $scope.cycles = cycles; + $scope.area_columns = area_columns; + $scope.eui_columns = eui_columns; + // allow "none" as an option + if (!eui_columns.find(c => c.id === null && c.displayName === '')) { + $scope.eui_columns.unshift({ id: null, displayName: '' }); + } + $scope.valid = false; + + const get_goals = () => { + goal_service.get_goals().then(result => { + $scope.goals = result.status == 'success' ? sort_goals(result.goals) : []; + }) + } + const sort_goals = (goals) => goals.sort((a, b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1) + get_goals() + + $scope.$watch('goal', (cur, old) => { + $scope.goal_changed = cur != old; + }, true) + + // ACCESS LEVEL INSTANCES + // Users do not have permissions to create goals on levels above their own in the tree + const remove_restricted_level_names = (user_ali) => { + const path_keys = Object.keys(user_ali.data.path) + $scope.level_names = [] + const reversed_names = $scope.all_level_names.slice().reverse() + for (let index in reversed_names) { + $scope.level_names.push(reversed_names[index]) + if (path_keys.includes(reversed_names[index].name)) { + break + } + } + $scope.level_names.reverse() + } + + /* Build out access_level_instances_by_depth recurrsively */ + let access_level_instances_by_depth = {}; + const calculate_access_level_instances_by_depth = (tree, depth = 1) => { + if (tree == undefined) return; + if (access_level_instances_by_depth[depth] == undefined) access_level_instances_by_depth[depth] = []; + tree.forEach(ali => { + if (ali.id == window.BE.access_level_instance_id) remove_restricted_level_names(ali) + access_level_instances_by_depth[depth].push({ id: ali.id, name: ali.data.name }); + calculate_access_level_instances_by_depth(ali.children, depth + 1); + }) + } + calculate_access_level_instances_by_depth($scope.access_level_tree, 1); + + $scope.change_selected_level_index = () => { + new_level_instance_depth = parseInt($scope.goal.level_name_index) + 1 + $scope.potential_level_instances = access_level_instances_by_depth[new_level_instance_depth]; + } + $scope.change_selected_level_index() + + $scope.set_goal = (goal) => { + $scope.goal = goal; + $scope.change_selected_level_index();; + } + + $scope.save_goal = () => { + $scope.goal_changed = false; + const goal_fn = $scope.goal.id ? goal_service.update_goal : goal_service.create_goal + // if new goal, assign org id + $scope.goal.organization = $scope.goal.organization || $scope.organization.id + goal_fn($scope.goal).then(result => { + if (result.status === 200 || result.status === 201) { + Notification.success({ message: 'Goal saved', delay: 5000 }); + $scope.errors = null; + $scope.goal.id = $scope.goal.id || result.data.id; + get_goals() + $scope.set_goal($scope.goal) + } else { + $scope.errors = [`Unexpected response status: ${result.status}`]; + let result_errors = 'errors' in result.data ? result.data.errors : result.data + if (result_errors instanceof Object) { + for (let key in result_errors) { + let key_string = key == 'non_field_errors' ? 'Error' : key; + $scope.errors.push(`${key_string}: ${JSON.stringify(result_errors[key])}`) + } + } else { + $scope.errors = $scope.errors.push(result_errors) + } + }; + }); + } + + $scope.delete_goal = (goal_id) => { + const goal = $scope.goals.find(goal => goal.id === goal_id) + if (!goal) return Notification.warning({ message: 'Unexpected Error', delay: 5000 }) + + if (!confirm(`Are you sure you want to delete Goal "${goal.name}"`)) return + + goal_service.delete_goal(goal_id).then((response) => { + if (response.status === 204) { + Notification.success({ message: 'Goal deleted successfully', delay: 5000 }); + } else { + Notification.warning({ message: 'Unexpected Error', delay: 5000 }) + } + get_goals() + if (goal_id == $scope.goal.id) { + $scope.goal = null; + } + }) + } + + $scope.new_goal = () => { + $scope.goal = {}; + } + + $scope.close = () => { + let goal_name = $scope.goal ? $scope.goal.name : null; + $uibModalInstance.close(goal_name) + } + } + ] +) diff --git a/seed/static/seed/js/controllers/portfolio_summary_controller.js b/seed/static/seed/js/controllers/portfolio_summary_controller.js new file mode 100644 index 0000000000..39f5baa8da --- /dev/null +++ b/seed/static/seed/js/controllers/portfolio_summary_controller.js @@ -0,0 +1,860 @@ +/** + * SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. + * See also https://github.com/seed-platform/seed/main/LICENSE.md + */ +angular.module('BE.seed.controller.portfolio_summary', []) + .controller('portfolio_summary_controller', [ + '$scope', + '$state', + '$stateParams', + '$uibModal', + 'urls', + 'inventory_service', + 'label_service', + 'goal_service', + 'cycles', + 'organization_payload', + 'access_level_tree', + 'auth_payload', + 'property_columns', + 'uiGridConstants', + 'gridUtil', + 'spinner_utility', + function ( + $scope, + $state, + $stateParams, + $uibModal, + urls, + inventory_service, + label_service, + goal_service, + cycles, + organization_payload, + access_level_tree, + auth_payload, + property_columns, + uiGridConstants, + gridUtil, + spinner_utility, + ) { + $scope.organization = organization_payload.organization; + // Ii there a better way to convert string units to displayUnits? + const area_units = $scope.organization.display_units_area.replace('**2', '²'); + const eui_units = $scope.organization.display_units_eui.replace('**2', '²'); + $scope.cycles = cycles.cycles; + $scope.access_level_tree = access_level_tree.access_level_tree; + $scope.level_names = access_level_tree.access_level_names; + const localStorageLabelKey = `grid.properties.labels`; + $scope.goal = {}; + $scope.columns = property_columns; + $scope.cycle_columns = []; + $scope.area_columns = []; + $scope.eui_columns = []; + let matching_column_names = []; + let table_column_ids = []; + + const initialize_columns = () => { + $scope.columns.forEach(c => { + const default_display = c.column_name == $scope.organization.property_display_field; + const matching = c.is_matching_criteria; + const area = c.data_type === 'area'; + const eui = c.data_type === 'eui'; + const other = ['property_name', 'property_type'].includes(c.column_name); + + if (default_display || matching || eui || area || other ) table_column_ids.push(c.id); + if (eui) $scope.eui_columns.push(c); + if (area) $scope.area_columns.push(c); + if (matching) matching_column_names.push(c.column_name); + }) + } + initialize_columns() + + // Can only sort based on baseline or current, not both. In the event of a conflict, use the more recent. + baseline_first = false + + // optionally pass a goal name to be set as $scope.goal - used on modal close + const get_goals = (goal_name=false) => { + goal_service.get_goals().then(result => { + $scope.goals = _.isEmpty(result.goals) ? [] : sort_goals(result.goals) + $scope.goal = goal_name ? + $scope.goals.find(goal => goal.name == goal_name) : + $scope.goals[0] + }) + } + const sort_goals = (goals) => goals.sort((a,b) => a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1) + get_goals() + + // If goal changes, reset grid filters and repopulate ui-grids + $scope.$watch('goal', () => { + if ($scope.gridApi) $scope.reset_sorts_filters(); + $scope.data_valid = false; + if (_.isEmpty($scope.goal)) { + $scope.valid = false; + $scope.summary_valid = false; + } else { + reset_data(); + } + }) + + const reset_data = () => { + $scope.valid = true; + format_goal_details(); + $scope.refresh_data(); + } + + // selected goal details + const format_goal_details = () => { + $scope.change_selected_level_index() + const get_column_name = (column_id) => $scope.columns.find(c => c.id == column_id).displayName + const get_cycle_name = (cycle_id) => $scope.cycles.find(c => c.id == cycle_id).name + const level_name = $scope.level_names[$scope.goal.level_name_index] + const access_level_instance = $scope.potential_level_instances.find(level => level.id == $scope.goal.access_level_instance).name + + $scope.goal_details = [ + ['Baseline Cycle', get_cycle_name($scope.goal.baseline_cycle)], + ['Current Cycle', get_cycle_name($scope.goal.current_cycle)], + [level_name, access_level_instance], + ['Portfolio Target', `${$scope.goal.target_percentage} %`], + ['Area Column', get_column_name($scope.goal.area_column)], + ['Primary Column', get_column_name($scope.goal.eui_column1)], + ] + if ($scope.goal.eui_column2) { + $scope.goal_details.push(['Secondary Column', get_column_name($scope.goal.eui_column2)]) + } + if ($scope.goal.eui_column3) { + $scope.goal_details.push(['Tertiary Column', get_column_name($scope.goal.eui_column3)]) + } + } + + // from inventory_list_controller + $scope.columnDisplayByName = {}; + for (const i in $scope.columns) { + $scope.columnDisplayByName[$scope.columns[i].name] = $scope.columns[i].displayName; + } + + // Build out access_level_instances_by_depth recurrsively + let access_level_instances_by_depth = {}; + const calculate_access_level_instances_by_depth = function (tree, depth = 1) { + if (tree == undefined) return; + if (access_level_instances_by_depth[depth] == undefined) access_level_instances_by_depth[depth] = []; + tree.forEach(ali => { + access_level_instances_by_depth[depth].push({ id: ali.id, name: ali.data.name }) + calculate_access_level_instances_by_depth(ali.children, depth + 1); + }) + } + calculate_access_level_instances_by_depth($scope.access_level_tree, 1) + + $scope.change_selected_level_index = function () { + new_level_instance_depth = parseInt($scope.goal.level_name_index) + 1 + $scope.potential_level_instances = access_level_instances_by_depth[new_level_instance_depth] + } + + // GOAL EDITOR MODAL + $scope.open_goal_editor_modal = () => { + const modalInstance = $uibModal.open({ + templateUrl: `${urls.static_url}seed/partials/goal_editor_modal.html`, + controller: 'goal_editor_modal_controller', + size: 'lg', + backdrop: 'static', + resolve: { + auth_payload: () => auth_payload, + organization: () => $scope.organization, + cycles: () => $scope.cycles, + area_columns: () => $scope.area_columns, + eui_columns: () => $scope.eui_columns, + access_level_tree: () => access_level_tree, + goal: () => $scope.goal, + }, + }); + + // on modal close + modalInstance.result.then((goal_name) => { + get_goals(goal_name) + }) + } + + $scope.refresh_data = () => { + $scope.summary_loading = true; + load_summary(); + $scope.load_inventory(1); + } + + const load_summary = () => { + $scope.show_access_level_instances = true; + $scope.summary_valid = false; + + goal_service.get_portfolio_summary($scope.goal.id).then(result => { + summary = result.data; + set_summary_grid_options(summary); + }).then(() => { + $scope.summary_loading = false; + $scope.summary_valid = true; + }) + } + + $scope.page_change = (page) => { + spinner_utility.show() + $scope.load_inventory(page) + } + $scope.load_inventory = (page) => { + $scope.data_loading = true; + + let access_level_instance_id = $scope.goal.access_level_instance + let combined_result = {} + let per_page = 50 + let current_cycle = {id: $scope.goal.current_cycle} + let baseline_cycle = {id: $scope.goal.baseline_cycle} + // order of cycle property filter is dynamic based on column_sorts + let cycle_priority = baseline_first ? [baseline_cycle, current_cycle]: [current_cycle, baseline_cycle] + + get_paginated_properties(page, per_page, cycle_priority[0], access_level_instance_id, true).then(result0 => { + $scope.inventory_pagination = result0.pagination + properties = result0.results + combined_result[cycle_priority[0].id] = properties; + property_ids = properties.map(p => p.id) + + get_paginated_properties(page, per_page, cycle_priority[1], access_level_instance_id, false, property_ids).then(result1 => { + properties = result1.results + combined_result[cycle_priority[1].id] = properties; + get_all_labels() + set_grid_options(combined_result) + + }).then(() => { + $scope.data_loading = false; + $scope.data_valid = true + }) + }) + } + + const get_paginated_properties = (page, chunk, cycle, access_level_instance_id, include_filters_sorts, include_property_ids=null) => { + fn = inventory_service.get_properties; + const [filters, sorts] = include_filters_sorts ? [$scope.column_filters, $scope.column_sorts] : [[],[]] + + return fn( + page, + chunk, + cycle, + undefined, // profile_id + undefined, // include_view_ids + undefined, // exclude_view_ids + true, // save_last_cycle + $scope.organization.id, + true, // include_related + filters, + sorts, + false, // ids_only + table_column_ids.join(), + access_level_instance_id, + include_property_ids, + ); + }; + + const percentage = (a, b) => { + if (!a || b == null) return null; + const value = Math.round((a - b) / a * 100); + return isNaN(value) ? null : value; + } + + // -------------- LABEL LOGIC ------------- + + $scope.max_label_width = 750; + $scope.get_label_column_width = (labels_col, key) => { + if (!$scope.show_full_labels[key]) { + return 30; + } + 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) => { + gridUtil.fakeElement(cell, {}, (newElm) => { + const e = angular.element(newElm); + e.attr('style', 'float: left;'); + const width = gridUtil.elementWidth(e); + if (width > maxWidth) { + maxWidth = width; + } + }); + }); + return maxWidth > $scope.max_label_width ? $scope.max_label_width : maxWidth + 2; + }; + + // Expand or contract labels col + $scope.show_full_labels = { baseline: false, current: false } + $scope.toggle_labels = (labels_col, key) => { + $scope.show_full_labels[key] = !$scope.show_full_labels[key]; + setTimeout(() => { + $scope.gridApi.grid.getColumn(labels_col).width = $scope.get_label_column_width(labels_col, key); + const icon = document.getElementById(`label-header-icon-${key}`); + icon.classList.add($scope.show_full_labels[key] ? 'fa-chevron-circle-left' : 'fa-chevron-circle-right'); + icon.classList.remove($scope.show_full_labels[key] ? 'fa-chevron-circle-right' : 'fa-chevron-circle-left'); + $scope.gridApi.grid.refresh(); + }, 0); + }; + + // retreive labels for cycle + const get_all_labels = () => { + get_labels('baseline'); + get_labels('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) => { + let labels = _.filter(current_labels, (label) => !_.isEmpty(label.is_applied)); + + // load saved label filter + const ids = inventory_service.loadSelectedLabels(localStorageLabelKey); + // $scope.selected_labels = _.filter(labels, (label) => _.includes(ids, label.id)); + + if (key == 'baseline') { + $scope.baseline_labels = labels + $scope.build_labels(key, $scope.baseline_labels); + } else { + $scope.current_labels = labels + $scope.build_labels(key, $scope.current_labels); + } + }); + }; + + // Find labels that should be displayed and organize by applied inventory id + $scope.show_labels_by_inventory_id = {baseline: {}, current: {}}; + $scope.build_labels = (key, labels) => { + $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); + } + } + } + }; + + // Builds the html to display labels associated with this row entity + $scope.display_labels = (entity, key) => { + const id = entity.id; + const labels = []; + const titles = []; + if ($scope.show_labels_by_inventory_id[key][id]) { + for (const i in $scope.show_labels_by_inventory_id[key][id]) { + const label = $scope.show_labels_by_inventory_id[key][id][i]; + labels.push('', $scope.show_full_labels[key] ? label.text : '', ''); + titles.push(label.text); + } + } + return ['', labels.join(''), ''].join(''); + }; + + + // Build column defs for baseline or current labels + const build_label_col_def = (labels_col, key) => { + const header_cell_template = `` + const cell_template = `
` + const width_fn = $scope.gridApi ? $scope.get_label_column_width(labels_col, key) : 30 + + return { + name: labels_col, + displayName: '', + headerCellTemplate: header_cell_template, + cellTemplate: cell_template, + enableColumnMenu: false, + enableColumnMoving: false, + enableColumnResizing: false, + enableFiltering: false, + enableHiding: false, + enableSorting: false, + exporterSuppressExport: true, + // pinnedLeft: true, + visible: true, + width: width_fn, + maxWidth: $scope.max_label_width + } + } + + // ------------ DATA TABLE LOGIC --------- + + const set_eui_goal = (baseline, current, property, preferred_columns) => { + // only check defined columns + for (let col of preferred_columns.filter(c => c)) { + if (baseline && property.baseline_eui == undefined) { + property.baseline_eui = baseline[col.name] + } + if (current && property.current_eui == undefined) { + property.current_eui = current[col.name] + } + } + + property.baseline_kbtu = Math.round(property.baseline_sqft * property.baseline_eui) || undefined + property.current_kbtu = Math.round(property.current_sqft * property.current_eui) || undefined + property.eui_change = percentage(property.baseline_eui, property.current_eui) + } + + const format_properties = (properties) => { + const area = $scope.columns.find(c => c.id == $scope.goal.area_column) + const preferred_columns = [$scope.columns.find(c => c.id == $scope.goal.eui_column1)] + if ($scope.goal.eui_column2) preferred_columns.push($scope.columns.find(c => c.id == $scope.goal.eui_column2)) + if ($scope.goal.eui_column3) preferred_columns.push($scope.columns.find(c => c.id == $scope.goal.eui_column3)) + + const baseline_cycle_name = $scope.cycles.find(c => c.id == $scope.goal.baseline_cycle).name + const current_cycle_name = $scope.cycles.find(c => c.id == $scope.goal.current_cycle).name + // some fields span cycles (id, name, type) + // and others are cycle specific (source EUI, sqft) + let current_properties = properties[$scope.goal.current_cycle] + let baseline_properties = properties[$scope.goal.baseline_cycle] + let flat_properties = baseline_first ? + [baseline_properties, current_properties].flat() : + [current_properties, baseline_properties].flat() + + // labels are related to property views, but cross cycles displays based on property + // create a lookup between property_view.id to property.id + $scope.property_lookup = {} + flat_properties.forEach(p => $scope.property_lookup[p.property_view_id] = p.id) + let unique_ids = [...new Set(flat_properties.map(property => property.id))] + let combined_properties = [] + unique_ids.forEach(id => { + // find matching properties + let baseline = baseline_properties.find(p => p.id == id) + let current = current_properties.find(p => p.id == id) + // set accumulator + let property = current || baseline + // add baseline stats + if (baseline) { + property.baseline_cycle = baseline_cycle_name + property.baseline_sqft = baseline[area.name] + } + // add current stats + if (current) { + property.current_cycle = current_cycle_name + property.current_sqft = current[area.name] + } + // comparison stats + property.sqft_change = percentage(property.current_sqft, property.baseline_sqft) + set_eui_goal(baseline, current, property, preferred_columns) + combined_properties.push(property) + }) + return combined_properties + } + + const apply_defaults = (cols, ...defaults) => { _.map(cols, (col) => _.defaults(col, ...defaults)) } + + const property_column_names = [...new Set( + [ + $scope.organization.property_display_field, + ...matching_column_names, + 'property_name', + 'property_type', + ] + )] + // handle cycle specific columns + const selected_columns = () => { + let cols = property_column_names.map(name => $scope.columns.find(col => col.column_name === name)) + const default_baseline = { headerCellClass: 'portfolio-summary-baseline-header', cellClass: 'portfolio-summary-baseline-cell' } + const default_current = { headerCellClass: 'portfolio-summary-current-header', cellClass: 'portfolio-summary-current-cell' } + const default_styles = { headerCellFilter: 'translate', minWidth: 75, width: 150 } + + const baseline_cols = [ + { field: 'baseline_cycle', displayName: 'Cycle'}, + { field: 'baseline_sqft', displayName: `Area (${area_units})`, cellFilter: 'number'}, + { field: 'baseline_eui', displayName: `EUI (${eui_units})`, cellFilter: 'number'}, + // ktbu acts as a derived column. Disable sorting filtering + { + field: 'baseline_kbtu', displayName: 'kBTU', cellFilter: 'number', + enableFiltering: false, enableSorting: false, + headerCellClass: 'derived-column-display-name portfolio-summary-baseline-header' + }, + build_label_col_def('baseline-labels', 'baseline') + ] + const current_cols = [ + { field: 'current_cycle', displayName: 'Cycle'}, + { field: 'current_sqft', displayName: `Area (${area_units})`, cellFilter: 'number'}, + { field: 'current_eui', displayName: `EUI (${eui_units})`, cellFilter: 'number'}, + { + field: 'current_kbtu', displayName: 'kBTU', cellFilter: 'number', + enableFiltering: false, enableSorting: false, + headerCellClass: 'derived-column-display-name portfolio-summary-current-header' + }, + build_label_col_def('current-labels', 'current') + ] + const summary_cols = [ + { field: 'sqft_change', displayName: 'Sq Ft % Change', enableFiltering: false, enableSorting: false, headerCellClass: 'derived-column-display-name' }, + { field: 'eui_change', displayName: 'EUI % Improvement', enableFiltering: false, enableSorting: false, headerCellClass: 'derived-column-display-name' }, + ] + + apply_defaults(baseline_cols, default_baseline) + apply_defaults(current_cols, default_current) + cols = [...cols, ...baseline_cols, ...current_cols, ...summary_cols] + + // Apply filters + // from inventory_list_controller + _.map(cols, (col) => { + let options = {}; + if (col.pinnedLeft) { + col.pinnedLeft = false; + } + // not an ideal solution. How is this done on the inventory list + if (col.column_name == 'pm_property_id') { + col.type = 'number' + } + if (col.data_type === 'datetime') { + options.cellFilter = 'date:\'yyyy-MM-dd h:mm a\''; + options.filter = inventory_service.dateFilter(); + } else if (['area', 'eui', 'float', 'number'].includes(col.data_type)) { + options.cellFilter = 'number: ' + $scope.organization.display_decimal_places; + options.filter = inventory_service.combinedFilter(); + } else { + options.filter = inventory_service.combinedFilter(); + } + return _.defaults(col, options, default_styles); + }) + + apply_cycle_sorts_and_filters(cols) + add_access_level_names(cols) + return cols + + } + + const apply_cycle_sorts_and_filters = (columns) => { + // Cycle specific columns filters and sorts must be set manually + const cycle_columns = ['baseline_cycle', 'baseline_sqft', 'baseline_eui', 'baseline_kbtu', 'current_cycle', 'current_sqft', 'current_eui', 'current_kbtu', 'sqft_change', 'eui_change'] + + columns.forEach(column => { + if (cycle_columns.includes(column.field)) { + let cycle_column = $scope.cycle_columns.find(c => c.name == column.field) + column.sort = cycle_column ? cycle_column.sort : {} + column.filter.term = cycle_column ? cycle_column.filters[0].term : null + } + }) + } + + const add_access_level_names = (cols) => { + $scope.organization.access_level_names.slice(1).reverse().forEach((level) => { + cols.unshift({ + name: level, + displayName: level, + group: 'access_level_instance', + enableColumnMenu: true, + enableColumnMoving: false, + enableColumnResizing: true, + enableFiltering: true, + enableHiding: true, + enableSorting: true, + enablePinning: false, + exporterSuppressExport: true, + pinnedLeft: true, + visible: true, + width: 100, + cellClass: 'ali-cell', + headerCellClass: 'ali-header', + }) + }) + } + + $scope.toggle_access_level_instances = function () { + $scope.show_access_level_instances = !$scope.show_access_level_instances + $scope.gridOptions.columnDefs.forEach((col) => { + if (col.group == 'access_level_instance') { + col.visible = $scope.show_access_level_instances + } + }) + $scope.gridApi.core.refresh(); + } + + const format_cycle_columns = (columns) => { + /* filtering is based on existing db columns. + ** The PortfilioSummary uses cycle specific columns that do not exist elsewhere ('baseline_eui', 'current_sqft') + ** To sort on these columns, override the column name to the cannonical column, and set the cycle filter order + ** ex: if sort = {name: 'baseline_sqft'}, set {name: 'gross_floor_area_##'} and filter for baseline properties frist. + + ** NOTE: + ** cant fitler on cycle - cycle is not a column + ** cant filter on kbtu, sqft_change, eui_change - not real columns. calc'ed from eui and sqft. (similar to derived columns) + */ + let eui_column = $scope.columns.find(c => c.id == $scope.goal.eui_column1) + let area_column = $scope.columns.find(c => c.id == $scope.goal.area_column) + + const cycle_column_lookup = { + 'baseline_eui': eui_column.name, + 'baseline_sqft': area_column.name, + 'current_eui': eui_column.name, + 'current_sqft': area_column.name, + } + $scope.cycle_columns = [] + + columns.forEach(column => { + if (cycle_column_lookup[column.name]) { + $scope.cycle_columns.push({...column}) + column.name = cycle_column_lookup[column.name] + } + }) + + return columns + + } + + const remove_conflict_columns = (grid_columns) => { + // Property's are returned from 2 different get requests. One for the current, one for the baseline + // The second filter is solely based on the property ids from the first + // Filtering on the first and second will result in unrepresntative data + // Remove the conflict to allow sorting/filtering on either baseline or current. + + const column_names = grid_columns.map(c => c.name); + const includes_baseline = column_names.some(name => name.includes('baseline')); + const includes_current = column_names.some(name => name.includes('current')); + const conflict = includes_baseline && includes_current; + + if (conflict) { + baseline_first = !baseline_first; + const excluded_name = baseline_first ? 'current' : 'baseline'; + grid_columns = grid_columns.filter(column => !column.name.includes(excluded_name)); + } else if (includes_baseline) { + baseline_first = true; + } else if (includes_current) { + baseline_first = false; + } + + return grid_columns + } + + + // from inventory_list_controller + const updateColumnFilterSort = () => { + let grid_columns = _.filter($scope.gridApi.saveState.save().columns, (col) => _.keys(col.sort).filter((key) => key !== 'ignoreSort').length + (_.get(col, 'filters[0].term', '') || '').length > 0); + // check filter/sort columns. Cannot filter on both baseline and current. choose the more recent filter/sort + grid_columns = remove_conflict_columns(grid_columns) + // convert cycle columnss to cannonical columns + let formatted_columns = format_cycle_columns(grid_columns) + + // inventory_service.saveGridSettings(`${localStorageKey}.sort`, { + // columns + // }); + $scope.column_filters = []; + // parse the filters and sorts + for (let column of formatted_columns) { + // format column if cycle specific + const { name, filters, sort } = column; + // remove the column id at the end of the name + const column_name = name.split('_').slice(0, -1).join('_'); + + for (const filter of filters) { + if (_.isEmpty(filter)) { + continue; + } + + // a filter can contain many comma-separated filters + const subFilters = _.map(_.split(filter.term, ','), _.trim); + for (const subFilter of subFilters) { + if (subFilter) { + const { string, operator, value } = parseFilter(subFilter); + const index = $scope.columns.findIndex((p) => p.name === column_name); + const display = [$scope.columnDisplayByName[name], string, value].join(' '); + $scope.column_filters.push({ + name, + column_name, + operator, + value, + display + }); + } + } + } + + if (sort.direction) { + // remove the column id at the end of the name + const column_name = name.split('_').slice(0, -1).join('_'); + const display = [$scope.columnDisplayByName[name], sort.direction].join(' '); + $scope.column_sorts = [{ + name, + column_name, + direction: sort.direction, + display, + priority: sort.priority + }]; + // $scope.column_sorts.sort((a, b) => a.priority > b.priority); + } + } + // $scope.isModified(); + }; + + // from inventory_list_controller + // https://regexr.com/6cka2 + const combinedRegex = /^(!?)=\s*(-?\d+(?:\.\d+)?)$|^(!?)=?\s*"((?:[^"]|\\")*)"$|^(<=?|>=?)\s*((-?\d+(?:\.\d+)?)|(\d{4}-\d{2}-\d{2}))$/; + const parseFilter = (expression) => { + // parses an expression string into an object containing operator and value + const filterData = expression.match(combinedRegex); + if (filterData) { + if (!_.isUndefined(filterData[2])) { + // Numeric Equality + const operator = filterData[1]; + const value = Number(filterData[2].replace('\\.', '.')); + if (operator === '!') { + return { string: 'is not', operator: 'ne', value }; + } + return { string: 'is', operator: 'exact', value }; + } + if (!_.isUndefined(filterData[4])) { + // Text Equality + const operator = filterData[3]; + const value = filterData[4]; + if (operator === '!') { + return { string: 'is not', operator: 'ne', value }; + } + return { string: 'is', operator: 'exact', value }; + } + if (!_.isUndefined(filterData[7])) { + // Numeric Comparison + const operator = filterData[5]; + const value = Number(filterData[6].replace('\\.', '.')); + switch (operator) { + case '<': + return { string: '<', operator: 'lt', value }; + case '<=': + return { string: '<=', operator: 'lte', value }; + case '>': + return { string: '>', operator: 'gt', value }; + case '>=': + return { string: '>=', operator: 'gte', value }; + } + } else { + // Date Comparison + const operator = filterData[5]; + const value = filterData[8]; + switch (operator) { + case '<': + return { string: '<', operator: 'lt', value }; + case '<=': + return { string: '<=', operator: 'lte', value }; + case '>': + return { string: '>', operator: 'gt', value }; + case '>=': + return { string: '>=', operator: 'gte', value }; + } + } + } else { + // Case-insensitive Contains + return { string: 'contains', operator: 'icontains', value: expression }; + } + }; + + const set_grid_options = (result) => { + $scope.data = format_properties(result) + spinner_utility.hide() + $scope.gridOptions = { + data: 'data', + columnDefs: selected_columns(), + enableFiltering: true, + enableHorizontalScrollbar: 1, + cellWidth: 200, + enableGridMenu: true, + exporterMenuCsv: false, + exporterMenuExcel: false, + exporterMenuPdf: false, + gridMenuShowHideColumns: false, + gridMenuCustomItems: [{ + title: 'Export Page to CSV', + action: ($event) => $scope.gridApi.exporter.csvExport('all', 'all'), + }], + onRegisterApi: (gridApi) => { + $scope.gridApi = gridApi; + + gridApi.core.on.sortChanged($scope, () => { + spinner_utility.show() + _.debounce(() => { + updateColumnFilterSort(); + $scope.load_inventory(1); + }, 500)(); + }); + + gridApi.core.on.filterChanged($scope, _.debounce(() => { + spinner_utility.show() + updateColumnFilterSort(); + $scope.load_inventory(1); + }, 2000) + ); + } + } + } + + $scope.reset_sorts_filters = () => { + $scope.reset_sorts() + $scope.reset_filters() + } + $scope.reset_sorts = () => { + $scope.column_sorts = [] + $scope.gridApi.core.refresh() + } + $scope.reset_filters = () => { + $scope.column_filters = [] + $scope.gridApi.grid.clearAllFilters() + } + + + // -------- SUMMARY LOGIC ------------ + + const summary_selected_columns = () => { + const default_baseline = { headerCellClass: 'portfolio-summary-baseline-header', cellClass: 'portfolio-summary-baseline-cell' } + const default_current = { headerCellClass: 'portfolio-summary-current-header', cellClass: 'portfolio-summary-current-cell' } + const default_styles = { headerCellFilter: 'translate' } + + const baseline_cols = [ + { field: 'baseline_cycle', displayName: 'Cycle' }, + { field: 'baseline_total_sqft', displayName: `Total Area (${area_units})`, cellFilter: 'number'}, + { field: 'baseline_total_kbtu', displayName: 'Total kBTU', cellFilter: 'number'}, + { field: 'baseline_weighted_eui', displayName: `EUI (${eui_units})`, cellFilter: 'number'}, + ] + const current_cols = [ + { field: 'current_cycle', displayName: 'Cycle' }, + { field: 'current_total_sqft', displayName: `Total Area (${area_units})`, cellFilter: 'number'}, + { field: 'current_total_kbtu', displayName: 'Total kBTU', cellFilter: 'number'}, + { field: 'current_weighted_eui', displayName: `EUI (${eui_units})` , cellFilter: 'number'}, + ] + const calc_cols = [ + { field: 'sqft_change', displayName: 'Area % Change' }, + { + field: 'eui_change', displayName: 'EUI % Improvement', cellClass: (grid, row, col, rowRenderIndex, colRenderIndex) => { + return row.entity.eui_change >= $scope.goal.target_percentage ? 'above-target' : 'below-target' + } + }, + ] + apply_defaults(baseline_cols, default_baseline, default_styles) + apply_defaults(current_cols, default_current, default_styles) + apply_defaults(calc_cols) + + return [...baseline_cols, ...current_cols, ...calc_cols] + } + + const format_summary = (summary) => { + const baseline = summary.baseline + const current = summary.current + return [{ + baseline_cycle: baseline.cycle_name, + baseline_total_sqft: baseline.total_sqft, + baseline_total_kbtu: baseline.total_kbtu, + baseline_weighted_eui: baseline.weighted_eui, + current_cycle: current.cycle_name, + current_total_sqft: current.total_sqft, + current_total_kbtu: current.total_kbtu, + current_weighted_eui: current.weighted_eui, + sqft_change: summary.sqft_change, + eui_change: summary.eui_change, + }] + } + + const set_summary_grid_options = (summary) => { + $scope.summary_data = format_summary(summary) + $scope.summaryGridOptions = { + data: 'summary_data', + columnDefs: summary_selected_columns(), + enableSorting: false, + } + } + + } + ] +) diff --git a/seed/static/seed/js/seed.js b/seed/static/seed/js/seed.js index 2254ebf36f..35e81b03cf 100644 --- a/seed/static/seed/js/seed.js +++ b/seed/static/seed/js/seed.js @@ -73,6 +73,7 @@ angular.module('BE.seed.controllers', [ 'BE.seed.controller.export_report_modal', 'BE.seed.controller.export_to_audit_template_modal', 'BE.seed.controller.filter_group_modal', + 'BE.seed.controller.goal_editor_modal', 'BE.seed.controller.geocode_modal', 'BE.seed.controller.green_button_upload_modal', 'BE.seed.controller.organization_delete_access_level_instance_modal', @@ -115,6 +116,7 @@ angular.module('BE.seed.controllers', [ 'BE.seed.controller.organization_sharing', 'BE.seed.controller.pairing', 'BE.seed.controller.pairing_settings', + 'BE.seed.controller.portfolio_summary', 'BE.seed.controller.postoffice_modal', 'BE.seed.controller.profile', 'BE.seed.controller.program_setup', @@ -165,6 +167,7 @@ angular.module('BE.seed.services', [ 'BE.seed.service.event', 'BE.seed.service.filter_groups', 'BE.seed.service.flippers', + 'BE.seed.service.goal', 'BE.seed.service.geocode', 'BE.seed.service.httpParamSerializerSeed', 'BE.seed.service.inventory', @@ -2827,6 +2830,37 @@ SEED_app.config([ ] } }) + .state({ + name: 'portfolio_summary', + url: '/insights/portfolio_summary', + templateUrl: static_url + 'seed/partials/portfolio_summary.html', + controller: 'portfolio_summary_controller', + resolve: { + valid_column_data_types: [function () { + return ['number', 'float', 'integer', 'area', 'eui', 'ghg', 'ghg_intensity']; + }], + property_columns: ['valid_column_data_types', '$stateParams', 'inventory_service', 'naturalSort', function (valid_column_data_types, $stateParams, inventory_service, naturalSort) { + return inventory_service.get_property_columns_for_org($stateParams.organization_id).then(function (columns) { + _.remove(columns, { table_name: 'TaxLotState' }); + return columns; + }); + }], + cycles: ['cycle_service', function (cycle_service) { + return cycle_service.get_cycles(); + }], + organization_payload: ['user_service', 'organization_service', function (user_service, organization_service) { + return organization_service.get_organization(user_service.get_organization().id); + }], + access_level_tree: ['organization_payload', 'organization_service', '$q', function (organization_payload, organization_service, $q) { + var organization_id = organization_payload.organization.id; + return organization_service.get_organization_access_level_tree(organization_id); + }], + auth_payload: ['auth_service', '$q', 'organization_payload', (auth_service, $q, organization_payload) => { + const organization_id = organization_payload.organization.id; + return auth_service.is_authorized(organization_id, ['requires_owner']); + }], + } + }) .state({ name: 'data_view', url: '/insights/custom/{id:int}', diff --git a/seed/static/seed/js/services/goal_service.js b/seed/static/seed/js/services/goal_service.js new file mode 100644 index 0000000000..1b753e1628 --- /dev/null +++ b/seed/static/seed/js/services/goal_service.js @@ -0,0 +1,59 @@ +/** + * SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. + * See also https://github.com/seed-platform/seed/main/LICENSE.md + */ +angular.module('BE.seed.service.goal', []).factory('goal_service', [ + '$http', + 'user_service', + ( + $http, + user_service, + ) => { + const goal_service = {}; + + goal_service.create_goal = (goal) => { + return $http.post('/api/v3/goals/', goal) + .then(response => response) + .catch((response) => response); + } + + goal_service.update_goal = (goal) => { + return $http.put(`/api/v3/goals/${goal.id}/`, goal) + .then(response => response) + .catch(response => response) + } + + goal_service.get_goals = () => { + return $http.get('/api/v3/goals/', { + params: { + organization_id: user_service.get_organization().id + } + }) + .then(response => response.data) + .catch(response => response); + } + + goal_service.delete_goal = (goal_id) => { + return $http.delete(`/api/v3/goals/${goal_id}`, { + params: { + organization_id: user_service.get_organization().id + } + }) + .then(response => response) + .catch(response => response) + } + + goal_service.get_portfolio_summary = (goal_id) => { + return $http.get(`/api/v3/goals/${goal_id}/portfolio_summary/`, { + params: { + organization_id: user_service.get_organization().id + } + }) + .then(response => response) + .catch(response => response) + } + + return goal_service + } + ] +) diff --git a/seed/static/seed/js/services/inventory_service.js b/seed/static/seed/js/services/inventory_service.js index 25b1cabde0..6449aff2ee 100644 --- a/seed/static/seed/js/services/inventory_service.js +++ b/seed/static/seed/js/services/inventory_service.js @@ -62,7 +62,9 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ column_filters = null, column_sorts = null, ids_only = null, - shown_column_ids = null + shown_column_ids = null, + access_level_instance_id = null, + include_property_ids, ) => { organization_id = organization_id == undefined ? user_service.get_organization().id : organization_id; @@ -97,16 +99,23 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ params.cycle = lastCycleId; } + let data = { + // Pass the specific ids if they exist + include_view_ids, + exclude_view_ids, + include_property_ids, + // Pass the current profile (if one exists) to limit the column data that is returned + profile_id, + } + // add access_level_instance if it exists + if (access_level_instance_id) { + data.access_level_instance_id = access_level_instance_id + } + return $http .post( '/api/v3/properties/filter/', - { - // Pass the specific ids if they exist - include_view_ids, - exclude_view_ids, - // Pass the current profile (if one exists) to limit the column data that is returned - profile_id - }, + data, { params } @@ -1205,6 +1214,14 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ } }); + inventory_service.filter_by_property = (cycle_id, property_ids) => { + return $http.post('/api/v3/properties/filter_by_property/', { + organization_id: user_service.get_organization().id, + cycle: cycle_id, + property_ids: property_ids + }).then(response => response.data) + } + return inventory_service; } ]); diff --git a/seed/static/seed/locales/en_US.json b/seed/static/seed/locales/en_US.json index 0467e74e48..75607a8942 100644 --- a/seed/static/seed/locales/en_US.json +++ b/seed/static/seed/locales/en_US.json @@ -47,6 +47,9 @@ "About SEED Platform™": "About SEED Platform™", "Accept": "Accept", "Accept Terms of Service?": "Accept Terms of Service?", + "Access Level": "Access Level", + "Access Level Instance": "Access Level Instance", + "Access Level Instance Information": "Access Level Instance Information", "Access Level Instances Errors": "Access Level Instances Errors", "Access Level Tree": "Access Level Tree", "Access Levels (AL)": "Access Levels (AL)", @@ -104,6 +107,8 @@ "Are you sure you want to unmerge these properties and then merge with the selected properties?": "Are you sure you want to unmerge these properties and then merge with the selected properties?", "Are you sure you want to unmerge these tax lots and then merge with the selected tax lots?": "Are you sure you want to unmerge these tax lots and then merge with the selected tax lots?", "Area": "Area", + "Area Column": "Area Column", + "Area Target Column": "Area Target Column", "As the admin of your SEED instance you can control what data is shared throughout your organization and between your sub-organizations as well as what data is shared externally with the public-at-large. The subset of data you choose to share with the public can be different than the subset shared between your sub-organizations.": "As the admin of your SEED instance you can control what data is shared throughout your organization and between your sub-organizations as well as what data is shared externally with the public-at-large. The subset of data you choose to share with the public can be different than the subset shared between your sub-organizations.", "Associated Building Tax Lot ID": "Associated Building Tax Lot ID", "Associated Tax Lot ID": "Associated Tax Lot ID", @@ -123,6 +128,7 @@ "Back to Home": "Back to Home", "Back to List": "Back to List", "Back to Mapping": "Back to Mapping", + "Baseline Cycle": "Baseline Cycle", "Benchmarking": "Benchmarking", "Block Number": "Block Number", "Body": "Body", @@ -209,6 +215,7 @@ "Condition Check": "Condition Check", "Conditioned Floor Area": "Conditioned Floor Area", "Configuration": "Configuration", + "Configure Goals": "Configure Goals", "Confirm": "Confirm", "Confirm Audit Template Building Import?": "Confirm Audit Template Building Import?", "Confirm Save Mappings?": "Confirm Save Mappings?", @@ -254,6 +261,8 @@ "Created": "Created", "Cross-Cycles": "Cross-Cycles", "Current Column Mapping Profile": "Current Column Mapping Profile", + "Current Cycle": "Current Cycle", + "Current Cycle will be measured against Baseline Cycle": "Current Cycle will be measured against Baseline Cycle", "Current Filters": "Current Filters", "Current Name": "Current Name", "Current password": "Current password", @@ -265,6 +274,7 @@ "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.": "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.", "Cycle": "Cycle", "Cycle Name": "Cycle Name", + "Cycle Selection": "Cycle Selection", "Cycle updated.": "Cycle updated.", "Cycle:": "Cycle:", "Cycles": "Cycles", @@ -358,6 +368,7 @@ "ESPM_FILE_UPLOAD": "ESPM Spreadsheet Upload (xlsx). The spreadsheet should contain a single property.", "ESPM_IMPORT_TEXT": "Choose an EnergyStar Portfolio Manager (ESPM) data importing method below: you can either upload a property spreadsheet previously downloaded from ESPM, or you can connect to ESPM directly to access the data.", "EUI": "EUI", + "EUI Target Columns": "EUI Target Columns", "EXCLUDE": "EXCLUDE", "EXTRA_DATA_COL_TYPE_CHANGE": "For “extra data” fields, this allows the user to set the type, such as Text, Number, Date, etc.", "Edit": "Edit", @@ -461,6 +472,8 @@ "GEOCODING_NEEDS_THREE_COLS": "Note, geocoding requires at least 1 column is used and populated to construct full addresses.", "GEOCODING_NOW": "Geocoding now...", "GJ\/m²\/year": "GJ\/m²\/year", + "GOAL": "GOAL", + "GOAL_SETUP_TEXT": "Configure one or more portfolio Energy Use Intensity (EUI) reduction goals below. Select a baseline cycle and a current cycle for comparison, indicate the level in your access level tree that this goal applies to, specify a percentage EUI improvement target, and indicate which fields in your data should be used for EUI and square footage information.", "GREENBUTTON_CONTENTS_TITLE": "Confirm GreenButton File Contents", "Generate Token": "Generate Token", "Geocode": "Geocode", @@ -476,12 +489,15 @@ "Go to Detail Page": "Go to Detail Page", "Go to Meters": "Go to Meters", "Go to Notes": "Go to Notes", + "Goal Setup": "Goal Setup", "Gross Floor Area": "Gross Floor Area", "HOW THE SYSTEM AUTO-MATCHES YOUR PROPERTIES AND TAX LOTS:": "HOW THE SYSTEM AUTO-MATCHES YOUR PROPERTIES AND TAX LOTS:", "HOW TO MANUALLY MATCH YOUR PROPERTIES AND TAX LOTS:": "HOW TO MANUALLY MATCH YOUR PROPERTIES AND TAX LOTS:", "HOW_SYSTEM_AUTO_MATCHES_EXPLANATION": "Your source data file(s) are presented in the table on the left. All properties\/tax lots where a possible data match exists are presented in a table on the right. The system attempts to auto-match records using shared unique IDs like: PM Property ID, Jurisdiction Tax Lot ID, and Custom IDs as well as Address information. Where the system believes a match exists between a record in your source file and an existing record it will auto-check the 'match' <\/i> checkbox — effectively making a match between these records.", "HTTP Error! Status Code: 404. The requested URL was not found.": "HTTP Error! Status Code: 404. The requested URL was not found.", "HTTP Error! Status Code: 500. Internal Server Error.": "HTTP Error! Status Code: 500. Internal Server Error.", + "Have your organization owner update the column's data type to \"Area\"": "Have your organization owner update the column's data type to \"Area\"", + "Have your organization owner update the data type of the column to \"EUI\"": "Have your organization owner update the data type of the column to \"EUI\"", "Hello %(first_name)s, ": "Hello %(first_name)s, ", "Hexagonal Bins": "Hexagonal Bins", "Highlights of SEED Platform™": "Highlights of SEED Platform™", @@ -555,6 +571,8 @@ "Left Axis": "Left Axis", "Left Half": "Left Half", "Level": "Level", + "Level Instance": "Level Instance", + "Loading Summary Data...": "Loading Summary Data...", "Loading data...": "Loading data...", "Loading labels...": "Loading labels...", "Loading...": "Loading...", @@ -668,6 +686,7 @@ "Name": "Name", "National Renewable Energy Laboratory": "National Renewable Energy Laboratory", "New Analysis": "New Analysis", + "New Goal": "New Goal", "New Note": "New Note", "New User Email": "New User Email", "New password": "New password", @@ -689,6 +708,7 @@ "Not Compliant": "Not Compliant", "Not Null": "Not Null", "Not all inventory items were successfully deleted": "Not all inventory items were successfully deleted", + "Not seeing your column?": "Not seeing your column?", "Note:": "Note:", "Note: Meters are labeled with the following format: \"Type - Source - Source ID\"": "Note: Meters are labeled with the following format: \"Type - Source - Source ID\"", "Note: Parent organization members are not automatically made members of sub-organizations.": "Note: Parent organization members are not automatically made members of sub-organizations.", @@ -735,6 +755,7 @@ "PM_METER_IMPORT_NO_ASSOCIATION": "Unable to Import - No associations to previously imported properties", "PM_METER_IMPORT_RESULTS": "Portfolio Manager Meter Import Results", "PM_PROPERTY_ID_MATCHING_CRITERIA_WARNING": "Removing PM Property ID from matching criteria can cause unexpected issues for Portfolio Manager Meter imports.", + "PORTFOLIO_SUMMARY_HEADER_TEXT": "The portfolio summary page compares 2 cycles to calculate progress toward an Energy Use Intensity reduction goal. Cycle selection and goal details can be customized by clicking the Configure Goals button below.", "POST_GEOCODING_COUNTS": "Updated counts after geocoding", "PROPERTY_MATCHING_FIELDS_REQUIREMENT": "At least one of the following Property fields is required", "PUBLIC": "PUBLIC", @@ -762,6 +783,9 @@ "Please visit our User Support website for tutorials and documentation to help you learn how to use SEED-Platform.": "Please visit our User Support website for tutorials and documentation to help you learn how to use SEED-Platform.", "Please wait while your data is loaded...": "Please wait while your data is loaded...", "Populate SEED Headers with best known matches": "Populate SEED Headers with best known matches", + "Portfolio Summary": "Portfolio Summary", + "Portfolio Summary will only include properties belonging to this Access Level Instance.": "Portfolio Summary will only include properties belonging to this Access Level Instance.", + "Portfolio Target": "Portfolio Target", "Postal Code": "Postal Code", "Postal Code (Property)": "Postal Code (Property)", "Postal Code (Tax Lot)": "Postal Code (Tax Lot)", @@ -771,6 +795,7 @@ "Preview": "Preview", "Preview Loading": "Preview Loading", "Previous": "Previous", + "Primary Column": "Primary Column", "Primary Tax Lot ID": "Primary Tax Lot ID", "Primary\/Secondary": "Primary\/Secondary", "Profile Info": "Profile Info", @@ -950,6 +975,7 @@ "Search display name": "Search display name", "Search field name": "Search field name", "Search table name": "Search table name", + "Secondary (optional)": "Secondary (optional)", "Security": "Security", "Security Token": "Security Token", "SeedOrg": "SeedOrg", @@ -1033,6 +1059,7 @@ "Target >= Actual for Compliance": "Target >= Actual for Compliance", "Target Column": "Target Column", "Target Field": "Target Field", + "Target to quantify Portfolio EUI improvement. Must be between 0 and 100.": "Target to quantify Portfolio EUI improvement. Must be between 0 and 100.", "Tax Lot": "Tax Lot", "Tax Lot Count": "Tax Lot Count", "Tax Lot Detail": "Tax Lot Detail", @@ -1048,6 +1075,7 @@ "Taxlot Display Field": "Taxlot Display Field", "Terms of Service": "Terms of Service", "Terms of Service as of %(tos.created|date:\"SHORT_DATE_FORMAT\")s": "Terms of Service as of %(tos.created|date:\"SHORT_DATE_FORMAT\")s", + "Tertiary (optional)": "Tertiary (optional)", "Test Connection": "Test Connection", "Text": "Text", "Thank you": "Thank you", @@ -1137,6 +1165,8 @@ "Update Property Labels": "Update Property Labels", "Update Salesforce": "Update Salesforce", "Update UBID": "Update UBID", + "Update the data type of the column to \"Area\" in": "Update the column's data type to \"Area\" in", + "Update the data type of the column to \"EUI\" in": "Update the data type of the column to \"EUI\" in", "Update with Audit Template": "Update with Audit Template", "Update with BuildingSync": "Update with BuildingSync", "Update with ESPM": "Update with ESPM", @@ -1273,6 +1303,7 @@ "ex: joe@company.com": "ex: joe@company.com", "first name": "first name", "for your SEED Platform user account": "for your SEED Platform user account", + "goal": "Goal", "gray": "gray", "green": "green", "has been uploaded to": "has been uploaded to", diff --git a/seed/static/seed/locales/fr_CA.json b/seed/static/seed/locales/fr_CA.json index 5b2ec4a6f2..b1d881e069 100644 --- a/seed/static/seed/locales/fr_CA.json +++ b/seed/static/seed/locales/fr_CA.json @@ -47,6 +47,9 @@ "About SEED Platform™": "À propos de SEED Platform™", "Accept": "Acceptez", "Accept Terms of Service?": "Accepter les conditions d'utilisation?", + "Access Level": "Niveau d'accès", + "Access Level Instance": "Instance de niveau d'accès", + "Access Level Instance Information": "Informations sur l'instance de niveau d'accès", "Access Level Instances Errors": "Erreurs d’instances de niveau d’accès", "Access Level Tree": "Graphe en arbre des niveaux d'accès", "Access Levels (AL)": "Niveaux d'accès (AL)", @@ -104,6 +107,8 @@ "Are you sure you want to unmerge these properties and then merge with the selected properties?": "Êtes-vous sûr de vouloir annuler la fusion de ces propriétés et fusionner avec les propriétés sélectionnées?", "Are you sure you want to unmerge these tax lots and then merge with the selected tax lots?": "Êtes-vous sûr de vouloir annuler la fusion de ces lots fiscaux pour ensuite fusionner avec les lots d'impôt sélectionnés?", "Area": "Superficie", + "Area Column": "Colonne de Superficie", + "Area Target Column": "Colonne Cible de Superficie", "As the admin of your SEED instance you can control what data is shared throughout your organization and between your sub-organizations as well as what data is shared externally with the public-at-large. The subset of data you choose to share with the public can be different than the subset shared between your sub-organizations.": "En tant qu'administrateur de votre instance SEED, vous pouvez contrôler les données partagées dans votre organisation et entre vos sous-organisations ainsi que les données partagées à l'externe avec le public. Le sous-ensemble de données que vous choisissez de partager avec le public peut être différent du sous-ensemble partagé entre vos sous-organisations.", "Associated Building Tax Lot ID": "ID de lot d'impôt du bâtiment associé", "Associated Tax Lot ID": "ID de lot d'impôt associé", @@ -123,6 +128,7 @@ "Back to Home": "Retour à l'Accueil", "Back to List": "Retour à la Liste", "Back to Mapping": "Retournez à les mappages", + "Baseline Cycle": "Cycle de référence", "Benchmarking": "Analyse comparative", "Block Number": "Numéro de bloc", "Body": "Corps", @@ -209,6 +215,7 @@ "Condition Check": "Vérification de l'état", "Conditioned Floor Area": "Surface climatisé", "Configuration": "Configuration", + "Configure Goals": "Configurer les Objectifs", "Confirm": "Confirmer", "Confirm Audit Template Building Import?": "Confirmer l'importation de la création du modèle d'audit ?", "Confirm Save Mappings?": "Confirmer enregistrer les mappages?", @@ -254,6 +261,8 @@ "Created": "Créé", "Cross-Cycles": "Entre les Cycles", "Current Column Mapping Profile": "Profil de mappage de colonne actuel", + "Current Cycle": "Cycle actuel", + "Current Cycle will be measured against Baseline Cycle": "Le cycle actuel sera mesuré par rapport au cycle de référence", "Current Filters": "Filtres Actuel", "Current Name": "Nom Actuel", "Current password": "Mot de passe actuel", @@ -265,6 +274,7 @@ "Custom emails can be sent to Building Owners using the templates defined below. The email will be sent to the SEED record's Owner Email address and is currently not configurable. The email 'from' address is the same as the server email address which is also used to email users their account information.": "Des e-mails personnalisés peuvent être envoyés aux propriétaires d'immeubles à l'aide des modèles définis ci-dessous. L'e-mail sera envoyé à l'adresse e-mail du propriétaire de l'enregistrement SEED et n'est actuellement pas configurable. L'adresse e-mail \"de\" est la même que l'adresse e-mail du serveur qui est également utilisée pour envoyer aux utilisateurs leurs informations de compte par e-mail.", "Cycle": "Cycle", "Cycle Name": "Nom du cycle", + "Cycle Selection": "Sélection des cycles", "Cycle updated.": "Cycle mis à jour.", "Cycle:": "Cycle:", "Cycles": "Cycles", @@ -358,6 +368,7 @@ "ESPM_FILE_UPLOAD": "Téléchargement de la feuille de calcul ESPM (xlsx). La feuille de calcul doit contenir une seule propriété.", "ESPM_IMPORT_TEXT": "Choisissez une méthode d'importation de données EnergyStar Portfolio Manager (ESPM) ci-dessous: vous pouvez soit télécharger une feuille de calcul de propriétés précédemment téléchargée depuis ESPM, soit vous connecter directement à ESPM pour accéder aux données.", "EUI": "IUE", + "EUI Target Columns": "Colonnes cibles EUI", "EXCLUDE": "EXCLURE", "EXTRA_DATA_COL_TYPE_CHANGE": "Pour les champs «données supplémentaires», cela permet à l'utilisateur de définir le type, tel que Texte, Numéro, Date, etc.", "Edit": "Modifier", @@ -461,6 +472,8 @@ "GEOCODING_NEEDS_THREE_COLS": "Remarque: le géocodage nécessite l'utilisation d'au moins une colonne et son remplissage pour construire des adresses complètes.", "GEOCODING_NOW": "Géocodage maintenant ...", "GJ\/m²\/year": "GJ\/m²\/année", + "GOAL": "OBJECTIF", + "GOAL_SETUP_TEXT": "Configurez un ou plusieurs objectifs de réduction de l'intensité de l'utilisation d'énergie (IUE) du portefeuille ci-dessous. Sélectionnez un cycle de référence et un cycle actuel à des fins de comparaison, identifiez le niveau dans votre arborescence de niveaux d'accès auquel cet objectif s'applique, spécifiez un objectif d'amélioration de l'IUE en pourcentage et indiquez quels champs de vos données doivent être utilisés pour les informations sur l'IUE et la superficie en pieds carrés.", "GREENBUTTON_CONTENTS_TITLE": "Confirmer le contenu du fichier GreenButton", "Generate Token": "Générer un jeton", "Geocode": "Géocodage", @@ -476,12 +489,15 @@ "Go to Detail Page": "Accéder à la page Détail", "Go to Meters": "Aller aux compteurs", "Go to Notes": "Accéder aux Notes", + "Goal Setup": "Configuration des objectifs", "Gross Floor Area": "Surface brute", "HOW THE SYSTEM AUTO-MATCHES YOUR PROPERTIES AND TAX LOTS:": "COMMENT LE SYSTÈME ADAPTE AUTOMATIQUEMENT VOS PROPRIETES ET LOTS D'IMPÔT:", "HOW TO MANUALLY MATCH YOUR PROPERTIES AND TAX LOTS:": "COMMENT APPORTER MANUELLEMENT VOS PROPRIETES ET LOTS D'IMPÔT:", "HOW_SYSTEM_AUTO_MATCHES_EXPLANATION": "Votre fichier de données source est présenté dans le tableau de gauche. Toutes les propriétés\/lots d'impôt où une correspondance de données possible existe sont présentées dans un tableau sur la droite. Le système tente de faire correspondre automatiquement les enregistrements à l'aide d'ID uniques partagés tels que: ID de Propriété PM, ID de Lot d'Impôt de Juridiction et les ID personnalisés, ainsi que des informations d'adresse. Lorsque le système croit qu'une correspondance existe entre un enregistrement de votre fichier source et un enregistrement existant, il vérifie automatiquement la 'correspondance' <\/i> case à cocher — faire efficacement une correspondance entre ces enregistrements.", "HTTP Error! Status Code: 404. The requested URL was not found.": "Erreur HTTP! Code d'état : 404. L'URL demandée est introuvable.", "HTTP Error! Status Code: 500. Internal Server Error.": "Erreur HTTP! Code d'état : 500. Erreur interne du serveur.", + "Have your organization owner update the column's data type to \"Area\"": "Demandez au propriétaire de votre organisation d s'assurer que le type de données de la colonne est \"Area\" dans", + "Have your organization owner update the data type of the column to \"EUI\"": "Demandez au propriétaire de votre organisation de mettre à jour le type de données de la colonne en \"EUI\".", "Hello %(first_name)s, ": "Bonjour% (first_name) s,", "Hexagonal Bins": "Bacs Hexagonaux", "Highlights of SEED Platform™": "Points forts de la Plate-Forme SEED™", @@ -555,6 +571,8 @@ "Left Axis": "Axe gauche", "Left Half": "Moitié gauche", "Level": "Niveau", + "Level Instance": "Instance de niveau", + "Loading Summary Data...": "Chargement des données récapitulatives...", "Loading data...": "Chargeant les données ...", "Loading labels...": "Chargement des étiquettes ...", "Loading...": "Chargeant...", @@ -668,6 +686,7 @@ "Name": "Nom", "National Renewable Energy Laboratory": "Laboratoire National Des Énergies Renouvelables", "New Analysis": "Nouvelle analyse", + "New Goal": "Nouvel Objectif", "New Note": "Nouvelle note", "New User Email": "E-mail du nouvel utilisateur", "New password": "Nouveau mot de passe", @@ -689,6 +708,7 @@ "Not Compliant": "Non conforme", "Not Null": "Pas nul", "Not all inventory items were successfully deleted": "Tous les éléments de l'inventaire n'ont pas été supprimés", + "Not seeing your column?": "Vous ne voyez pas votre colonne?", "Note:": "Remarque:", "Note: Meters are labeled with the following format: \"Type - Source - Source ID\"": "Remarque: Les compteurs sont étiquetés au format suivant: \"la catégorie - Source - Source ID\"", "Note: Parent organization members are not automatically made members of sub-organizations.": "Remarque: les membres de l'organisation parente ne sont pas automatiquement devenus membres de sous-organisations.", @@ -735,6 +755,7 @@ "PM_METER_IMPORT_NO_ASSOCIATION": "Importation impossible - Aucune association à des propriétés précédemment importées", "PM_METER_IMPORT_RESULTS": "Résultats d'importation de Portfolio Manager", "PM_PROPERTY_ID_MATCHING_CRITERIA_WARNING": "La suppression de l'ID de propriété PM des critères de correspondance peut entraîner des problèmes inattendus pour les importations de compteurs Portfolio Manager.", + "PORTFOLIO_SUMMARY_HEADER_TEXT": "La page de résumé du portefeuille compare 2 cycles pour calculer les progrès vers un objectif de réduction de l'intensité de l'utilisation d'énergie. La sélection du cycle et les détails des objectifs peuvent être personnalisés en cliquant sur le bouton Configurer les Objectifs ci-dessous.", "POST_GEOCODING_COUNTS": "Nombre mis à jour après le géocodage", "PROPERTY_MATCHING_FIELDS_REQUIREMENT": "Au moins un des champs de propriété suivants est requis", "PUBLIC": "PUBLIC", @@ -762,6 +783,9 @@ "Please visit our User Support website for tutorials and documentation to help you learn how to use SEED-Platform.": "S'il vous plaît visitez notre site Web d'assistance aux utilisateurs pour les tutoriels et la documentation pour vous aider à apprendre à utiliser SEED-Platform.", "Please wait while your data is loaded...": "Veuillez patienter pendant que vos données sont chargées ...", "Populate SEED Headers with best known matches": "Remplissez les en-têtes SEED avec les correspondances les plus connues", + "Portfolio Summary": "Résumé du portefeuille", + "Portfolio Summary will only include properties belonging to this Access Level Instance.": "Le résumé du portefeuille inclura uniquement les propriétés appartenant à cette instance de niveau d'accès.", + "Portfolio Target": "Cible du portefeuille", "Postal Code": "Code postal", "Postal Code (Property)": "Code postal (propriété)", "Postal Code (Tax Lot)": "Code postal (lot d'impôt)", @@ -771,6 +795,7 @@ "Preview": "Aperçu", "Preview Loading": "Aperçu Chargement", "Previous": "Précédente", + "Primary Column": "Colonne principale", "Primary Tax Lot ID": "ID de lot d'impôt primaire", "Primary\/Secondary": "Primaire\/secondaire", "Profile Info": "Informations sur le profil", @@ -950,6 +975,7 @@ "Search display name": "Rechercher le nom d'affichage", "Search field name": "Rechercher le nom du champ", "Search table name": "Rechercher le nom de la table", + "Secondary (optional)": "Secondaire (facultatif)", "Security": "Sécurité", "Security Token": "Jeton d'authentification", "SeedOrg": "SeedOrg", @@ -1033,6 +1059,7 @@ "Target >= Actual for Compliance": "Cible >= Réel pour la conformité", "Target Column": "Colonne cible", "Target Field": "Champ cible", + "Target to quantify Portfolio EUI improvement. Must be between 0 and 100.": "Cible pour quantifier l’amélioration de l’IUE du portefeuille. Doit être compris entre 0 et 100.", "Tax Lot": "Lot d'impôt", "Tax Lot Count": "Nombre de lots d'impôt", "Tax Lot Detail": "Détail du lot d'impôt", @@ -1048,6 +1075,7 @@ "Taxlot Display Field": "Champ d'affichage du lot de taxes", "Terms of Service": "Conditions d'utilisation", "Terms of Service as of %(tos.created|date:\"SHORT_DATE_FORMAT\")s": "Conditions de service à partir de% (tos.created | date: \"SHORT_DATE_FORMAT\") s", + "Tertiary (optional)": "Tertiaire (facultatif)", "Test Connection": "Tester la Connexion", "Text": "Texte", "Thank you": "Merci", @@ -1137,6 +1165,8 @@ "Update Property Labels": "Mettre à jour les étiquettes de propriété", "Update Salesforce": "Mettre à jour Salesforce", "Update UBID": "Mettre à jour UBID", + "Update the data type of the column to \"Area\" in": "Assurez-vous que le type de données de la colonne est bien \"Area\" dans", + "Update the data type of the column to \"EUI\" in": "Assurez-vous que le type de données de la colonne est \"EUI\" dans", "Update with Audit Template": "Mise à jour avec Audit Template", "Update with BuildingSync": "Mettre à jour avec BuildingSync", "Update with ESPM": "Mise à jour avec ESPM", @@ -1273,6 +1303,7 @@ "ex: joe@company.com": "ex: joe@company.com", "first name": "Prénom", "for your SEED Platform user account": "pour votre compte utilisateur SEED Platform", + "goal": "Objectif", "gray": "gris", "green": "vert", "has been uploaded to": "a été téléchargé à", diff --git a/seed/static/seed/partials/goal_editor_modal.html b/seed/static/seed/partials/goal_editor_modal.html new file mode 100644 index 0000000000..25a0ce8a1b --- /dev/null +++ b/seed/static/seed/partials/goal_editor_modal.html @@ -0,0 +1,230 @@ + diff --git a/seed/static/seed/partials/insights_nav.html b/seed/static/seed/partials/insights_nav.html index 28aaef8e68..8744207adb 100644 --- a/seed/static/seed/partials/insights_nav.html +++ b/seed/static/seed/partials/insights_nav.html @@ -1,4 +1,5 @@ -Program Overview -Property Insights -Default Reports -Custom Reports + Program OverviewProperty InsightsDefault ReportsCustom ReportsPortfolio Summary +
+
+
+
+ {$ detail[0] $}: +
+
+ {$ detail[1] $} +
+
+
+
+
+
+ {$ detail[0] $}: +
+
+ {$ detail[1] $} +
+
+ +
+ +
+
+ + +
+

+ Portfolio Summary +

+
+
+

{$:: 'Loading Summary Data...' | translate $}

+ + + +
+
+ +
+ + +
+
+ +
+
+ {$ inventory_pagination.start $}-{$ inventory_pagination.end $} of + {$ inventory_pagination.total $} + + ({$ selected_display $} - Select All - Select + None) + + + +
+
+ +
+
+
+
+ +
+
+

Loading Data...

+ + + +
+ diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index 3eacbb218f..575ae11ac1 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -1816,6 +1816,7 @@ a:not([href]) { background-image: linear-gradient(transparent, #fff); position: fixed; bottom: 66px; + pointer-events: none; } .gradient-height { @@ -5074,7 +5075,209 @@ ul.r-list { border-bottom: 1px solid #ddd; } } +.portfolio-summary-wrapper { + display: flex; + flex-direction: column; + + .goals-header-text { + padding: 10px; + padding-bottom: 0; + } + .goal-actions-wrapper { + display: flex; + margin: 0 10px; + div { + margin: 0 10px; + } + + .goal-select-wrapper { + display: flex; + min-height: 30px; + margin: 15px; + .goal-select-label { + margin: auto; + } + .goal-selection { + #goal-select { + width: 30vw; + max-width: 512px; + background: #f5f5f5; + border-radius: 5px; + padding: 5px; + } + } + button { + margin-left: 10px; + height: 100%; + } + } + } + #portfolio-summary-selection-wrapper { + margin: 10px 7px; + display: flex; + + #portfolio-summary-selections { + margin-top: 22px; + padding-left: 10px; + width: 220px; + + .portfolio-summary-select-option { + font-size: 10px; + padding: 4px 0 6px 0; + } + .portfolio-summary-constant { + border: 1px solid gray; + border-radius: 2px; + background: rgb(229, 229, 229); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-left: 4px; + } + .starting-legend { + background-color: #f8f1cf; + } + .ending-legend { + background-color: #f8d0c4; + } + + .portfolio-summary-form-control { + height: 20px; + width: 100px; + } + } + + #portfolio-summary { + width: 100%; + padding-bottom: 30px; + margin: 0 16px 10px; + border-bottom: 1px solid rgb(199, 199, 199); + } + } + .portfolio-summary-btn { + height: 28px; + padding: 0 10px; + } + #portfolio-summary-grid { + margin: 0 24px; + } + + #portfolio-summary-grid, + #portfolio-summary { + .portfolio-summary-baseline-header { + background-color: #f8f1cf; + } + .ui-grid-row:nth-child(odd) .portfolio-summary-baseline-cell { + background-color: #f8f5eb !important; + } + + .ui-grid-row:nth-child(even) .portfolio-summary-baseline-cell { + background-color: #e0ded3 !important; + } + + .portfolio-summary-current-header { + background-color: #f8d0c4; + } + + .ui-grid-row:nth-child(odd) .portfolio-summary-current-cell { + background-color: #f9eee9 !important; + } + + .ui-grid-row:nth-child(even) .portfolio-summary-current-cell { + background-color: #dfd3ce !important; + } + + .above-target { + background: #9dca8f; + } + + .below-target { + background: #ca8f8f; + } + + .portfolioSummary-gridOptions-wrapper { + height: calc(100vh - 490px); + + .ui-grid { + height: 100%; + } + } + } + .portfolio-summary-loading { + border: 1px solid rgb(188, 188, 188); + border-radius: 5px; + height: 100px; + padding: 0 50px; + position: relative; + background: rgba(128, 128, 128, 0.1); + color: rgb(128, 128, 128); + } + .portfolio-summary-item-count { + margin: 0 24px; + margin-top: 2px; + + justify-content: space-between; + display: flex; + } + + .goal-details-container { + padding: 10px; + display: flex; + border: 1px solid rgb(197, 197, 197); + border-radius: 5px; + background: rgb(241, 241, 241); + width: fit-content; + margin: 0 0 16px 25px; + + .goal-details-column { + .goal-detail { + display: flex; + .goal-detail-key { + text-align: right; + width: 120px; + padding-right: 10px; + } + .goal-detail-value { + font-weight: bold; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 150px; + } + } + } + } +} + +.goals-text { + padding: 5px 100px 10px 10px; + font-size: 1.1em; +} + +#goal-details { + max-height: 70vh; + overflow: scroll; + // display: flex; + // column-gap: 10px; + + .goal-label { + width: 150px; + } + .goal-form-control { + height: unset; + padding: 0; + } +} +#goal-list { + max-height: 485px; +} +#goal-messages { + margin: 10px; + padding: 10px; + border-radius: 10px; + text-align: left; +} .analysis-results-section { padding: 5px 10px; margin: 0; @@ -5162,3 +5365,10 @@ tags-input .tags .tag-item { background: #337ab7; } } +.goal-name { + overflow: hidden; + text-overflow: ellipsis; +} +.tooltip-long { + word-wrap: break-word; +} diff --git a/seed/templates/seed/_scripts.html b/seed/templates/seed/_scripts.html index 5ba68dc94a..aff5cbc2f3 100644 --- a/seed/templates/seed/_scripts.html +++ b/seed/templates/seed/_scripts.html @@ -73,6 +73,7 @@ + @@ -115,6 +116,7 @@ + @@ -151,6 +153,7 @@ + diff --git a/seed/tests/test_goals.py b/seed/tests/test_goals.py new file mode 100644 index 0000000000..ee2f608d2b --- /dev/null +++ b/seed/tests/test_goals.py @@ -0,0 +1,385 @@ +# !/usr/bin/env python +# encoding: utf-8 +""" +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/seed/main/LICENSE.md +""" +import json +from datetime import datetime + +from django.urls import reverse_lazy + +from seed.landing.models import SEEDUser as User +from seed.models import Column, Goal +from seed.test_helpers.fake import ( + FakeColumnFactory, + FakeCycleFactory, + FakePropertyFactory, + FakePropertyStateFactory, + FakePropertyViewFactory +) +from seed.tests.util import AccessLevelBaseTestCase +from seed.utils.organizations import create_organization + + +class GoalViewTests(AccessLevelBaseTestCase): + def setUp(self): + super().setUp() + self.cycle_factory = FakeCycleFactory(organization=self.org, user=self.root_owner_user) + self.column_factory = FakeColumnFactory(organization=self.org) + self.property_factory = FakePropertyFactory(organization=self.org) + self.property_view_factory = FakePropertyViewFactory(organization=self.org) + self.property_state_factory = FakePropertyStateFactory(organization=self.org) + + # cycles + self.cycle1 = self.cycle_factory.get_cycle(start=datetime(2001, 1, 1), end=datetime(2002, 1, 1)) + self.cycle2 = self.cycle_factory.get_cycle(start=datetime(2002, 1, 1), end=datetime(2003, 1, 1)) + self.cycle3 = self.cycle_factory.get_cycle(start=datetime(2003, 1, 1), end=datetime(2004, 1, 1)) + + self.root_ali = self.org.root + self.child_ali = self.org.root.get_children().first() + + # columns + extra_eui = Column.objects.create( + table_name='PropertyState', + column_name='extra_eui', + organization=self.org, + is_extra_data=True, + ) + extra_area = Column.objects.create( + table_name='PropertyState', + column_name='extra_area', + organization=self.org, + is_extra_data=True, + ) + + # properties + # property_details_{property}{cycle} + property_details_11 = self.property_state_factory.get_details() + property_details_11['source_eui'] = 1 + property_details_11['gross_floor_area'] = 2 + property_details_11['extra_data'] = {'extra_eui': '10', 'extra_area': '20'} + + property_details_13 = self.property_state_factory.get_details() + property_details_13['source_eui'] = 3 + property_details_13['source_eui_weather_normalized'] = 4 + property_details_13['gross_floor_area'] = 5 + property_details_13['extra_data'] = {'extra_eui': 20, 'extra_area': 50} + + property_details_31 = self.property_state_factory.get_details() + property_details_31['source_eui'] = 6 + property_details_31['gross_floor_area'] = 7 + property_details_31['extra_data'] = {'extra_eui': 'abcd', 'extra_area': 'xyz'} + + property_details_33 = self.property_state_factory.get_details() + property_details_33['source_eui'] = 8 + property_details_33['source_eui_weather_normalized'] = 9 + property_details_33['gross_floor_area'] = 10 + property_details_33['extra_data'] = {'extra_eui': 40, 'extra_area': 100} + + self.property1 = self.property_factory.get_property(access_level_instance=self.child_ali) + self.property2 = self.property_factory.get_property(access_level_instance=self.child_ali) + self.property3 = self.property_factory.get_property(access_level_instance=self.child_ali) + + self.state_11 = self.property_state_factory.get_property_state(**property_details_11) + self.state_13 = self.property_state_factory.get_property_state(**property_details_13) + self.state_2 = self.property_state_factory.get_property_state(**property_details_11) + self.state_31 = self.property_state_factory.get_property_state(**property_details_31) + self.state_33 = self.property_state_factory.get_property_state(**property_details_33) + + self.view11 = self.property_view_factory.get_property_view(prprty=self.property1, state=self.state_11, cycle=self.cycle1) + self.view13 = self.property_view_factory.get_property_view(prprty=self.property1, state=self.state_13, cycle=self.cycle3) + self.view2 = self.property_view_factory.get_property_view(prprty=self.property2, state=self.state_2, cycle=self.cycle2) + self.view31 = self.property_view_factory.get_property_view(prprty=self.property3, state=self.state_31, cycle=self.cycle1) + self.view33 = self.property_view_factory.get_property_view(prprty=self.property3, state=self.state_33, cycle=self.cycle3) + + self.root_goal = Goal.objects.create( + organization=self.org, + baseline_cycle=self.cycle1, + current_cycle=self.cycle3, + access_level_instance=self.root_ali, + eui_column1=Column.objects.get(organization=self.org.id, column_name='source_eui_weather_normalized'), + eui_column2=Column.objects.get(organization=self.org.id, column_name='source_eui'), + eui_column3=Column.objects.get(organization=self.org.id, column_name='site_eui'), + area_column=Column.objects.get(organization=self.org.id, column_name='gross_floor_area'), + target_percentage=20, + name='root_goal' + ) + self.child_goal = Goal.objects.create( + organization=self.org, + baseline_cycle=self.cycle1, + current_cycle=self.cycle3, + access_level_instance=self.child_ali, + eui_column1=Column.objects.get(organization=self.org.id, column_name='source_eui_weather_normalized'), + eui_column2=Column.objects.get(organization=self.org.id, column_name='source_eui'), + eui_column3=None, + area_column=Column.objects.get(organization=self.org.id, column_name='gross_floor_area'), + target_percentage=20, + name='child_goal' + ) + + self.child_goal_extra = Goal.objects.create( + organization=self.org, + baseline_cycle=self.cycle1, + current_cycle=self.cycle3, + access_level_instance=self.child_ali, + eui_column1=extra_eui, + eui_column2=None, + eui_column3=None, + area_column=extra_area, + target_percentage=20, + name='child_goal_extra' + ) + + user2_details = { + 'username': 'test_user2@demo.com', + 'password': 'test_pass2', + 'email': 'test_user2@demo.com', + } + self.user2 = User.objects.create_superuser(**user2_details) + self.org2, _, _ = create_organization(self.user2, "org2") + + def test_goal_list(self): + url = reverse_lazy('api:v3:goals-list') + '?organization_id=' + str(self.org.id) + self.login_as_root_member() + response = self.client.get(url, contemt_type='application/json') + assert response.status_code == 200 + assert len(response.json()['goals']) == 3 + + self.login_as_child_member() + response = self.client.get(url, contemt_type='application/json') + assert response.status_code == 200 + assert len(response.json()['goals']) == 2 + + def test_goal_retrieve(self): + self.login_as_child_member() + url = reverse_lazy('api:v3:goals-detail', args=[self.child_goal.id]) + '?organization_id=' + str(self.org.id) + response = self.client.get(url, content_type='application/json') + assert response.status_code == 200 + assert response.json()['id'] == self.child_goal.id + + url = reverse_lazy('api:v3:goals-detail', args=[999]) + '?organization_id=' + str(self.org.id) + response = self.client.get(url, content_type='application/json') + assert response.status_code == 404 + assert response.json()['message'] == 'No such resource.' + + url = reverse_lazy('api:v3:goals-detail', args=[self.root_goal.id]) + '?organization_id=' + str(self.org.id) + response = self.client.get(url, content_type='application/json') + assert response.status_code == 404 + assert response.json()['message'] == 'No such resource.' + + def test_goal_destroy(self): + goal_count = Goal.objects.count() + + # invalid permission + self.login_as_child_member() + url = reverse_lazy('api:v3:goals-detail', args=[self.root_goal.id]) + '?organization_id=' + str(self.org.id) + response = self.client.delete(url, content_type='application/json') + assert response.status_code == 404 + assert Goal.objects.count() == goal_count + + # valid + url = reverse_lazy('api:v3:goals-detail', args=[self.child_goal.id]) + '?organization_id=' + str(self.org.id) + response = self.client.delete(url, content_type='application/json') + assert response.status_code == 204 + assert Goal.objects.count() == goal_count - 1 + + def test_goal_create(self): + goal_count = Goal.objects.count() + url = reverse_lazy('api:v3:goals-list') + '?organization_id=' + str(self.org.id) + goal_columns = [ + 'placeholder', + Column.objects.get(organization=self.org.id, column_name='source_eui_weather_normalized').id, + Column.objects.get(organization=self.org.id, column_name='source_eui').id, + Column.objects.get(organization=self.org.id, column_name='site_eui').id, + Column.objects.get(organization=self.org.id, column_name='gross_floor_area').id, + ] + + def reset_goal_data(name): + return { + 'organization': self.org.id, + 'baseline_cycle': self.cycle1.id, + 'current_cycle': self.cycle3.id, + 'access_level_instance': self.child_ali.id, + 'eui_column1': goal_columns[1], + 'eui_column2': goal_columns[2], + 'eui_column3': goal_columns[3], + 'area_column': goal_columns[4], + 'target_percentage': 20, + 'name': name + } + goal_data = reset_goal_data('child_goal 2') + + # success + self.login_as_child_member() + response = self.client.post( + url, + data=json.dumps(goal_data), + content_type='application/json' + ) + assert response.status_code == 201 + assert Goal.objects.count() == goal_count + 1 + goal_count = Goal.objects.count() + + # invalid permission + goal_data['access_level_instance'] = self.root_ali.id + response = self.client.post( + url, + data=json.dumps(goal_data), + content_type='application/json' + ) + assert response.status_code == 404 + assert Goal.objects.count() == goal_count + + # invalid data + goal_data['access_level_instance'] = self.child_ali.id + goal_data['baseline_cycle'] = 999 + goal_data['eui_column1'] = 998 + response = self.client.post(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 400 + errors = response.json() + assert errors['name'] == ['goal with this name already exists.'] + assert errors['baseline_cycle'] == ['Invalid pk "999" - object does not exist.'] + assert errors['eui_column1'] == ['Invalid pk "998" - object does not exist.'] + assert Goal.objects.count() == goal_count + + # cycles must be unique + goal_data = reset_goal_data('child_goal 3') + goal_data['current_cycle'] = self.cycle1.id + + response = self.client.post(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 400 + assert response.json()['non_field_errors'] == ['Cycles must be unique.'] + + # columns must be unique + goal_data = reset_goal_data('child_goal 3') + goal_data['eui_column2'] = goal_columns[1] + + response = self.client.post(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 400 + assert response.json()['non_field_errors'] == ['Columns must be unique.'] + + # missing data + goal_data = reset_goal_data('') + goal_data.pop('name') + goal_data.pop('baseline_cycle') + goal_data.pop('eui_column1') + response = self.client.post(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 400 + errors = response.json() + assert errors['name'] == ['This field is required.'] + assert errors['baseline_cycle'] == ['This field is required.'] + assert errors['eui_column1'] == ['This field is required.'] + + # column2 and 3 are optional + goal_data = reset_goal_data('child_goal 3') + goal_data['eui_column2'] = None + goal_data['eui_column3'] = None + response = self.client.post(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 201 + assert response.json()['eui_column1'] == goal_columns[1] + assert response.json()['eui_column2'] is None + assert response.json()['eui_column3'] is None + assert Goal.objects.count() == goal_count + 1 + + # incorrect org + goal_data = reset_goal_data('wrong org goal') + goal_data['organization'] = self.org2.id + response = self.client.post(url, data=json.dumps(goal_data), content_type='application/json') + assert response.json()['non_field_errors'] == ['Organization mismatch.'] + + def test_goal_update(self): + original_goal = Goal.objects.get(id=self.child_goal.id) + + self.login_as_child_member() + url = reverse_lazy('api:v3:goals-detail', args=[self.child_goal.id]) + '?organization_id=' + str(self.org.id) + goal_data = { + 'baseline_cycle': self.cycle2.id, + 'target_percentage': 99, + } + response = self.client.put(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 200 + assert response.json()['target_percentage'] == 99 + assert response.json()['baseline_cycle'] == self.cycle2.id + assert response.json()['eui_column1'] == original_goal.eui_column1.id + + # invalid permission + goal_data = { + 'access_level_instance': self.root_ali.id + } + response = self.client.put(url, data=json.dumps(goal_data), content_type='application/json') + assert response.status_code == 404 + assert response.json()['message'] == 'No such resource.' + + # unexpected fields are ignored + goal_data = { + 'name': 'child_goal y', + 'baseline_cycle': self.cycle1.id, + 'unexpected': 'invalid' + } + response = self.client.put(url, data=json.dumps(goal_data), content_type='application/json') + assert response.json()['name'] == 'child_goal y' + assert response.json()['baseline_cycle'] == self.cycle1.id + assert response.json()['eui_column1'] == original_goal.eui_column1.id + assert 'extra_data' not in response.json() + + # invalid data + goal_data = { + 'eui_column1': 999, + 'baseline_cycle': 999, + 'target_percentage': 999, + } + response = self.client.put(url, data=json.dumps(goal_data), content_type='application/json') + errors = response.json()['errors'] + assert errors['eui_column1'] == ['Invalid pk "999" - object does not exist.'] + assert errors['baseline_cycle'] == ['Invalid pk "999" - object does not exist.'] + + def test_portfolio_summary(self): + self.login_as_child_member() + url = reverse_lazy('api:v3:goals-portfolio-summary', args=[self.root_goal.id]) + '?organization_id=' + str(self.org.id) + response = self.client.get(url, content_type='application/json') + assert response.status_code == 404 + assert response.json()['message'] == 'No such resource.' + + url = reverse_lazy('api:v3:goals-portfolio-summary', args=[self.child_goal.id]) + '?organization_id=' + str(self.org.id) + response = self.client.get(url, content_type='application/json') + summary = response.json() + exp_summary = { + 'baseline': { + 'cycle_name': '2001 Annual', + 'total_kbtu': 44, + 'total_sqft': 9, + 'weighted_eui': 4 + }, + 'current': { + 'cycle_name': '2003 Annual', + 'total_kbtu': 110, + 'total_sqft': 15, + 'weighted_eui': 7}, + 'eui_change': -75, + 'sqft_change': 40 + } + + assert summary == exp_summary + + # with extra data + url = reverse_lazy('api:v3:goals-portfolio-summary', args=[self.child_goal_extra.id]) + '?organization_id=' + str(self.org.id) + response = self.client.get(url, content_type='application/json') + summary = response.json() + exp_summary = { + 'baseline': { + 'cycle_name': '2001 Annual', + 'total_kbtu': 200, + 'total_sqft': 20, + 'weighted_eui': 10 + }, + 'current': { + 'cycle_name': '2003 Annual', + 'total_kbtu': 5000, + 'total_sqft': 150, + 'weighted_eui': 33}, + 'eui_change': -229, + 'sqft_change': 86 + } + + assert summary == exp_summary diff --git a/seed/utils/goals.py b/seed/utils/goals.py new file mode 100644 index 0000000000..8b359fd6d8 --- /dev/null +++ b/seed/utils/goals.py @@ -0,0 +1,63 @@ +# !/usr/bin/env python +# encoding: utf-8 +""" +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/seed/main/LICENSE.md +""" +from django.db.models import Case, F, FloatField, IntegerField, Value, When +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast, Coalesce + + +def get_eui_expression(goal): + """ + goal.eui_columnx is designed to only accept columns of data_type=eui (columns like site_eui, or source_eui) + however the user may choose to use an extra_data column that has been typed on the frontend as 'eui'. + This frontend change does not effect the db, and extra_data fields are stored as JSON objects + extra_data = {Name: value} where value can be any type. + + This function dynamically finds the highest priority eui column and sets its type to Integer + """ + priority = [] + + # Iterate through the columns in priority order + for eui_column in goal.eui_columns(): + if eui_column.is_extra_data: + eui_column_expression = extra_data_expression(eui_column, None) + else: + eui_column_expression = Cast(F(f'state__{eui_column.column_name}'), output_field=FloatField()) + + priority.append(eui_column_expression) + + # default value + priority.append(Value(None, output_field=FloatField())) + # Coalesce to pick the first non-null value + eui_expression = Coalesce(*priority, output_field=FloatField()) + + return eui_expression + + +def get_area_expression(goal): + """ + goal.area_column is designed to only accept columns of data_type=area (columns like gross_foor_area) + however the user may choose to use an extra_data column that has been typed on the frontend as 'area'. + This frontend change does not effect the db, and extra_data fields are stored as JSON objects + extra_data = {Name: value} where value can be any type. + """ + if goal.area_column.is_extra_data: + return extra_data_expression(goal.area_column, 0.0) + else: + return Cast(F(f'state__{goal.area_column.column_name}'), output_field=IntegerField()) + + +def extra_data_expression(column, default_value): + """ + extra_data is a JSON object and could be any data type. Convert to float if possible + """ + return Case( + # use regex to determine if value can be converted to a number (int or float) + When(**{f'state__extra_data__{column.column_name}__regex': r'^\d+(\.\d+)?$'}, + then=Cast(KeyTextTransform(column.column_name, 'state__extra_data'), output_field=FloatField())), + default=Value(default_value), + output_field=FloatField() + ) diff --git a/seed/utils/inventory_filter.py b/seed/utils/inventory_filter.py index 1e5ad6da4c..796e383363 100644 --- a/seed/utils/inventory_filter.py +++ b/seed/utils/inventory_filter.py @@ -31,7 +31,6 @@ def get_filtered_results(request: Request, inventory_type: Literal['property', ' page = request.query_params.get('page') per_page = request.query_params.get('per_page') org_id = request.query_params.get('organization_id') - access_level_instance_id = request.access_level_instance_id cycle_id = request.query_params.get('cycle') ids_only = request.query_params.get('ids_only', 'false').lower() == 'true' # check if there is a query parameter for the profile_id. If so, then use that one @@ -43,7 +42,15 @@ def get_filtered_results(request: Request, inventory_type: Literal['property', ' {'status': 'error', 'message': 'Need to pass organization_id as query parameter'}, status=status.HTTP_400_BAD_REQUEST) org = Organization.objects.get(id=org_id) + + access_level_instance_id = request.data.get('access_level_instance_id', request.access_level_instance_id) access_level_instance = AccessLevelInstance.objects.get(pk=access_level_instance_id) + user_ali = AccessLevelInstance.objects.get(pk=request.access_level_instance_id) + if not (user_ali == access_level_instance or access_level_instance.is_descendant_of(user_ali)): + return JsonResponse({ + 'status': 'error', + 'message': f'No access_level_instance with id {access_level_instance_id}.' + }, status=status.HTTP_404_NOT_FOUND) if cycle_id: cycle = Cycle.objects.get(organization_id=org_id, pk=cycle_id) @@ -161,6 +168,10 @@ def get_filtered_results(request: Request, inventory_type: Literal['property', ' if 'exclude_view_ids' in request.data and request.data['exclude_view_ids']: views_list = views_list.exclude(id__in=request.data['exclude_view_ids']) + # return property views limited to the 'include_property_ids' list if not empty + if include_property_ids := request.data.get('include_property_ids'): + views_list = views_list.filter(property__id__in=include_property_ids) + if ids_only: id_list = list(views_list.values_list('id', flat=True)) return JsonResponse({ diff --git a/seed/utils/properties.py b/seed/utils/properties.py index d3301d564c..89af927229 100644 --- a/seed/utils/properties.py +++ b/seed/utils/properties.py @@ -6,6 +6,7 @@ """ import itertools import json +import logging # Imports from Django from django.http import JsonResponse @@ -26,6 +27,12 @@ from seed.serializers.pint import apply_display_unit_preferences from seed.utils.search import build_view_filters_and_sorts +logging.basicConfig( + format='%(asctime)s %(levelname)-8s %(message)s', + level=logging.ERROR, + datefmt='%Y-%m-%d %H:%M:%S' +) + def get_changed_fields(old, new): """Return changed fields as json string""" diff --git a/seed/views/v3/goals.py b/seed/views/v3/goals.py new file mode 100644 index 0000000000..04c36a8bcd --- /dev/null +++ b/seed/views/v3/goals.py @@ -0,0 +1,163 @@ +""" +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/seed/main/LICENSE.md +""" +from django.db.models import F, Sum +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from quantityfield.units import ureg +from rest_framework import status +from rest_framework.decorators import action + +from seed.decorators import ajax_request_class +from seed.lib.superperms.orgs.decorators import ( + has_hierarchy_access, + has_perm_class +) +from seed.models import AccessLevelInstance, Goal, Organization, PropertyView +from seed.serializers.goals import GoalSerializer +from seed.serializers.pint import collapse_unit +from seed.utils.api import OrgMixin +from seed.utils.api_schema import swagger_auto_schema_org_query_param +from seed.utils.goals import get_area_expression, get_eui_expression +from seed.utils.viewsets import ModelViewSetWithoutPatch + + +@method_decorator( + name='retrieve', + decorator=[ + swagger_auto_schema_org_query_param, + has_perm_class('requires_viewer'), + has_hierarchy_access(goal_id_kwarg='pk') + ] +) +@method_decorator( + name='destroy', + decorator=[ + swagger_auto_schema_org_query_param, + has_perm_class('requires_member'), + has_hierarchy_access(goal_id_kwarg="pk") + ] +) +@method_decorator( + name='create', + decorator=[ + swagger_auto_schema_org_query_param, + has_perm_class('requires_member'), + has_hierarchy_access(body_ali_id="access_level_instance") + ] +) +class GoalViewSet(ModelViewSetWithoutPatch, OrgMixin): + serializer_class = GoalSerializer + queryset = Goal.objects.all() + + @swagger_auto_schema_org_query_param + @has_perm_class('requires_viewer') + def list(self, request): + organization_id = self.get_organization(request) + access_level_instance = AccessLevelInstance.objects.get(pk=request.access_level_instance_id) + + goals = Goal.objects.filter( + organization=organization_id, + access_level_instance__lft__gte=access_level_instance.lft, + access_level_instance__rgt__lte=access_level_instance.rgt + ) + + return JsonResponse({ + 'status': 'success', + 'goals': self.serializer_class(goals, many=True).data + }) + + @swagger_auto_schema_org_query_param + @has_perm_class('requires_member') + @has_hierarchy_access(goal_id_kwarg='pk') + def update(self, request, pk): + try: + goal = Goal.objects.get(pk=pk) + except Goal.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'errors': "No such resource." + }) + + serializer = GoalSerializer(goal, data=request.data, partial=True) + + if not serializer.is_valid(): + return JsonResponse({ + 'status': 'error', + 'errors': serializer.errors, + }, status=status.HTTP_400_BAD_REQUEST) + + serializer.save() + + return JsonResponse(serializer.data) + + @ajax_request_class + @swagger_auto_schema_org_query_param + @has_perm_class('requires_viewer') + @has_hierarchy_access(goal_id_kwarg='pk') + @action(detail=True, methods=['GET']) + def portfolio_summary(self, request, pk): + """ + Gets a Portfolio Summary dictionary given a goal + """ + org_id = int(self.get_organization(request)) + org = Organization.objects.get(pk=org_id) + goal = Goal.objects.get(pk=pk) + summary = {} + for cycle in [goal.baseline_cycle, goal.current_cycle]: + property_views = PropertyView.objects.select_related('property', 'state') \ + .filter( + property__organization_id=org_id, + cycle_id=cycle.id, + property__access_level_instance__lft__gte=goal.access_level_instance.lft, + property__access_level_instance__rgt__lte=goal.access_level_instance.rgt, + ) + + # Create annotations for kbtu calcs. 'eui' is based on goal column priority + property_views = property_views.annotate( + eui=get_eui_expression(goal), + area=get_area_expression(goal), + ).annotate( + kbtu=F('eui') * F('area') + ) + + aggregated_data = property_views.aggregate( + total_kbtu=Sum('kbtu'), + total_sqft=Sum('area') + ) + total_kbtu = aggregated_data['total_kbtu'] + total_sqft = aggregated_data['total_sqft'] + + if total_kbtu: + total_kbtu = int(total_kbtu) + + if total_kbtu is not None and total_sqft: + # apply units for potential unit conversion (no org setting for type ktbu so it is ignored) + total_sqft = total_sqft * ureg('ft**2') + weighted_eui = total_kbtu * ureg('kBtu/year') / total_sqft + weighted_eui = int(collapse_unit(org, weighted_eui)) + else: + weighted_eui = None + + if total_sqft is not None: + total_sqft = collapse_unit(org, total_sqft) + + cycle_type = 'current' if cycle == goal.current_cycle else 'baseline' + + summary[cycle_type] = { + 'cycle_name': cycle.name, + 'total_sqft': total_sqft, + 'total_kbtu': total_kbtu, + 'weighted_eui': weighted_eui + } + + def percentage(a, b): + if not a or not b: + return None + return int((a - b) / a * 100) or 0 + + summary['sqft_change'] = percentage(summary['current']['total_sqft'], summary['baseline']['total_sqft']) + summary['eui_change'] = percentage(summary['baseline']['weighted_eui'], summary['current']['weighted_eui']) + + return summary diff --git a/seed/views/v3/properties.py b/seed/views/v3/properties.py index 5030229f16..ae073b5dc2 100644 --- a/seed/views/v3/properties.py +++ b/seed/views/v3/properties.py @@ -98,6 +98,13 @@ logger = logging.getLogger(__name__) +logging.basicConfig( + format='%(asctime)s %(levelname)-8s %(message)s', + level=logging.ERROR, + datefmt='%Y-%m-%d %H:%M:%S' +) + + # Global toggle that controls whether or not to display the raw extra # data fields in the columns returned for the view. DISPLAY_RAW_EXTRADATA = True