diff --git a/seed/api/v3/urls.py b/seed/api/v3/urls.py index de8ce81ac8..e0b0f55270 100644 --- a/seed/api/v3/urls.py +++ b/seed/api/v3/urls.py @@ -31,12 +31,14 @@ 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.goal_notes import GoalNoteViewSet from seed.views.v3.goals import GoalViewSet from seed.views.v3.green_assessment_properties import ( GreenAssessmentPropertyViewSet ) from seed.views.v3.green_assessment_urls import GreenAssessmentURLViewSet from seed.views.v3.green_assessments import GreenAssessmentViewSet +from seed.views.v3.historical_notes import HistoricalNoteViewSet from seed.views.v3.import_files import ImportFileViewSet from seed.views.v3.label_inventories import LabelInventoryViewSet from seed.views.v3.labels import LabelViewSet @@ -125,6 +127,8 @@ properties_router.register(r'notes', NoteViewSet, basename='property-notes') properties_router.register(r'scenarios', PropertyScenarioViewSet, basename='property-scenarios') properties_router.register(r'events', EventViewSet, basename='property-events') +properties_router.register(r'goal_notes', GoalNoteViewSet, basename='property-goal-notes') +properties_router.register(r'historical_notes', HistoricalNoteViewSet, basename='property-historical-notes') # This is a third level router, so we need to register it with the second level router meters_router = nested_routers.NestedSimpleRouter(properties_router, r'meters', lookup='meter') diff --git a/seed/migrations/0217_goalnote.py b/seed/migrations/0217_goalnote.py new file mode 100644 index 0000000000..22fe5170b0 --- /dev/null +++ b/seed/migrations/0217_goalnote.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.23 on 2024-02-01 22:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('seed', '0216_goal'), + ] + + operations = [ + migrations.CreateModel( + name='GoalNote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(blank=True, choices=[('Is this a new construction or acquisition?', 'Is this a new construction or acquisition?'), ('Do you have data to report?', 'Do you have data to report?'), ('Is this value correct?', 'Is this value correct?'), ('Are these values correct?', 'Are these values correct?'), ('Other or multiple flags; explain in Additional Notes field', 'Other or multiple flags; explain in Additional Notes field')], max_length=1024, null=True)), + ('resolution', models.CharField(blank=True, max_length=1024, null=True)), + ('passed_checks', models.BooleanField(default=False)), + ('new_or_acquired', models.BooleanField(default=False)), + ('goal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='seed.goal')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='seed.property')), + ], + ), + ] diff --git a/seed/migrations/0218_historicalnote.py b/seed/migrations/0218_historicalnote.py new file mode 100644 index 0000000000..63049a5d38 --- /dev/null +++ b/seed/migrations/0218_historicalnote.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.23 on 2024-02-12 20:27 + +import django.db.models.deletion +from django.db import migrations, models, transaction + + +@transaction.atomic +def backfill_historical_notes(apps, schema_editor): + Property = apps.get_model("seed", "Property") + HistoricalNote = apps.get_model("seed", "HistoricalNote") + + properties_to_update = Property.objects.filter(historical_note__isnull=True) + + historical_notes_to_create = [ + HistoricalNote(property=property, text='') + for property in properties_to_update + ] + HistoricalNote.objects.bulk_create(historical_notes_to_create) + + +class Migration(migrations.Migration): + + dependencies = [ + ('seed', '0217_goalnote'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalNote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(blank=True)), + ('property', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='historical_note', to='seed.property')), + ], + ), + migrations.RunPython(backfill_historical_notes) + ] diff --git a/seed/models/__init__.py b/seed/models/__init__.py index b1f52abc43..24dfd4289e 100644 --- a/seed/models/__init__.py +++ b/seed/models/__init__.py @@ -45,6 +45,7 @@ from .ubid_models import * # noqa from .uniformat import * # noqa from .goals import * # noqa +from .goal_notes import * # noqa from .certification import ( # noqa GreenAssessment, diff --git a/seed/models/goal_notes.py b/seed/models/goal_notes.py new file mode 100644 index 0000000000..cbd6139121 --- /dev/null +++ b/seed/models/goal_notes.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.db import models + +from seed.models import Goal, Property + + +class GoalNote(models.Model): + QUESTION_CHOICES = ( + ('Is this a new construction or acquisition?', 'Is this a new construction or acquisition?'), + ('Do you have data to report?', 'Do you have data to report?'), + ('Is this value correct?', 'Is this value correct?'), + ('Are these values correct?', 'Are these values correct?'), + ('Other or multiple flags; explain in Additional Notes field', 'Other or multiple flags; explain in Additional Notes field'), + ) + + goal = models.ForeignKey(Goal, on_delete=models.CASCADE) + property = models.ForeignKey(Property, on_delete=models.CASCADE) + + question = models.CharField(max_length=1024, choices=QUESTION_CHOICES, blank=True, null=True) + resolution = models.CharField(max_length=1024, blank=True, null=True) + passed_checks = models.BooleanField(default=False) + new_or_acquired = models.BooleanField(default=False) + + def serialize(self): + from seed.serializers.goal_notes import GoalNoteSerializer + serializer = GoalNoteSerializer(self) + return serializer.data diff --git a/seed/models/goals.py b/seed/models/goals.py index 14ccc8442f..f4fcf22718 100644 --- a/seed/models/goals.py +++ b/seed/models/goals.py @@ -2,10 +2,20 @@ 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 django.db.models import Q +from django.db.models.signals import post_save +from django.dispatch import receiver -from seed.models import AccessLevelInstance, Column, Cycle, Organization +from seed.models import ( + AccessLevelInstance, + Column, + Cycle, + Organization, + Property +) class Goal(models.Model): @@ -28,3 +38,36 @@ 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] + + def properties(self): + properties = Property.objects.filter( + Q(views__cycle=self.baseline_cycle) | + Q(views__cycle=self.current_cycle), + access_level_instance__lft__gte=self.access_level_instance.lft, + access_level_instance__rgt__lte=self.access_level_instance.rgt + ).distinct() + + return properties + + +@receiver(post_save, sender=Goal) +def post_save_goal(sender, instance, **kwargs): + from seed.models import GoalNote + + # retrieve a flat set of all property ids associated with this goal + goal_property_ids = set(instance.properties().values_list('id', flat=True)) + + # retrieve a flat set of all property ids from the previous goal (through goal note which has not been created/updated yet) + previous_property_ids = set(instance.goalnote_set.values_list('property_id', flat=True)) + + # create, or update has added more properties to the goal + new_property_ids = goal_property_ids - previous_property_ids + # update has removed properties from the goal + removed_property_ids = previous_property_ids - goal_property_ids + + if new_property_ids: + new_goal_notes = [GoalNote(goal=instance, property_id=id) for id in new_property_ids] + GoalNote.objects.bulk_create(new_goal_notes) + + if removed_property_ids: + GoalNote.objects.filter(goal=instance, property_id__in=removed_property_ids).delete() diff --git a/seed/models/notes.py b/seed/models/notes.py index efe80b3b47..275cf1b4e9 100644 --- a/seed/models/notes.py +++ b/seed/models/notes.py @@ -9,7 +9,7 @@ from seed.landing.models import SEEDUser as User from seed.lib.superperms.orgs.models import Organization -from seed.models import MAX_NAME_LENGTH, PropertyView, TaxLotView +from seed.models import MAX_NAME_LENGTH, Property, PropertyView, TaxLotView from seed.utils.generic import obj_to_dict @@ -108,3 +108,13 @@ def create_from_edit(self, user_id, view, new_values, previous_values): def to_dict(self): return obj_to_dict(self) + + +class HistoricalNote(models.Model): + text = models.TextField(blank=True) + property = models.OneToOneField(Property, on_delete=models.CASCADE, related_name='historical_note') + + def serialize(self): + from seed.serializers.historical_notes import HistoricalNoteSerializer + serializer = HistoricalNoteSerializer(self) + return serializer.data diff --git a/seed/models/properties.py b/seed/models/properties.py index e5e640b218..39820ca6e0 100644 --- a/seed/models/properties.py +++ b/seed/models/properties.py @@ -148,6 +148,13 @@ def set_default_access_level_instance(sender, instance, **kwargs): raise ValidationError("cannot change property's ALI to Ali different that related taxlots.") +@receiver(post_save, sender=Property) +def post_save_property(sender, instance, created, **kwargs): + if created: + from seed.models import HistoricalNote + HistoricalNote.objects.get_or_create(property=instance) + + class PropertyState(models.Model): """Store a single property. This contains all the state information about the property diff --git a/seed/models/tax_lot_properties.py b/seed/models/tax_lot_properties.py index 682f88f9e4..e7f1992743 100644 --- a/seed/models/tax_lot_properties.py +++ b/seed/models/tax_lot_properties.py @@ -125,6 +125,7 @@ def serialize( show_columns: Optional[list[int]], columns_from_database: list[dict], include_related: bool = True, + goal_id: int = False, ) -> list[dict]: """ This method takes a list of TaxLotViews or PropertyViews and returns the data along @@ -269,6 +270,12 @@ def serialize( if obj_dict.get('measures'): del obj_dict['measures'] + # add goal note data + if goal_id: + goal_note = obj.property.goalnote_set.filter(goal=goal_id).first() + obj_dict['goal_note'] = goal_note.serialize() if goal_note else None + obj_dict['historical_note'] = obj.property.historical_note.serialize() + results.append(obj_dict) return results diff --git a/seed/serializers/goal_notes.py b/seed/serializers/goal_notes.py new file mode 100644 index 0000000000..bb78fc83ea --- /dev/null +++ b/seed/serializers/goal_notes.py @@ -0,0 +1,14 @@ +""" +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 rest_framework import serializers + +from seed.models import GoalNote + + +class GoalNoteSerializer(serializers.ModelSerializer): + + class Meta: + model = GoalNote + fields = '__all__' diff --git a/seed/serializers/historical_notes.py b/seed/serializers/historical_notes.py new file mode 100644 index 0000000000..743b08c31f --- /dev/null +++ b/seed/serializers/historical_notes.py @@ -0,0 +1,14 @@ +""" +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 rest_framework import serializers + +from seed.models import HistoricalNote + + +class HistoricalNoteSerializer(serializers.ModelSerializer): + + class Meta: + model = HistoricalNote + fields = '__all__' diff --git a/seed/static/seed/js/controllers/portfolio_summary_controller.js b/seed/static/seed/js/controllers/portfolio_summary_controller.js index 08e4d18103..7b7dbe9905 100644 --- a/seed/static/seed/js/controllers/portfolio_summary_controller.js +++ b/seed/static/seed/js/controllers/portfolio_summary_controller.js @@ -210,7 +210,7 @@ angular.module('BE.seed.controller.portfolio_summary', []) // 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 => { + get_paginated_properties(page, per_page, cycle_priority[0], access_level_instance_id, true, null, $scope.goal.id).then(result0 => { $scope.inventory_pagination = result0.pagination properties = result0.results combined_result[cycle_priority[0].id] = properties; @@ -229,7 +229,7 @@ angular.module('BE.seed.controller.portfolio_summary', []) }) } - const get_paginated_properties = (page, chunk, cycle, access_level_instance_id, include_filters_sorts, include_property_ids=null) => { + const get_paginated_properties = (page, chunk, cycle, access_level_instance_id, include_filters_sorts, include_property_ids=null, goal_id=null) => { fn = inventory_service.get_properties; const [filters, sorts] = include_filters_sorts ? [$scope.column_filters, $scope.column_sorts] : [[],[]] @@ -249,6 +249,7 @@ angular.module('BE.seed.controller.portfolio_summary', []) table_column_ids.join(), access_level_instance_id, include_property_ids, + goal_id, // optional param to retrieve goal note details ); }; @@ -444,8 +445,9 @@ angular.module('BE.seed.controller.portfolio_summary', []) return combined_properties } - const combine_properties = (a, b) => { + const combine_properties = (current, baseline) => { // Given 2 properties, find non null values and combine into a single property + let [a, b] = baseline_first ? [baseline, current] : [current, baseline]; let c = {}; Object.keys(a).forEach(key => c[key] = a[key] !== null ? a[key] : b[key]) return c @@ -462,6 +464,15 @@ angular.module('BE.seed.controller.portfolio_summary', []) 'year_built' ] )] + + $scope.question_options = [ + {id: 0, value: null}, + {id: 1, value: 'Is this a new construction or acquisition?'}, + {id: 2, value: 'Do you have data to report?'}, + {id: 3, value: 'Is this value correct?'}, + {id: 4, value: 'Are these values correct?'}, + {id: 5, value: 'Other or multiple flags; explain in Additional Notes field'}, + ] // handle cycle specific columns const selected_columns = () => { let cols = property_column_names.map(name => $scope.columns.find(col => col.column_name === name)) @@ -497,9 +508,92 @@ angular.module('BE.seed.controller.portfolio_summary', []) { field: 'eui_change', displayName: 'EUI % Improvement', enableFiltering: false, enableSorting: false, headerCellClass: 'derived-column-display-name' }, ] + const goal_note_cols = [ + { + field: 'goal_note.question', + displayName: 'Question', + enableFiltering: false, + enableSorting: false, + editableCellTemplate: "ui-grid/dropdownEditor", + editDropdownOptionsArray: $scope.question_options, + editDropdownIdLabel: 'value', + enableCellEdit: $scope.write_permission, + cellClass: (grid, row, col, rowRenderIndex, colRenderIndex) => $scope.write_permission && 'cell-dropdown', + // if user has write permission show a dropdown inidcator + width: 350, + cellTemplate: ` +
+ + {{row.entity.goal_note.question}} + + +
+ ` + }, + { + field: 'goal_note.resolution', + displayName: 'Resolution', + enableFiltering: false, + enableSorting: false, + enableCellEdit: true, + ediableCellTempalte: 'ui-grid/cellTitleValidator', + cellClass: 'cell-edit', + width: 300, + }, + { + field: 'historical_note.text', + displayName: 'Historical Notes', + enableFiltering: false, + enableSorting: false, + enableCellEdit: true, + ediableCellTempalte: 'ui-grid/cellTitleValidator', + cellClass: 'cell-edit', + width: 300, + }, + { + field: 'goal_note.passed_checks', + displayName: 'Passed Checks', + enableFiltering: false, + enableSorting: false, + editableCellTemplate: "ui-grid/dropdownEditor", + editDropdownOptionsArray: [{id: 1, value: true}, {id: 2, value: false}], + editDropdownIdLabel: 'value', + enableCellEdit: $scope.write_permission, + cellClass: (grid, row, col, rowRenderIndex, colRenderIndex) => $scope.write_permission && 'cell-dropdown', + // if user has write permission show a dropdown inidcator + cellTemplate: ` +
+ {{row.entity.goal_note.passed_checks}} + + +
+ ` + }, + { + field: 'goal_note.new_or_acquired', + displayName: 'New Build or Acquired', + enableFiltering: false, + enableSorting: false, + editableCellTemplate: "ui-grid/dropdownEditor", + editDropdownOptionsArray: [{id: 1, value: true}, {id: 2, value: false}], + editDropdownIdLabel: 'value', + enableCellEdit: $scope.write_permission, + cellClass: (grid, row, col, rowRenderIndex, colRenderIndex) => $scope.write_permission && 'cell-dropdown', + // if user has write permission show a dropdown inidcator + cellTemplate: ` +
+ {{row.entity.goal_note.new_or_acquired}} + + +
+ ` + }, + + ] + apply_defaults(baseline_cols, default_baseline) apply_defaults(current_cols, default_current) - cols = [...cols, ...baseline_cols, ...current_cols, ...summary_cols] + cols = [...cols, ...baseline_cols, ...current_cols, ...summary_cols, ...goal_note_cols] // Apply filters // from inventory_list_controller @@ -611,7 +705,7 @@ angular.module('BE.seed.controller.portfolio_summary', []) 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 + // 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); @@ -787,6 +881,16 @@ angular.module('BE.seed.controller.portfolio_summary', []) $scope.load_inventory(1); }, 2000) ); + + gridApi.edit.on.afterCellEdit($scope, (rowEntity, colDef, newValue, oldValue) => { + [model, field] = colDef.field.split('.') + + if (model == 'historical_note') { + goal_service.update_historical_note( rowEntity.id, rowEntity.historical_note.id, {[field]: newValue} ) + } else if (model == 'goal_note') { + goal_service.update_goal_note( rowEntity.id, rowEntity.goal_note.id, { [field]: newValue } ) + } + }) } } } diff --git a/seed/static/seed/js/seed.js b/seed/static/seed/js/seed.js index 35e81b03cf..ded2e5087a 100644 --- a/seed/static/seed/js/seed.js +++ b/seed/static/seed/js/seed.js @@ -12,6 +12,7 @@ angular.module('BE.seed.vendor_dependencies', [ 'ui.grid', 'ui.grid.draggable-rows', 'ui.grid.exporter', + 'ui.grid.edit', 'ui.grid.moveColumns', 'ui.grid.pinning', 'ui.grid.resizeColumns', diff --git a/seed/static/seed/js/services/goal_service.js b/seed/static/seed/js/services/goal_service.js index 1b753e1628..03c52cfe53 100644 --- a/seed/static/seed/js/services/goal_service.js +++ b/seed/static/seed/js/services/goal_service.js @@ -23,6 +23,16 @@ angular.module('BE.seed.service.goal', []).factory('goal_service', [ .catch(response => response) } + goal_service.update_goal_note = (property, goal_note, data) => { + return $http.put(`/api/v3/properties/${property}/goal_notes/${goal_note}/`, + data, + {params: { organization_id: user_service.get_organization().id }} + ) + .then(response => response) + .catch(response => response) + + } + goal_service.get_goals = () => { return $http.get('/api/v3/goals/', { params: { @@ -53,6 +63,16 @@ angular.module('BE.seed.service.goal', []).factory('goal_service', [ .catch(response => response) } + goal_service.update_historical_note = (property, historical_note, data) => { + data.property = property + return $http.put(`/api/v3/properties/${property}/historical_notes/${historical_note}/`, + data, + {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 832e518e24..798ecd7e0a 100644 --- a/seed/static/seed/js/services/inventory_service.js +++ b/seed/static/seed/js/services/inventory_service.js @@ -64,7 +64,8 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ ids_only = null, shown_column_ids = null, access_level_instance_id = null, - include_property_ids + include_property_ids, + goal_id = null, ) => { organization_id = organization_id == undefined ? user_service.get_organization().id : organization_id; @@ -111,6 +112,9 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ if (access_level_instance_id) { data.access_level_instance_id = access_level_instance_id; } + if (goal_id) { + data.goal_id = goal_id + } return $http .post( diff --git a/seed/static/seed/partials/portfolio_summary.html b/seed/static/seed/partials/portfolio_summary.html index b9cae23bc1..35ee53bf3c 100644 --- a/seed/static/seed/partials/portfolio_summary.html +++ b/seed/static/seed/partials/portfolio_summary.html @@ -109,6 +109,7 @@

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

ui-grid-resize-columns ui-grid-group-columns ui-grid-exporter + ui-grid-edit >
diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index 6d01af1990..02809c3b11 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -5234,7 +5234,7 @@ ul.r-list { } .above-target { - background: #9dca8f; + background: #d0e6d4; } .below-target { @@ -5428,7 +5428,31 @@ tags-input .tags .tag-item { word-wrap: break-word; } +.cell-dropdown { + cursor: copy; +} +.cell-edit { + cursor: cell; +} +.cell-dropdown-indicator { + display: flex; + justify-content: space-between; + align-items: center; + margin-right: 3px; + i { + color: gray; + } +} + .deprecation-warning { padding: 10px; color: #c24e00; } +.cell-pass { + background-color: #d0e6d4; + border-top: 1px solid #d4d4d4; +} +.cell-fail { + background-color: #fefbd1; + border-top: 1px solid #d4d4d4; +} diff --git a/seed/tests/test_goals.py b/seed/tests/test_goals.py index 23ba231ad6..d5215bc922 100644 --- a/seed/tests/test_goals.py +++ b/seed/tests/test_goals.py @@ -10,7 +10,7 @@ from django.urls import reverse_lazy from seed.landing.models import SEEDUser as User -from seed.models import Column, Goal +from seed.models import Column, Goal, GoalNote, HistoricalNote from seed.test_helpers.fake import ( FakeColumnFactory, FakeCycleFactory, @@ -80,18 +80,21 @@ def setUp(self): 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.property4 = self.property_factory.get_property(access_level_instance=self.root_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.state_41 = 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.view21 = 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.view41 = self.property_view_factory.get_property_view(prprty=self.property4, state=self.state_41, cycle=self.cycle1) self.root_goal = Goal.objects.create( organization=self.org, @@ -142,12 +145,12 @@ def setUp(self): 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') + response = self.client.get(url, content_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') + response = self.client.get(url, content_type='application/json') assert response.status_code == 200 assert len(response.json()['goals']) == 2 @@ -192,6 +195,7 @@ def test_goal_destroy(self): def test_goal_create(self): goal_count = Goal.objects.count() + goal_note_count = GoalNote.objects.count() url = reverse_lazy('api:v3:goals-list') + '?organization_id=' + str(self.org.id) goal_columns = [ 'placeholder', @@ -243,6 +247,8 @@ def reset_goal_data(name): ) assert response.status_code == 201 assert Goal.objects.count() == goal_count + 1 + assert GoalNote.objects.count() == goal_note_count + 3 + goal_count = Goal.objects.count() # invalid data @@ -304,6 +310,7 @@ def reset_goal_data(name): def test_goal_update(self): original_goal = Goal.objects.get(id=self.child_goal.id) + goal_note_count = GoalNote.objects.count() # invalid permission self.login_as_child_member() @@ -322,29 +329,98 @@ def test_goal_update(self): assert response.json()['target_percentage'] == 99 assert response.json()['baseline_cycle'] == self.cycle2.id assert response.json()['eui_column1'] == original_goal.eui_column1.id + # changing to cycle 2 adds a new property (and goal_note) + assert GoalNote.objects.count() == goal_note_count + 1 + + goal_data = {'baseline_cycle': self.cycle1.id} + response = self.client.put(url, data=json.dumps(goal_data), content_type='application/json') + assert GoalNote.objects.count() == goal_note_count # unexpected fields are ignored goal_data = { 'name': 'child_goal y', - 'baseline_cycle': self.cycle1.id, + 'baseline_cycle': self.cycle2.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()['baseline_cycle'] == self.cycle2.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, + 'eui_column1': -1, + 'baseline_cycle': -1, + 'target_percentage': -1, } 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.'] + assert errors['eui_column1'] == ['Invalid pk "-1" - object does not exist.'] + assert errors['baseline_cycle'] == ['Invalid pk "-1" - object does not exist.'] + + def test_goal_note_update(self): + goal_note = GoalNote.objects.get(goal_id=self.root_goal.id, property_id=self.property4) + assert goal_note.question is None + assert goal_note.resolution is None + + goal_note_data = { + 'question': 'Do you have data to report?', + 'resolution': 'updated res', + } + url = reverse_lazy('api:v3:property-goal-notes-detail', args=[self.property4.id, goal_note.id]) + '?organization_id=' + str(self.org.id) + self.login_as_child_member() + response = self.client.put(url, data=json.dumps(goal_note_data), content_type='application/json') + assert response.status_code == 404 + + self.login_as_root_member() + response = self.client.put(url, data=json.dumps(goal_note_data), content_type='application/json') + assert response.status_code == 200 + response_goal = response.json() + assert response_goal['question'] == 'Do you have data to report?' + assert response_goal['resolution'] == 'updated res' + + # reset goal note + goal_note_data = { + 'question': None, + 'resolution': None, + } + response = self.client.put(url, data=json.dumps(goal_note_data), content_type='application/json') + assert response.status_code == 200 + response_goal = response.json() + assert response_goal['question'] is None + assert response_goal['resolution'] is None + + # child user can only update resolution + self.login_as_child_member() + goal_note = GoalNote.objects.get(goal_id=self.child_goal.id, property_id=self.property1) + goal_note_data = { + 'question': 'Do you have data to report?', + 'resolution': 'updated res', + 'passed_checks': True, + 'new_or_acquired': True, + } + url = reverse_lazy('api:v3:property-goal-notes-detail', args=[self.property1.id, goal_note.id]) + '?organization_id=' + str(self.org.id) + response = self.client.put(url, data=json.dumps(goal_note_data), content_type='application/json') + assert response.status_code == 200 + response_goal = response.json() + assert response_goal['question'] is None + assert response_goal['resolution'] == 'updated res' + assert response_goal['passed_checks'] is False + assert response_goal['new_or_acquired'] is False + + def test_historical_note_update(self): + self.login_as_child_member() + assert self.property1.historical_note.text == '' + url = reverse_lazy('api:v3:property-historical-notes-detail', args=[self.property1.id, self.property1.historical_note.id]) + '?organization_id=' + str(self.org.id) + data = { + 'property': self.property1.id, + 'text': 'updated text' + } + response = self.client.put(url, data=json.dumps(data), content_type='application/json') + assert response.status_code == 200 + assert response.json()['text'] == 'updated text' + assert HistoricalNote.objects.get(property=self.property1).text == 'updated text' def test_portfolio_summary(self): self.login_as_child_member() diff --git a/seed/utils/inventory_filter.py b/seed/utils/inventory_filter.py index 662ab99427..eff3b9d9af 100644 --- a/seed/utils/inventory_filter.py +++ b/seed/utils/inventory_filter.py @@ -36,6 +36,7 @@ def get_filtered_results(request: Request, inventory_type: Literal['property', ' # check if there is a query parameter for the profile_id. If so, then use that one profile_id = request.query_params.get('profile_id', profile_id) shown_column_ids = request.query_params.get('shown_column_ids') + goal_id = request.data.get('goal_id') if not org_id: return JsonResponse( @@ -263,7 +264,8 @@ def get_filtered_results(request: Request, inventory_type: Literal['property', ' views, show_columns, columns_from_database, - include_related + include_related, + goal_id ) except DataError as e: return JsonResponse( diff --git a/seed/utils/viewsets.py b/seed/utils/viewsets.py index 8387d9f9fc..1c8edebdea 100644 --- a/seed/utils/viewsets.py +++ b/seed/utils/viewsets.py @@ -51,7 +51,7 @@ PERMISSIONS_CLASSES = (SEEDOrgPermissions,) -class UpdateWithoutPatchModelMixin(object): +class UpdateWithoutPatchModelMixin(GenericViewSet): # Taken from: https://github.com/encode/django-rest-framework/pull/3081#issuecomment-518396378 # Rebuilds the UpdateModelMixin without the patch action def update(self, request, *args, **kwargs): diff --git a/seed/views/v3/goal_notes.py b/seed/views/v3/goal_notes.py new file mode 100644 index 0000000000..80ce7aa6f0 --- /dev/null +++ b/seed/views/v3/goal_notes.py @@ -0,0 +1,60 @@ +""" +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.http import JsonResponse +from rest_framework import status + +from seed.lib.superperms.orgs.decorators import ( + has_hierarchy_access, + has_perm_class +) +from seed.models import AccessLevelInstance, GoalNote +from seed.serializers.goal_notes import GoalNoteSerializer +from seed.utils.api import OrgMixin +from seed.utils.api_schema import swagger_auto_schema_org_query_param +from seed.utils.viewsets import UpdateWithoutPatchModelMixin + + +class GoalNoteViewSet(UpdateWithoutPatchModelMixin, OrgMixin): + # Update is the only necessary endpoint + # Create is handled on Goal create through post_save signal + # List and Retrieve are handled on a per property basis + # Delete is handled through Goal or Property cascade deletes + + serializer_class = GoalNoteSerializer + queryset = GoalNote.objects.all() + + @swagger_auto_schema_org_query_param + @has_perm_class('requires_member') + @has_hierarchy_access(property_id_kwarg='property_pk') # should this be nested under the goal or properties router? + def update(self, request, property_pk, pk): + try: + goal_note = GoalNote.objects.get(property=property_pk, pk=pk) + except GoalNote.DoesNotExist: + return JsonResponse({ + 'status': 'error', + 'errors': "No such resource." + }, status=status.HTTP_404_NOT_FOUND) + + data = self.get_permission_data(request.data, request.access_level_instance_id) + serializer = GoalNoteSerializer(goal_note, data=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) + + def get_permission_data(self, data, access_level_instance_id): + # leaf users are only permitted to update 'resolution' + access_level_instance = AccessLevelInstance.objects.get(pk=access_level_instance_id) + write_permission = access_level_instance.is_root() or not access_level_instance.is_leaf() + if write_permission: + return data + + return {'resolution': data.get('resolution')} if 'resolution' in data else {} diff --git a/seed/views/v3/historical_notes.py b/seed/views/v3/historical_notes.py new file mode 100644 index 0000000000..a75ed2507c --- /dev/null +++ b/seed/views/v3/historical_notes.py @@ -0,0 +1,32 @@ +""" +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.utils.decorators import method_decorator + +from seed.lib.superperms.orgs.decorators import ( + has_hierarchy_access, + has_perm_class +) +from seed.models import HistoricalNote +from seed.serializers.historical_notes import HistoricalNoteSerializer +from seed.utils.api import OrgMixin +from seed.utils.api_schema import swagger_auto_schema_org_query_param +from seed.utils.viewsets import UpdateWithoutPatchModelMixin + + +@method_decorator( + name='update', + decorator=[ + swagger_auto_schema_org_query_param, + has_perm_class('requires_member'), + has_hierarchy_access(property_id_kwarg="property_pk") + ] +) +class HistoricalNoteViewSet(UpdateWithoutPatchModelMixin, OrgMixin): + # Update is the only necessary endpoint + # Create is handled on Property create through post_save signal + # List and Retrieve are handled on a per property basis + # Delete is handled through Property cascade deletes + serializer_class = HistoricalNoteSerializer + queryset = HistoricalNote.objects.all()