From f3390eff2ff5fe8eca899d2b3f199868fcea1575 Mon Sep 17 00:00:00 2001 From: Cyrus Hiatt Date: Mon, 25 Nov 2024 10:15:22 -0800 Subject: [PATCH] Add tile sort order to bulk data manager (#11638) * Remove unused argument * Add sortorder to tile_excel * Make sortorder non-nullable in loadstaging * Add sortorder to loadstaging model * Add sortorder to staging_to_tile pg function * Add hardcoded sortorder of 0 to bulk excel * Add fixed sortorder of 0 to single csv * Support sortorder in arches_stage_string_data_for_bulk_edit sql fn * nit * Update bulk concept edit to support sortorder * Add changelog entry * Check imported tile sort order #11408 --------- Co-authored-by: Johnathan Clementi --- .../app/etl_modules/branch_excel_importer.py | 4 +- arches/app/etl_modules/bulk_edit_concept.py | 4 +- arches/app/etl_modules/import_single_csv.py | 6 +- arches/app/etl_modules/tile_excel_importer.py | 6 +- .../migrations/11408_loadstaging_sortorder.py | 409 ++++++++++++++++++ arches/app/models/models.py | 1 + releases/8.0.0.md | 1 + tests/bulkdata/tile_excel_tests.py | 5 + .../data/uploadedfiles/tile_excel_test.xlsx | Bin 9777 -> 11154 bytes 9 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 arches/app/models/migrations/11408_loadstaging_sortorder.py diff --git a/arches/app/etl_modules/branch_excel_importer.py b/arches/app/etl_modules/branch_excel_importer.py index ca811a22750..7899d0c32d9 100644 --- a/arches/app/etl_modules/branch_excel_importer.py +++ b/arches/app/etl_modules/branch_excel_importer.py @@ -205,8 +205,9 @@ def process_worksheet(self, worksheet, cursor, node_lookup, nodegroup_lookup): tile_value_json, passes_validation = self.create_tile_value( cell_values, data_node_lookup, node_lookup, row_details, cursor ) + sortorder = 0 cursor.execute( - """INSERT INTO load_staging (nodegroupid, legacyid, resourceid, tileid, parenttileid, value, loadid, nodegroup_depth, source_description, passes_validation, operation) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + """INSERT INTO load_staging (nodegroupid, legacyid, resourceid, tileid, parenttileid, value, loadid, nodegroup_depth, source_description, passes_validation, operation, sortorder) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", ( row_details["nodegroup_id"], legacyid, @@ -221,6 +222,7 @@ def process_worksheet(self, worksheet, cursor, node_lookup, nodegroup_lookup): ), # source_description passes_validation, operation, + sortorder, ), ) except KeyError: diff --git a/arches/app/etl_modules/bulk_edit_concept.py b/arches/app/etl_modules/bulk_edit_concept.py index 8a33701efec..86237a6fffd 100644 --- a/arches/app/etl_modules/bulk_edit_concept.py +++ b/arches/app/etl_modules/bulk_edit_concept.py @@ -393,8 +393,8 @@ def stage_data( try: sql = ( """ - INSERT INTO load_staging (value, tileid, nodegroupid, parenttileid, resourceid, loadid, nodegroup_depth, source_description, operation, passes_validation) - (SELECT tiledata, tileid, nodegroupid, parenttileid, resourceinstanceid, %(load_id)s, 0, 'bulk_edit', 'update', true + INSERT INTO load_staging (value, tileid, nodegroupid, parenttileid, resourceid, loadid, nodegroup_depth, source_description, operation, passes_validation, sortorder) + (SELECT tiledata, tileid, nodegroupid, parenttileid, resourceinstanceid, %(load_id)s, 0, 'bulk_edit', 'update', true, sortorder FROM tiles WHERE nodegroupid in (SELECT nodegroupid FROM nodes WHERE nodeid = %(node_id)s) AND tiledata -> %(node_id)s ? %(old_id)s diff --git a/arches/app/etl_modules/import_single_csv.py b/arches/app/etl_modules/import_single_csv.py index d2bdb2ebf24..145358c9fad 100644 --- a/arches/app/etl_modules/import_single_csv.py +++ b/arches/app/etl_modules/import_single_csv.py @@ -555,8 +555,9 @@ def populate_staging_table( nodegroup_depth, source_description, operation, - passes_validation - ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + passes_validation, + sortorder + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", ( nodegroup, legacyid, @@ -568,6 +569,7 @@ def populate_staging_table( csv_file_name, "insert", passes_validation, + 0, ), ) diff --git a/arches/app/etl_modules/tile_excel_importer.py b/arches/app/etl_modules/tile_excel_importer.py index a9feb06ef49..a124c6a02f9 100644 --- a/arches/app/etl_modules/tile_excel_importer.py +++ b/arches/app/etl_modules/tile_excel_importer.py @@ -81,7 +81,6 @@ def run_load_task_async(self, request): def create_tile_value( self, - cell_values, data_node_lookup, node_lookup, nodegroup_alias, @@ -176,6 +175,7 @@ def process_worksheet(self, worksheet, cursor, node_lookup, nodegroup_lookup): raise ValueError(_("All rows must have a valid resource id")) node_values = cell_values[3:-3] + sortorder = cell_values[-3] if cell_values[-3] else 0 try: row_count += 1 row_details = dict(zip(data_node_lookup[nodegroup_alias], node_values)) @@ -194,7 +194,6 @@ def process_worksheet(self, worksheet, cursor, node_lookup, nodegroup_lookup): ) legacyid, resourceid = self.set_legacy_id(resourceid) tile_value_json, passes_validation = self.create_tile_value( - cell_values, data_node_lookup, node_lookup, nodegroup_alias, @@ -214,7 +213,7 @@ def process_worksheet(self, worksheet, cursor, node_lookup, nodegroup_lookup): if TileModel.objects.filter(pk=tileid).exists(): operation = "update" cursor.execute( - """INSERT INTO load_staging (nodegroupid, legacyid, resourceid, tileid, parenttileid, value, loadid, nodegroup_depth, source_description, passes_validation, operation) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", + """INSERT INTO load_staging (nodegroupid, legacyid, resourceid, tileid, parenttileid, value, loadid, nodegroup_depth, source_description, passes_validation, operation, sortorder) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""", ( row_details["nodegroup_id"], legacyid, @@ -229,6 +228,7 @@ def process_worksheet(self, worksheet, cursor, node_lookup, nodegroup_lookup): ), # source_description passes_validation, operation, + sortorder, ), ) except KeyError: diff --git a/arches/app/models/migrations/11408_loadstaging_sortorder.py b/arches/app/models/migrations/11408_loadstaging_sortorder.py new file mode 100644 index 00000000000..9420764f28c --- /dev/null +++ b/arches/app/models/migrations/11408_loadstaging_sortorder.py @@ -0,0 +1,409 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("models", "10437_node_alias_not_null"), + ] + + update_arches_staging_to_tile = """ + CREATE OR REPLACE FUNCTION public.__arches_staging_to_tile( + load_id uuid) + RETURNS boolean + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + AS $BODY$ + DECLARE + status boolean; + staged_value jsonb; + tile_data jsonb; + old_data jsonb; + passed boolean; + source text; + op text; + selected_resource text; + graph_id uuid; + instance_id uuid; + legacy_id text; + file_id uuid; + tile_id uuid; + tile_id_tree uuid; + parent_id uuid; + nodegroup_id uuid; + sort_order integer; + resource_instance_lifecycle_state_uuid uuid; + _file jsonb; + _key text; + _value text; + tile_data_value jsonb; + resource_object jsonb; + resource_obejct_array jsonb; + BEGIN + FOR staged_value, instance_id, legacy_id, tile_id, parent_id, nodegroup_id, passed, graph_id, source, op, resource_instance_lifecycle_state_uuid, sort_order IN + ( + SELECT value, resourceid, legacyid, tileid, parenttileid, ls.nodegroupid, passes_validation, n.graphid, source_description, operation, rils.id, ls.sortorder + FROM load_staging ls + INNER JOIN (SELECT DISTINCT nodegroupid, graphid FROM nodes) n + ON ls.nodegroupid = n.nodegroupid + INNER JOIN (SELECT graphid, resource_instance_lifecycle_id FROM graphs) g + ON g.graphid = n.graphid + INNER JOIN (SELECT id, resource_instance_lifecycle_id FROM resource_instance_lifecycle_states WHERE is_initial_state = true) rils + ON g.resource_instance_lifecycle_id = rils.resource_instance_lifecycle_id + WHERE loadid = load_id + ORDER BY nodegroup_depth ASC + ) + LOOP + IF passed THEN + SELECT resourceinstanceid FROM resource_instances INTO selected_resource WHERE resourceinstanceid = instance_id; + -- create a resource first if the resource is not yet created + IF NOT FOUND THEN + INSERT INTO resource_instances(resourceinstanceid, graphid, legacyid, createdtime, resource_instance_lifecycle_state_id) + VALUES (instance_id, graph_id, legacy_id, now(), resource_instance_lifecycle_state_uuid); + -- create resource instance edit log + INSERT INTO edit_log (resourceclassid, resourceinstanceid, edittype, timestamp, note, transactionid) + VALUES (graph_id, instance_id, 'create', now(), 'loaded from staging_table', load_id); + END IF; + + -- create a tile one by one + tile_data := '{}'::jsonb; + FOR _key, _value IN SELECT * FROM jsonb_each_text(staged_value) + LOOP + tile_data_value = _value::jsonb -> 'value'; + IF (_value::jsonb ->> 'datatype') in ('resource-instance-list', 'resource-instance') AND tile_data_value <> null THEN + resource_obejct_array = '[]'::jsonb; + FOR resource_object IN SELECT * FROM jsonb_array_elements(tile_data_value) LOOP + resource_object = jsonb_set(resource_object, '{resourceXresourceId}', to_jsonb(uuid_generate_v1mc())); + resource_obejct_array = resource_obejct_array || resource_object; + END LOOP; + tile_data_value = resource_obejct_array; + END IF; + tile_data = jsonb_set(tile_data, format('{"%s"}', _key)::text[], coalesce(tile_data_value, 'null')); + END LOOP; + + IF op = 'update' THEN + SELECT tiledata FROM tiles INTO old_data WHERE resourceinstanceid = instance_id AND tileid = tile_id; + IF NOT FOUND THEN + INSERT INTO tiles(tileid, tiledata, nodegroupid, parenttileid, resourceinstanceid, sortorder) + VALUES (tile_id, tile_data, nodegroup_id, parent_id, instance_id, sort_order); + INSERT INTO edit_log (resourceclassid, resourceinstanceid, nodegroupid, tileinstanceid, edittype, newvalue, timestamp, note, transactionid) + VALUES (graph_id, instance_id, nodegroup_id, tile_id, 'tile create', tile_data::jsonb, now(), 'loaded from staging_table', load_id); + ELSE + UPDATE tiles + SET tiledata = tile_data, sortorder = sort_order + WHERE tileid = tile_id; + INSERT INTO edit_log (resourceclassid, resourceinstanceid, nodegroupid, tileinstanceid, edittype, newvalue, oldvalue, timestamp, note, transactionid) + VALUES (graph_id, instance_id, nodegroup_id, tile_id, 'tile edit', tile_data::jsonb, old_data, now(), 'loaded from staging_table', load_id); + END IF; + ELSIF op = 'insert' THEN + INSERT INTO tiles(tileid, tiledata, nodegroupid, parenttileid, resourceinstanceid, sortorder) + VALUES (tile_id, tile_data, nodegroup_id, parent_id, instance_id, sort_order); + INSERT INTO edit_log (resourceclassid, resourceinstanceid, nodegroupid, tileinstanceid, edittype, newvalue, timestamp, note, transactionid) + VALUES (graph_id, instance_id, nodegroup_id, tile_id, 'tile create', tile_data::jsonb, now(), 'loaded from staging_table', load_id); + END IF; + END IF; + END LOOP; + FOR staged_value, tile_id IN + ( + SELECT value, tileid + FROM load_staging + WHERE loadid = load_id + ) + LOOP + FOR _key, _value IN SELECT * FROM jsonb_each_text(staged_value) + LOOP + CASE + WHEN (_value::jsonb ->> 'datatype') = 'file-list' THEN + FOR _file IN SELECT * FROM jsonb_array_elements(_value::jsonb -> 'value') LOOP + file_id = _file ->> 'file_id'; + UPDATE files SET tileid = tile_id WHERE fileid = file_id::uuid; + END LOOP; + WHEN (_value::jsonb ->> 'datatype') in ('resource-instance-list', 'resource-instance') THEN + PERFORM __arches_refresh_tile_resource_relationships(tile_id); + ELSE + END CASE; + END LOOP; + END LOOP; + UPDATE load_event SET (load_end_time, complete, successful) = (now(), true, true) WHERE loadid = load_id; + PERFORM refresh_transaction_geojson_geometries(load_id); + SELECT successful INTO status FROM load_event WHERE loadid = load_id; + RETURN status; + END; + $BODY$; + """ + + reverse_arches_staging_to_tile = """ + CREATE OR REPLACE FUNCTION public.__arches_staging_to_tile( + load_id uuid) + RETURNS boolean + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + AS $BODY$ + DECLARE + status boolean; + staged_value jsonb; + tile_data jsonb; + old_data jsonb; + passed boolean; + source text; + op text; + selected_resource text; + graph_id uuid; + instance_id uuid; + legacy_id text; + file_id uuid; + tile_id uuid; + tile_id_tree uuid; + parent_id uuid; + nodegroup_id uuid; + resource_instance_lifecycle_state_uuid uuid; + _file jsonb; + _key text; + _value text; + tile_data_value jsonb; + resource_object jsonb; + resource_obejct_array jsonb; + BEGIN + FOR staged_value, instance_id, legacy_id, tile_id, parent_id, nodegroup_id, passed, graph_id, source, op, resource_instance_lifecycle_state_uuid IN + ( + SELECT value, resourceid, legacyid, tileid, parenttileid, ls.nodegroupid, passes_validation, n.graphid, source_description, operation, rils.id + FROM load_staging ls + INNER JOIN (SELECT DISTINCT nodegroupid, graphid FROM nodes) n + ON ls.nodegroupid = n.nodegroupid + INNER JOIN (SELECT graphid, resource_instance_lifecycle_id FROM graphs) g + ON g.graphid = n.graphid + INNER JOIN (SELECT id, resource_instance_lifecycle_id FROM resource_instance_lifecycle_states WHERE is_initial_state = true) rils + ON g.resource_instance_lifecycle_id = rils.resource_instance_lifecycle_id + WHERE loadid = load_id + ORDER BY nodegroup_depth ASC + ) + LOOP + IF passed THEN + SELECT resourceinstanceid FROM resource_instances INTO selected_resource WHERE resourceinstanceid = instance_id; + -- create a resource first if the resource is not yet created + IF NOT FOUND THEN + INSERT INTO resource_instances(resourceinstanceid, graphid, legacyid, createdtime, resource_instance_lifecycle_state_id) + VALUES (instance_id, graph_id, legacy_id, now(), resource_instance_lifecycle_state_uuid); + -- create resource instance edit log + INSERT INTO edit_log (resourceclassid, resourceinstanceid, edittype, timestamp, note, transactionid) + VALUES (graph_id, instance_id, 'create', now(), 'loaded from staging_table', load_id); + END IF; + + -- create a tile one by one + tile_data := '{}'::jsonb; + FOR _key, _value IN SELECT * FROM jsonb_each_text(staged_value) + LOOP + tile_data_value = _value::jsonb -> 'value'; + IF (_value::jsonb ->> 'datatype') in ('resource-instance-list', 'resource-instance') AND tile_data_value <> null THEN + resource_obejct_array = '[]'::jsonb; + FOR resource_object IN SELECT * FROM jsonb_array_elements(tile_data_value) LOOP + resource_object = jsonb_set(resource_object, '{resourceXresourceId}', to_jsonb(uuid_generate_v1mc())); + resource_obejct_array = resource_obejct_array || resource_object; + END LOOP; + tile_data_value = resource_obejct_array; + END IF; + tile_data = jsonb_set(tile_data, format('{"%s"}', _key)::text[], coalesce(tile_data_value, 'null')); + END LOOP; + + IF op = 'update' THEN + SELECT tiledata FROM tiles INTO old_data WHERE resourceinstanceid = instance_id AND tileid = tile_id; + IF NOT FOUND THEN + INSERT INTO tiles(tileid, tiledata, nodegroupid, parenttileid, resourceinstanceid) + VALUES (tile_id, tile_data, nodegroup_id, parent_id, instance_id); + INSERT INTO edit_log (resourceclassid, resourceinstanceid, nodegroupid, tileinstanceid, edittype, newvalue, timestamp, note, transactionid) + VALUES (graph_id, instance_id, nodegroup_id, tile_id, 'tile create', tile_data::jsonb, now(), 'loaded from staging_table', load_id); + ELSE + UPDATE tiles + SET tiledata = tile_data + WHERE tileid = tile_id; + INSERT INTO edit_log (resourceclassid, resourceinstanceid, nodegroupid, tileinstanceid, edittype, newvalue, oldvalue, timestamp, note, transactionid) + VALUES (graph_id, instance_id, nodegroup_id, tile_id, 'tile edit', tile_data::jsonb, old_data, now(), 'loaded from staging_table', load_id); + END IF; + ELSIF op = 'insert' THEN + INSERT INTO tiles(tileid, tiledata, nodegroupid, parenttileid, resourceinstanceid) + VALUES (tile_id, tile_data, nodegroup_id, parent_id, instance_id); + INSERT INTO edit_log (resourceclassid, resourceinstanceid, nodegroupid, tileinstanceid, edittype, newvalue, timestamp, note, transactionid) + VALUES (graph_id, instance_id, nodegroup_id, tile_id, 'tile create', tile_data::jsonb, now(), 'loaded from staging_table', load_id); + END IF; + END IF; + END LOOP; + FOR staged_value, tile_id IN + ( + SELECT value, tileid + FROM load_staging + WHERE loadid = load_id + ) + LOOP + FOR _key, _value IN SELECT * FROM jsonb_each_text(staged_value) + LOOP + CASE + WHEN (_value::jsonb ->> 'datatype') = 'file-list' THEN + FOR _file IN SELECT * FROM jsonb_array_elements(_value::jsonb -> 'value') LOOP + file_id = _file ->> 'file_id'; + UPDATE files SET tileid = tile_id WHERE fileid = file_id::uuid; + END LOOP; + WHEN (_value::jsonb ->> 'datatype') in ('resource-instance-list', 'resource-instance') THEN + PERFORM __arches_refresh_tile_resource_relationships(tile_id); + ELSE + END CASE; + END LOOP; + END LOOP; + UPDATE load_event SET (load_end_time, complete, successful) = (now(), true, true) WHERE loadid = load_id; + PERFORM refresh_transaction_geojson_geometries(load_id); + SELECT successful INTO status FROM load_event WHERE loadid = load_id; + RETURN status; + END; + $BODY$; + """ + + update_arches_stage_string_data_for_bulk_edit = """ + CREATE OR REPLACE FUNCTION public.__arches_stage_string_data_for_bulk_edit( + load_id uuid, + graph_id uuid, + node_id uuid, + module_id uuid, + resourceinstance_ids uuid[], + operation text, + old_text text, + new_text text, + language_code text, + case_insensitive boolean, + update_limit integer) + RETURNS void + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + AS $BODY$ + DECLARE + tile_id uuid; + tile_data jsonb; + nodegroup_id uuid; + parenttile_id uuid; + resourceinstance_id uuid; + text_replacing_like text; + BEGIN + INSERT INTO load_staging (tileid, value, nodegroupid, parenttileid, resourceid, loadid, nodegroup_depth, source_description, operation, passes_validation, sortorder) + SELECT DISTINCT t.tileid, t.tiledata, t.nodegroupid, t.parenttileid, t.resourceinstanceid, load_id, 0, 'bulk_edit', 'update', true, t.sortorder + FROM tiles t, nodes n + WHERE t.nodegroupid = n.nodegroupid + AND CASE + WHEN graph_id IS NULL THEN true + ELSE n.graphid = graph_id + END + AND CASE + WHEN node_id IS NULL THEN n.datatype = 'string' + ELSE n.nodeid = node_id + END + AND CASE + WHEN resourceinstance_ids IS NULL THEN true + ELSE t.resourceinstanceid = ANY(resourceinstance_ids) + END + AND CASE operation + WHEN 'trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'capitalize' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> INITCAP(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'capitalize_trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(INITCAP(t.tiledata -> nodeid::text -> language_code ->> 'value')) + WHEN 'upper' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> UPPER(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'upper_trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(UPPER(t.tiledata -> nodeid::text -> language_code ->> 'value')) + WHEN 'lower' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> LOWER(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'lower_trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(LOWER(t.tiledata -> nodeid::text -> language_code ->> 'value')) + WHEN 'replace_i' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> REGEXP_REPLACE(t.tiledata -> nodeid::text -> language_code ->> 'value', old_text, new_text, 'gi') + WHEN 'replace' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> REGEXP_REPLACE(t.tiledata -> nodeid::text -> language_code ->> 'value', old_text, new_text, 'g') + END + LIMIT update_limit; + END; + $BODY$; + """ + + reverse_arches_stage_string_data_for_bulk_edit = """ + CREATE OR REPLACE FUNCTION public.__arches_stage_string_data_for_bulk_edit( + load_id uuid, + graph_id uuid, + node_id uuid, + module_id uuid, + resourceinstance_ids uuid[], + operation text, + old_text text, + new_text text, + language_code text, + case_insensitive boolean, + update_limit integer) + RETURNS void + LANGUAGE 'plpgsql' + COST 100 + VOLATILE PARALLEL UNSAFE + AS $BODY$ + DECLARE + tile_id uuid; + tile_data jsonb; + nodegroup_id uuid; + parenttile_id uuid; + resourceinstance_id uuid; + text_replacing_like text; + BEGIN + INSERT INTO load_staging (tileid, value, nodegroupid, parenttileid, resourceid, loadid, nodegroup_depth, source_description, operation, passes_validation) + SELECT DISTINCT t.tileid, t.tiledata, t.nodegroupid, t.parenttileid, t.resourceinstanceid, load_id, 0, 'bulk_edit', 'update', true + FROM tiles t, nodes n + WHERE t.nodegroupid = n.nodegroupid + AND CASE + WHEN graph_id IS NULL THEN true + ELSE n.graphid = graph_id + END + AND CASE + WHEN node_id IS NULL THEN n.datatype = 'string' + ELSE n.nodeid = node_id + END + AND CASE + WHEN resourceinstance_ids IS NULL THEN true + ELSE t.resourceinstanceid = ANY(resourceinstance_ids) + END + AND CASE operation + WHEN 'trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'capitalize' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> INITCAP(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'capitalize_trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(INITCAP(t.tiledata -> nodeid::text -> language_code ->> 'value')) + WHEN 'upper' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> UPPER(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'upper_trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(UPPER(t.tiledata -> nodeid::text -> language_code ->> 'value')) + WHEN 'lower' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> LOWER(t.tiledata -> nodeid::text -> language_code ->> 'value') + WHEN 'lower_trim' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> TRIM(LOWER(t.tiledata -> nodeid::text -> language_code ->> 'value')) + WHEN 'replace_i' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> REGEXP_REPLACE(t.tiledata -> nodeid::text -> language_code ->> 'value', old_text, new_text, 'gi') + WHEN 'replace' THEN + t.tiledata -> nodeid::text -> language_code ->> 'value' <> REGEXP_REPLACE(t.tiledata -> nodeid::text -> language_code ->> 'value', old_text, new_text, 'g') + END + LIMIT update_limit; + END; + $BODY$; + """ + + operations = [ + migrations.AddField( + model_name="loadstaging", + name="sortorder", + field=models.IntegerField(default=0), + ), + migrations.RunSQL( + update_arches_staging_to_tile, + reverse_arches_staging_to_tile, + ), + migrations.RunSQL( + update_arches_stage_string_data_for_bulk_edit, + reverse_arches_stage_string_data_for_bulk_edit, + ), + ] diff --git a/arches/app/models/models.py b/arches/app/models/models.py index dcd7004292c..a92d5e6f8bd 100644 --- a/arches/app/models/models.py +++ b/arches/app/models/models.py @@ -2200,6 +2200,7 @@ class LoadStaging(models.Model): legacyid = models.TextField(blank=True, null=True) resourceid = models.UUIDField(serialize=False, blank=True, null=True) tileid = models.UUIDField(serialize=False, blank=True, null=True) + sortorder = models.IntegerField(blank=False, null=False, default=0) parenttileid = models.UUIDField(serialize=False, blank=True, null=True) passes_validation = models.BooleanField(blank=True, null=True) nodegroup_depth = models.IntegerField(default=1) diff --git a/releases/8.0.0.md b/releases/8.0.0.md index 91189c8d23c..3b7046a6932 100644 --- a/releases/8.0.0.md +++ b/releases/8.0.0.md @@ -27,6 +27,7 @@ Arches 8.0.0 Release Notes - Removes sample index from new projects, updates test coverage behavior [#11591](https://github.com/archesproject/arches/issues/11519) - Make number datatype node values searchable in the main search [#11619](https://github.com/archesproject/arches/issues/11619) - Prevent navigation to a new browser tab when clicking Manage link in index.htm [#11635](https://github.com/archesproject/arches/issues/11635) +- Add support for tile sort order to the bulk data manager [#11638](https://github.com/archesproject/arches/pull/11638) ### Dependency changes ``` diff --git a/tests/bulkdata/tile_excel_tests.py b/tests/bulkdata/tile_excel_tests.py index 267a60ebdcc..75f9899ada5 100644 --- a/tests/bulkdata/tile_excel_tests.py +++ b/tests/bulkdata/tile_excel_tests.py @@ -23,6 +23,7 @@ from django.core.management import call_command from django.test import TransactionTestCase +from arches.app.models.models import TileModel from arches.app.utils.betterJSONSerializer import JSONDeserializer from arches.app.utils.data_management.resource_graphs.importer import ( import_graph as ResourceGraphImporter, @@ -57,3 +58,7 @@ def test_cli(self): ) call_command("etl", "tile-excel-importer", source=excel_file_path, stdout=out) self.assertIn("succeeded", out.getvalue()) + + new_tiles = TileModel.objects.all() + self.assertEqual(new_tiles.count(), 6) + self.assertEqual(new_tiles.filter(sortorder=1).count(), 2) diff --git a/tests/fixtures/data/uploadedfiles/tile_excel_test.xlsx b/tests/fixtures/data/uploadedfiles/tile_excel_test.xlsx index a06af945698e35b16673f39e729476efae29a4a6..5f167143b032f02055f48097f310514904584614 100644 GIT binary patch literal 11154 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