diff --git a/locale/en_US/LC_MESSAGES/django.mo b/locale/en_US/LC_MESSAGES/django.mo index 0a30fd0abc..ac1cfba9a6 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 dc80f745ec..61ad78b06b 100644 --- a/locale/en_US/LC_MESSAGES/django.po +++ b/locale/en_US/LC_MESSAGES/django.po @@ -360,6 +360,12 @@ msgstr "Advanced Settings" msgid "Alias" msgstr "Alias" +msgid "All Canonical Fields" +msgstr "All Canonical Fields" + +msgid "All Extra Data Fields" +msgstr "All Extra Data Fields" + msgid "An Audit Template organization token, user email and password are required" msgstr "An Audit Template organization token, user email and password are required" @@ -970,6 +976,12 @@ msgstr "Create a New Sub-Organization" msgid "Create a Program to get started!" msgstr "Create a Program to get started!" +msgid "Create a Property" +msgstr "Create a Property" + +msgid "Create a Tax Lot" +msgstr "Create a Tax Lot" + msgid "Create a new rule" msgstr "Create a new rule" diff --git a/locale/es/LC_MESSAGES/django.mo b/locale/es/LC_MESSAGES/django.mo index 30db919bae..e95a4fb361 100644 Binary files a/locale/es/LC_MESSAGES/django.mo and b/locale/es/LC_MESSAGES/django.mo differ diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index a39abe07bd..125d94ddd2 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: lokalise.com\n" "Project-Id-Version: SEED Platform\n" -"PO-Revision-Date: 2024-12-14 00:26\n" +"PO-Revision-Date: 2025-01-08 18:08\n" "Last-Translator: lokalise.com\n" "Language-Team: lokalise.com\n\n" "Language: es\n" @@ -461,6 +461,12 @@ msgstr "Configuración avanzada" msgid "Alias" msgstr "Alias" +msgid "All Canonical Fields" +msgstr "Todos los campos canónicos" + +msgid "All Extra Data Fields" +msgstr "Todos los campos de datos adicionales" + #, fuzzy msgid "An Audit Template organization token, user email and password are required" msgstr "Se requiere un token de organización de Plantilla de Auditoría, un correo electrónico de usuario y una contraseña" @@ -1245,6 +1251,12 @@ msgstr "Crear una nueva suborganización" msgid "Create a Program to get started!" msgstr "Cree un programa para empezar" +msgid "Create a Property" +msgstr "Crear una propiedad" + +msgid "Create a Tax Lot" +msgstr "Crear un lote de impuestos" + #, fuzzy msgid "Create a new rule" msgstr "Crear una nueva regla" diff --git a/locale/fr_CA/LC_MESSAGES/django.mo b/locale/fr_CA/LC_MESSAGES/django.mo index 805cb30b40..9ce4a1e48e 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 1e29ab3360..4ec6eb18f5 100644 --- a/locale/fr_CA/LC_MESSAGES/django.po +++ b/locale/fr_CA/LC_MESSAGES/django.po @@ -364,6 +364,12 @@ msgstr "Réglages avancés" msgid "Alias" msgstr "Alias" +msgid "All Canonical Fields" +msgstr "Tous les champs canoniques" + +msgid "All Extra Data Fields" +msgstr "Tous les champs de données supplémentaires" + msgid "An Audit Template organization token, user email and password are required" msgstr "Un jeton d'organisation Audit Template, un e-mail d'utilisateur et un mot de passe sont requis" @@ -980,6 +986,12 @@ msgstr "Créer une nouvelle sous-organisation" msgid "Create a Program to get started!" msgstr "Créez un programme pour commencer !" +msgid "Create a Property" +msgstr "Créer une propriété" + +msgid "Create a Tax Lot" +msgstr "Créer un lot fiscal" + msgid "Create a new rule" msgstr "Créer une nouvelle règle" diff --git a/seed/static/seed/js/controllers/inventory_create_controller.js b/seed/static/seed/js/controllers/inventory_create_controller.js new file mode 100644 index 0000000000..ec16c9decd --- /dev/null +++ b/seed/static/seed/js/controllers/inventory_create_controller.js @@ -0,0 +1,270 @@ +/** + * SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. + * See also https://github.com/SEED-platform/seed/blob/main/LICENSE.md + */ +angular.module('SEED.controller.inventory_create', []).controller('inventory_create_controller', [ + '$scope', + '$q', + '$state', + '$stateParams', + 'ah_service', + 'inventory_service', + 'Notification', + 'simple_modal_service', + 'spinner_utility', + 'access_level_tree', + 'all_columns', + 'cycles', + 'profiles', + 'matching_criteria_columns_payload', + // eslint-disable-next-line func-names + function ( + $scope, + $q, + $state, + $stateParams, + ah_service, + inventory_service, + Notification, + simple_modal_service, + spinner_utility, + access_level_tree, + all_columns, + cycles, + profiles, + matching_criteria_columns_payload + ) { + // INIT + $scope.inventory_type = $stateParams.inventory_type; + const [primary, related, related_type] = $scope.inventory_type === 'taxlots' ? + ['TaxLotState', 'PropertyState', 'properties'] : + ['PropertyState', 'TaxLotState', 'taxlots']; + + $scope.cycles = cycles.cycles; + $scope.profiles = profiles; + $scope.profile = []; + $scope.columns = all_columns; + $scope.form_errors = []; + $scope.data = { + PropertyState: { state: { extra_data: {} } }, + TaxLotState: { state: { extra_data: {} } } + }; + $scope.config = { + cycle: $scope.cycles[0].id + }; + const matching_property_column_names = matching_criteria_columns_payload.PropertyState.join(', '); + const matching_taxlot_column_names = matching_criteria_columns_payload.TaxLotState.join(', '); + + $scope.matching_columns = []; + $scope.extra_columns = []; + $scope.canonical_columns = []; + $scope.columns.forEach((c) => { + if (c.table_name === primary) { + if (c.is_matching_criteria) $scope.matching_columns.push(c); + if (c.is_extra_data) $scope.extra_columns.push(c); + if (!c.is_extra_data && !c.derived_column) $scope.canonical_columns.push(c); + } + }); + // create a copy, not a reference. from_column contents is a list of columns dicts + $scope.form_columns = [...$scope.matching_columns]; + // form_values allows value persistance + $scope.form_values = []; + + // DATA VALIDATION + $scope.$watch(() => ({ data: $scope.data, form_columns: $scope.form_columns, config: $scope.config }), () => { + check_form_errors(); + }, true); + + const check_form_errors = () => { + $scope.form_errors = []; + $scope.valid = $scope.config.cycle && $scope.config.access_level_instance; + if (!$scope.config.cycle) $scope.form_errors.push('Cycle is required'); + check_duplicates(); + check_matching_criteria(); + }; + + const check_duplicates = () => { + const display_name_counts = {}; + let has_duplicates = false; + $scope.form_columns.forEach((col) => { + display_name_counts[col.displayName] = (display_name_counts[col.displayName] || 0) + 1; + }); + $scope.form_columns.forEach((col) => { + col.is_duplicate = display_name_counts[col.displayName] > 1; + if (col.is_duplicate) has_duplicates = true; + }); + if (has_duplicates) $scope.form_errors.push('Duplicate columns are not allowed'); + }; + + const check_matching_criteria = () => { + const tables_present = { PropertyState: 0, TaxLotState: 0 }; + const matching_values = { PropertyState: 0, TaxLotState: 0 }; + $scope.form_columns.forEach((c) => { + tables_present[c.table_name] += 1; + if (c.is_matching_criteria && c.value) { + matching_values[c.table_name] += 1; + } + }); + const property_present = $scope.inventory_type === 'properties' || tables_present.PropertyState; + const taxlot_present = $scope.inventory_type === 'taxlots' || tables_present.TaxLotState; + + if (property_present && !matching_values.PropertyState) { + $scope.form_errors.push(`At least one of the following Property fields is required: ${matching_property_column_names}`); + } + if (taxlot_present && !matching_values.TaxLotState) { + $scope.form_errors.push(`At least one of the following TaxLot fields is required: ${matching_taxlot_column_names}`); + } + }; + + // ACCESS LEVEL TREE + $scope.access_level_tree = access_level_tree.access_level_tree; + $scope.level_names = access_level_tree.access_level_names.map((level, i) => ({ + index: i, + name: level + })); + const access_level_instances_by_depth = ah_service.calculate_access_level_instances_by_depth($scope.access_level_tree); + $scope.change_selected_level_index = () => { + const new_level_instance_depth = parseInt($scope.config.level_name_index, 10) + 1; + $scope.potential_level_instances = access_level_instances_by_depth[new_level_instance_depth]; + }; + // default selection of ali info + $scope.config.level_name_index = $scope.level_names.at(-1).index; + $scope.change_selected_level_index(); + $scope.config.access_level_instance = $scope.potential_level_instances.at(0).id; + + // COLUMN LIST PROFILES + $scope.set_columns = (type) => { + remove_empty_last_column(); + switch (type) { + case 'canonical': + $scope.form_columns = Array.from(new Set([...$scope.form_columns, ...$scope.canonical_columns])); + break; + case 'extra': + $scope.form_columns = Array.from(new Set([...$scope.form_columns, ...$scope.extra_columns])); + break; + default: + $scope.form_columns = [...$scope.matching_columns]; + $scope.form_columns = $scope.form_columns.map((c) => ({ ...c, value: null, is_duplicate: false })); + $scope.form_values = []; + } + }; + + // FORM LOGIC + $scope.remove_column = (index, column) => { + $scope.form_columns.splice(index, 1); + $scope.form_values[index] = null; + set_column_value(column, null); + check_form_errors(); + }; + + $scope.add_column = () => $scope.form_columns.push({ displayName: '', primary }); + + const remove_empty_last_column = () => { + if (!_.isEmpty($scope.form_columns) && $scope.form_columns.at(-1).displayName === '') { + $scope.form_columns.pop(); + } + }; + + $scope.change_profile = () => { + const profile_column_names = $scope.profile.columns.map((p) => p.column_name); + $scope.form_columns = $scope.columns.filter((c) => profile_column_names.includes(c.column_name)); + }; + + $scope.select_column = (column, index) => { + // preserve value if column is duplicate + if ($scope.form_columns.includes(column)) return; + column.value = $scope.form_values.at(index); + $scope.form_columns[index] = column; + }; + + $scope.change_column = (displayName, index) => { + const defaults = { + table_name: primary, + is_extra_data: true, + is_matching_criteria: false, + data_type: 'string' + }; + let column = $scope.columns.find((c) => c.displayName === displayName) || { displayName }; + column = { ...defaults, ...column }; + column.value = $scope.form_values.at(index); + $scope.form_columns[index] = column; + }; + + $scope.change_value = (column, index) => { + $scope.form_values[index] = column.value; + set_column_value(column, column.value); + }; + + const set_column_value = (column, value) => { + const column_name = column.column_name || column.displayName; + if (!column_name) return; + // assign to either data's PropertyState or TaxLotState data + const target = column.is_extra_data ? $scope.data[column.table_name].state.extra_data : $scope.data[column.table_name].state; + target[column_name] = value === '' ? undefined : value; + }; + + const format_data = () => { + let related_data; + const related_empty = _.isEqual($scope.data[related].state, { extra_data: {} }); + const tax_id = $scope.data.TaxLotState.state.jurisdiction_tax_lot_id; + + if (!related_empty) { + if (tax_id) { + // properties and taxlots share a link via a 'lot_number' field on the property representing the jurisdiction_tax_lot_id + $scope.data.PropertyState.state.lot_number = tax_id; + } + related_data = { ...$scope.config, ...$scope.data[related] }; + } + const primary_data = { ...$scope.config, ...$scope.data[primary] }; + + return { primary_data, related_data }; + }; + + $scope.save_inventory = () => { + const { primary_data, related_data } = format_data(); + const type_name = $scope.inventory_type === 'taxlots' ? 'Tax Lot' : 'Property'; + const cycle_name = $scope.cycles.find((c) => c.id === $scope.config.cycle).name; + const ali_name = $scope.access_level_tree.find((ali) => ali.id === $scope.config.access_level_instance).name; + const modalOptions = { + type: 'default', + okButtonText: 'Confirm', + headerText: `Create new ${type_name}`, + bodyText: `Create ${ali_name} ${type_name} in Cycle ${cycle_name}?` + }; + const successOptions = { + type: 'default', + okButtonText: `View ${type_name}`, + headerText: 'Success', + bodyText: `Successfully created ${type_name}` + }; + + simple_modal_service.showModal(modalOptions).then(() => { + spinner_utility.show(); + const primary_promise = () => inventory_service.create_inventory(primary_data, $scope.inventory_type); + const related_promise = (view_id) => inventory_service.create_inventory(related_data, related_type, view_id).then(() => view_id); + + primary_promise().then((response) => { + // if inventory is a duplicate, view_id will be null + const view_id = response.data.view_id; + return related_data && view_id ? related_promise(view_id) : view_id; + }).then((view_id) => { + spinner_utility.hide(); + if (view_id) { + Notification.success(`Successfully created ${type_name}`); + } else { + Notification.info('Duplicate record exists'); + throw new Error('Duplicate record exists'); + } + simple_modal_service.showModal(successOptions).then(() => { + window.location.href = `/app/#/${$scope.inventory_type}/${view_id}`; + }).catch(() => { + $state.reload(); + }); + }).catch(() => { + Notification.error(`Failed to create ${type_name}`); + spinner_utility.hide(); + }); + }); + }; + } +]); diff --git a/seed/static/seed/js/seed.js b/seed/static/seed/js/seed.js index 00be6a3b4b..058d277ced 100644 --- a/seed/static/seed/js/seed.js +++ b/seed/static/seed/js/seed.js @@ -89,6 +89,7 @@ 'SEED.controller.insights_program', 'SEED.controller.insights_property', 'SEED.controller.inventory_column_list_profiles', + 'SEED.controller.inventory_create', 'SEED.controller.inventory_cycles', 'SEED.controller.inventory_detail', 'SEED.controller.inventory_detail_analyses', @@ -2243,6 +2244,45 @@ ] } }) + .state({ + name: 'inventory_create', + url: '/{inventory_type:properties|taxlots}/create', + templateUrl: `${static_url}seed/partials/inventory_create.html`, + controller: 'inventory_create_controller', + resolve: { + access_level_tree: [ + 'organization_service', 'user_service', + (organization_service, user_service) => { + const organization_id = user_service.get_organization().id; + return organization_service.get_descendant_access_level_tree(organization_id); + } + ], + all_columns: [ + '$stateParams', + 'inventory_service', + ($stateParams, inventory_service) => { + if ($stateParams.inventory_type === 'properties') { + return inventory_service.get_property_columns(); + } + return inventory_service.get_taxlot_columns(); + } + ], + cycles: ['cycle_service', (cycle_service) => cycle_service.get_cycles()], + profiles: [ + '$stateParams', + 'inventory_service', + ($stateParams, inventory_service) => { + const inventory_type = $stateParams.inventory_type === 'properties' ? 'Property' : 'Tax Lot'; + return inventory_service.get_column_list_profiles('List View Profile', inventory_type); + } + ], + matching_criteria_columns_payload: [ + 'organization_service', + 'user_service', + (organization_service, user_service) => organization_service.matching_criteria_columns(user_service.get_organization().id) + ] + } + }) .state({ name: 'inventory_cycles', url: '/{inventory_type:properties|taxlots}/cycles', diff --git a/seed/static/seed/js/services/inventory_service.js b/seed/static/seed/js/services/inventory_service.js index a698015265..51c9093fa5 100644 --- a/seed/static/seed/js/services/inventory_service.js +++ b/seed/static/seed/js/services/inventory_service.js @@ -1294,6 +1294,13 @@ angular.module('SEED.service.inventory', []).factory('inventory_service', [ taxlot_view_ids }).then((response) => response.data); + inventory_service.create_inventory = (data, inventory_type, view_id = null) => $http.post(`/api/v3/${inventory_type}/form_create/`, data, { + params: { + organization_id: user_service.get_organization().id, + related_view_id: view_id + } + }); + return inventory_service; } ]); diff --git a/seed/static/seed/locales/en_US.json b/seed/static/seed/locales/en_US.json index 4af53e0abe..00d34e0890 100644 --- a/seed/static/seed/locales/en_US.json +++ b/seed/static/seed/locales/en_US.json @@ -114,6 +114,8 @@ "Admin": "Admin", "Advanced Settings": "Advanced Settings", "Alias": "Alias", + "All Canonical Fields": "All Canonical Fields", + "All Extra Data Fields": "All Extra Data Fields", "An Audit Template organization token, user email and password are required": "An Audit Template organization token, user email and password are required", "An error occurred while processing the file. Please ensure that your file meets the required specifications.": "An error occurred while processing the file. Please ensure that your file meets the required specifications.", "Analyses": "Analyses", @@ -313,6 +315,8 @@ "Create a New Project": "Create a New Project", "Create a New Sub-Organization": "Create a New Sub-Organization", "Create a Program to get started!": "Create a Program to get started!", + "Create a Property": "Create a Property", + "Create a Tax Lot": "Create a Tax Lot", "Create a new rule": "Create a new rule", "Create a new sub-organization": "Create a new sub-organization", "Create a new...": "Create a new...", diff --git a/seed/static/seed/locales/es.json b/seed/static/seed/locales/es.json index 0f09adadf4..6c4f2f34f0 100644 --- a/seed/static/seed/locales/es.json +++ b/seed/static/seed/locales/es.json @@ -114,6 +114,8 @@ "Admin": "Admin", "Advanced Settings": "Configuración avanzada", "Alias": "Alias", + "All Canonical Fields": "Todos los campos canónicos", + "All Extra Data Fields": "Todos los campos de datos adicionales", "An Audit Template organization token, user email and password are required": "Se requiere un token de organización de Plantilla de Auditoría, un correo electrónico de usuario y una contraseña", "An error occurred while processing the file. Please ensure that your file meets the required specifications.": "Se ha producido un error al procesar el archivo. Asegúrese de que su archivo cumple las especificaciones requeridas.", "Analyses": "Análisis", @@ -313,6 +315,8 @@ "Create a New Project": "Crear un nuevo proyecto", "Create a New Sub-Organization": "Crear una nueva suborganización", "Create a Program to get started!": "Cree un programa para empezar", + "Create a Property": "Crear una propiedad", + "Create a Tax Lot": "Crear un lote de impuestos", "Create a new rule": "Crear una nueva regla", "Create a new sub-organization": "Crear una nueva suborganización", "Create a new...": "Crear un nuevo...", diff --git a/seed/static/seed/locales/fr_CA.json b/seed/static/seed/locales/fr_CA.json index 85bec50011..d1971f4902 100644 --- a/seed/static/seed/locales/fr_CA.json +++ b/seed/static/seed/locales/fr_CA.json @@ -114,6 +114,8 @@ "Admin": "Admin", "Advanced Settings": "Réglages avancés", "Alias": "Alias", + "All Canonical Fields": "Tous les champs canoniques", + "All Extra Data Fields": "Tous les champs de données supplémentaires", "An Audit Template organization token, user email and password are required": "Un jeton d'organisation Audit Template, un e-mail d'utilisateur et un mot de passe sont requis", "An error occurred while processing the file. Please ensure that your file meets the required specifications.": "Une erreur s'est produite lors du traitement du fichier. Assurez-vous que votre fichier respecte les spécifications requises.", "Analyses": "Analyses", @@ -313,6 +315,8 @@ "Create a New Project": "Créer un nouveau projet", "Create a New Sub-Organization": "Créer une nouvelle sous-organisation", "Create a Program to get started!": "Créez un programme pour commencer !", + "Create a Property": "Créer une propriété", + "Create a Tax Lot": "Créer un lot fiscal", "Create a new rule": "Créer une nouvelle règle", "Create a new sub-organization": "Créer une nouvelle sous-organisation", "Create a new...": "Créer un nouveau...", diff --git a/seed/static/seed/partials/inventory_create.html b/seed/static/seed/partials/inventory_create.html new file mode 100644 index 0000000000..8db6d7077b --- /dev/null +++ b/seed/static/seed/partials/inventory_create.html @@ -0,0 +1,135 @@ + + +
+ +
+ +
+
+
+ +
+
+
+

{$ inventory_type === 'taxlots' ? 'Create a Tax Lot' : 'Create a Property' | translate $}

+
+
+ +
+
+
+ +
+
+
+
    +
  • {$ message $}
  • +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Inventory TypeColumnValueMatching CriteriaExtra DataData Type
{$ column.table_name.slice(0, -5) | translate $} + + + + {$ column.data_type | translate $} + +
+ +
+
+
diff --git a/seed/static/seed/partials/inventory_nav.html b/seed/static/seed/partials/inventory_nav.html index eca1a717ae..b03ee26a0e 100644 --- a/seed/static/seed/partials/inventory_nav.html +++ b/seed/static/seed/partials/inventory_nav.html @@ -6,3 +6,5 @@ Map Data Summary +Create a Tax Lot +Create a Property diff --git a/seed/static/seed/scss/style.scss b/seed/static/seed/scss/style.scss index 415b693897..bf83e93021 100755 --- a/seed/static/seed/scss/style.scss +++ b/seed/static/seed/scss/style.scss @@ -1668,6 +1668,11 @@ a:not([href]) { border-collapse: separate; border-spacing: 5px; } + + .content-row { + display: flex; + margin: 10px 0; + } } } diff --git a/seed/templates/seed/_scripts.html b/seed/templates/seed/_scripts.html index 242b7c30c7..37c107d9ed 100644 --- a/seed/templates/seed/_scripts.html +++ b/seed/templates/seed/_scripts.html @@ -85,6 +85,7 @@ + diff --git a/seed/tests/test_property_views.py b/seed/tests/test_property_views.py index 08a85747b4..61e3784ec0 100644 --- a/seed/tests/test_property_views.py +++ b/seed/tests/test_property_views.py @@ -658,6 +658,86 @@ def test_meters_exist(self): false_result = self.client.post(url, false_post_params, content_type="application/json") self.assertEqual(b"false", false_result.content) + def test_property_form_create(self): + original_state_count = PropertyState.objects.count() + original_view_count = PropertyView.objects.count() + original_property_count = Property.objects.count() + original_taxlot_view_count = TaxLotView.objects.count() + + url = reverse("api:v3:properties-form-create") + f"?organization_id={self.org.pk}" + + state_data = { + "state": { + "pm_property_id": "1", + "custom_id_1": "2", + "extra_data": {"Extra Data Column": "3"}, + }, + "taxlot_state": {}, + } + + data = {"access_level_instance": self.org.root.id, "cycle": self.cycle.id, **state_data} + + response = self.client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + assert response.json().get("view_id") + # For a new property, counts should only increase by 1 + assert PropertyState.objects.count() == original_state_count + 1 + assert PropertyView.objects.count() == original_view_count + 1 + assert Property.objects.count() == original_property_count + 1 + + # verify with existing matches + cycle2 = self.cycle_factory.get_cycle(start=datetime(2000, 10, 10, tzinfo=get_current_timezone())) + self.property_view_factory.get_property_view(cycle=cycle2, pm_property_id="4", custom_id_1="5") + + state_data = { + "state": { + "pm_property_id": "4", + "custom_id_1": "5", + "extra_data": {"Extra Data Column": "6"}, + }, + "taxlot_state": {}, + } + data = {"access_level_instance": self.org.root.id, "cycle": self.cycle.id, **state_data} + + self.client.post(url, json.dumps(data), content_type="application/json") + # For property merges, counts should increase by 2 (new property, and merged property) + assert PropertyState.objects.count() == original_state_count + 3 + assert PropertyView.objects.count() == original_view_count + 3 + assert Property.objects.count() == original_property_count + 3 + + # property associated with a taxlot + property_data = { + "access_level_instance": self.org.root.id, + "cycle": self.cycle.id, + "state": {"pm_property_id": "7", "custom_id_1": "8", "extra_data": {"Extra Data Column": "9"}, "lot_number": "10"}, + } + taxlot_data = { + "access_level_instance": self.org.root.id, + "cycle": self.cycle.id, + "state": {"jurisdiction_tax_lot_id": "10", "address_line_1": "A"}, + } + + p_response = self.client.post(url, json.dumps(property_data), content_type="application/json") + assert p_response.status_code == 200 + view_id = p_response.json().get("view_id") + taxlot_url = reverse("api:v3:taxlots-form-create") + f"?organization_id={self.org.pk}&related_view_id={view_id}" + t_response = self.client.post(taxlot_url, json.dumps(taxlot_data), content_type="application/json") + assert t_response.status_code == 200 + + property_view = PropertyView.objects.get(pk=view_id) + property_state = property_view.state + taxlot_view = property_view.taxlotproperty_set.first().taxlot_view + taxlot_state = taxlot_view.state + assert property_state.pm_property_id == "7" + assert property_state.lot_number == "10" + assert taxlot_state.jurisdiction_tax_lot_id == "10" + assert taxlot_state.address_line_1 == "A" + + assert PropertyState.objects.count() == original_state_count + 4 + assert PropertyView.objects.count() == original_view_count + 4 + assert Property.objects.count() == original_property_count + 4 + assert TaxLotView.objects.count() == original_taxlot_view_count + 1 + class PropertyViewTestsPermissions(AccessLevelBaseTestCase): def setUp(self): diff --git a/seed/tests/test_taxlot_views.py b/seed/tests/test_taxlot_views.py index cbf6a07856..80d651046b 100644 --- a/seed/tests/test_taxlot_views.py +++ b/seed/tests/test_taxlot_views.py @@ -11,7 +11,18 @@ from seed.data_importer.tasks import geocode_and_match_buildings_task from seed.landing.models import SEEDUser as User -from seed.models import DATA_STATE_MAPPING, VIEW_LIST_TAXLOT, Column, Note, PropertyView, StatusLabel, TaxLot, TaxLotProperty, TaxLotView +from seed.models import ( + DATA_STATE_MAPPING, + VIEW_LIST_TAXLOT, + Column, + Note, + PropertyView, + StatusLabel, + TaxLot, + TaxLotProperty, + TaxLotState, + TaxLotView, +) from seed.test_helpers.fake import ( FakeColumnListProfileFactory, FakeCycleFactory, @@ -331,6 +342,64 @@ def test_taxlots_cycles_list(self): self.assertEqual(result_2[0][field_1_key], "value_2") self.assertEqual(result_2[0]["id"], taxlot_2.id) + def test_taxlot_form_create(self): + original_state_count = TaxLotState.objects.count() + original_view_count = TaxLotView.objects.count() + original_taxlot_count = TaxLot.objects.count() + original_property_view_count = PropertyView.objects.count() + + url = reverse("api:v3:taxlots-form-create") + f"?organization_id={self.org.pk}" + + state_data = { + "jurisdiction_tax_lot_id": "1", + "custom_id_1": "2", + "extra_data": {"Extra Data Column": "3"}, + } + + data = {"access_level_instance": self.org.root.id, "cycle": self.cycle.id, "state": state_data} + + response = self.client.post(url, json.dumps(data), content_type="application/json") + assert response.status_code == 200 + assert response.json().get("view_id") + + # For a new tax lot, counts should only increase by 1 + assert TaxLotState.objects.count() == original_state_count + 1 + assert TaxLotView.objects.count() == original_view_count + 1 + assert TaxLot.objects.count() == original_taxlot_count + 1 + + # taxlot associated with a property + taxlot_data = { + "access_level_instance": self.org.root.id, + "cycle": self.cycle.id, + "state": {"jurisdiction_tax_lot_id": "10", "address_line_1": "A"}, + } + property_data = { + "access_level_instance": self.org.root.id, + "cycle": self.cycle.id, + "state": {"pm_property_id": "7", "custom_id_1": "8", "extra_data": {"Extra Data Column": "9"}, "lot_number": "10"}, + } + + t_response = self.client.post(url, json.dumps(taxlot_data), content_type="application/json") + assert t_response.status_code == 200 + view_id = t_response.json().get("view_id") + property_url = reverse("api:v3:properties-form-create") + f"?organization_id={self.org.pk}&related_view_id={view_id}" + p_response = self.client.post(property_url, json.dumps(property_data), content_type="application/json") + assert p_response.status_code == 200 + + taxlot_view = TaxLotView.objects.get(pk=view_id) + taxlot_state = taxlot_view.state + property_view = taxlot_view.taxlotproperty_set.first().property_view + property_state = property_view.state + assert property_state.pm_property_id == "7" + assert property_state.lot_number == "10" + assert taxlot_state.jurisdiction_tax_lot_id == "10" + assert taxlot_state.address_line_1 == "A" + + assert TaxLotState.objects.count() == original_state_count + 2 + assert TaxLotView.objects.count() == original_view_count + 2 + assert TaxLot.objects.count() == original_taxlot_count + 2 + assert PropertyView.objects.count() == original_property_view_count + 1 + class TaxLotViewTestPermissions(AccessLevelBaseTestCase): def setUp(self): diff --git a/seed/utils/inventory.py b/seed/utils/inventory.py new file mode 100644 index 0000000000..9f3d3cd713 --- /dev/null +++ b/seed/utils/inventory.py @@ -0,0 +1,39 @@ +from seed.models import Column, Property, PropertyAuditLog, PropertyState, PropertyView, TaxLot, TaxLotAuditLog, TaxLotState, TaxLotView +from seed.utils.match import get_matching_criteria_column_names + + +def create_inventory(inventory_type, org_id, cycle_id, access_level_instance_id, new_state_data): + inventory_class, view_class, state_class, auditlog_class, cycle_query, viewset = _inventorty_config(inventory_type) + + # Create extra data columns if necessary + extra_data = new_state_data.get("extra_data", {}) + for column in extra_data: + Column.objects.get_or_create(is_extra_data=True, column_name=column, organization_id=org_id, table_name=state_class.__name__) + + # Create stub state + state = state_class.objects.create(organization_id=org_id) + # get_or_create existing inventory and view + matching_columns = get_matching_criteria_column_names(org_id, state_class.__name__) + filter_query = {col: new_state_data.get(col) for col in matching_columns if col in new_state_data} + filter_query.update({"organization": org_id, cycle_query: cycle_id, f"{inventory_type}view__isnull": False}) + matching_state = state_class.objects.filter(**filter_query).first() + if matching_state: + view = getattr(matching_state, viewset).first() + inventory = getattr(view, inventory_type) + else: + inventory = inventory_class.objects.create(organization_id=org_id, access_level_instance_id=access_level_instance_id) + view_data = {"cycle_id": cycle_id, inventory_type: inventory, "state": state} + view = view_class.objects.create(**view_data) + + auditlog_class.objects.create(organization_id=org_id, state=state, view=view, name="Form Creation") + + return view + + +def _inventorty_config(inventory_type): + if inventory_type == "property": + return Property, PropertyView, PropertyState, PropertyAuditLog, "propertyview__cycle", "propertyview_set" + elif inventory_type == "taxlot": + return TaxLot, TaxLotView, TaxLotState, TaxLotAuditLog, "taxlotview__cycle", "taxlotview_set" + else: + raise ValueError(f"Invalid inventory type: {inventory_type}") diff --git a/seed/views/v3/properties.py b/seed/views/v3/properties.py index 4acae95453..05764a15da 100644 --- a/seed/views/v3/properties.py +++ b/seed/views/v3/properties.py @@ -88,9 +88,10 @@ from seed.tasks import update_state_derived_data from seed.utils.api import OrgMixin, ProfileIdMixin, api_endpoint_class from seed.utils.api_schema import AutoSchemaHelper, swagger_auto_schema_org_query_param +from seed.utils.inventory import create_inventory from seed.utils.inventory_filter import get_filtered_results from seed.utils.labels import get_labels -from seed.utils.match import MergeLinkPairError, match_merge_link +from seed.utils.match import MergeLinkPairError, get_matching_criteria_column_names, match_merge_link from seed.utils.merge import merge_properties from seed.utils.meters import PropertyMeterReadingsExporter from seed.utils.properties import get_changed_fields, pair_unpair_property_taxlot, properties_across_cycles, update_result_with_master @@ -1194,7 +1195,7 @@ def update(self, request, pk=None): log = PropertyAuditLog.objects.select_related().filter(state=property_view.state).order_by("-id").first() - if log.name in {"Manual Edit", "Manual Match", "System Match", "Merge current state in migration"}: + if log.name in {"Manual Edit", "Manual Match", "System Match", "Merge current state in migration", "Form Creation"}: # Convert this to using the serializer to save the data. This will override the previous values # in the state object. @@ -1950,6 +1951,49 @@ def facility_bps_export_to_cts(self, request): return response + @swagger_auto_schema( + manual_parameters=[AutoSchemaHelper.query_org_id_field()], + request_body=AutoSchemaHelper.schema_factory( + { + "access_level_instance": "integer", + "cycle": "integer", + "state": "object", + }, + description="state object represpents a PropertySstate's data", + ), + ) + @api_endpoint_class + @ajax_request_class + @has_perm_class("requires_member") + @action(detail=False, methods=["POST"]) + def form_create(self, request): + """Manaully create a property""" + org_id = self.get_organization(request) + data = request.data + new_state_data = data.get("state") + + try: + access_level_instance = AccessLevelInstance.objects.get(pk=data.get("access_level_instance"), organization_id=org_id) + cycle = Cycle.objects.get(pk=data.get("cycle"), organization_id=org_id) + except AccessLevelInstance.DoesNotExist: + return JsonResponse({"status": "error", "message": "Access Level Instance does not exist"}, status=status.HTTP_404_NOT_FOUND) + except Cycle.DoesNotExist: + return JsonResponse({"status": "error", "message": "Cycle does not exist"}, status=status.HTTP_404_NOT_FOUND) + + matching_columns = get_matching_criteria_column_names(org_id, "PropertyState") + if not (matching_columns & new_state_data.keys()): + return JsonResponse( + {"status": "error", "message": f"At least one of the following matching fields are required: {matching_columns}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + view = create_inventory("property", org_id, cycle.id, access_level_instance.id, new_state_data) + # if taxlot_view_id passed, link property and taxlot + if taxlot_view_id := request.query_params.get("related_view_id"): + TaxLotProperty.objects.get_or_create(property_view_id=view.id, taxlot_view_id=taxlot_view_id, cycle_id=cycle.id) + + return self.update(request, pk=view.id) + def _row_from_views(views): data = pd.Series() diff --git a/seed/views/v3/taxlots.py b/seed/views/v3/taxlots.py index 670a40e6f6..f5634524a0 100644 --- a/seed/views/v3/taxlots.py +++ b/seed/views/v3/taxlots.py @@ -24,6 +24,7 @@ MERGE_STATE_MERGED, MERGE_STATE_NEW, Column, + Cycle, DerivedColumn, InventoryGroupMapping, Note, @@ -40,9 +41,10 @@ from seed.tasks import update_state_derived_data from seed.utils.api import OrgMixin, ProfileIdMixin, api_endpoint_class from seed.utils.api_schema import AutoSchemaHelper, swagger_auto_schema_org_query_param +from seed.utils.inventory import create_inventory from seed.utils.inventory_filter import get_filtered_results from seed.utils.labels import get_labels -from seed.utils.match import MergeLinkPairError, match_merge_link +from seed.utils.match import MergeLinkPairError, get_matching_criteria_column_names, match_merge_link from seed.utils.merge import merge_taxlots from seed.utils.properties import get_changed_fields, pair_unpair_property_taxlot, update_result_with_master from seed.utils.taxlots import taxlots_across_cycles @@ -676,7 +678,7 @@ def update(self, request, pk): log = TaxLotAuditLog.objects.select_related().filter(state=taxlot_view.state).order_by("-id").first() - if log.name in {"Manual Edit", "Manual Match", "System Match", "Merge current state in migration"}: + if log.name in {"Manual Edit", "Manual Match", "System Match", "Merge current state in migration", "Form Creation"}: # Convert this to using the serializer to save the data. This will override the # previous values in the state object. @@ -731,6 +733,51 @@ def update(self, request, pk): else: return JsonResponse(result, status=status.HTTP_404_NOT_FOUND) + @swagger_auto_schema( + manual_parameters=[AutoSchemaHelper.query_org_id_field()], + request_body=AutoSchemaHelper.schema_factory( + { + "access_level_instance": "integer", + "cycle": "integer", + "state": "object", + }, + description="state object represpents a TaxLotState's data", + ), + ) + @api_endpoint_class + @ajax_request_class + @has_perm_class("requires_member") + @action(detail=False, methods=["POST"]) + def form_create(self, request): + org_id = self.get_organization(request) + data = request.data + new_state_data = data.get("state") + + try: + access_level_instance = AccessLevelInstance.objects.get(pk=data.get("access_level_instance"), organization_id=org_id) + cycle = Cycle.objects.get(pk=data.get("cycle"), organization_id=org_id) + except AccessLevelInstance.DoesNotExist: + return JsonResponse({"status": "error", "message": "Access Level Instance does not exist"}, status=status.HTTP_404_NOT_FOUND) + except Cycle.DoesNotExist: + return JsonResponse({"status": "error", "message": "Cycle does not exist"}, status=status.HTTP_404_NOT_FOUND) + + matching_columns = get_matching_criteria_column_names(org_id, "TaxLotState") + if not (matching_columns & new_state_data.keys()): + return JsonResponse( + {"status": "error", "message": f"At least one of the following matching fields are required: {matching_columns}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if new_state_data == {"extra_data": {}}: + return JsonResponse({"status": "error", "message": "No data provided"}, status=status.HTTP_400_BAD_REQUEST) + + view = create_inventory("taxlot", org_id, cycle.id, access_level_instance.id, new_state_data) + # if property_view_id passed, link property and taxlot + if property_view_id := request.query_params.get("related_view_id"): + TaxLotProperty.objects.get_or_create(property_view_id=property_view_id, taxlot_view_id=view.id, cycle_id=cycle.id) + + return self.update(request, pk=view.id) + def update_derived_data(state_ids, org_id): derived_columns = DerivedColumn.objects.filter(organization_id=org_id)