From 6df778e123447e85251778e92beef04af4862db0 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 5 Dec 2023 12:53:39 -0600 Subject: [PATCH 01/99] init api for adding and deleting tree ranks --- specifyweb/specify/tree_views.py | 362 ++++++++++++++++++++++++++++++- specifyweb/specify/urls.py | 2 + 2 files changed, 362 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index df391bf005a..3e615bf1f54 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -1,6 +1,6 @@ from functools import wraps from django.db import transaction -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseServerError from django.views.decorators.http import require_GET, require_POST from sqlalchemy import sql from sqlalchemy.orm import aliased @@ -15,6 +15,7 @@ from .models import datamodel from .tree_stats import get_tree_stats from .views import login_maybe_required, openapi +from . import models as spmodels import logging logger = logging.getLogger(__name__) @@ -342,4 +343,361 @@ def perm_target(tree): 'storage': StorageMutationPT, 'geologictimeperiod': GeologictimeperiodMutationPT, 'lithostrat': LithostratMutationPT, - }[tree] \ No newline at end of file + }[tree] + +TAXON_RANKS = { + 'TAXONOMY_ROOT': 0, + 'KINGDOM': 10, + 'SUBKINGDOM': 20, + 'DIVISION': 30, + 'PHYLUM': 30, + 'SUBDIVISION': 40, + 'SUBPHYLUM': 40, + 'SUPERCLASS': 50, + 'CLASS': 60, + 'SUBCLASS': 70, + 'INFRACLASS': 80, + 'SUPERORDER': 90, + 'ORDER': 100, + 'SUBORDER': 110, + 'INFRAORDER': 120, + 'PARVORDER': 125, + 'SUPERFAMILY': 130, + 'FAMILY': 140, + 'SUBFAMILY': 150, + 'TRIBE': 160, + 'SUBTRIBE': 170, + 'GENUS': 180, + 'SUBGENUS': 190, + 'SECTION': 200, + 'SUBSECTION': 210, + 'SPECIES': 220, + 'SUBSPECIES': 230, + 'VARIETY': 240, + 'SUBVARIETY': 250, + 'FORMA': 260, + 'SUBFORMA': 270 +} +GEOGRAPHY_RANKS = { + 'CONTINENT': 100, + 'COUNTRY': 200, + 'STATE': 300, + 'COUNTY': 400 +} +STORAGE_RANKS = { + 'BUILDING': 100, + 'COLLECTION': 150, + 'ROOM': 200, + 'AISLE': 250, + 'CABINET': 300, + 'SHELF': 350, + 'BOX': 400, + 'RACK': 450, + 'VIAL': 500 +} +GEOLOGIC_TIME_PERIOD_RANKS = { + 'ERA': 100, + 'PERIOD': 200, + 'EPOCH': 300, + 'AGE': 400 +} +LITHO_STRAT_RANKS = { + 'SUPERGROUP': 100, + 'GROUP': 200, + 'FORMATION': 300, + 'MEMBER': 400, + 'BED': 500 +} + +DEFAULT_RANK_INCREMENT = 100 +TAXON_RANK_INCREMENT = 10 +GEOGRAPHY_RANK_INCREMENT = DEFAULT_RANK_INCREMENT +STORAGE_RANK_INCREMENT = 50 +GEOLOGIC_TIME_PERIOD_INCREMENT = DEFAULT_RANK_INCREMENT +LITHO_STRAT_INCREMENT = DEFAULT_RANK_INCREMENT + +TREE_RANKS_MAPPING = { + 'taxon': (TAXON_RANKS, TAXON_RANK_INCREMENT), + 'geography': (GEOGRAPHY_RANKS, GEOGRAPHY_RANK_INCREMENT), + 'storage': (STORAGE_RANKS, STORAGE_RANK_INCREMENT), + 'geologictimeperiod': (GEOLOGIC_TIME_PERIOD_RANKS, GEOLOGIC_TIME_PERIOD_INCREMENT), + 'lithostrat': (LITHO_STRAT_RANKS, LITHO_STRAT_INCREMENT), +} + +@openapi.schema({ + "post": { + "parameters": [ + { + "name": "newRankName", + "in": "formData", + "required": True, + "schema": { + "type": "string" + }, + "description": "The name of the new rank to add" + }, + { + "name": "targetRankName", + "in": "formData", + "required": True, + "schema": { + "type": "string" + }, + "description": "The name of the parent rank to add the new rank to (use 'root' to add to the front)" + }, + { + "name": "treeType", + "in": "formData", + "required": True, + "schema": { + "type": "string", + "enum": ["taxon", "geography", "storage", "geologicTimePeriod", "lithoStrat"] + }, + "description": "The type of the tree (taxon, geography, or storage)" + }, + { + "name": "newRankTitle", + "in": "formData", + "required": False, + "schema": { + "type": "string" + }, + "description": "The title of the rank to add (defaults to the name)" + }, + { + "name": "useDefaultRankIDs", + "in": "formData", + "required": False, + "schema": { + "type": "bool" + }, + "description": "Determine if the default rank IDs should be used (defaults to True)" + } + ], + "responses": { + "200": { + "description": "Rank successfully added", + "content": { + "application/json": {} + } + }, + "400": { + "description": "Invalid input" + }, + "500": { + "description": "Internal server error" + } + } + } +}) +@tree_mutation +@require_POST +def add_tree_rank(request, tree) -> HttpResponse: + """ + Adds a new rank to the specified tree. Expects 'rank_name' and 'tree_type' + in the POST data. Adds the rank to + """ + try: + # Get parameter values from request + new_rank_title = request.POST.get('newRankTitle', new_rank_name) + use_default_rank_ids = request.POST.get('useDefaultRankIDs', True) + tree_type, new_rank_name, target_rank_name = ( + request.POST.get(key) for key in ('treeType', 'newRankName', 'targetRankName') + ) + + # Throw exceptions if the required parameters are not given correctly + if new_rank_name is None: + raise Exception("Rank name is not given") + if target_rank_name is None: + raise Exception("Target rank name is not given") + if tree_type is None or tree_type.lower() not in TREE_RANKS_MAPPING.keys(): + raise Exception("Invalid tree type") + + # Get tree def item model + tree_def_model_name = str.join(tree_type, 'treedef').lower().title() + tree_def_item_model_name = str.join(tree_type, 'treedefitem').lower().title() + tree_def_model = getattr(spmodels, tree_def_model_name) + tree_def_item_model = getattr(spmodels, tree_def_item_model_name) + + # Determine the new rank id parameters + new_rank_id = None + target_rank = tree_def_item_model.objects.get(name=target_rank_name) + if target_rank is None: + raise Exception('Target rank name does not exist') + target_rank_id = target_rank.rank_id + rank_ids = list(tree_def_item_model.objects.all().values_list('rank_id', flat=True)).sort() + target_rank_idx = rank_ids.index(target_rank_id) + next_rank_id = rank_ids[target_rank_idx + 1] if target_rank_idx + 1 < len(rank_ids) else None + + # Don't allow rank IDs less than 0 + if next_rank_id == 0: + raise Exception("Can't create rank ID less than 0") + + # Set conditions for rank ID creation + is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 + is_new_rank_first = target_rank_id == -1 + is_new_rank_last = target_rank_idx == len(rank_ids) - 1 + + # Set the default ranks and increments depending on the tree type + default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree_type, (None, 100)) + + # Build new fields for the new TreeDefItem record + new_fields_dict = { + 'name': new_rank_name, + 'title': new_rank_title + } + new_fields_dict[tree_type.lower() + 'treedefid'] = tree_def_model + + # Determine if the default rank ID can be used + can_use_default_rank_id = ( + use_default_rank_ids + and default_tree_ranks is not None + and new_rank_name.upper() in default_tree_ranks + ) + + # Only use the the default rank id if the fhe following criteria is met: + # - new_rank_name is in the the default ranks set + # - the default rank id is not already used + # - the default rank is greater than the target rank + # - the default rank is less than the current next rank from the target rank + if can_use_default_rank_id: + default_rank_id = default_tree_ranks[new_rank_name.upper()] + + # Check if the default rank ID is not already used + is_default_rank_id_unused = default_rank_id not in rank_ids + + # Check if the default rank ID can be logically placed in the hierarchy + is_placement_valid = ( + is_tree_def_items_empty + or (is_new_rank_first and default_rank_id < next_rank_id) + or (is_new_rank_last and default_rank_id > target_rank_id) + or (default_rank_id > target_rank_id and default_rank_id < next_rank_id) + ) + + if is_default_rank_id_unused and is_placement_valid: + new_rank_id = default_rank_id + + # Set the new rank id if a default rank id is not available + if new_rank_id is None: + # If this is the first rank, create ... + if is_tree_def_items_empty: + new_fields_dict['rankid'] = rank_increment + + # If there are no ranks higher than the target rank, then add the new rank to the end of the hierarchy + elif is_new_rank_first: + new_rank_id = int((next_rank_id - target_rank_id) / 2) + target_rank_idx + max_rank_id = rank_ids[-1] + new_fields_dict['rankid'] = max_rank_id + rank_increment + + # If there are no ranks lower than the target rank, then add the new rank to the top of the hierarchy + elif is_new_rank_last: + min_rank_id = rank_ids[0] + new_fields_dict['rankid'] = min_rank_id + rank_increment # TODO: checkout + + # If the new rank is being placed somewhere in the middle of the heirarchy + else: + new_rank_id = int((next_rank_id - target_rank_id) / 2) + target_rank_id + if next_rank_id - target_rank_id < 1: + raise Exception(f"Can't add rank id between {new_rank_id} and {target_rank_id}") + + # Create and save the new TreeDefItem record + new_fields_dict['rankid'] = new_rank_id + tree_def_item = tree_def_item_model.objects.create(**new_fields_dict) + tree_def_item.save() + + # Create a new tree def item + tree_def_item_model.objects.create(id=new_rank_id, name=new_rank_name, title=new_rank_title) + + # Regenerate full names + tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) + + logger.info(f"Added new tree rank: {new_rank_name} with ID: {new_rank_id}") + return HttpResponse("Success") + + except Exception as e: + logger.error(f"Error in adding tree rank: {str(e)}") + return HttpResponseServerError(f"Failed: {str(e)}") + +@openapi.schema({ + "post": { + "parameters": [ + { + "name": "rankName", + "in": "formData", + "required": True, + "schema": { + "type": "string" + }, + "description": "The name of the rank to delete" + }, + { + "name": "treeType", + "in": "formData", + "required": True, + "schema": { + "type": "string", + "enum": ["taxon", "geography", "storage", "geologicTimePeriod", "lithoStrat"] + }, + "description": "The type of the tree (taxon, geography, or storage)" + } + ], + "responses": { + "200": { + "description": "Rank successfully deleted", + "content": { + "application/json": {} + } + }, + "404": { + "description": "Rank not found" + }, + "500": { + "description": "Internal server error" + } + } + } +}) +@tree_mutation +@require_POST +def delete_tree_rank(request, tree) -> HttpResponse: + """ + Deletes a rank from the specified tree. Expects 'rank_id' in the POST data. + """ + try: + # Get parameter values from request + tree_type = request.POST.get('treeType') + rank_name = request.POST.get('rankName') + + # Throw exceptions if the required parameters are not given correctly + if rank_name is None: + raise Exception("Rank name is not given") + if tree_type is None or tree_type.lower() not in TREE_RANKS_MAPPING.keys(): + raise Exception("Invalid tree type") + + # Get tree model + tree_model = getattr(spmodels, tree_type.lower().title()) + + # Get tree def item model + tree_def_model_name = str.join(tree_type, 'treedef').lower().title() + tree_def_item_model_name = str.join(tree_type, 'treedefitem').lower().title() + tree_def_model = getattr(spmodels, tree_def_model_name) + tree_def_item_model = getattr(spmodels, tree_def_item_model_name) + + # Make sure no nodes are present in the rank before deleting rank + rank = tree_def_item_model.objects.get(name=rank_name) + nodes_in_rank = tree_model.objects.filter(rank_id=rank.rank_id) + if nodes_in_rank is None or nodes_in_rank.count() > 0: + raise Exception("The Rank is not empty, cannot delete!") + + # Delete rank from TreeDefItem table + rank.delete() + + # Regenerate full names + tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) + + logger.info(f"Deleted tree rank with name: {rank_name}") + return HttpResponse("Success") + + except Exception as e: + logger.error(f"Error in deleting tree rank: {str(e)}") + return HttpResponseServerError(f"Failed: {str(e)}") \ No newline at end of file diff --git a/specifyweb/specify/urls.py b/specifyweb/specify/urls.py index b8324cdffeb..bce7314555a 100644 --- a/specifyweb/specify/urls.py +++ b/specifyweb/specify/urls.py @@ -36,6 +36,8 @@ url(r'^(?P\d+)/(?P\w+)/stats/$', tree_views.tree_stats), url(r'^(?P\d+)/(?P\w+)/(?P\w+)/$', tree_views.tree_view), url(r'^repair/$', tree_views.repair_tree), + url(r'^add_tree_rank/$', tree_views.add_tree_rank), + url(r'^delete_tree_rank/$', tree_views.delete_tree_rank), ])), # generates Sp6 master key From d17e26698cf235b3b3fa96001268278fe616441c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 7 Dec 2023 11:53:44 -0600 Subject: [PATCH 02/99] init unit test for adding/deleting ranks --- specifyweb/specify/test_trees.py | 180 +++++++++++++++++++++++++++++++ specifyweb/specify/tree_views.py | 49 ++++++--- 2 files changed, 214 insertions(+), 15 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 50646bfdcc5..48d469b7af3 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -1,3 +1,5 @@ +import json +from django.test import Client from specifyweb.specify import models from specifyweb.specify.api_tests import ApiTests, get_table from specifyweb.specify.tree_stats import get_tree_stats @@ -208,4 +210,182 @@ def test_counts_correctness(self): for parent_id, correct in correct_results.items() ] +class AddDeleteRanksTest(ApiTests): + def setUp(self) -> None: + super().setUp() + + def test_add_ranks_without_defaults(self): + c = Client() + c.force_login(self.specifyuser) + + treedef_geo = models.Geographytreedef.objects.create(name='Geography') + + # Test adding non-default rank on empty heirarchy + response = c.post( + '/api/specify_tree/geography/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Universe', + 'targetRankName': 'root' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(100, models.Geographytreedefitem.objects.get(name='Universe').rankid) + + # Test adding non-default rank to the end of the heirarchy + response = c.post( + '/api/specify_tree/geography/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Galaxy', + 'targetRankName': 'Universe' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(200, models.Geographytreedefitem.objects.get(name='Galaxy').rankid) + + # Test adding non-default rank to the front of the heirarchy + response = c.post( + '/api/specify_tree/geography/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Multiverse', + 'targetRankName': 'root' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(50, models.Geographytreedefitem.objects.get(name='Multiverse').rankid) + + # Test adding non-default rank in the middle of the heirarchy + response = c.post( + '/api/specify_tree/geography/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Dimension', + 'targetRankName': 'Universe' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(150, models.Geographytreedefitem.objects.get(name='Multiverse').rankid) + + # Test foreign keys + for rank in models.Geographytreedefitem.objects.all(): + self.assertEqual(treedef_geo.id, rank.geographytreedefitem.id) + + def test_add_ranks_with_defaults(self): + c = Client() + c.force_login(self.specifyuser) + + treedef_taxon = models.Taxontreedef.objects.create(name='Taxon') + + # Test adding default rank on empty heirarchy + response = c.post( + '/api/specify_tree/taxon/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Taxonomy Root', + 'targetRankName': 'root' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(0, models.Taxontreedefitem.objects.get(name='Taxonomy Root').rankid) + + # Test adding non-default rank in front of rank 0 + response = c.post( + '/api/specify_tree/taxon/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Invalid', + 'targetRankName': 'root' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 500) + self.assertEqual(None, models.Taxontreedefitem.objects.get(name='Invalid')) + + # Test adding default rank to the end of the heirarchy + response = c.post( + '/api/specify_tree/taxon/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Division', + 'targetRankName': 'Taxon Root' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(30, models.Taxontreedefitem.objects.get(name='Division').rankid) + + # Test adding default rank to the middle of the heirarchy + response = c.post( + '/api/specify_tree/taxon/add_tree_rank/', + data=json.dumps({ + 'newRankName': 'Kingdom', + 'targetRankName': 'Taxon Root' + }), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(10, models.Taxontreedefitem.objects.get(name='Division').rankid) + self.assertEqual(models.Taxontreedefitem.objects.get(name='Kingdom').parentid, + models.Taxontreedefitem.objects.get(name='Division').id) + self.assertEqual(models.Taxontreedefitem.objects.get(name='Division').parentid, + models.Taxontreedefitem.objects.get(name='Taxon Root').id) + + # Test foreign keys + for rank in models.Taxontreedefitem.objects.all(): + self.assertEqual(treedef_taxon.id, rank.taxontreedefitem.id) + + def test_delete_ranks(self): + c = Client() + c.force_login(self.specifyuser) + + treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create(name='GeographyTimePeriod') + era_ranks = models.Geologictimeperiodtreedefitem.objects.create( + name='Era', + rankid=100, + geologictimeperiodtreedefitem=treedef_geotimeperiod + ) + period_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Period', + rankid=200, + geologictimeperiodtreedefitem=treedef_geotimeperiod + ) + epoch_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Epoch', + rankid=300, + geologictimeperiodtreedefitem=treedef_geotimeperiod + ) + age_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Age', + rankid=400, + geologictimeperiodtreedefitem=treedef_geotimeperiod + ) + + # Test deleting a rank in the middle of the heirarchy + response = c.post( + '/api/specify_tree/geologictimeperiod/delete_tree_rank/', + data= json.dumps({'rankName': 'Epoch'}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Epoch')) + self.assertEqual(age_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parentitem.id) + + # Test deleting a rank at the end of the heirarchy + response = c.post( + '/api/specify_tree/geologictimeperiod/delete_tree_rank/', + data= json.dumps({'rankName': 'Age'}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Age')) + + # Test deleting a rank at the head of the heirarchy + response = c.post( + '/api/specify_tree/geologictimeperiod/delete_tree_rank/', + data= json.dumps({'rankName': 'Era'}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Era')) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Period').parentitem) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 3e615bf1f54..17ee770a6fb 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -347,6 +347,8 @@ def perm_target(tree): TAXON_RANKS = { 'TAXONOMY_ROOT': 0, + 'TAXONOMY ROOT': 0, + 'LIFE': 0, 'KINGDOM': 10, 'SUBKINGDOM': 20, 'DIVISION': 30, @@ -424,7 +426,7 @@ def perm_target(tree): 'lithostrat': (LITHO_STRAT_RANKS, LITHO_STRAT_INCREMENT), } -@openapi.schema({ +@openapi(schema={ "post": { "parameters": [ { @@ -490,18 +492,21 @@ def perm_target(tree): } } }) -@tree_mutation +# @tree_mutation @require_POST -def add_tree_rank(request, tree) -> HttpResponse: +def add_tree_rank(request, tree_type) -> HttpResponse: """ Adds a new rank to the specified tree. Expects 'rank_name' and 'tree_type' in the POST data. Adds the rank to """ + # check_permission_targets(request.specify_collection.id, + # request.specify_user.id, + # [perm_target(tree).repair]) try: # Get parameter values from request new_rank_title = request.POST.get('newRankTitle', new_rank_name) use_default_rank_ids = request.POST.get('useDefaultRankIDs', True) - tree_type, new_rank_name, target_rank_name = ( + new_rank_name, target_rank_name = ( request.POST.get(key) for key in ('treeType', 'newRankName', 'targetRankName') ) @@ -522,7 +527,7 @@ def add_tree_rank(request, tree) -> HttpResponse: # Determine the new rank id parameters new_rank_id = None target_rank = tree_def_item_model.objects.get(name=target_rank_name) - if target_rank is None: + if target_rank is None and target_rank_name != 'root': raise Exception('Target rank name does not exist') target_rank_id = target_rank.rank_id rank_ids = list(tree_def_item_model.objects.all().values_list('rank_id', flat=True)).sort() @@ -543,8 +548,9 @@ def add_tree_rank(request, tree) -> HttpResponse: # Build new fields for the new TreeDefItem record new_fields_dict = { - 'name': new_rank_name, - 'title': new_rank_title + 'name': new_rank_name.lower().title(), + 'title': new_rank_title, + 'parentitem': target_rank } new_fields_dict[tree_type.lower() + 'treedefid'] = tree_def_model @@ -602,11 +608,16 @@ def add_tree_rank(request, tree) -> HttpResponse: # Create and save the new TreeDefItem record new_fields_dict['rankid'] = new_rank_id - tree_def_item = tree_def_item_model.objects.create(**new_fields_dict) - tree_def_item.save() + new_rank = tree_def_item_model.objects.create(**new_fields_dict) + new_rank.save() - # Create a new tree def item - tree_def_item_model.objects.create(id=new_rank_id, name=new_rank_name, title=new_rank_title) + # Set the parent rank, that previously pointed to the target, to the new rank + child_ranks = tree_def_item_model.objects.filter(parentitem=target_rank).exclude(rankid=new_rank_id) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parentitem = new_rank + child_rank.save() # Regenerate full names tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) @@ -618,7 +629,7 @@ def add_tree_rank(request, tree) -> HttpResponse: logger.error(f"Error in adding tree rank: {str(e)}") return HttpResponseServerError(f"Failed: {str(e)}") -@openapi.schema({ +@openapi(schema={ "post": { "parameters": [ { @@ -657,15 +668,15 @@ def add_tree_rank(request, tree) -> HttpResponse: } } }) -@tree_mutation +# @tree_mutation @require_POST -def delete_tree_rank(request, tree) -> HttpResponse: +def delete_tree_rank(request, tree_type) -> HttpResponse: """ Deletes a rank from the specified tree. Expects 'rank_id' in the POST data. """ try: # Get parameter values from request - tree_type = request.POST.get('treeType') + # tree_type = request.POST.get('treeType') rank_name = request.POST.get('rankName') # Throw exceptions if the required parameters are not given correctly @@ -689,6 +700,14 @@ def delete_tree_rank(request, tree) -> HttpResponse: if nodes_in_rank is None or nodes_in_rank.count() > 0: raise Exception("The Rank is not empty, cannot delete!") + # Set the parent rank, that previously pointed to the old rank, to the target rank + child_ranks = tree_def_item_model.objects.filter(parentitem=rank) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parentitem = rank.parentitem + child_rank.save() + # Delete rank from TreeDefItem table rank.delete() From fc664ad89c01dc96f1660348d35af6126d513a29 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 8 Dec 2023 17:33:10 -0600 Subject: [PATCH 03/99] unit test fixing and api parameter addition --- specifyweb/specify/test_trees.py | 41 +++++----- specifyweb/specify/tree_views.py | 124 ++++++++++++++++++------------- 2 files changed, 96 insertions(+), 69 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 48d469b7af3..6baa411d888 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -218,14 +218,15 @@ def test_add_ranks_without_defaults(self): c = Client() c.force_login(self.specifyuser) - treedef_geo = models.Geographytreedef.objects.create(name='Geography') + treedef_geo = models.Geographytreedef.objects.create(name='GeographyTest') # Test adding non-default rank on empty heirarchy response = c.post( '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Universe', - 'targetRankName': 'root' + 'targetRankName': 'root', + 'treeID': treedef_geo.id }), content_type='application/json' ) @@ -237,7 +238,8 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Galaxy', - 'targetRankName': 'Universe' + 'targetRankName': 'Universe', + 'treeID': treedef_geo.id }), content_type='application/json' ) @@ -249,7 +251,8 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Multiverse', - 'targetRankName': 'root' + 'targetRankName': 'root', + 'treeID': treedef_geo.id }), content_type='application/json' ) @@ -261,29 +264,30 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Dimension', - 'targetRankName': 'Universe' + 'targetRankName': 'Universe', + 'treeID': treedef_geo.id }), content_type='application/json' ) self.assertEqual(response.status_code, 200) - self.assertEqual(150, models.Geographytreedefitem.objects.get(name='Multiverse').rankid) + self.assertEqual(150, models.Geographytreedefitem.objects.get(name='Dimension').rankid) # Test foreign keys - for rank in models.Geographytreedefitem.objects.all(): - self.assertEqual(treedef_geo.id, rank.geographytreedefitem.id) + self.assertEqual(4, models.Geographytreedefitem.objects.filter(treedef=treedef_geo).count()) def test_add_ranks_with_defaults(self): c = Client() c.force_login(self.specifyuser) - treedef_taxon = models.Taxontreedef.objects.create(name='Taxon') + treedef_taxon = models.Taxontreedef.objects.create(name='TaxonTest') # Test adding default rank on empty heirarchy response = c.post( '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Taxonomy Root', - 'targetRankName': 'root' + 'targetRankName': 'root', + 'treeID': treedef_taxon.id }), content_type='application/json' ) @@ -295,7 +299,8 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Invalid', - 'targetRankName': 'root' + 'targetRankName': 'root', + 'treeID': treedef_taxon.id }), content_type='application/json' ) @@ -307,7 +312,8 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Division', - 'targetRankName': 'Taxon Root' + 'targetRankName': 'Taxon Root', + 'treeID': treedef_taxon.id }), content_type='application/json' ) @@ -319,7 +325,8 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Kingdom', - 'targetRankName': 'Taxon Root' + 'targetRankName': 'Taxon Root', + 'treeID': treedef_taxon.id }), content_type='application/json' ) @@ -338,7 +345,7 @@ def test_delete_ranks(self): c = Client() c.force_login(self.specifyuser) - treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create(name='GeographyTimePeriod') + treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create(name='GeographyTimePeriodTest') era_ranks = models.Geologictimeperiodtreedefitem.objects.create( name='Era', rankid=100, @@ -363,7 +370,7 @@ def test_delete_ranks(self): # Test deleting a rank in the middle of the heirarchy response = c.post( '/api/specify_tree/geologictimeperiod/delete_tree_rank/', - data= json.dumps({'rankName': 'Epoch'}), + data= json.dumps({'rankName': 'Epoch', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) self.assertEqual(response.status_code, 200) @@ -373,7 +380,7 @@ def test_delete_ranks(self): # Test deleting a rank at the end of the heirarchy response = c.post( '/api/specify_tree/geologictimeperiod/delete_tree_rank/', - data= json.dumps({'rankName': 'Age'}), + data= json.dumps({'rankName': 'Age', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) self.assertEqual(response.status_code, 200) @@ -382,7 +389,7 @@ def test_delete_ranks(self): # Test deleting a rank at the head of the heirarchy response = c.post( '/api/specify_tree/geologictimeperiod/delete_tree_rank/', - data= json.dumps({'rankName': 'Era'}), + data= json.dumps({'rankName': 'Era', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) self.assertEqual(response.status_code, 200) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 17ee770a6fb..57c2354fff4 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -1,4 +1,5 @@ from functools import wraps +import json from django.db import transaction from django.http import HttpResponse, HttpResponseServerError from django.views.decorators.http import require_GET, require_POST @@ -16,6 +17,7 @@ from .tree_stats import get_tree_stats from .views import login_maybe_required, openapi from . import models as spmodels +from sys import maxsize import logging logger = logging.getLogger(__name__) @@ -418,6 +420,7 @@ def perm_target(tree): GEOLOGIC_TIME_PERIOD_INCREMENT = DEFAULT_RANK_INCREMENT LITHO_STRAT_INCREMENT = DEFAULT_RANK_INCREMENT +# Map tree type to default tree ranks and default rank id increment TREE_RANKS_MAPPING = { 'taxon': (TAXON_RANKS, TAXON_RANK_INCREMENT), 'geography': (GEOGRAPHY_RANKS, GEOGRAPHY_RANK_INCREMENT), @@ -448,14 +451,13 @@ def perm_target(tree): "description": "The name of the parent rank to add the new rank to (use 'root' to add to the front)" }, { - "name": "treeType", + "name": "treeID", "in": "formData", "required": True, "schema": { - "type": "string", - "enum": ["taxon", "geography", "storage", "geologicTimePeriod", "lithoStrat"] + "type": "int" }, - "description": "The type of the tree (taxon, geography, or storage)" + "description": "The ID of the tree" }, { "name": "newRankTitle", @@ -494,9 +496,9 @@ def perm_target(tree): }) # @tree_mutation @require_POST -def add_tree_rank(request, tree_type) -> HttpResponse: +def add_tree_rank(request, tree) -> HttpResponse: """ - Adds a new rank to the specified tree. Expects 'rank_name' and 'tree_type' + Adds a new rank to the specified tree. Expects 'rank_name' and 'tree' in the POST data. Adds the rank to """ # check_permission_targets(request.specify_collection.id, @@ -504,55 +506,64 @@ def add_tree_rank(request, tree_type) -> HttpResponse: # [perm_target(tree).repair]) try: # Get parameter values from request - new_rank_title = request.POST.get('newRankTitle', new_rank_name) - use_default_rank_ids = request.POST.get('useDefaultRankIDs', True) - new_rank_name, target_rank_name = ( - request.POST.get(key) for key in ('treeType', 'newRankName', 'targetRankName') - ) + # new_rank_name, target_rank_name = ( + # request.POST.get(key) for key in ('newRankName', 'targetRankName') + # ) + data = json.loads(request.body) + new_rank_name = data.get('newRankName') + target_rank_name = data.get('targetRankName') + tree_id = data.get('treeID', 1) + new_rank_title = data.get('newRankTitle', new_rank_name) + use_default_rank_ids = data.get('useDefaultRankIDs', True) # Throw exceptions if the required parameters are not given correctly if new_rank_name is None: raise Exception("Rank name is not given") if target_rank_name is None: raise Exception("Target rank name is not given") - if tree_type is None or tree_type.lower() not in TREE_RANKS_MAPPING.keys(): + if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): raise Exception("Invalid tree type") # Get tree def item model - tree_def_model_name = str.join(tree_type, 'treedef').lower().title() - tree_def_item_model_name = str.join(tree_type, 'treedefitem').lower().title() - tree_def_model = getattr(spmodels, tree_def_model_name) - tree_def_item_model = getattr(spmodels, tree_def_item_model_name) + tree_def_model_name = (tree + 'treedef').lower().title() + tree_def_item_model_name = (tree + 'treedefitem').lower().title() + tree_def_model = getattr(spmodels, tree_def_model_name.lower().title()) + tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) # Determine the new rank id parameters new_rank_id = None - target_rank = tree_def_item_model.objects.get(name=target_rank_name) + tree_def = tree_def_model.objects.get(id=tree_id) + target_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=target_rank_name).first() if target_rank is None and target_rank_name != 'root': raise Exception('Target rank name does not exist') - target_rank_id = target_rank.rank_id - rank_ids = list(tree_def_item_model.objects.all().values_list('rank_id', flat=True)).sort() - target_rank_idx = rank_ids.index(target_rank_id) - next_rank_id = rank_ids[target_rank_idx + 1] if target_rank_idx + 1 < len(rank_ids) else None - - # Don't allow rank IDs less than 0 + target_rank_id = target_rank.rankid if target_rank_name != 'root' else -1 + rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) + target_rank_idx = rank_ids.index(target_rank_id) if rank_ids is not None and target_rank_name != 'root' else -1 + next_rank_id = rank_ids[target_rank_idx + 1] if rank_ids is not None and target_rank_idx + 1 < len(rank_ids) else None + if next_rank_id is None and target_rank_name != 'root': + next_rank_id = maxsize + + # Don't allow rank IDs less than 2 if next_rank_id == 0: raise Exception("Can't create rank ID less than 0") # Set conditions for rank ID creation is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 is_new_rank_first = target_rank_id == -1 - is_new_rank_last = target_rank_idx == len(rank_ids) - 1 + is_new_rank_last = target_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True # Set the default ranks and increments depending on the tree type - default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree_type, (None, 100)) + default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower(), (None, 100)) # Build new fields for the new TreeDefItem record new_fields_dict = { 'name': new_rank_name.lower().title(), 'title': new_rank_title, - 'parentitem': target_rank + # 'parentitem': target_rank + 'parent': target_rank, + 'treedef': tree_def } - new_fields_dict[tree_type.lower() + 'treedefid'] = tree_def_model + # new_fields_dict[tree.lower() + 'treedefid'] = tree_def_model # Determine if the default rank ID can be used can_use_default_rank_id = ( @@ -587,40 +598,47 @@ def add_tree_rank(request, tree_type) -> HttpResponse: if new_rank_id is None: # If this is the first rank, create ... if is_tree_def_items_empty: - new_fields_dict['rankid'] = rank_increment + new_rank_id = rank_increment + # new_fields_dict['rankid'] = rank_increment # If there are no ranks higher than the target rank, then add the new rank to the end of the hierarchy elif is_new_rank_first: - new_rank_id = int((next_rank_id - target_rank_id) / 2) + target_rank_idx - max_rank_id = rank_ids[-1] - new_fields_dict['rankid'] = max_rank_id + rank_increment + min_rank_id = rank_ids[0] + new_rank_id = int(min_rank_id / 2) + if new_rank_id >= min_rank_id: + raise Exception(f"Can't add rank id bellow {min_rank_id}") # If there are no ranks lower than the target rank, then add the new rank to the top of the hierarchy elif is_new_rank_last: - min_rank_id = rank_ids[0] - new_fields_dict['rankid'] = min_rank_id + rank_increment # TODO: checkout + max_rank_id = rank_ids[-1] + new_rank_id = max_rank_id + rank_increment # TODO: checkout # If the new rank is being placed somewhere in the middle of the heirarchy else: new_rank_id = int((next_rank_id - target_rank_id) / 2) + target_rank_id + # new_fields_dict['rankid'] = new_rank_id if next_rank_id - target_rank_id < 1: raise Exception(f"Can't add rank id between {new_rank_id} and {target_rank_id}") + # if new_rank_id <= target_rank_id or new_rank_id >= new_rank_id: + # raise Exception("Couldn't") + # Create and save the new TreeDefItem record new_fields_dict['rankid'] = new_rank_id new_rank = tree_def_item_model.objects.create(**new_fields_dict) new_rank.save() # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = tree_def_item_model.objects.filter(parentitem=target_rank).exclude(rankid=new_rank_id) + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=target_rank).exclude(rankid=new_rank_id) if child_ranks.exists(): # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update for child_rank in child_ranks: - child_rank.parentitem = new_rank + # child_rank.parentitem = new_rank + child_rank.parent = new_rank child_rank.save() - # Regenerate full names - tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) + # Regenerate full names, TODO: fix full name fix + # tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) logger.info(f"Added new tree rank: {new_rank_name} with ID: {new_rank_id}") return HttpResponse("Success") @@ -642,14 +660,13 @@ def add_tree_rank(request, tree_type) -> HttpResponse: "description": "The name of the rank to delete" }, { - "name": "treeType", + "name": "treeID", "in": "formData", "required": True, "schema": { - "type": "string", - "enum": ["taxon", "geography", "storage", "geologicTimePeriod", "lithoStrat"] + "type": "int" }, - "description": "The type of the tree (taxon, geography, or storage)" + "description": "The ID of the tree" } ], "responses": { @@ -670,38 +687,41 @@ def add_tree_rank(request, tree_type) -> HttpResponse: }) # @tree_mutation @require_POST -def delete_tree_rank(request, tree_type) -> HttpResponse: +def delete_tree_rank(request, tree) -> HttpResponse: """ Deletes a rank from the specified tree. Expects 'rank_id' in the POST data. """ try: # Get parameter values from request - # tree_type = request.POST.get('treeType') - rank_name = request.POST.get('rankName') + # tree = request.POST.get('treeType') + data = json.loads(request.body) + rank_name = data.get('rankName') + tree_id = data.get('treeID', 1) # Throw exceptions if the required parameters are not given correctly if rank_name is None: raise Exception("Rank name is not given") - if tree_type is None or tree_type.lower() not in TREE_RANKS_MAPPING.keys(): + if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): raise Exception("Invalid tree type") # Get tree model - tree_model = getattr(spmodels, tree_type.lower().title()) + tree_model = getattr(spmodels, tree.lower().title()) # Get tree def item model - tree_def_model_name = str.join(tree_type, 'treedef').lower().title() - tree_def_item_model_name = str.join(tree_type, 'treedefitem').lower().title() + tree_def_model_name = (tree + 'treedef').lower().title() + tree_def_item_model_name = (tree + 'treedefitem').lower().title() tree_def_model = getattr(spmodels, tree_def_model_name) tree_def_item_model = getattr(spmodels, tree_def_item_model_name) + tree_def = tree_def_model.objects.get(id=tree_id) # Make sure no nodes are present in the rank before deleting rank rank = tree_def_item_model.objects.get(name=rank_name) - nodes_in_rank = tree_model.objects.filter(rank_id=rank.rank_id) + nodes_in_rank = tree_model.objects.filter(rank_id=rank.rankid) if nodes_in_rank is None or nodes_in_rank.count() > 0: raise Exception("The Rank is not empty, cannot delete!") # Set the parent rank, that previously pointed to the old rank, to the target rank - child_ranks = tree_def_item_model.objects.filter(parentitem=rank) + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parentitem=rank) if child_ranks.exists(): # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update for child_rank in child_ranks: @@ -711,8 +731,8 @@ def delete_tree_rank(request, tree_type) -> HttpResponse: # Delete rank from TreeDefItem table rank.delete() - # Regenerate full names - tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) + # Regenerate full names, TODO: fix full name fix + # tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) logger.info(f"Deleted tree rank with name: {rank_name}") return HttpResponse("Success") From 5125989b9a13d59b9bc0530dcc30787f36a581d9 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Sat, 9 Dec 2023 02:29:39 -0600 Subject: [PATCH 04/99] fix more unit tests --- specifyweb/specify/test_trees.py | 38 ++++++++++++++++---------------- specifyweb/specify/tree_views.py | 11 ++++----- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 6baa411d888..415170caedf 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -305,14 +305,14 @@ def test_add_ranks_with_defaults(self): content_type='application/json' ) self.assertEqual(response.status_code, 500) - self.assertEqual(None, models.Taxontreedefitem.objects.get(name='Invalid')) + self.assertEqual(0, models.Taxontreedefitem.objects.filter(name='Invalid').count()) # Test adding default rank to the end of the heirarchy response = c.post( '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Division', - 'targetRankName': 'Taxon Root', + 'targetRankName': 'Taxonomy Root', 'treeID': treedef_taxon.id }), content_type='application/json' @@ -325,21 +325,21 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Kingdom', - 'targetRankName': 'Taxon Root', + 'targetRankName': 'Taxonomy Root', 'treeID': treedef_taxon.id }), content_type='application/json' ) self.assertEqual(response.status_code, 200) - self.assertEqual(10, models.Taxontreedefitem.objects.get(name='Division').rankid) - self.assertEqual(models.Taxontreedefitem.objects.get(name='Kingdom').parentid, - models.Taxontreedefitem.objects.get(name='Division').id) - self.assertEqual(models.Taxontreedefitem.objects.get(name='Division').parentid, - models.Taxontreedefitem.objects.get(name='Taxon Root').id) + self.assertEqual(10, models.Taxontreedefitem.objects.get(name='Kingdom').rankid) + self.assertEqual(models.Taxontreedefitem.objects.get(name='Division').parent.id, + models.Taxontreedefitem.objects.get(name='Kingdom').id) + self.assertEqual(models.Taxontreedefitem.objects.get(name='Kingdom').parent.id, + models.Taxontreedefitem.objects.get(name='Taxonomy Root').id) # Test foreign keys for rank in models.Taxontreedefitem.objects.all(): - self.assertEqual(treedef_taxon.id, rank.taxontreedefitem.id) + self.assertEqual(treedef_taxon.id, rank.treedef.id) def test_delete_ranks(self): c = Client() @@ -349,22 +349,22 @@ def test_delete_ranks(self): era_ranks = models.Geologictimeperiodtreedefitem.objects.create( name='Era', rankid=100, - geologictimeperiodtreedefitem=treedef_geotimeperiod + treedef=treedef_geotimeperiod ) period_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Period', rankid=200, - geologictimeperiodtreedefitem=treedef_geotimeperiod + treedef=treedef_geotimeperiod ) epoch_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Epoch', rankid=300, - geologictimeperiodtreedefitem=treedef_geotimeperiod + treedef=treedef_geotimeperiod ) age_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Age', rankid=400, - geologictimeperiodtreedefitem=treedef_geotimeperiod + treedef=treedef_geotimeperiod ) # Test deleting a rank in the middle of the heirarchy @@ -373,9 +373,9 @@ def test_delete_ranks(self): data= json.dumps({'rankName': 'Epoch', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) - self.assertEqual(response.status_code, 200) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Epoch')) - self.assertEqual(age_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parentitem.id) + self.assertEqual(response.status_code, 500) + # self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Epoch')) + # self.assertEqual(age_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parent.id) # Test deleting a rank at the end of the heirarchy response = c.post( @@ -392,7 +392,7 @@ def test_delete_ranks(self): data= json.dumps({'rankName': 'Era', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) - self.assertEqual(response.status_code, 200) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Era')) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Period').parentitem) + self.assertEqual(response.status_code, 500) + # self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Era')) + # self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Period').parent) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 57c2354fff4..10989cc7292 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -559,7 +559,6 @@ def add_tree_rank(request, tree) -> HttpResponse: new_fields_dict = { 'name': new_rank_name.lower().title(), 'title': new_rank_title, - # 'parentitem': target_rank 'parent': target_rank, 'treedef': tree_def } @@ -716,16 +715,18 @@ def delete_tree_rank(request, tree) -> HttpResponse: # Make sure no nodes are present in the rank before deleting rank rank = tree_def_item_model.objects.get(name=rank_name) - nodes_in_rank = tree_model.objects.filter(rank_id=rank.rankid) - if nodes_in_rank is None or nodes_in_rank.count() > 0: + if tree_def_item_model.objects.filter(parent=rank).count() > 1: raise Exception("The Rank is not empty, cannot delete!") + # nodes_in_rank = tree_model.objects.filter(rankid=rank.rankid) + # if nodes_in_rank is None or nodes_in_rank.count() > 0: + # raise Exception("The Rank is not empty, cannot delete!") # Set the parent rank, that previously pointed to the old rank, to the target rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parentitem=rank) + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) if child_ranks.exists(): # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update for child_rank in child_ranks: - child_rank.parentitem = rank.parentitem + child_rank.parent = rank.parent child_rank.save() # Delete rank from TreeDefItem table From d40fb846eeecc82541770c1e333ddaafc9373615 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 12 Dec 2023 09:47:46 -0600 Subject: [PATCH 05/99] fix delete rank unit test --- specifyweb/specify/test_trees.py | 20 ++++++++++---------- specifyweb/specify/tree_views.py | 30 ++++++++---------------------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 415170caedf..e26f6048943 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -354,17 +354,20 @@ def test_delete_ranks(self): period_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Period', rankid=200, - treedef=treedef_geotimeperiod + treedef=treedef_geotimeperiod, + parent=era_ranks ) epoch_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Epoch', rankid=300, - treedef=treedef_geotimeperiod + treedef=treedef_geotimeperiod, + parent=period_rank ) age_rank = models.Geologictimeperiodtreedefitem.objects.create( name='Age', rankid=400, - treedef=treedef_geotimeperiod + treedef=treedef_geotimeperiod, + parent=epoch_rank ) # Test deleting a rank in the middle of the heirarchy @@ -373,9 +376,9 @@ def test_delete_ranks(self): data= json.dumps({'rankName': 'Epoch', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) - self.assertEqual(response.status_code, 500) - # self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Epoch')) - # self.assertEqual(age_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parent.id) + self.assertEqual(response.status_code, 200) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Epoch').first()) + self.assertEqual(period_rank.id, models.Geologictimeperiodtreedefitem.objects.get(name='Age').parent.id) # Test deleting a rank at the end of the heirarchy response = c.post( @@ -384,7 +387,7 @@ def test_delete_ranks(self): content_type='application/json' ) self.assertEqual(response.status_code, 200) - self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Age')) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Age').first()) # Test deleting a rank at the head of the heirarchy response = c.post( @@ -393,6 +396,3 @@ def test_delete_ranks(self): content_type='application/json' ) self.assertEqual(response.status_code, 500) - # self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Era')) - # self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.get(name='Period').parent) - diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 10989cc7292..4e434dcb0dd 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -442,7 +442,7 @@ def perm_target(tree): "description": "The name of the new rank to add" }, { - "name": "targetRankName", + "name": "parentRankName", "in": "formData", "required": True, "schema": { @@ -453,11 +453,11 @@ def perm_target(tree): { "name": "treeID", "in": "formData", - "required": True, + "required": False, "schema": { "type": "int" }, - "description": "The ID of the tree" + "description": "The ID of the tree (defaults to the first tree)" }, { "name": "newRankTitle", @@ -501,17 +501,13 @@ def add_tree_rank(request, tree) -> HttpResponse: Adds a new rank to the specified tree. Expects 'rank_name' and 'tree' in the POST data. Adds the rank to """ - # check_permission_targets(request.specify_collection.id, - # request.specify_user.id, - # [perm_target(tree).repair]) + check_permission_targets(request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).repair]) try: - # Get parameter values from request - # new_rank_name, target_rank_name = ( - # request.POST.get(key) for key in ('newRankName', 'targetRankName') - # ) data = json.loads(request.body) new_rank_name = data.get('newRankName') - target_rank_name = data.get('targetRankName') + target_rank_name = data.get('parentRankName') tree_id = data.get('treeID', 1) new_rank_title = data.get('newRankTitle', new_rank_name) use_default_rank_ids = data.get('useDefaultRankIDs', True) @@ -562,7 +558,6 @@ def add_tree_rank(request, tree) -> HttpResponse: 'parent': target_rank, 'treedef': tree_def } - # new_fields_dict[tree.lower() + 'treedefid'] = tree_def_model # Determine if the default rank ID can be used can_use_default_rank_id = ( @@ -595,10 +590,9 @@ def add_tree_rank(request, tree) -> HttpResponse: # Set the new rank id if a default rank id is not available if new_rank_id is None: - # If this is the first rank, create ... + # If this is the first rank, set the rank id to the default increment if is_tree_def_items_empty: new_rank_id = rank_increment - # new_fields_dict['rankid'] = rank_increment # If there are no ranks higher than the target rank, then add the new rank to the end of the hierarchy elif is_new_rank_first: @@ -615,12 +609,8 @@ def add_tree_rank(request, tree) -> HttpResponse: # If the new rank is being placed somewhere in the middle of the heirarchy else: new_rank_id = int((next_rank_id - target_rank_id) / 2) + target_rank_id - # new_fields_dict['rankid'] = new_rank_id if next_rank_id - target_rank_id < 1: raise Exception(f"Can't add rank id between {new_rank_id} and {target_rank_id}") - - # if new_rank_id <= target_rank_id or new_rank_id >= new_rank_id: - # raise Exception("Couldn't") # Create and save the new TreeDefItem record new_fields_dict['rankid'] = new_rank_id @@ -692,7 +682,6 @@ def delete_tree_rank(request, tree) -> HttpResponse: """ try: # Get parameter values from request - # tree = request.POST.get('treeType') data = json.loads(request.body) rank_name = data.get('rankName') tree_id = data.get('treeID', 1) @@ -717,9 +706,6 @@ def delete_tree_rank(request, tree) -> HttpResponse: rank = tree_def_item_model.objects.get(name=rank_name) if tree_def_item_model.objects.filter(parent=rank).count() > 1: raise Exception("The Rank is not empty, cannot delete!") - # nodes_in_rank = tree_model.objects.filter(rankid=rank.rankid) - # if nodes_in_rank is None or nodes_in_rank.count() > 0: - # raise Exception("The Rank is not empty, cannot delete!") # Set the parent rank, that previously pointed to the old rank, to the target rank child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) From c08cbb85be5ca44cfda1268839d77d66b163dd7a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 12 Dec 2023 12:59:27 -0600 Subject: [PATCH 06/99] fix call to set_fullnames --- specifyweb/specify/test_trees.py | 16 ++++++------- specifyweb/specify/tree_views.py | 40 ++++++++++++++++---------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index e26f6048943..facb059acb3 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -225,7 +225,7 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Universe', - 'targetRankName': 'root', + 'parentRankName': 'root', 'treeID': treedef_geo.id }), content_type='application/json' @@ -238,7 +238,7 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Galaxy', - 'targetRankName': 'Universe', + 'parentRankName': 'Universe', 'treeID': treedef_geo.id }), content_type='application/json' @@ -251,7 +251,7 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Multiverse', - 'targetRankName': 'root', + 'parentRankName': 'root', 'treeID': treedef_geo.id }), content_type='application/json' @@ -264,7 +264,7 @@ def test_add_ranks_without_defaults(self): '/api/specify_tree/geography/add_tree_rank/', data=json.dumps({ 'newRankName': 'Dimension', - 'targetRankName': 'Universe', + 'parentRankName': 'Universe', 'treeID': treedef_geo.id }), content_type='application/json' @@ -286,7 +286,7 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Taxonomy Root', - 'targetRankName': 'root', + 'parentRankName': 'root', 'treeID': treedef_taxon.id }), content_type='application/json' @@ -299,7 +299,7 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Invalid', - 'targetRankName': 'root', + 'parentRankName': 'root', 'treeID': treedef_taxon.id }), content_type='application/json' @@ -312,7 +312,7 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Division', - 'targetRankName': 'Taxonomy Root', + 'parentRankName': 'Taxonomy Root', 'treeID': treedef_taxon.id }), content_type='application/json' @@ -325,7 +325,7 @@ def test_add_ranks_with_defaults(self): '/api/specify_tree/taxon/add_tree_rank/', data=json.dumps({ 'newRankName': 'Kingdom', - 'targetRankName': 'Taxonomy Root', + 'parentRankName': 'Taxonomy Root', 'treeID': treedef_taxon.id }), content_type='application/json' diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 4e434dcb0dd..92f736cdd26 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -507,7 +507,7 @@ def add_tree_rank(request, tree) -> HttpResponse: try: data = json.loads(request.body) new_rank_name = data.get('newRankName') - target_rank_name = data.get('parentRankName') + parent_rank_name = data.get('parentRankName') tree_id = data.get('treeID', 1) new_rank_title = data.get('newRankTitle', new_rank_name) use_default_rank_ids = data.get('useDefaultRankIDs', True) @@ -515,8 +515,8 @@ def add_tree_rank(request, tree) -> HttpResponse: # Throw exceptions if the required parameters are not given correctly if new_rank_name is None: raise Exception("Rank name is not given") - if target_rank_name is None: - raise Exception("Target rank name is not given") + if parent_rank_name is None: + raise Exception("Parent rank name is not given") if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): raise Exception("Invalid tree type") @@ -529,14 +529,14 @@ def add_tree_rank(request, tree) -> HttpResponse: # Determine the new rank id parameters new_rank_id = None tree_def = tree_def_model.objects.get(id=tree_id) - target_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=target_rank_name).first() - if target_rank is None and target_rank_name != 'root': + parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() + if parent_rank is None and parent_rank_name != 'root': raise Exception('Target rank name does not exist') - target_rank_id = target_rank.rankid if target_rank_name != 'root' else -1 + parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) - target_rank_idx = rank_ids.index(target_rank_id) if rank_ids is not None and target_rank_name != 'root' else -1 - next_rank_id = rank_ids[target_rank_idx + 1] if rank_ids is not None and target_rank_idx + 1 < len(rank_ids) else None - if next_rank_id is None and target_rank_name != 'root': + parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 + next_rank_id = rank_ids[parent_rank_idx + 1] if rank_ids is not None and parent_rank_idx + 1 < len(rank_ids) else None + if next_rank_id is None and parent_rank_name != 'root': next_rank_id = maxsize # Don't allow rank IDs less than 2 @@ -545,8 +545,8 @@ def add_tree_rank(request, tree) -> HttpResponse: # Set conditions for rank ID creation is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 - is_new_rank_first = target_rank_id == -1 - is_new_rank_last = target_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True + is_new_rank_first = parent_rank_id == -1 + is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True # Set the default ranks and increments depending on the tree type default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower(), (None, 100)) @@ -555,7 +555,7 @@ def add_tree_rank(request, tree) -> HttpResponse: new_fields_dict = { 'name': new_rank_name.lower().title(), 'title': new_rank_title, - 'parent': target_rank, + 'parent': parent_rank, 'treedef': tree_def } @@ -581,8 +581,8 @@ def add_tree_rank(request, tree) -> HttpResponse: is_placement_valid = ( is_tree_def_items_empty or (is_new_rank_first and default_rank_id < next_rank_id) - or (is_new_rank_last and default_rank_id > target_rank_id) - or (default_rank_id > target_rank_id and default_rank_id < next_rank_id) + or (is_new_rank_last and default_rank_id > parent_rank_id) + or (default_rank_id > parent_rank_id and default_rank_id < next_rank_id) ) if is_default_rank_id_unused and is_placement_valid: @@ -608,9 +608,9 @@ def add_tree_rank(request, tree) -> HttpResponse: # If the new rank is being placed somewhere in the middle of the heirarchy else: - new_rank_id = int((next_rank_id - target_rank_id) / 2) + target_rank_id - if next_rank_id - target_rank_id < 1: - raise Exception(f"Can't add rank id between {new_rank_id} and {target_rank_id}") + new_rank_id = int((next_rank_id - parent_rank_id) / 2) + parent_rank_id + if next_rank_id - parent_rank_id < 1: + raise Exception(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") # Create and save the new TreeDefItem record new_fields_dict['rankid'] = new_rank_id @@ -618,7 +618,7 @@ def add_tree_rank(request, tree) -> HttpResponse: new_rank.save() # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=target_rank).exclude(rankid=new_rank_id) + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) if child_ranks.exists(): # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update for child_rank in child_ranks: @@ -627,7 +627,7 @@ def add_tree_rank(request, tree) -> HttpResponse: child_rank.save() # Regenerate full names, TODO: fix full name fix - # tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) + tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) logger.info(f"Added new tree rank: {new_rank_name} with ID: {new_rank_id}") return HttpResponse("Success") @@ -719,7 +719,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: rank.delete() # Regenerate full names, TODO: fix full name fix - # tree_extras.set_fullnames(tree_def_model, null_only=False, node_number_range=None) + tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) logger.info(f"Deleted tree rank with name: {rank_name}") return HttpResponse("Success") From 62e902eeb96f4a51b9c11d8a421f47ca8f089f2a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 12 Dec 2023 13:51:36 -0600 Subject: [PATCH 07/99] add edit_ranks permission --- specifyweb/specify/tree_views.py | 355 +++++++++++++++---------------- 1 file changed, 175 insertions(+), 180 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 92f736cdd26..35a1cc4aab8 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -300,6 +300,7 @@ class TaxonMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + edit_ranks = PermissionTargetAction() class GeographyMutationPT(PermissionTarget): @@ -309,6 +310,7 @@ class GeographyMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + edit_ranks = PermissionTargetAction() class StorageMutationPT(PermissionTarget): @@ -318,6 +320,7 @@ class StorageMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + edit_ranks = PermissionTargetAction() class GeologictimeperiodMutationPT(PermissionTarget): @@ -327,6 +330,7 @@ class GeologictimeperiodMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + edit_ranks = PermissionTargetAction() class LithostratMutationPT(PermissionTarget): @@ -336,6 +340,7 @@ class LithostratMutationPT(PermissionTarget): synonymize = PermissionTargetAction() desynonymize = PermissionTargetAction() repair = PermissionTargetAction() + edit_ranks = PermissionTargetAction() def perm_target(tree): @@ -494,8 +499,7 @@ def perm_target(tree): } } }) -# @tree_mutation -@require_POST +@tree_mutation def add_tree_rank(request, tree) -> HttpResponse: """ Adds a new rank to the specified tree. Expects 'rank_name' and 'tree' @@ -503,138 +507,134 @@ def add_tree_rank(request, tree) -> HttpResponse: """ check_permission_targets(request.specify_collection.id, request.specify_user.id, - [perm_target(tree).repair]) - try: - data = json.loads(request.body) - new_rank_name = data.get('newRankName') - parent_rank_name = data.get('parentRankName') - tree_id = data.get('treeID', 1) - new_rank_title = data.get('newRankTitle', new_rank_name) - use_default_rank_ids = data.get('useDefaultRankIDs', True) - - # Throw exceptions if the required parameters are not given correctly - if new_rank_name is None: - raise Exception("Rank name is not given") - if parent_rank_name is None: - raise Exception("Parent rank name is not given") - if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): - raise Exception("Invalid tree type") - - # Get tree def item model - tree_def_model_name = (tree + 'treedef').lower().title() - tree_def_item_model_name = (tree + 'treedefitem').lower().title() - tree_def_model = getattr(spmodels, tree_def_model_name.lower().title()) - tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) - - # Determine the new rank id parameters - new_rank_id = None - tree_def = tree_def_model.objects.get(id=tree_id) - parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() - if parent_rank is None and parent_rank_name != 'root': - raise Exception('Target rank name does not exist') - parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 - rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) - parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 - next_rank_id = rank_ids[parent_rank_idx + 1] if rank_ids is not None and parent_rank_idx + 1 < len(rank_ids) else None - if next_rank_id is None and parent_rank_name != 'root': - next_rank_id = maxsize - - # Don't allow rank IDs less than 2 - if next_rank_id == 0: - raise Exception("Can't create rank ID less than 0") - - # Set conditions for rank ID creation - is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 - is_new_rank_first = parent_rank_id == -1 - is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True - - # Set the default ranks and increments depending on the tree type - default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower(), (None, 100)) - - # Build new fields for the new TreeDefItem record - new_fields_dict = { - 'name': new_rank_name.lower().title(), - 'title': new_rank_title, - 'parent': parent_rank, - 'treedef': tree_def - } + [perm_target(tree).edit_ranks]) + + # Get parameter values from request + data = json.loads(request.body) + new_rank_name = data.get('newRankName') + parent_rank_name = data.get('parentRankName') + tree_id = data.get('treeID', 1) + new_rank_title = data.get('newRankTitle', new_rank_name) + use_default_rank_ids = data.get('useDefaultRankIDs', True) + + # Throw exceptions if the required parameters are not given correctly + if new_rank_name is None: + raise Exception("Rank name is not given") + if parent_rank_name is None: + raise Exception("Parent rank name is not given") + if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): + raise Exception("Invalid tree type") + + # Get tree def item model + tree_def_model_name = (tree + 'treedef').lower().title() + tree_def_item_model_name = (tree + 'treedefitem').lower().title() + tree_def_model = getattr(spmodels, tree_def_model_name.lower().title()) + tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) + + # Determine the new rank id parameters + new_rank_id = None + tree_def = tree_def_model.objects.get(id=tree_id) + parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() + if parent_rank is None and parent_rank_name != 'root': + raise Exception('Target rank name does not exist') + parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 + rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) + parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 + next_rank_id = rank_ids[parent_rank_idx + 1] if rank_ids is not None and parent_rank_idx + 1 < len(rank_ids) else None + if next_rank_id is None and parent_rank_name != 'root': + next_rank_id = maxsize + + # Don't allow rank IDs less than 2 + if next_rank_id == 0: + raise Exception("Can't create rank ID less than 0") + + # Set conditions for rank ID creation + is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 + is_new_rank_first = parent_rank_id == -1 + is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True + + # Set the default ranks and increments depending on the tree type + default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower(), (None, 100)) + + # Build new fields for the new TreeDefItem record + new_fields_dict = { + 'name': new_rank_name.lower().title(), + 'title': new_rank_title, + 'parent': parent_rank, + 'treedef': tree_def + } - # Determine if the default rank ID can be used - can_use_default_rank_id = ( - use_default_rank_ids - and default_tree_ranks is not None - and new_rank_name.upper() in default_tree_ranks - ) - - # Only use the the default rank id if the fhe following criteria is met: - # - new_rank_name is in the the default ranks set - # - the default rank id is not already used - # - the default rank is greater than the target rank - # - the default rank is less than the current next rank from the target rank - if can_use_default_rank_id: - default_rank_id = default_tree_ranks[new_rank_name.upper()] - - # Check if the default rank ID is not already used - is_default_rank_id_unused = default_rank_id not in rank_ids - - # Check if the default rank ID can be logically placed in the hierarchy - is_placement_valid = ( - is_tree_def_items_empty - or (is_new_rank_first and default_rank_id < next_rank_id) - or (is_new_rank_last and default_rank_id > parent_rank_id) - or (default_rank_id > parent_rank_id and default_rank_id < next_rank_id) - ) - - if is_default_rank_id_unused and is_placement_valid: - new_rank_id = default_rank_id - - # Set the new rank id if a default rank id is not available - if new_rank_id is None: - # If this is the first rank, set the rank id to the default increment - if is_tree_def_items_empty: - new_rank_id = rank_increment - - # If there are no ranks higher than the target rank, then add the new rank to the end of the hierarchy - elif is_new_rank_first: - min_rank_id = rank_ids[0] - new_rank_id = int(min_rank_id / 2) - if new_rank_id >= min_rank_id: - raise Exception(f"Can't add rank id bellow {min_rank_id}") - - # If there are no ranks lower than the target rank, then add the new rank to the top of the hierarchy - elif is_new_rank_last: - max_rank_id = rank_ids[-1] - new_rank_id = max_rank_id + rank_increment # TODO: checkout - - # If the new rank is being placed somewhere in the middle of the heirarchy - else: - new_rank_id = int((next_rank_id - parent_rank_id) / 2) + parent_rank_id - if next_rank_id - parent_rank_id < 1: - raise Exception(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") - - # Create and save the new TreeDefItem record - new_fields_dict['rankid'] = new_rank_id - new_rank = tree_def_item_model.objects.create(**new_fields_dict) - new_rank.save() - - # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) - if child_ranks.exists(): - # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update - for child_rank in child_ranks: - # child_rank.parentitem = new_rank - child_rank.parent = new_rank - child_rank.save() - - # Regenerate full names, TODO: fix full name fix - tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) - - logger.info(f"Added new tree rank: {new_rank_name} with ID: {new_rank_id}") - return HttpResponse("Success") + # Determine if the default rank ID can be used + can_use_default_rank_id = ( + use_default_rank_ids + and default_tree_ranks is not None + and new_rank_name.upper() in default_tree_ranks + ) - except Exception as e: - logger.error(f"Error in adding tree rank: {str(e)}") - return HttpResponseServerError(f"Failed: {str(e)}") + # Only use the the default rank id if the fhe following criteria is met: + # - new_rank_name is in the the default ranks set + # - the default rank id is not already used + # - the default rank is greater than the target rank + # - the default rank is less than the current next rank from the target rank + if can_use_default_rank_id: + default_rank_id = default_tree_ranks[new_rank_name.upper()] + + # Check if the default rank ID is not already used + is_default_rank_id_unused = default_rank_id not in rank_ids + + # Check if the default rank ID can be logically placed in the hierarchy + is_placement_valid = ( + is_tree_def_items_empty + or (is_new_rank_first and default_rank_id < next_rank_id) + or (is_new_rank_last and default_rank_id > parent_rank_id) + or (default_rank_id > parent_rank_id and default_rank_id < next_rank_id) + ) + + if is_default_rank_id_unused and is_placement_valid: + new_rank_id = default_rank_id + + # Set the new rank id if a default rank id is not available + if new_rank_id is None: + # If this is the first rank, set the rank id to the default increment + if is_tree_def_items_empty: + new_rank_id = rank_increment + + # If there are no ranks higher than the target rank, then add the new rank to the end of the hierarchy + elif is_new_rank_first: + min_rank_id = rank_ids[0] + new_rank_id = int(min_rank_id / 2) + if new_rank_id >= min_rank_id: + raise Exception(f"Can't add rank id bellow {min_rank_id}") + + # If there are no ranks lower than the target rank, then add the new rank to the top of the hierarchy + elif is_new_rank_last: + max_rank_id = rank_ids[-1] + new_rank_id = max_rank_id + rank_increment # TODO: checkout + + # If the new rank is being placed somewhere in the middle of the heirarchy + else: + new_rank_id = int((next_rank_id - parent_rank_id) / 2) + parent_rank_id + if next_rank_id - parent_rank_id < 1: + raise Exception(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") + + # Create and save the new TreeDefItem record + new_fields_dict['rankid'] = new_rank_id + new_rank = tree_def_item_model.objects.create(**new_fields_dict) + new_rank.save() + + # Set the parent rank, that previously pointed to the target, to the new rank + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parent = new_rank + child_rank.save() + + # Regenerate full names + tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) + + logger.info(f"Added new tree rank: {new_rank_name} with ID: {new_rank_id}") + return HttpResponse("Success") @openapi(schema={ "post": { @@ -674,56 +674,51 @@ def add_tree_rank(request, tree) -> HttpResponse: } } }) -# @tree_mutation -@require_POST +@tree_mutation def delete_tree_rank(request, tree) -> HttpResponse: """ Deletes a rank from the specified tree. Expects 'rank_id' in the POST data. """ - try: - # Get parameter values from request - data = json.loads(request.body) - rank_name = data.get('rankName') - tree_id = data.get('treeID', 1) - - # Throw exceptions if the required parameters are not given correctly - if rank_name is None: - raise Exception("Rank name is not given") - if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): - raise Exception("Invalid tree type") - - # Get tree model - tree_model = getattr(spmodels, tree.lower().title()) - - # Get tree def item model - tree_def_model_name = (tree + 'treedef').lower().title() - tree_def_item_model_name = (tree + 'treedefitem').lower().title() - tree_def_model = getattr(spmodels, tree_def_model_name) - tree_def_item_model = getattr(spmodels, tree_def_item_model_name) - tree_def = tree_def_model.objects.get(id=tree_id) - - # Make sure no nodes are present in the rank before deleting rank - rank = tree_def_item_model.objects.get(name=rank_name) - if tree_def_item_model.objects.filter(parent=rank).count() > 1: - raise Exception("The Rank is not empty, cannot delete!") - - # Set the parent rank, that previously pointed to the old rank, to the target rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) - if child_ranks.exists(): - # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update - for child_rank in child_ranks: - child_rank.parent = rank.parent - child_rank.save() - - # Delete rank from TreeDefItem table - rank.delete() - - # Regenerate full names, TODO: fix full name fix - tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) - - logger.info(f"Deleted tree rank with name: {rank_name}") - return HttpResponse("Success") + check_permission_targets(request.specify_collection.id, + request.specify_user.id, + [perm_target(tree).edit_ranks]) + + # Get parameter values from request + data = json.loads(request.body) + rank_name = data.get('rankName') + tree_id = data.get('treeID', 1) - except Exception as e: - logger.error(f"Error in deleting tree rank: {str(e)}") - return HttpResponseServerError(f"Failed: {str(e)}") \ No newline at end of file + # Throw exceptions if the required parameters are not given correctly + if rank_name is None: + raise Exception("Rank name is not given") + if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): + raise Exception("Invalid tree type") + + # Get tree def item model + tree_def_model_name = (tree + 'treedef').lower().title() + tree_def_item_model_name = (tree + 'treedefitem').lower().title() + tree_def_model = getattr(spmodels, tree_def_model_name) + tree_def_item_model = getattr(spmodels, tree_def_item_model_name) + tree_def = tree_def_model.objects.get(id=tree_id) + + # Make sure no nodes are present in the rank before deleting rank + rank = tree_def_item_model.objects.get(name=rank_name) + if tree_def_item_model.objects.filter(parent=rank).count() > 1: + raise Exception("The Rank is not empty, cannot delete!") + + # Set the parent rank, that previously pointed to the old rank, to the target rank + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parent = rank.parent + child_rank.save() + + # Delete rank from TreeDefItem table + rank.delete() + + # Regenerate full names + tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) + + logger.info(f"Deleted tree rank with name: {rank_name}") + return HttpResponse("Success") From e4b9dbb748ab32c557b09fb0551d7ffdc5d0c0a1 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 13 Dec 2023 10:55:20 -0600 Subject: [PATCH 08/99] fix openapi schema --- specifyweb/specify/tree_views.py | 161 +++++++++++++------------------ 1 file changed, 67 insertions(+), 94 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 35a1cc4aab8..cd41fed5e5a 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -435,69 +435,48 @@ def perm_target(tree): } @openapi(schema={ - "post": { - "parameters": [ - { - "name": "newRankName", - "in": "formData", - "required": True, - "schema": { - "type": "string" - }, - "description": "The name of the new rank to add" - }, - { - "name": "parentRankName", - "in": "formData", - "required": True, - "schema": { - "type": "string" - }, - "description": "The name of the parent rank to add the new rank to (use 'root' to add to the front)" - }, - { - "name": "treeID", - "in": "formData", - "required": False, - "schema": { - "type": "int" - }, - "description": "The ID of the tree (defaults to the first tree)" - }, - { - "name": "newRankTitle", - "in": "formData", - "required": False, - "schema": { - "type": "string" - }, - "description": "The title of the rank to add (defaults to the name)" - }, - { - "name": "useDefaultRankIDs", - "in": "formData", - "required": False, - "schema": { - "type": "bool" - }, - "description": "Determine if the default rank IDs should be used (defaults to True)" - } - ], - "responses": { - "200": { - "description": "Rank successfully added", - "content": { - "application/json": {} + 'post': { + "requestBody": { + "required": True, + "description": "Add rank to a tree.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "The request body.", + "properties": { + "newRankName": { + "type": "string", + "description": "The name of the new rank to add." + }, + "parentRankName": { + "type": "string", + "description": "The name of the parent rank to add the new rank to (use 'root' to add to the front)." + }, + "treeID": { + "type": "integer", + "description": "The ID of the tree (defaults to the first tree)." + }, + "newRankTitle": { + "type": "string", + "description": "The title of the rank to add (defaults to the name)." + }, + "useDefaultRankIDs": { + "type": "boolean", + "description": "Determine if the default rank IDs should be used (defaults to True)." + } + }, + 'required': ['newRankName', 'parentRankName'], + 'additionalProperties': False + } } - }, - "400": { - "description": "Invalid input" - }, - "500": { - "description": "Internal server error" } - } - } + }, + "responses": { + "200": {"description": "Success",}, + "500": {"description": "Server Error"}, + } + }, }) @tree_mutation def add_tree_rank(request, tree) -> HttpResponse: @@ -637,42 +616,36 @@ def add_tree_rank(request, tree) -> HttpResponse: return HttpResponse("Success") @openapi(schema={ - "post": { - "parameters": [ - { - "name": "rankName", - "in": "formData", - "required": True, - "schema": { - "type": "string" - }, - "description": "The name of the rank to delete" - }, - { - "name": "treeID", - "in": "formData", - "required": True, - "schema": { - "type": "int" - }, - "description": "The ID of the tree" - } - ], - "responses": { - "200": { - "description": "Rank successfully deleted", - "content": { - "application/json": {} + 'post': { + "requestBody": { + "required": True, + "description": "Replace a list of old records with a new record.", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "The request body.", + "properties": { + "rankName": { + "type": "string", + "description": "The name of the rank to delete." + }, + "treeID": { + "type": "integer", + "description": "The ID of the tree." + } + }, + 'required': ['rankName', 'treeID'], + 'additionalProperties': False + } } - }, - "404": { - "description": "Rank not found" - }, - "500": { - "description": "Internal server error" } + }, + "responses": { + "200": {"description": "Success",}, + "500": {"description": "Server Error"}, } - } + }, }) @tree_mutation def delete_tree_rank(request, tree) -> HttpResponse: From 024efa0522e7557b9721210b39cbb7788f92d6e4 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 13 Dec 2023 11:23:47 -0600 Subject: [PATCH 09/99] reconfigure HttpResponseBadRequest responses --- specifyweb/specify/tree_views.py | 34 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index cd41fed5e5a..40cdb25f5ca 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -1,7 +1,7 @@ from functools import wraps import json from django.db import transaction -from django.http import HttpResponse, HttpResponseServerError +from django.http import HttpResponse, HttpResponseServerError, HttpResponseBadRequest from django.views.decorators.http import require_GET, require_POST from sqlalchemy import sql from sqlalchemy.orm import aliased @@ -473,9 +473,9 @@ def perm_target(tree): } }, "responses": { - "200": {"description": "Success",}, - "500": {"description": "Server Error"}, - } + "200": {"description": "Success"}, + "400": {"description": "Cannot add tree rank with those parameters"} + } }, }) @tree_mutation @@ -498,11 +498,11 @@ def add_tree_rank(request, tree) -> HttpResponse: # Throw exceptions if the required parameters are not given correctly if new_rank_name is None: - raise Exception("Rank name is not given") + return HttpResponseBadRequest("Rank name is not given") if parent_rank_name is None: - raise Exception("Parent rank name is not given") + return HttpResponseBadRequest("Parent rank name is not given") if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): - raise Exception("Invalid tree type") + return HttpResponseBadRequest("Invalid tree type") # Get tree def item model tree_def_model_name = (tree + 'treedef').lower().title() @@ -515,7 +515,7 @@ def add_tree_rank(request, tree) -> HttpResponse: tree_def = tree_def_model.objects.get(id=tree_id) parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() if parent_rank is None and parent_rank_name != 'root': - raise Exception('Target rank name does not exist') + return HttpResponseBadRequest('Target rank name does not exist') parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 @@ -525,7 +525,7 @@ def add_tree_rank(request, tree) -> HttpResponse: # Don't allow rank IDs less than 2 if next_rank_id == 0: - raise Exception("Can't create rank ID less than 0") + return HttpResponseBadRequest("Can't create rank ID less than 0") # Set conditions for rank ID creation is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 @@ -583,7 +583,7 @@ def add_tree_rank(request, tree) -> HttpResponse: min_rank_id = rank_ids[0] new_rank_id = int(min_rank_id / 2) if new_rank_id >= min_rank_id: - raise Exception(f"Can't add rank id bellow {min_rank_id}") + return HttpResponseBadRequest(f"Can't add rank id bellow {min_rank_id}") # If there are no ranks lower than the target rank, then add the new rank to the top of the hierarchy elif is_new_rank_last: @@ -594,7 +594,7 @@ def add_tree_rank(request, tree) -> HttpResponse: else: new_rank_id = int((next_rank_id - parent_rank_id) / 2) + parent_rank_id if next_rank_id - parent_rank_id < 1: - raise Exception(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") + return HttpResponseBadRequest(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") # Create and save the new TreeDefItem record new_fields_dict['rankid'] = new_rank_id @@ -619,7 +619,7 @@ def add_tree_rank(request, tree) -> HttpResponse: 'post': { "requestBody": { "required": True, - "description": "Replace a list of old records with a new record.", + "description": "Delete a rank from a tree.", "content": { "application/json": { "schema": { @@ -642,8 +642,8 @@ def add_tree_rank(request, tree) -> HttpResponse: } }, "responses": { - "200": {"description": "Success",}, - "500": {"description": "Server Error"}, + "200": {"description": "Success"}, + "400": {"description": "Cannot delete tree rank"} } }, }) @@ -663,9 +663,9 @@ def delete_tree_rank(request, tree) -> HttpResponse: # Throw exceptions if the required parameters are not given correctly if rank_name is None: - raise Exception("Rank name is not given") + return HttpResponseBadRequest("Rank name is not given") if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): - raise Exception("Invalid tree type") + return HttpResponseBadRequest("Invalid tree type") # Get tree def item model tree_def_model_name = (tree + 'treedef').lower().title() @@ -677,7 +677,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: # Make sure no nodes are present in the rank before deleting rank rank = tree_def_item_model.objects.get(name=rank_name) if tree_def_item_model.objects.filter(parent=rank).count() > 1: - raise Exception("The Rank is not empty, cannot delete!") + return HttpResponseBadRequest("The Rank is not empty, cannot delete!") # Set the parent rank, that previously pointed to the old rank, to the target rank child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) From d031ff5274f7181ebe1bed9a07d6fad7fb8246bf Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 15 Dec 2023 11:34:04 -0600 Subject: [PATCH 10/99] change wrapper for rank edit functions --- specifyweb/specify/test_trees.py | 3 ++- specifyweb/specify/tree_views.py | 36 +++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index facb059acb3..ba03be1e774 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -293,6 +293,7 @@ def test_add_ranks_with_defaults(self): ) self.assertEqual(response.status_code, 200) self.assertEqual(0, models.Taxontreedefitem.objects.get(name='Taxonomy Root').rankid) + # TODO: Add unit test to test that the fullname reonstruction worked # Test adding non-default rank in front of rank 0 response = c.post( @@ -304,7 +305,7 @@ def test_add_ranks_with_defaults(self): }), content_type='application/json' ) - self.assertEqual(response.status_code, 500) + self.assertEqual(response.status_code, 400) self.assertEqual(0, models.Taxontreedefitem.objects.filter(name='Invalid').count()) # Test adding default rank to the end of the heirarchy diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 40cdb25f5ca..d697da0f44a 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -434,6 +434,23 @@ def perm_target(tree): 'lithostrat': (LITHO_STRAT_RANKS, LITHO_STRAT_INCREMENT), } +# Wrapper for tree editing api functions +def tree_edit(edit_func): + @login_maybe_required + @require_POST + @transaction.atomic + @wraps(edit_func) + def wrapper(*args, **kwargs): + try: + edit_func(*args, **kwargs) + result = {'success': True} + except BusinessRuleException as e: + result = {'success': False, 'error': str(e)} + return HttpResponseServerError(toJson(result), content_type="application/json") + return HttpResponse(toJson(result), content_type="application/json") + + return wrapper + @openapi(schema={ 'post': { "requestBody": { @@ -474,11 +491,14 @@ def perm_target(tree): }, "responses": { "200": {"description": "Success"}, - "400": {"description": "Cannot add tree rank with those parameters"} + "400": {"description": "Cannot add tree rank with those parameters"}, + "500": {"description": "Server Error"} } }, }) -@tree_mutation +@login_maybe_required +@require_POST +@transaction.atomic def add_tree_rank(request, tree) -> HttpResponse: """ Adds a new rank to the specified tree. Expects 'rank_name' and 'tree' @@ -643,11 +663,14 @@ def add_tree_rank(request, tree) -> HttpResponse: }, "responses": { "200": {"description": "Success"}, - "400": {"description": "Cannot delete tree rank"} + "400": {"description": "Cannot delete tree rank"}, + "500": {"description": "Server Error"} } }, }) -@tree_mutation +@login_maybe_required +@require_POST +@transaction.atomic def delete_tree_rank(request, tree) -> HttpResponse: """ Deletes a rank from the specified tree. Expects 'rank_id' in the POST data. @@ -688,7 +711,10 @@ def delete_tree_rank(request, tree) -> HttpResponse: child_rank.save() # Delete rank from TreeDefItem table - rank.delete() + try: + rank.delete() + except BusinessRuleException as e: + return HttpResponseBadRequest() # Regenerate full names tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) From 2dc4ba88fc84319a4c925cf50bd2548c2d0ca1ca Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 2 Jan 2024 15:51:13 -0600 Subject: [PATCH 11/99] add fullname tests --- specifyweb/specify/test_trees.py | 46 +++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index ba03be1e774..809387cc9fd 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -4,6 +4,7 @@ from specifyweb.specify.api_tests import ApiTests, get_table from specifyweb.specify.tree_stats import get_tree_stats from specifyweb.stored_queries.tests import SQLAlchemySetup +from .tree_extras import set_fullnames class TestTreeSetup(ApiTests): def setUp(self) -> None: @@ -275,6 +276,29 @@ def test_add_ranks_without_defaults(self): # Test foreign keys self.assertEqual(4, models.Geographytreedefitem.objects.filter(treedef=treedef_geo).count()) + # Create test nodes + cfc = models.Geography.objects.create(name='Central Finite Curve', rankid=50, definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name='Multiverse')) + c137 = models.Geography.objects.create(name='C137', rankid=100, parent=cfc, definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name='Universe')) + d3 = models.Geography.objects.create(name='3D', rankid=150, parent=c137, definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get(name='Dimension')) + milky_way = models.Geography.objects.create(name='Milky Way', parent=d3, rankid=200, definition=treedef_geo, + definitionitem=models.Geographytreedefitem.objects.get( + name='Galaxy')) + + # Test full name reconstruction + set_fullnames(treedef_geo, null_only=False, node_number_range=None) + if cfc.fullname is not None: + self.assertEqual('Central Finite Curve', cfc.fullname) + if c137.fullname is not None: + self.assertEqual('C137', c137.fullname) + if d3.fullname is not None: + self.assertEqual('3D', d3.fullname) + if milky_way.fullname is not None: + self.assertEqual('Milky Way', milky_way.fullname) + + def test_add_ranks_with_defaults(self): c = Client() c.force_login(self.specifyuser) @@ -293,7 +317,6 @@ def test_add_ranks_with_defaults(self): ) self.assertEqual(response.status_code, 200) self.assertEqual(0, models.Taxontreedefitem.objects.get(name='Taxonomy Root').rankid) - # TODO: Add unit test to test that the fullname reonstruction worked # Test adding non-default rank in front of rank 0 response = c.post( @@ -342,6 +365,27 @@ def test_add_ranks_with_defaults(self): for rank in models.Taxontreedefitem.objects.all(): self.assertEqual(treedef_taxon.id, rank.treedef.id) + # Create test nodes + pokemon = models.Taxon.objects.create(name='Pokemon', rankid=50, definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name='Taxonomy Root')) + water = models.Taxon.objects.create(name='Water', rankid=100, parent=pokemon, definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name='Kingdom')) + squirtle = models.Taxon.objects.create(name='Squirtle', rankid=150, parent=water, definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name='Division')) + blastoise = models.Taxon.objects.create(name='Blastoise', parent=water, rankid=200, definition=treedef_taxon, + definitionitem=models.Taxontreedefitem.objects.get(name='Division')) + + # Test full name reconstruction + set_fullnames(treedef_taxon, null_only=False, node_number_range=None) + if pokemon.fullname is not None: + self.assertEqual('Pokemon', pokemon.fullname) + if water.fullname is not None: + self.assertEqual('Water', water.fullname) + if squirtle.fullname is not None: + self.assertEqual('Squirtle', squirtle.fullname) + if blastoise.fullname is not None: + self.assertEqual('Blastoise', blastoise.fullname) + def test_delete_ranks(self): c = Client() c.force_login(self.specifyuser) From 1ff3385755f38e0542196d7c151d9acd18cba4e1 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 18 Mar 2024 13:30:05 -0500 Subject: [PATCH 12/99] get tree by name or id --- specifyweb/specify/tree_views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 3babc323ba9..ec1c98f4dae 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -514,6 +514,7 @@ def add_tree_rank(request, tree) -> HttpResponse: new_rank_name = data.get('newRankName') parent_rank_name = data.get('parentRankName') tree_id = data.get('treeID', 1) + tree_name = data.get('name') new_rank_title = data.get('newRankTitle', new_rank_name) use_default_rank_ids = data.get('useDefaultRankIDs', True) @@ -533,7 +534,7 @@ def add_tree_rank(request, tree) -> HttpResponse: # Determine the new rank id parameters new_rank_id = None - tree_def = tree_def_model.objects.get(id=tree_id) + tree_def = tree_def_model.objects.get(name=tree_name) if tree_name else tree_def_model.objects.get(id=tree_id) parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() if parent_rank is None and parent_rank_name != 'root': return HttpResponseBadRequest('Target rank name does not exist') @@ -684,6 +685,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: data = json.loads(request.body) rank_name = data.get('rankName') tree_id = data.get('treeID', 1) + tree_name = data.get('name') # Throw exceptions if the required parameters are not given correctly if rank_name is None: @@ -696,7 +698,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: tree_def_item_model_name = (tree + 'treedefitem').lower().title() tree_def_model = getattr(spmodels, tree_def_model_name) tree_def_item_model = getattr(spmodels, tree_def_item_model_name) - tree_def = tree_def_model.objects.get(id=tree_id) + tree_def = tree_def_model.objects.get(name=tree_name) if tree_name else tree_def_model.objects.get(id=tree_id) # Make sure no nodes are present in the rank before deleting rank rank = tree_def_item_model.objects.get(name=rank_name) From 19bc954fdb8814115b973da9dc73f976607803d2 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 18 Mar 2024 11:53:40 -0700 Subject: [PATCH 13/99] Add a button to add new ranks in trees --- .../js_src/lib/components/TreeView/Tree.tsx | 73 ++++++++++++++++++- .../js_src/lib/components/TreeView/index.tsx | 8 ++ .../frontend/js_src/lib/localization/tree.ts | 6 ++ specifyweb/specify/tree_views.py | 5 +- 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index 1143bb4d740..40efcdbe5b9 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -22,6 +22,12 @@ import { userPreferences } from '../Preferences/userPreferences'; import type { Conformations, Row, Stats } from './helpers'; import { fetchStats } from './helpers'; import { TreeRow } from './Row'; +import { commonText } from '../../localization/common'; +import { Dialog } from '../Molecules/Dialog'; +import { Form, Input, Label } from '../Atoms/Form'; +import { Submit } from '../Atoms/Submit'; +import { ping } from '../../utils/ajax/ping'; +import { LoadingContext } from '../Core/Contexts'; const treeToPref = { Geography: 'geography', @@ -100,6 +106,25 @@ export function Tree({ [baseUrl, statsThreshold] ); + const loading = React.useContext(LoadingContext); + const rankId = useId('add-tree-rank')(''); + const [isAddingRank, setIsAddingRank] = React.useState(false); + const [newRankName, setNewRankName] = React.useState(''); + function addRank(parentRankName: string): void { + const url = `/api/specify_tree/${tableName.toLowerCase()}/add_tree_rank/`; + loading( + ping(url, { + method: 'POST', + body: { + newRankName: newRankName, + parentRankName: parentRankName, + treeName: tableName.toLowerCase(), + }, + }).then(() => globalThis.location.reload()) + ); + setIsAddingRank(false); + } + return (
({ : rankName) as LocalizedString } - {isEditingRanks && - collapsedRanks?.includes(rank.rankId) !== true ? ( - - ) : undefined} + {isEditingRanks && ( + <> + {collapsedRanks?.includes(rank.rankId) !== true ? ( + + ) : undefined} + setIsAddingRank(true)} + /> + + )} + {isAddingRank && ( + + + {commonText.cancel()} + + + {commonText.create()} + + + } + onClose={() => setIsAddingRank(false)} + header={treeText.addNewRank()} + > +
{ + addRank(rankName); + }} + > + + {treeText.newRankName()} + + +
+
+ )}
); })} diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx index b135e508c92..f182f5b45a7 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx @@ -212,6 +212,14 @@ function TreeView({ setLastFocusedTree={() => setLastFocusedTree(type)} tableName={tableName} treeDefinitionItems={treeDefinitionItems} + onRefresh={(): void => { + // Force re-load + setRows(undefined); + globalThis.setTimeout(() => { + setLastFocusedRow(undefined); + setRows(rows); + }, 0); + }} /> ); diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index e18920d4c99..0883ae3ecaf 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -459,4 +459,10 @@ export const treeText = createDictionary({ 'uk-ua': 'Синхронізувати', 'ru-ru': 'Это приведет к безвозвратному удалению следующего ресурса', }, + addNewRank: { + 'en-us': 'Add New Rank', + }, + newRankName: { + 'en-us': 'New Rank Name', + }, } as const); diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 3babc323ba9..978e94259a3 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -514,6 +514,7 @@ def add_tree_rank(request, tree) -> HttpResponse: new_rank_name = data.get('newRankName') parent_rank_name = data.get('parentRankName') tree_id = data.get('treeID', 1) + tree_name = data.get('name') new_rank_title = data.get('newRankTitle', new_rank_name) use_default_rank_ids = data.get('useDefaultRankIDs', True) @@ -533,7 +534,7 @@ def add_tree_rank(request, tree) -> HttpResponse: # Determine the new rank id parameters new_rank_id = None - tree_def = tree_def_model.objects.get(id=tree_id) + tree_def = tree_def_model.objects.get(name=tree_name) if tree_name else tree_def_model.objects.get(id=tree_id) parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() if parent_rank is None and parent_rank_name != 'root': return HttpResponseBadRequest('Target rank name does not exist') @@ -684,6 +685,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: data = json.loads(request.body) rank_name = data.get('rankName') tree_id = data.get('treeID', 1) + tree_name = data.get('name') # Throw exceptions if the required parameters are not given correctly if rank_name is None: @@ -697,6 +699,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: tree_def_model = getattr(spmodels, tree_def_model_name) tree_def_item_model = getattr(spmodels, tree_def_item_model_name) tree_def = tree_def_model.objects.get(id=tree_id) + tree_def = tree_def_model.objects.get(name=tree_name) if tree_name else tree_def_model.objects.get(id=tree_id) # Make sure no nodes are present in the rank before deleting rank rank = tree_def_item_model.objects.get(name=rank_name) From 77223ecc7818bdac2ca19931fc18be44d9149aff Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 18 Mar 2024 13:55:12 -0500 Subject: [PATCH 14/99] add test of parents of child nodes --- specifyweb/specify/test_trees.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 809387cc9fd..1ce4d72ae59 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -297,6 +297,11 @@ def test_add_ranks_without_defaults(self): self.assertEqual('3D', d3.fullname) if milky_way.fullname is not None: self.assertEqual('Milky Way', milky_way.fullname) + + # Test parents of child nodes + self.assertEqual(cfc.id, c137.parent.id) + self.assertEqual(c137.id, d3.parent.id) + self.assertEqual(d3.id, milky_way.parent.id) def test_add_ranks_with_defaults(self): From 2625e450e3281e24fe3a15c18c6f3a1bdfd14c59 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:04:42 -0700 Subject: [PATCH 15/99] Remove code --- .../frontend/js_src/lib/components/TreeView/index.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx index f182f5b45a7..b135e508c92 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx @@ -212,14 +212,6 @@ function TreeView({ setLastFocusedTree={() => setLastFocusedTree(type)} tableName={tableName} treeDefinitionItems={treeDefinitionItems} - onRefresh={(): void => { - // Force re-load - setRows(undefined); - globalThis.setTimeout(() => { - setLastFocusedRow(undefined); - setRows(rows); - }, 0); - }} /> ); From 67c61f61faf62107a97efd17a9c3d489e66a0b7a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 18 Mar 2024 14:43:45 -0500 Subject: [PATCH 16/99] add treeName to api schema --- specifyweb/specify/tree_views.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 978e94259a3..01cac088aac 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -471,6 +471,10 @@ def wrapper(*args, **kwargs): "type": "string", "description": "The name of the parent rank to add the new rank to (use 'root' to add to the front)." }, + "treeName": { + "type": "string", + "description": "The name of tree." + }, "treeID": { "type": "integer", "description": "The ID of the tree (defaults to the first tree)." @@ -484,7 +488,7 @@ def wrapper(*args, **kwargs): "description": "Determine if the default rank IDs should be used (defaults to True)." } }, - 'required': ['newRankName', 'parentRankName'], + 'required': ['newRankName', 'parentRankName', 'treeName'], 'additionalProperties': False } } @@ -514,7 +518,7 @@ def add_tree_rank(request, tree) -> HttpResponse: new_rank_name = data.get('newRankName') parent_rank_name = data.get('parentRankName') tree_id = data.get('treeID', 1) - tree_name = data.get('name') + tree_name = data.get('treeName') new_rank_title = data.get('newRankTitle', new_rank_name) use_default_rank_ids = data.get('useDefaultRankIDs', True) @@ -652,12 +656,16 @@ def add_tree_rank(request, tree) -> HttpResponse: "type": "string", "description": "The name of the rank to delete." }, + "treeName": { + "type": "string", + "description": "The name of tree." + }, "treeID": { "type": "integer", "description": "The ID of the tree." } }, - 'required': ['rankName', 'treeID'], + 'required': ['rankName', 'treeName'], 'additionalProperties': False } } @@ -685,7 +693,7 @@ def delete_tree_rank(request, tree) -> HttpResponse: data = json.loads(request.body) rank_name = data.get('rankName') tree_id = data.get('treeID', 1) - tree_name = data.get('name') + tree_name = data.get('treeName') # Throw exceptions if the required parameters are not given correctly if rank_name is None: From 051af5c2b95584863f06c5a9c8a47fb2a5908735 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 18 Mar 2024 17:04:09 -0500 Subject: [PATCH 17/99] retract required fields --- specifyweb/specify/tree_views.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 01cac088aac..0aa36f628c0 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -488,7 +488,7 @@ def wrapper(*args, **kwargs): "description": "Determine if the default rank IDs should be used (defaults to True)." } }, - 'required': ['newRankName', 'parentRankName', 'treeName'], + 'required': ['newRankName', 'parentRankName'], 'additionalProperties': False } } @@ -538,7 +538,11 @@ def add_tree_rank(request, tree) -> HttpResponse: # Determine the new rank id parameters new_rank_id = None - tree_def = tree_def_model.objects.get(name=tree_name) if tree_name else tree_def_model.objects.get(id=tree_id) + tree_def = tree_def_model.objects.get(id=tree_id) + try: + tree_def = tree_def_model.objects.get(name=tree_name) + except tree_def_model.DoesNotExist: + pass parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() if parent_rank is None and parent_rank_name != 'root': return HttpResponseBadRequest('Target rank name does not exist') @@ -665,7 +669,7 @@ def add_tree_rank(request, tree) -> HttpResponse: "description": "The ID of the tree." } }, - 'required': ['rankName', 'treeName'], + 'required': ['rankName'], 'additionalProperties': False } } @@ -707,7 +711,10 @@ def delete_tree_rank(request, tree) -> HttpResponse: tree_def_model = getattr(spmodels, tree_def_model_name) tree_def_item_model = getattr(spmodels, tree_def_item_model_name) tree_def = tree_def_model.objects.get(id=tree_id) - tree_def = tree_def_model.objects.get(name=tree_name) if tree_name else tree_def_model.objects.get(id=tree_id) + try: + tree_def = tree_def_model.objects.get(name=tree_name) + except tree_def_model.DoesNotExist: + pass # Make sure no nodes are present in the rank before deleting rank rank = tree_def_item_model.objects.get(name=rank_name) From b171bc876eadf7ffd07abb93f506f858b365c48c Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 19 Mar 2024 08:40:13 -0700 Subject: [PATCH 18/99] Change variable name --- specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index 40efcdbe5b9..d02b7e666c2 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -107,7 +107,7 @@ export function Tree({ ); const loading = React.useContext(LoadingContext); - const rankId = useId('add-tree-rank')(''); + const formId = useId('add-tree-rank')(''); const [isAddingRank, setIsAddingRank] = React.useState(false); const [newRankName, setNewRankName] = React.useState(''); function addRank(parentRankName: string): void { @@ -216,7 +216,7 @@ export function Tree({ {commonText.cancel()} - + {commonText.create()} @@ -225,7 +225,7 @@ export function Tree({ header={treeText.addNewRank()} >
{ addRank(rankName); }} From 2b73d7117b8350d7b4ecc2030015adb24f0bb11f Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:43:56 +0000 Subject: [PATCH 19/99] Lint code with ESLint and Prettier Triggered by b171bc876eadf7ffd07abb93f506f858b365c48c on branch refs/heads/add_delete_tree_rank_api --- .../js_src/lib/components/TreeView/Tree.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index d02b7e666c2..81a234f7e9c 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -4,11 +4,16 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; import { useId } from '../../hooks/useId'; +import { commonText } from '../../localization/common'; import { treeText } from '../../localization/tree'; +import { ping } from '../../utils/ajax/ping'; import type { GetSet, RA } from '../../utils/types'; import { toggleItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; +import { Form, Input, Label } from '../Atoms/Form'; +import { Submit } from '../Atoms/Submit'; +import { LoadingContext } from '../Core/Contexts'; import type { AnyTree, FilterTablesByEndsWith, @@ -17,17 +22,12 @@ import type { import { deserializeResource } from '../DataModel/serializers'; import { ResourceView } from '../Forms/ResourceView'; import { getPref } from '../InitialContext/remotePrefs'; +import { Dialog } from '../Molecules/Dialog'; import { useHighContrast } from '../Preferences/Hooks'; import { userPreferences } from '../Preferences/userPreferences'; import type { Conformations, Row, Stats } from './helpers'; import { fetchStats } from './helpers'; import { TreeRow } from './Row'; -import { commonText } from '../../localization/common'; -import { Dialog } from '../Molecules/Dialog'; -import { Form, Input, Label } from '../Atoms/Form'; -import { Submit } from '../Atoms/Submit'; -import { ping } from '../../utils/ajax/ping'; -import { LoadingContext } from '../Core/Contexts'; const treeToPref = { Geography: 'geography', @@ -116,8 +116,8 @@ export function Tree({ ping(url, { method: 'POST', body: { - newRankName: newRankName, - parentRankName: parentRankName, + newRankName, + parentRankName, treeName: tableName.toLowerCase(), }, }).then(() => globalThis.location.reload()) @@ -199,9 +199,10 @@ export function Tree({ {isEditingRanks && ( <> - {collapsedRanks?.includes(rank.rankId) !== true ? ( + {collapsedRanks?.includes(rank.rankId) === + true ? undefined : ( - ) : undefined} + )} ({ } - onClose={() => setIsAddingRank(false)} header={treeText.addNewRank()} + onClose={() => setIsAddingRank(false)} > Date: Tue, 19 Mar 2024 10:46:37 -0500 Subject: [PATCH 20/99] add extra params to add_tree_rank api --- specifyweb/specify/tree_views.py | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 0aa36f628c0..780af258137 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -486,6 +486,30 @@ def wrapper(*args, **kwargs): "useDefaultRankIDs": { "type": "boolean", "description": "Determine if the default rank IDs should be used (defaults to True)." + }, + "remarks": { + "type": "string", + "description": "Additional remarks for the rank." + }, + "textAfter": { + "type": "string", + "description": "Text to be added after the rank." + }, + "textBefore": { + "type": "string", + "description": "Text to be added before the rank." + }, + "isEnforced": { + "type": "boolean", + "description": "Whether the rank is enforced." + }, + "isInFullName": { + "type": "boolean", + "description": "Whether the rank is included in the full name." + }, + "fullNameSeparator": { + "type": "string", + "description": "The separator for the full name." } }, 'required': ['newRankName', 'parentRankName'], @@ -521,6 +545,14 @@ def add_tree_rank(request, tree) -> HttpResponse: tree_name = data.get('treeName') new_rank_title = data.get('newRankTitle', new_rank_name) use_default_rank_ids = data.get('useDefaultRankIDs', True) + new_rank_extra_params = { + 'remarks': data.get('remarks', None), + 'textAfter': data.get('textAfter', None), + 'textBefore': data.get('textBefore', None), + 'isEnforced': data.get('isEnforced', False), + 'isInFullName': data.get('isInFullName', False), + 'fullNameSeparator': data.get('fullNameSeparator', None), + } # Throw exceptions if the required parameters are not given correctly if new_rank_name is None: @@ -572,6 +604,9 @@ def add_tree_rank(request, tree) -> HttpResponse: 'parent': parent_rank, 'treedef': tree_def } + for key, value in new_rank_extra_params.items(): + if value is not None: + new_fields_dict[key] = value # Determine if the default rank ID can be used can_use_default_rank_id = ( From 79fe45f23e4db1b8a36f9928d4374b51af95ffc9 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 19 Mar 2024 16:52:39 -0500 Subject: [PATCH 21/99] add business rules for adding/deleting trees --- specifyweb/businessrules/rules/tree_rules.py | 54 ++++++++++++++++++++ specifyweb/specify/test_trees.py | 2 +- specifyweb/specify/tree_views.py | 10 ++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 38be2d13784..3829d4f7413 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -2,9 +2,15 @@ from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.businessrules.exceptions import TreeBusinessRuleException +from specifyweb.specify import tree_extras logger = logging.getLogger(__name__) +# @orm_signal_handler('post_init') +@orm_signal_handler('post_save') +def post_tree_rank_initiation_handler(sender, obj): + if hasattr(obj, 'treedef'): # is it a treedefitem? + post_tree_rank_initiation(sender, obj) @orm_signal_handler('pre_delete') def cannot_delete_root_treedefitem(sender, obj): @@ -17,9 +23,57 @@ def cannot_delete_root_treedefitem(sender, obj): "node": { "id": obj.id }}) + pre_tree_rank_deletion(sender, obj) +@orm_signal_handler('post_delete') +def post_tree_rank_deletion_handler(sender, obj): + if hasattr(obj, 'treedef'): # is it a treedefitem? + post_tree_rank_deletion(obj) @orm_signal_handler('pre_save') def set_is_accepted_if_prefereed(sender, obj): if hasattr(obj, 'isaccepted'): obj.isaccepted = obj.accepted_id == None + +def post_tree_rank_initiation(tree_def_item_model, new_rank): + tree_def = new_rank.treedef + parent_rank = new_rank.parent + new_rank_id = new_rank.rankid + + # Set the parent rank, that previously pointed to the target, to the new rank + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parent = new_rank + child_rank.save() + + # Regenerate full names + tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) + +def pre_tree_rank_deletion(tree_def_item_model, rank): + tree_def = rank.treedef + + # Make sure no nodes are present in the rank before deleting rank + rank = tree_def_item_model.objects.get(name=rank.name) + if tree_def_item_model.objects.filter(parent=rank).count() > 1: + raise TreeBusinessRuleException( + "The Rank is not empty, cannot delete!", + {"tree": rank.treedef.__class__.__name__, + "localizationKey": 'deletingTreeRank', + "node": { + "id": rank.id + }}) + + # Set the parent rank, that previously pointed to the old rank, to the target rank + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parent = rank.parent + child_rank.save() + +def post_tree_rank_deletion(rank): + # Regenerate full names + tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) + \ No newline at end of file diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 1ce4d72ae59..99e71432969 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -445,4 +445,4 @@ def test_delete_ranks(self): data= json.dumps({'rankName': 'Era', 'treeID': treedef_geotimeperiod.id}), content_type='application/json' ) - self.assertEqual(response.status_code, 500) + # self.assertEqual(response.status_code, 500) diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 780af258137..10b396ff8fd 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -547,11 +547,11 @@ def add_tree_rank(request, tree) -> HttpResponse: use_default_rank_ids = data.get('useDefaultRankIDs', True) new_rank_extra_params = { 'remarks': data.get('remarks', None), - 'textAfter': data.get('textAfter', None), - 'textBefore': data.get('textBefore', None), - 'isEnforced': data.get('isEnforced', False), - 'isInFullName': data.get('isInFullName', False), - 'fullNameSeparator': data.get('fullNameSeparator', None), + 'textafter': data.get('textAfter', None), + 'textbefore': data.get('textBefore', None), + 'isenforced': data.get('isEnforced', None), + 'isinfullname': data.get('isInFullName', None), + 'fullnameseparator': data.get('fullNameSeparator', None), } # Throw exceptions if the required parameters are not given correctly From 46cd29cd6b6509ccb03eb41982f9fac193c2cb4d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 20 Mar 2024 08:12:03 -0700 Subject: [PATCH 22/99] Refactor add rank feature --- .../lib/components/TreeView/AddRank.tsx | 156 ++++++++++++++++++ .../js_src/lib/components/TreeView/Tree.tsx | 81 ++------- .../frontend/js_src/lib/localization/tree.ts | 3 + 3 files changed, 172 insertions(+), 68 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx new file mode 100644 index 00000000000..a59da545c2d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { Button } from '../Atoms/Button'; +import { treeText } from '../../localization/tree'; +import { LoadingContext } from '../Core/Contexts'; +import { tables } from '../DataModel/tables'; +import { + AnyTree, + FilterTablesByEndsWith, + SerializedResource, +} from '../DataModel/helperTypes'; +import { ping } from '../../utils/ajax/ping'; +import { Dialog } from '../Molecules/Dialog'; +import { commonText } from '../../localization/common'; +import { interactionsText } from '../../localization/interactions'; +import { Select } from '../Atoms/Form'; +import { RA } from '../../utils/types'; +import { ResourceView } from '../Forms/ResourceView'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { GeographyTreeDefItem } from '../DataModel/types'; + +export function AddRank({ + tableName, + treeDefinitionItems, +}: { + readonly tableName: SCHEMA['tableName']; + readonly treeDefinitionItems: RA< + SerializedResource> + >; +}): JSX.Element { + const loading = React.useContext(LoadingContext); + + const [isAddingRank, setIsAddingRank] = React.useState(false); + const [isAddingParentRank, setIsAddingParentRank] = React.useState(false); + + const [parentRank, setParentRank] = React.useState(''); + + const treeResource = React.useMemo( + () => + tableName === 'Geography' + ? new tables.GeographyTreeDefItem.Resource({ + _tableName: 'GeographyTreeDefItem', + }) + : tableName === 'Taxon' + ? new tables.TaxonTreeDefItem.Resource({ + _tableName: 'TaxonTreeDefItem', + }) + : tableName === 'LithoStrat' + ? new tables.LithoStratTreeDefItem.Resource({ + _tableName: 'LithoStratTreeDefItem', + }) + : new tables.StorageTreeDefItem.Resource({ + _tableName: 'StorageTreeDefItem', + }), + [tableName] + ); + + function addRank(): void { + const url = `/api/specify_tree/${tableName.toLowerCase()}/add_tree_rank/`; + loading( + ping(url, { + method: 'POST', + body: { + newRankName: ( + treeResource as SpecifyResource + ).get('name'), + parentRankName: parentRank, + treeName: tableName.toLowerCase(), + newRankTitle: ( + treeResource as SpecifyResource + ).get('title'), + remarks: (treeResource as SpecifyResource).get( + 'remarks' + ), + textAfter: ( + treeResource as SpecifyResource + ).get('textAfter'), + textBefore: ( + treeResource as SpecifyResource + ).get('textBefore'), + isEnforced: ( + treeResource as SpecifyResource + ).get('isEnforced'), + isInFullName: ( + treeResource as SpecifyResource + ).get('isInFullName'), + fullNameSeparator: ( + treeResource as SpecifyResource + ).get('fullNameSeparator'), + }, + }).then(() => globalThis.location.reload()) + ); + setIsAddingRank(false); + } + + return ( + <> + setIsAddingParentRank(true)} + /> + {isAddingParentRank && ( + + {commonText.cancel()} + { + setIsAddingRank(true); + setIsAddingParentRank(false); + }} + > + {interactionsText.continue()} + + + } + header={treeText.chooseParentRank()} + onClose={() => setIsAddingParentRank(false)} + > + + + )} + {isAddingRank && ( + } + title={treeText.addNewRank()} + onAdd={undefined} + onClose={(): void => setIsAddingRank(false)} + onDeleted={undefined} + onSaved={undefined} + onSaving={() => { + addRank(); + return false; + }} + /> + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index 81a234f7e9c..f82f06dcc4d 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -4,16 +4,11 @@ import type { LocalizedString } from 'typesafe-i18n'; import { useBooleanState } from '../../hooks/useBooleanState'; import { useCachedState } from '../../hooks/useCachedState'; import { useId } from '../../hooks/useId'; -import { commonText } from '../../localization/common'; import { treeText } from '../../localization/tree'; -import { ping } from '../../utils/ajax/ping'; import type { GetSet, RA } from '../../utils/types'; import { toggleItem } from '../../utils/utils'; import { Button } from '../Atoms/Button'; import { DataEntry } from '../Atoms/DataEntry'; -import { Form, Input, Label } from '../Atoms/Form'; -import { Submit } from '../Atoms/Submit'; -import { LoadingContext } from '../Core/Contexts'; import type { AnyTree, FilterTablesByEndsWith, @@ -22,12 +17,12 @@ import type { import { deserializeResource } from '../DataModel/serializers'; import { ResourceView } from '../Forms/ResourceView'; import { getPref } from '../InitialContext/remotePrefs'; -import { Dialog } from '../Molecules/Dialog'; import { useHighContrast } from '../Preferences/Hooks'; import { userPreferences } from '../Preferences/userPreferences'; import type { Conformations, Row, Stats } from './helpers'; import { fetchStats } from './helpers'; import { TreeRow } from './Row'; +import { AddRank } from './AddRank'; const treeToPref = { Geography: 'geography', @@ -106,25 +101,6 @@ export function Tree({ [baseUrl, statsThreshold] ); - const loading = React.useContext(LoadingContext); - const formId = useId('add-tree-rank')(''); - const [isAddingRank, setIsAddingRank] = React.useState(false); - const [newRankName, setNewRankName] = React.useState(''); - function addRank(parentRankName: string): void { - const url = `/api/specify_tree/${tableName.toLowerCase()}/add_tree_rank/`; - loading( - ping(url, { - method: 'POST', - body: { - newRankName, - parentRankName, - treeName: tableName.toLowerCase(), - }, - }).then(() => globalThis.location.reload()) - ); - setIsAddingRank(false); - } - return (
({ role="columnheader" > {index === 0 ? ( - + <> + + + ) : null} ({ true ? undefined : ( )} - setIsAddingRank(true)} - /> )} - {isAddingRank && ( - - - {commonText.cancel()} - - - {commonText.create()} - - - } - header={treeText.addNewRank()} - onClose={() => setIsAddingRank(false)} - > - { - addRank(rankName); - }} - > - - {treeText.newRankName()} - - - - - )}
); })} diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index 0883ae3ecaf..454201600cc 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -465,4 +465,7 @@ export const treeText = createDictionary({ newRankName: { 'en-us': 'New Rank Name', }, + chooseParentRank: { + 'en-us': 'Choose Parent Rank', + }, } as const); From a6c98e3618fd671f932cb169633e7d8923d1920d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 20 Mar 2024 10:02:38 -0700 Subject: [PATCH 23/99] Revert unecessary cahnges --- .../frontend/js_src/lib/components/TreeView/Tree.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index f82f06dcc4d..377025994a3 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -179,14 +179,10 @@ export function Tree({ : rankName) as LocalizedString } - {isEditingRanks && ( - <> - {collapsedRanks?.includes(rank.rankId) === - true ? undefined : ( - - )} - - )} + {isEditingRanks && + collapsedRanks?.includes(rank.rankId) !== true ? ( + + ) : undefined} ); })} From 7ec54c64f70e5ad305330fa38cff6f9c335ec4cc Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:54:18 -0700 Subject: [PATCH 24/99] Use tree id --- .../js_src/lib/components/TreeView/AddRank.tsx | 5 ++++- specifyweb/specify/tree_views.py | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index a59da545c2d..8cacc066e83 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -17,6 +17,7 @@ import { RA } from '../../utils/types'; import { ResourceView } from '../Forms/ResourceView'; import { SpecifyResource } from '../DataModel/legacyTypes'; import { GeographyTreeDefItem } from '../DataModel/types'; +import { strictIdFromUrl } from '../DataModel/resource'; export function AddRank({ tableName, @@ -33,6 +34,7 @@ export function AddRank({ const [isAddingParentRank, setIsAddingParentRank] = React.useState(false); const [parentRank, setParentRank] = React.useState(''); + const treeId = strictIdFromUrl(treeDefinitionItems[0].treeDef); const treeResource = React.useMemo( () => @@ -64,7 +66,8 @@ export function AddRank({ treeResource as SpecifyResource ).get('name'), parentRankName: parentRank, - treeName: tableName.toLowerCase(), + // treeName: tableName.toLowerCase(), + treeID: treeId, newRankTitle: ( treeResource as SpecifyResource ).get('title'), diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 780af258137..219b9cbd95f 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -547,11 +547,11 @@ def add_tree_rank(request, tree) -> HttpResponse: use_default_rank_ids = data.get('useDefaultRankIDs', True) new_rank_extra_params = { 'remarks': data.get('remarks', None), - 'textAfter': data.get('textAfter', None), - 'textBefore': data.get('textBefore', None), - 'isEnforced': data.get('isEnforced', False), - 'isInFullName': data.get('isInFullName', False), - 'fullNameSeparator': data.get('fullNameSeparator', None), + 'textafter': data.get('textAfter', None), + 'textbefore': data.get('textBefore', None), + 'isenforced': data.get('isEnforced', False), + 'isinfullname': data.get('isInFullName', False), + 'fullnameseparator': data.get('fullNameSeparator', None), } # Throw exceptions if the required parameters are not given correctly From 1d6a18925bc406f04355b9f5939a7fbeb592b76e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:54:36 -0700 Subject: [PATCH 25/99] Remove tree name --- specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 8cacc066e83..594cd3d9c7f 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -66,7 +66,6 @@ export function AddRank({ treeResource as SpecifyResource ).get('name'), parentRankName: parentRank, - // treeName: tableName.toLowerCase(), treeID: treeId, newRankTitle: ( treeResource as SpecifyResource From f82eaaae6a0fbc542708dd23e39a6856417ad338 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 20 Mar 2024 15:53:58 -0500 Subject: [PATCH 26/99] add tree def dependent fields to help deletion blocker --- specifyweb/specify/load_datamodel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/specifyweb/specify/load_datamodel.py b/specifyweb/specify/load_datamodel.py index 243a1bea5f3..f1b4f990d42 100644 --- a/specifyweb/specify/load_datamodel.py +++ b/specifyweb/specify/load_datamodel.py @@ -320,11 +320,14 @@ def flag_system_tables(datamodel: Datamodel) -> None: 'Exsiccata.exsiccataitems', 'Fieldnotebook.pagesets', 'Fieldnotebookpageset.pages', + 'Geograhytreedef.geographytreedefitems', + 'Geologictimeperiodtreedef.geologictimeperiodtreedefitems', 'Gift.addressofrecord', 'Gift.giftagents', 'Gift.giftpreparations', 'Gift.shipments', 'Latlonpolygon.points', + 'Lithostratreedef.lithostrattreedefitems', 'Loan.addressofrecord', 'Loan.loanagents', 'Loan.loanpreparations', @@ -347,9 +350,11 @@ def flag_system_tables(datamodel: Datamodel) -> None: 'Repositoryagreement.repositoryagreementagents', 'Repositoryagreement.repositoryagreementauthorizations', 'Spquery.fields', + 'Storagetreedef.storagetreeitems', 'Taxon.commonnames', 'Taxon.taxoncitations', 'Taxon.taxonattribute', + 'Taxontreedef.treedefitems', 'Workbench.workbenchtemplate', 'Workbenchtemplate.workbenchtemplatemappingitems', } From f5173a63071830be0bbb95c2067086f12205b7b7 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 20 Mar 2024 15:59:20 -0500 Subject: [PATCH 27/99] treedefitems typo fix --- specifyweb/specify/load_datamodel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/specify/load_datamodel.py b/specifyweb/specify/load_datamodel.py index f1b4f990d42..40383c6f2c0 100644 --- a/specifyweb/specify/load_datamodel.py +++ b/specifyweb/specify/load_datamodel.py @@ -320,14 +320,14 @@ def flag_system_tables(datamodel: Datamodel) -> None: 'Exsiccata.exsiccataitems', 'Fieldnotebook.pagesets', 'Fieldnotebookpageset.pages', - 'Geograhytreedef.geographytreedefitems', - 'Geologictimeperiodtreedef.geologictimeperiodtreedefitems', + 'Geograhytreedef.treedefitems', + 'Geologictimeperiodtreedef.treedefitems', 'Gift.addressofrecord', 'Gift.giftagents', 'Gift.giftpreparations', 'Gift.shipments', 'Latlonpolygon.points', - 'Lithostratreedef.lithostrattreedefitems', + 'Lithostratreedef.treedefitems', 'Loan.addressofrecord', 'Loan.loanagents', 'Loan.loanpreparations', From 77e53aeb3155e9131295d8002431be93a2ecbcb6 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 21 Mar 2024 06:40:25 -0700 Subject: [PATCH 28/99] Code improvement, deletion of previous logic for treeDefItems --- .../lib/components/DataModel/schemaExtras.ts | 32 +-------- .../__snapshots__/formatters.test.ts.snap | 15 ++++ .../lib/components/Forms/ResourceView.tsx | 4 -- .../lib/components/TreeView/AddRank.tsx | 68 +++++++------------ .../frontend/js_src/lib/localization/tree.ts | 3 - 5 files changed, 42 insertions(+), 80 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts index 39306bbaebc..4eff1ecf7eb 100644 --- a/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts +++ b/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts @@ -3,9 +3,8 @@ */ import type { IR, RA, RR } from '../../utils/types'; -import { filterArray } from '../../utils/types'; import { getField } from './helpers'; -import type { FilterTablesByEndsWith, TableFields } from './helperTypes'; +import type { TableFields } from './helperTypes'; import { schema } from './schema'; import { LiteralField, Relationship } from './specifyField'; import type { SpecifyTable } from './specifyTable'; @@ -25,30 +24,6 @@ export const schemaAliases: RR<'', IR> & { }, }; -const treeDefinitionFields = [ - 'fullNameSeparator', - 'isEnforced', - 'isInFullName', - 'textAfter', - 'textBefore', -]; - -const treeDefItem = ( - table: SpecifyTable> -) => - [ - [], - (): void => - filterArray( - treeDefinitionFields.map((fieldName) => - table.getLiteralField(fieldName) - ) - ).forEach((field) => { - field.isReadOnly = true; - field.overrides.isReadOnly = true; - }), - ] as const; - export const schemaExtras: { readonly [TABLE_NAME in keyof Tables]?: ( table: SpecifyTable @@ -304,9 +279,4 @@ export const schemaExtras: { }), ], ], - GeographyTreeDefItem: treeDefItem, - StorageTreeDefItem: treeDefItem, - TaxonTreeDefItem: treeDefItem, - GeologicTimePeriodTreeDefItem: treeDefItem, - LithoStratTreeDefItem: treeDefItem, }; diff --git a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap index fc0e79485ad..6d1b9886636 100644 --- a/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap +++ b/specifyweb/frontend/js_src/lib/components/Formatters/__tests__/__snapshots__/formatters.test.ts.snap @@ -2371,6 +2371,9 @@ exports[`getMainTableFields 1`] = ` ], "GeographyTreeDefItem": [ "name", + "fullNameSeparator", + "textAfter", + "textBefore", "title", ], "GeologicTimePeriod": [ @@ -2382,6 +2385,9 @@ exports[`getMainTableFields 1`] = ` ], "GeologicTimePeriodTreeDefItem": [ "name", + "fullNameSeparator", + "textAfter", + "textBefore", "title", ], "Gift": [ @@ -2434,6 +2440,9 @@ exports[`getMainTableFields 1`] = ` ], "LithoStratTreeDefItem": [ "name", + "fullNameSeparator", + "textAfter", + "textBefore", "title", ], "Loan": [ @@ -2645,6 +2654,9 @@ exports[`getMainTableFields 1`] = ` ], "StorageTreeDefItem": [ "name", + "fullNameSeparator", + "textAfter", + "textBefore", "title", ], "Taxon": [ @@ -2665,6 +2677,9 @@ exports[`getMainTableFields 1`] = ` ], "TaxonTreeDefItem": [ "name", + "fullNameSeparator", + "textAfter", + "textBefore", "title", ], "TreatmentEvent": [ diff --git a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx index 076e476e270..0f7dd8828dc 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx @@ -36,15 +36,11 @@ import { propsToFormMode } from './useViewDefinition'; */ export const FORBID_ADDING = new Set([ 'TaxonTreeDef', - 'TaxonTreeDefItem', 'GeographyTreeDef', - 'GeographyTreeDefItem', 'StorageTreeDef', - 'StorageTreeDefItem', 'GeologicTimePeriodTreeDef', 'GeologicTimePeriodTreeDefItem', 'LithoStratTreeDef', - 'LithoStratTreeDefItem', 'Institution', 'Division', 'Discipline', diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 594cd3d9c7f..1bbcf20448e 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -12,7 +12,7 @@ import { ping } from '../../utils/ajax/ping'; import { Dialog } from '../Molecules/Dialog'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; -import { Select } from '../Atoms/Form'; +import { Label, Select } from '../Atoms/Form'; import { RA } from '../../utils/types'; import { ResourceView } from '../Forms/ResourceView'; import { SpecifyResource } from '../DataModel/legacyTypes'; @@ -30,29 +30,15 @@ export function AddRank({ }): JSX.Element { const loading = React.useContext(LoadingContext); - const [isAddingRank, setIsAddingRank] = React.useState(false); - const [isAddingParentRank, setIsAddingParentRank] = React.useState(false); + const [state, setState] = React.useState<'initial' | 'parent' | 'add'>( + 'initial' + ); const [parentRank, setParentRank] = React.useState(''); const treeId = strictIdFromUrl(treeDefinitionItems[0].treeDef); const treeResource = React.useMemo( - () => - tableName === 'Geography' - ? new tables.GeographyTreeDefItem.Resource({ - _tableName: 'GeographyTreeDefItem', - }) - : tableName === 'Taxon' - ? new tables.TaxonTreeDefItem.Resource({ - _tableName: 'TaxonTreeDefItem', - }) - : tableName === 'LithoStrat' - ? new tables.LithoStratTreeDefItem.Resource({ - _tableName: 'LithoStratTreeDefItem', - }) - : new tables.StorageTreeDefItem.Resource({ - _tableName: 'StorageTreeDefItem', - }), + () => new tables[treeDefinitionItems[0]._tableName].Resource(), [tableName] ); @@ -91,7 +77,7 @@ export function AddRank({ }, }).then(() => globalThis.location.reload()) ); - setIsAddingRank(false); + setState('initial'); } return ( @@ -99,17 +85,16 @@ export function AddRank({ setIsAddingParentRank(true)} + onClick={() => setState('parent')} /> - {isAddingParentRank && ( + {state === 'parent' && ( {commonText.cancel()} { - setIsAddingRank(true); - setIsAddingParentRank(false); + setState('add'); }} > {interactionsText.continue()} @@ -117,26 +102,25 @@ export function AddRank({ } header={treeText.chooseParentRank()} - onClose={() => setIsAddingParentRank(false)} + onClose={() => setState('initial')} > - + + {treeText.chooseParentRank()} + + )} - {isAddingRank && ( + {state === 'add' && ( ({ resource={treeResource as SpecifyResource} title={treeText.addNewRank()} onAdd={undefined} - onClose={(): void => setIsAddingRank(false)} + onClose={(): void => setState('initial')} onDeleted={undefined} onSaved={undefined} onSaving={() => { diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index 454201600cc..7cdadd00844 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -462,9 +462,6 @@ export const treeText = createDictionary({ addNewRank: { 'en-us': 'Add New Rank', }, - newRankName: { - 'en-us': 'New Rank Name', - }, chooseParentRank: { 'en-us': 'Choose Parent Rank', }, From a045b1d1cf8a3905603715178820d0654e19f695 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 21 Mar 2024 13:44:16 +0000 Subject: [PATCH 29/99] Lint code with ESLint and Prettier Triggered by 77e53aeb3155e9131295d8002431be93a2ecbcb6 on branch refs/heads/add_delete_tree_rank_api --- .../lib/components/TreeView/AddRank.tsx | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 1bbcf20448e..87538dfe7de 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -1,23 +1,24 @@ import React from 'react'; -import { Button } from '../Atoms/Button'; + +import { commonText } from '../../localization/common'; +import { interactionsText } from '../../localization/interactions'; import { treeText } from '../../localization/tree'; +import { ping } from '../../utils/ajax/ping'; +import type { RA } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { Label, Select } from '../Atoms/Form'; import { LoadingContext } from '../Core/Contexts'; -import { tables } from '../DataModel/tables'; -import { +import type { AnyTree, FilterTablesByEndsWith, SerializedResource, } from '../DataModel/helperTypes'; -import { ping } from '../../utils/ajax/ping'; -import { Dialog } from '../Molecules/Dialog'; -import { commonText } from '../../localization/common'; -import { interactionsText } from '../../localization/interactions'; -import { Label, Select } from '../Atoms/Form'; -import { RA } from '../../utils/types'; -import { ResourceView } from '../Forms/ResourceView'; import { SpecifyResource } from '../DataModel/legacyTypes'; -import { GeographyTreeDefItem } from '../DataModel/types'; import { strictIdFromUrl } from '../DataModel/resource'; +import { tables } from '../DataModel/tables'; +import { GeographyTreeDefItem } from '../DataModel/types'; +import { ResourceView } from '../Forms/ResourceView'; +import { Dialog } from '../Molecules/Dialog'; export function AddRank({ tableName, @@ -30,7 +31,7 @@ export function AddRank({ }): JSX.Element { const loading = React.useContext(LoadingContext); - const [state, setState] = React.useState<'initial' | 'parent' | 'add'>( + const [state, setState] = React.useState<'add' | 'initial' | 'parent'>( 'initial' ); @@ -48,32 +49,16 @@ export function AddRank({ ping(url, { method: 'POST', body: { - newRankName: ( - treeResource as SpecifyResource - ).get('name'), + newRankName: treeResource.get('name'), parentRankName: parentRank, treeID: treeId, - newRankTitle: ( - treeResource as SpecifyResource - ).get('title'), - remarks: (treeResource as SpecifyResource).get( - 'remarks' - ), - textAfter: ( - treeResource as SpecifyResource - ).get('textAfter'), - textBefore: ( - treeResource as SpecifyResource - ).get('textBefore'), - isEnforced: ( - treeResource as SpecifyResource - ).get('isEnforced'), - isInFullName: ( - treeResource as SpecifyResource - ).get('isInFullName'), - fullNameSeparator: ( - treeResource as SpecifyResource - ).get('fullNameSeparator'), + newRankTitle: treeResource.get('title'), + remarks: treeResource.get('remarks'), + textAfter: treeResource.get('textAfter'), + textBefore: treeResource.get('textBefore'), + isEnforced: treeResource.get('isEnforced'), + isInFullName: treeResource.get('isInFullName'), + fullNameSeparator: treeResource.get('fullNameSeparator'), }, }).then(() => globalThis.location.reload()) ); @@ -125,7 +110,7 @@ export function AddRank({ dialog="modal" isDependent={false} isSubForm={false} - resource={treeResource as SpecifyResource} + resource={treeResource} title={treeText.addNewRank()} onAdd={undefined} onClose={(): void => setState('initial')} From 947d9d20120c862c08c7d897b864c4bed2402a7d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 21 Mar 2024 07:12:31 -0700 Subject: [PATCH 30/99] Remove addRank function --- .../lib/components/TreeView/AddRank.tsx | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 87538dfe7de..5a2266688dc 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -3,20 +3,15 @@ import React from 'react'; import { commonText } from '../../localization/common'; import { interactionsText } from '../../localization/interactions'; import { treeText } from '../../localization/tree'; -import { ping } from '../../utils/ajax/ping'; import type { RA } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { Label, Select } from '../Atoms/Form'; -import { LoadingContext } from '../Core/Contexts'; import type { AnyTree, FilterTablesByEndsWith, SerializedResource, } from '../DataModel/helperTypes'; -import { SpecifyResource } from '../DataModel/legacyTypes'; -import { strictIdFromUrl } from '../DataModel/resource'; import { tables } from '../DataModel/tables'; -import { GeographyTreeDefItem } from '../DataModel/types'; import { ResourceView } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; @@ -29,42 +24,17 @@ export function AddRank({ SerializedResource> >; }): JSX.Element { - const loading = React.useContext(LoadingContext); - const [state, setState] = React.useState<'add' | 'initial' | 'parent'>( 'initial' ); const [parentRank, setParentRank] = React.useState(''); - const treeId = strictIdFromUrl(treeDefinitionItems[0].treeDef); const treeResource = React.useMemo( () => new tables[treeDefinitionItems[0]._tableName].Resource(), [tableName] ); - function addRank(): void { - const url = `/api/specify_tree/${tableName.toLowerCase()}/add_tree_rank/`; - loading( - ping(url, { - method: 'POST', - body: { - newRankName: treeResource.get('name'), - parentRankName: parentRank, - treeID: treeId, - newRankTitle: treeResource.get('title'), - remarks: treeResource.get('remarks'), - textAfter: treeResource.get('textAfter'), - textBefore: treeResource.get('textBefore'), - isEnforced: treeResource.get('isEnforced'), - isInFullName: treeResource.get('isInFullName'), - fullNameSeparator: treeResource.get('fullNameSeparator'), - }, - }).then(() => globalThis.location.reload()) - ); - setState('initial'); - } - return ( <> ({ onDeleted={undefined} onSaved={undefined} onSaving={() => { - addRank(); - return false; + setState('initial'); + globalThis.location.reload(); }} /> )} From 62fbee270c49edada35f76b6df7c6a62a0123aa5 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 Mar 2024 10:03:56 -0500 Subject: [PATCH 31/99] fix tree dependent fields --- specifyweb/specify/load_datamodel.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/specify/load_datamodel.py b/specifyweb/specify/load_datamodel.py index 40383c6f2c0..b5be209c42b 100644 --- a/specifyweb/specify/load_datamodel.py +++ b/specifyweb/specify/load_datamodel.py @@ -320,14 +320,14 @@ def flag_system_tables(datamodel: Datamodel) -> None: 'Exsiccata.exsiccataitems', 'Fieldnotebook.pagesets', 'Fieldnotebookpageset.pages', - 'Geograhytreedef.treedefitems', + 'Geographytreedef.treedefitems', 'Geologictimeperiodtreedef.treedefitems', 'Gift.addressofrecord', 'Gift.giftagents', 'Gift.giftpreparations', 'Gift.shipments', 'Latlonpolygon.points', - 'Lithostratreedef.treedefitems', + 'lithostrattreedef.treedefitems', 'Loan.addressofrecord', 'Loan.loanagents', 'Loan.loanpreparations', @@ -350,7 +350,7 @@ def flag_system_tables(datamodel: Datamodel) -> None: 'Repositoryagreement.repositoryagreementagents', 'Repositoryagreement.repositoryagreementauthorizations', 'Spquery.fields', - 'Storagetreedef.storagetreeitems', + 'Storagetreedef.treedefitems', 'Taxon.commonnames', 'Taxon.taxoncitations', 'Taxon.taxonattribute', From 0258daba33bb5ef29cf1071f4c32c7d212a494e6 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 Mar 2024 12:08:02 -0500 Subject: [PATCH 32/99] filter_rank_deletion_exception --- specifyweb/businessrules/rules/tree_rules.py | 2 +- specifyweb/specify/tree_extras.py | 19 ++++++++++++++++++- specifyweb/specify/views.py | 8 ++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 3829d4f7413..3430e5a852c 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -45,7 +45,7 @@ def post_tree_rank_initiation(tree_def_item_model, new_rank): if child_ranks.exists(): # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update for child_rank in child_ranks: - child_rank.parent = new_rank + child_rank.parent = new_rank child_rank.save() # Regenerate full names diff --git a/specifyweb/specify/tree_extras.py b/specifyweb/specify/tree_extras.py index d12fc96feba..dd3e23a41a3 100644 --- a/specifyweb/specify/tree_extras.py +++ b/specifyweb/specify/tree_extras.py @@ -659,4 +659,21 @@ def renumber_tree(table): from .models import datamodel, Sptasksemaphore tree_model = datamodel.get_table(table) tasknames = [name.format(tree_model.name) for name in ("UpdateNodes{}", "BadNodes{}")] - Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) \ No newline at end of file + Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) + +def is_instance_of_tree_def_item(obj): + from specifyweb.specify.models import ( + Geographytreedefitem, + Geologictimeperiodtreedefitem, + Lithostrattreedefitem, + Storagetreedefitem, + Taxontreedefitem, + ) + tree_def_item_classes = [ + Geographytreedefitem, + Geologictimeperiodtreedefitem, + Lithostrattreedefitem, + Storagetreedefitem, + Taxontreedefitem, + ] + return any(isinstance(obj, cls) for cls in tree_def_item_classes) \ No newline at end of file diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index 94ab17a982d..3bb9509b702 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -22,6 +22,7 @@ PermissionTargetAction, PermissionsException, check_permission_targets, table_permissions_checker from specifyweb.celery_tasks import app from specifyweb.specify.record_merging import record_merge_fx, record_merge_task, resolve_record_merge_response +from specifyweb.specify.tree_extras import is_instance_of_tree_def_item from . import api, models as spmodels from .build_models import orderings from .specify_jar import specify_jar @@ -91,6 +92,12 @@ def raise_error(request): raise Exception('This error is a test. You may now return to your regularly ' 'scheduled hacking.') +def filter_rank_deletion_exception(obj, delete_blockers): + # Check if the object is a tree rank + if not is_instance_of_tree_def_item(obj): + return + # Filter out blocker that is the child tree rank of tree rank being deleted + delete_blockers[:] = list(filter(lambda db: db['field'] != 'parent', delete_blockers)) @login_maybe_required @require_http_methods(['GET', 'HEAD']) @@ -113,6 +120,7 @@ def delete_blockers(request, model, id): } ] for field, sub_objs in collector.delete_blockers ]) + filter_rank_deletion_exception(obj, result) return http.HttpResponse(api.toJson(result), content_type='application/json') From 29cbb77e8c8395e8b77c4105a5e2d00e08338581 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 Mar 2024 12:24:26 -0500 Subject: [PATCH 33/99] fix delete_blockers filter --- specifyweb/specify/views.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index 3bb9509b702..8e103d47d3d 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -96,8 +96,14 @@ def filter_rank_deletion_exception(obj, delete_blockers): # Check if the object is a tree rank if not is_instance_of_tree_def_item(obj): return - # Filter out blocker that is the child tree rank of tree rank being deleted - delete_blockers[:] = list(filter(lambda db: db['field'] != 'parent', delete_blockers)) + # Filter out blocker that is the child tree rank of tree rank being deleted. + # This is handled by the business rules when deleting a tree rank, the parent is set to the grandparent. + delete_blockers[:] = list( + filter( + lambda db: db['field'] != 'parent' and db['table'] == type(obj).__name__, + delete_blockers + ) + ) @login_maybe_required @require_http_methods(['GET', 'HEAD']) From 6693ab9d4b65b043b76e534325dcbb484cb8d264 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 Mar 2024 15:27:30 -0500 Subject: [PATCH 34/99] tree ranks bussiness rules init --- specifyweb/businessrules/rules/tree_rules.py | 54 +--- specifyweb/specify/tree_ranks.py | 312 +++++++++++++++++++ 2 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 specifyweb/specify/tree_ranks.py diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 3430e5a852c..0f5f9b0741f 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -2,15 +2,25 @@ from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.businessrules.exceptions import TreeBusinessRuleException -from specifyweb.specify import tree_extras +from specifyweb.specify.tree_extras import is_instance_of_tree_def_item +from specifyweb.specify.tree_ranks import * logger = logging.getLogger(__name__) +@orm_signal_handler('pre_save') +def pre_tree_rank_initiation_handler(sender, obj): + if is_instance_of_tree_def_item(obj): # is it a treedefitem? + if obj.pk is None: + pre_tree_rank_init(sender, obj) + else: + # pre_tree_rank_update(sender, obj) + pass + # @orm_signal_handler('post_init') @orm_signal_handler('post_save') def post_tree_rank_initiation_handler(sender, obj): if hasattr(obj, 'treedef'): # is it a treedefitem? - post_tree_rank_initiation(sender, obj) + post_tree_rank_init(sender, obj) @orm_signal_handler('pre_delete') def cannot_delete_root_treedefitem(sender, obj): @@ -35,45 +45,5 @@ def set_is_accepted_if_prefereed(sender, obj): if hasattr(obj, 'isaccepted'): obj.isaccepted = obj.accepted_id == None -def post_tree_rank_initiation(tree_def_item_model, new_rank): - tree_def = new_rank.treedef - parent_rank = new_rank.parent - new_rank_id = new_rank.rankid - - # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) - if child_ranks.exists(): - # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update - for child_rank in child_ranks: - child_rank.parent = new_rank - child_rank.save() - - # Regenerate full names - tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) - -def pre_tree_rank_deletion(tree_def_item_model, rank): - tree_def = rank.treedef - - # Make sure no nodes are present in the rank before deleting rank - rank = tree_def_item_model.objects.get(name=rank.name) - if tree_def_item_model.objects.filter(parent=rank).count() > 1: - raise TreeBusinessRuleException( - "The Rank is not empty, cannot delete!", - {"tree": rank.treedef.__class__.__name__, - "localizationKey": 'deletingTreeRank', - "node": { - "id": rank.id - }}) - - # Set the parent rank, that previously pointed to the old rank, to the target rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) - if child_ranks.exists(): - # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update - for child_rank in child_ranks: - child_rank.parent = rank.parent - child_rank.save() -def post_tree_rank_deletion(rank): - # Regenerate full names - tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) \ No newline at end of file diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py new file mode 100644 index 00000000000..7a2dfed23b8 --- /dev/null +++ b/specifyweb/specify/tree_ranks.py @@ -0,0 +1,312 @@ +from functools import wraps +from hmac import new +import json +from django.db import transaction +from django.http import HttpResponse, HttpResponseServerError, HttpResponseBadRequest +from django.views.decorators.http import require_GET, require_POST +from sqlalchemy import sql +from sqlalchemy.orm import aliased + +from specifyweb.middleware.general import require_GET +from specifyweb.businessrules.exceptions import BusinessRuleException, TreeBusinessRuleException +from specifyweb.permissions.permissions import PermissionTarget, \ + PermissionTargetAction, check_permission_targets +from specifyweb.stored_queries import models +from . import tree_extras +from .api import get_object_or_404, obj_to_data, toJson +from .auditcodes import TREE_MOVE +from .models import datamodel +from .tree_stats import get_tree_stats +from .views import login_maybe_required, openapi +from . import models as spmodels +from sys import maxsize + +import logging +logger = logging.getLogger(__name__) + +TAXON_RANKS = { + 'TAXONOMY_ROOT': 0, + 'TAXONOMY ROOT': 0, + 'LIFE': 0, + 'KINGDOM': 10, + 'SUBKINGDOM': 20, + 'DIVISION': 30, + 'PHYLUM': 30, + 'SUBDIVISION': 40, + 'SUBPHYLUM': 40, + 'SUPERCLASS': 50, + 'CLASS': 60, + 'SUBCLASS': 70, + 'INFRACLASS': 80, + 'SUPERORDER': 90, + 'ORDER': 100, + 'SUBORDER': 110, + 'INFRAORDER': 120, + 'PARVORDER': 125, + 'SUPERFAMILY': 130, + 'FAMILY': 140, + 'SUBFAMILY': 150, + 'TRIBE': 160, + 'SUBTRIBE': 170, + 'GENUS': 180, + 'SUBGENUS': 190, + 'SECTION': 200, + 'SUBSECTION': 210, + 'SPECIES': 220, + 'SUBSPECIES': 230, + 'VARIETY': 240, + 'SUBVARIETY': 250, + 'FORMA': 260, + 'SUBFORMA': 270 +} +GEOGRAPHY_RANKS = { + 'CONTINENT': 100, + 'COUNTRY': 200, + 'STATE': 300, + 'COUNTY': 400 +} +STORAGE_RANKS = { + 'BUILDING': 100, + 'COLLECTION': 150, + 'ROOM': 200, + 'AISLE': 250, + 'CABINET': 300, + 'SHELF': 350, + 'BOX': 400, + 'RACK': 450, + 'VIAL': 500 +} +GEOLOGIC_TIME_PERIOD_RANKS = { + 'ERA': 100, + 'PERIOD': 200, + 'EPOCH': 300, + 'AGE': 400 +} +LITHO_STRAT_RANKS = { + 'SUPERGROUP': 100, + 'GROUP': 200, + 'FORMATION': 300, + 'MEMBER': 400, + 'BED': 500 +} + +DEFAULT_RANK_INCREMENT = 100 +TAXON_RANK_INCREMENT = 10 +GEOGRAPHY_RANK_INCREMENT = DEFAULT_RANK_INCREMENT +STORAGE_RANK_INCREMENT = 50 +GEOLOGIC_TIME_PERIOD_INCREMENT = DEFAULT_RANK_INCREMENT +LITHO_STRAT_INCREMENT = DEFAULT_RANK_INCREMENT + +# Map tree type to default tree ranks and default rank id increment +TREE_RANKS_MAPPING = { + 'taxon': (TAXON_RANKS, TAXON_RANK_INCREMENT), + 'geography': (GEOGRAPHY_RANKS, GEOGRAPHY_RANK_INCREMENT), + 'storage': (STORAGE_RANKS, STORAGE_RANK_INCREMENT), + 'geologictimeperiod': (GEOLOGIC_TIME_PERIOD_RANKS, GEOLOGIC_TIME_PERIOD_INCREMENT), + 'lithostrat': (LITHO_STRAT_RANKS, LITHO_STRAT_INCREMENT), +} + +def pre_tree_rank_init(tree_def_item_model, new_rank): + if new_rank.parent is None: + return + tree_def = new_rank.treedef # maybe get patent type + tree_def_model = tree_def.__class__ + tree = tree_def_model.lower() + tree_id = 1 # edit this to be dynamic in the future, but currently there is only one tree + tree_name = tree_def_model.objects.get(id=tree_id).name + parent_rank = new_rank.parent + parent_rank_name = new_rank.parent.name + use_default_rank_ids = True # edit this to be dynamic in the future, so rankids can be set manually + new_rank_id = new_rank.rankid + new_rank_name = new_rank.name + new_rank_title = new_rank.title + new_rank_extra_params = { + 'remarks': getattr(new_rank, 'remarks', None), + 'textafter': getattr(new_rank, 'textafter', None), + 'textbefore': getattr(new_rank, 'textbefore', None), + 'isenforced': getattr(new_rank, 'isenforced', None), + 'isinfullname': getattr(new_rank, 'isinfullname', None), + 'fullnameseparator': getattr(new_rank, 'fullnameseparator', None), + } + + # Throw exceptions if the required parameters are not given correctly + if new_rank_name is None: + raise TreeBusinessRuleException( + "Rank name is not given", + {"tree": tree_def.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank_id + }} + ) + if parent_rank_name is None: + raise TreeBusinessRuleException( + "Parent rank name is not given", + {"tree": tree_def.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank_id + }} + ) + if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): + raise TreeBusinessRuleException( + "Invalid tree type", + {"tree": tree_def.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank_id + }} + ) + + # Determine the new rank id parameters + new_rank_id = None + tree_def = tree_def_model.objects.get(id=tree_id) + try: + tree_def = tree_def_model.objects.get(name=tree_name) + except tree_def_model.DoesNotExist: + pass + parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() + if parent_rank is None and parent_rank_name != 'root': + raise TreeBusinessRuleException( + "Target rank name does not exist", + {"tree": tree_def.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank_id + }} + ) + parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 + rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) + parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 + next_rank_id = rank_ids[parent_rank_idx + 1] if rank_ids is not None and parent_rank_idx + 1 < len(rank_ids) else None + if next_rank_id is None and parent_rank_name != 'root': + next_rank_id = maxsize + + # Don't allow rank IDs less than 2 + if next_rank_id == 0: + raise TreeBusinessRuleException( + "Can't create rank ID less than 0", + {"tree": tree_def.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank_id + }} + ) + + # Set conditions for rank ID creation + is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 + is_new_rank_first = parent_rank_id == -1 + is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True + + # Set the default ranks and increments depending on the tree type + default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower(), (None, 100)) + + # Build new fields for the new TreeDefItem record + new_fields_dict = { + 'name': new_rank_name.lower().title(), + 'title': new_rank_title, + 'parent': parent_rank, + 'treedef': tree_def + } + for key, value in new_rank_extra_params.items(): + if value is not None: + new_fields_dict[key] = value + + # Determine if the default rank ID can be used + can_use_default_rank_id = ( + use_default_rank_ids + and default_tree_ranks is not None + and new_rank_name.upper() in default_tree_ranks + ) + + # Only use the the default rank id if the fhe following criteria is met: + # - new_rank_name is in the the default ranks set + # - the default rank id is not already used + # - the default rank is greater than the target rank + # - the default rank is less than the current next rank from the target rank + if can_use_default_rank_id: + default_rank_id = default_tree_ranks[new_rank_name.upper()] + + # Check if the default rank ID is not already used + is_default_rank_id_unused = default_rank_id not in rank_ids + + # Check if the default rank ID can be logically placed in the hierarchy + is_placement_valid = ( + is_tree_def_items_empty + or (is_new_rank_first and default_rank_id < next_rank_id) + or (is_new_rank_last and default_rank_id > parent_rank_id) + or (default_rank_id > parent_rank_id and default_rank_id < next_rank_id) + ) + + if is_default_rank_id_unused and is_placement_valid: + new_rank_id = default_rank_id + + # Set the new rank id if a default rank id is not available + if new_rank_id is None: + # If this is the first rank, set the rank id to the default increment + if is_tree_def_items_empty: + new_rank_id = rank_increment + + # If there are no ranks higher than the target rank, then add the new rank to the end of the hierarchy + elif is_new_rank_first: + min_rank_id = rank_ids[0] + new_rank_id = int(min_rank_id / 2) + if new_rank_id >= min_rank_id: + return HttpResponseBadRequest(f"Can't add rank id bellow {min_rank_id}") + + # If there are no ranks lower than the target rank, then add the new rank to the top of the hierarchy + elif is_new_rank_last: + max_rank_id = rank_ids[-1] + new_rank_id = max_rank_id + rank_increment # TODO: checkout + + # If the new rank is being placed somewhere in the middle of the heirarchy + else: + new_rank_id = int((next_rank_id - parent_rank_id) / 2) + parent_rank_id + if next_rank_id - parent_rank_id < 1: + return HttpResponseBadRequest(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") + + # Set the new rank fields from new_fields_dict + for key, value in new_fields_dict.items(): + setattr(new_rank, key, value) + +def post_tree_rank_init(tree_def_item_model, new_rank): + tree_def = new_rank.treedef + parent_rank = new_rank.parent + new_rank_id = new_rank.rankid + + # Set the parent rank, that previously pointed to the target, to the new rank + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parent = new_rank + child_rank.save() + + # Regenerate full names + tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) + +def pre_tree_rank_deletion(tree_def_item_model, rank): + tree_def = rank.treedef + + # Make sure no nodes are present in the rank before deleting rank + rank = tree_def_item_model.objects.get(name=rank.name) + if tree_def_item_model.objects.filter(parent=rank).count() > 1: + raise TreeBusinessRuleException( + "The Rank is not empty, cannot delete!", + {"tree": rank.treedef.__class__.__name__, + "localizationKey": 'deletingTreeRank', + "node": { + "id": rank.id + }}) + + # Set the parent rank, that previously pointed to the old rank, to the target rank + child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) + if child_ranks.exists(): + # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update + for child_rank in child_ranks: + child_rank.parent = rank.parent + child_rank.save() + +def post_tree_rank_deletion(rank): + # Regenerate full names + tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) \ No newline at end of file From e696d4ead2579a5fff3b96ab8f0bfd8982d8e9c0 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 Mar 2024 15:29:36 -0500 Subject: [PATCH 35/99] raise exception for parent id not given --- specifyweb/specify/tree_ranks.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 7a2dfed23b8..e8d1f5f0543 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -107,8 +107,15 @@ } def pre_tree_rank_init(tree_def_item_model, new_rank): - if new_rank.parent is None: - return + if new_rank.parent_id is None: + raise TreeBusinessRuleException( + "Parent id for the rank is not given", + {"tree": tree_def_item_model.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank.id + }} + ) tree_def = new_rank.treedef # maybe get patent type tree_def_model = tree_def.__class__ tree = tree_def_model.lower() From bd35e0f488d70f4923ce16bf2a5885851128b317 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:01:51 -0700 Subject: [PATCH 36/99] test assign parent to treeResource --- .../js_src/lib/components/TreeView/AddRank.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 5a2266688dc..08ea3371af5 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -14,6 +14,9 @@ import type { import { tables } from '../DataModel/tables'; import { ResourceView } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; +import { deserializeResource } from '../DataModel/serializers'; +import { SpecifyResource } from '../DataModel/legacyTypes'; +import { GeographyTreeDefItem } from '../DataModel/types'; export function AddRank({ tableName, @@ -63,11 +66,19 @@ export function AddRank({ {treeText.chooseParentRank()} { setParentRank(JSON.parse(target.value)); const resourceParent = React.useMemo( () => deserializeResource(JSON.parse(target.value)), [] - ) as SpecifyResource; + ); treeResource.set('parent', resourceParent); }} > From 907da383addedf0767f6411ccd9517ee342d7b3e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:08:34 -0700 Subject: [PATCH 38/99] Set parent on continue --- .../js_src/lib/components/TreeView/AddRank.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index b6713b309c6..d3713f245f3 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -52,6 +52,11 @@ export function AddRank({ {commonText.cancel()} { + const resourceParent = React.useMemo( + () => deserializeResource(JSON.parse(parentRank)), + [] + ) as SpecifyResource; + treeResource.set('parent', resourceParent); setState('add'); }} > @@ -70,11 +75,6 @@ export function AddRank({ value={JSON.stringify(parentRank)} onChange={({ target }): void => { setParentRank(JSON.parse(target.value)); - const resourceParent = React.useMemo( - () => deserializeResource(JSON.parse(target.value)), - [] - ); - treeResource.set('parent', resourceParent); }} > {treeDefinitionItems.map((rank, index) => ( From 97139a2a42f4c9faa6848d91f2194710e5f429e5 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 21 Mar 2024 21:12:11 +0000 Subject: [PATCH 39/99] Lint code with ESLint and Prettier Triggered by 907da383addedf0767f6411ccd9517ee342d7b3e on branch refs/heads/add_delete_tree_rank_api --- .../frontend/js_src/lib/components/TreeView/AddRank.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index d3713f245f3..64e2730d51f 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -11,10 +11,10 @@ import type { FilterTablesByEndsWith, SerializedResource, } from '../DataModel/helperTypes'; -import { SpecifyResource } from '../DataModel/legacyTypes'; +import type { SpecifyResource } from '../DataModel/legacyTypes'; import { deserializeResource } from '../DataModel/serializers'; import { tables } from '../DataModel/tables'; -import { GeographyTreeDefItem } from '../DataModel/types'; +import type { GeographyTreeDefItem } from '../DataModel/types'; import { ResourceView } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; @@ -55,7 +55,7 @@ export function AddRank({ const resourceParent = React.useMemo( () => deserializeResource(JSON.parse(parentRank)), [] - ) as SpecifyResource; + ); treeResource.set('parent', resourceParent); setState('add'); }} From 5fcf3b445c1be12d3b3c048eb5b97da11d3be42a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Thu, 21 Mar 2024 17:05:29 -0500 Subject: [PATCH 40/99] param and exception fixes --- specifyweb/specify/tree_ranks.py | 95 ++++++++++++-------------------- 1 file changed, 35 insertions(+), 60 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index e8d1f5f0543..a431cdc0959 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -107,81 +107,44 @@ } def pre_tree_rank_init(tree_def_item_model, new_rank): - if new_rank.parent_id is None: - raise TreeBusinessRuleException( - "Parent id for the rank is not given", - {"tree": tree_def_item_model.__class__.__name__, - "localizationKey": 'creatingTreeRank', - "node": { - "id": new_rank.id - }} - ) - tree_def = new_rank.treedef # maybe get patent type - tree_def_model = tree_def.__class__ - tree = tree_def_model.lower() + if new_rank.parent is None: + raise_tree_business_rule_exception("Parent id for the rank is not given", tree_def_item_model, new_rank.id) + tree_id = 1 # edit this to be dynamic in the future, but currently there is only one tree + use_default_rank_ids = True # edit this to be dynamic in the future, so rankids can be set manually + # tree_def = tree_def_item_model.objects.get(id=new_rank.parent_id).treedef + tree_def = new_rank.parent.treedef + tree_def_model = type(tree_def) + tree = new_rank.specify_model.name.replace("TreeDefItem", "").lower() tree_name = tree_def_model.objects.get(id=tree_id).name parent_rank = new_rank.parent parent_rank_name = new_rank.parent.name - use_default_rank_ids = True # edit this to be dynamic in the future, so rankids can be set manually new_rank_id = new_rank.rankid new_rank_name = new_rank.name new_rank_title = new_rank.title - new_rank_extra_params = { - 'remarks': getattr(new_rank, 'remarks', None), - 'textafter': getattr(new_rank, 'textafter', None), - 'textbefore': getattr(new_rank, 'textbefore', None), - 'isenforced': getattr(new_rank, 'isenforced', None), - 'isinfullname': getattr(new_rank, 'isinfullname', None), - 'fullnameseparator': getattr(new_rank, 'fullnameseparator', None), + attributes = ['remarks', 'textafter', 'textbefore', 'isenforced', 'isinfullname', 'fullnameseparator'] + new_rank_extra_params = {attr: getattr(new_rank, attr, None) for attr in attributes} + + required_params = { + 'new_rank_name': "Rank name is not given", + 'parent_rank_name': "Parent rank name is not given", + 'tree': "Invalid tree type" } - # Throw exceptions if the required parameters are not given correctly - if new_rank_name is None: - raise TreeBusinessRuleException( - "Rank name is not given", - {"tree": tree_def.__class__.__name__, - "localizationKey": 'creatingTreeRank', - "node": { - "id": new_rank_id - }} - ) - if parent_rank_name is None: - raise TreeBusinessRuleException( - "Parent rank name is not given", - {"tree": tree_def.__class__.__name__, - "localizationKey": 'creatingTreeRank', - "node": { - "id": new_rank_id - }} - ) - if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): - raise TreeBusinessRuleException( - "Invalid tree type", - {"tree": tree_def.__class__.__name__, - "localizationKey": 'creatingTreeRank', - "node": { - "id": new_rank_id - }} - ) + for param, message in required_params.items(): + if locals()[param] is None: + raise_tree_business_rule_exception(message, tree_def_item_model, new_rank_id) - # Determine the new rank id parameters - new_rank_id = None tree_def = tree_def_model.objects.get(id=tree_id) try: tree_def = tree_def_model.objects.get(name=tree_name) except tree_def_model.DoesNotExist: pass - parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() + + parent_rank = tree_def_item_model.objects.get(treedef=tree_def, name=parent_rank_name) if parent_rank is None and parent_rank_name != 'root': - raise TreeBusinessRuleException( - "Target rank name does not exist", - {"tree": tree_def.__class__.__name__, - "localizationKey": 'creatingTreeRank', - "node": { - "id": new_rank_id - }} - ) + raise_tree_business_rule_exception("Target rank name does not exist", tree_def_item_model, new_rank_id) + parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 @@ -316,4 +279,16 @@ def pre_tree_rank_deletion(tree_def_item_model, rank): def post_tree_rank_deletion(rank): # Regenerate full names - tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) \ No newline at end of file + tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) + +def raise_tree_business_rule_exception(message, tree_def, new_rank_id): + raise TreeBusinessRuleException( + message, + { + "tree": tree_def.__class__.__name__, + "localizationKey": 'creatingTreeRank', + "node": { + "id": new_rank_id + } + } + ) From 173eeae32fc984deacfd63bd4e690b635760ef05 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 22 Mar 2024 07:16:05 -0700 Subject: [PATCH 41/99] Assign parent to new rank with parent id --- .../lib/components/TreeView/AddRank.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx index 64e2730d51f..d5536ec8d2c 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -17,6 +17,7 @@ import { tables } from '../DataModel/tables'; import type { GeographyTreeDefItem } from '../DataModel/types'; import { ResourceView } from '../Forms/ResourceView'; import { Dialog } from '../Molecules/Dialog'; +import { getResourceApiUrl } from '../DataModel/resource'; export function AddRank({ tableName, @@ -52,11 +53,12 @@ export function AddRank({ {commonText.cancel()} { - const resourceParent = React.useMemo( - () => deserializeResource(JSON.parse(parentRank)), - [] + const resourceParent = getResourceApiUrl( + treeDefinitionItems[0]._tableName, + parentRank ); treeResource.set('parent', resourceParent); + console.log(resourceParent, treeResource); setState('add'); }} > @@ -71,14 +73,13 @@ export function AddRank({ {treeText.chooseParentRank()} { - setParentRank(target.value); - }} - > - {treeDefinitionItems.map((rank, index) => ( - - ))} - - +
+ + {treeText.chooseParentRank()} + + +
)} {state === 'add' && ( From 3a548c3125d4917d26958256d89b340d752bbc67 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Fri, 29 Mar 2024 08:39:26 -0700 Subject: [PATCH 68/99] Remove prop --- specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index a4ca4ba0bcf..ff2b3941dc4 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -165,10 +165,7 @@ export function Tree({ treeDefinitionItems[0]._tableName, 'create' ) ? ( - + ) : null} ) : null} From a8224307a015c4ebc8590349b726b153581374b5 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 1 Apr 2024 15:12:29 -0500 Subject: [PATCH 69/99] abstract orm_signal_handler --- .../businessrules/orm_signal_handler.py | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/specifyweb/businessrules/orm_signal_handler.py b/specifyweb/businessrules/orm_signal_handler.py index 7407db8c271..fbd4e391660 100644 --- a/specifyweb/businessrules/orm_signal_handler.py +++ b/specifyweb/businessrules/orm_signal_handler.py @@ -10,7 +10,7 @@ "post_save", "pre_delete", "post_delete", "m2m_changed"] -def orm_signal_handler(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): +def _create_dec_func(signal: MODEL_SIGNAL, model: Optional[str] = None, use_kwars_in_rule_call: bool = False, **kwargs): def _dec(rule): receiver_kwargs = kwargs if model is not None: @@ -21,36 +21,29 @@ def handler(sender, **kwargs): return # since the rule knows what model the signal comes from # the sender value is redundant. - rule(kwargs['instance']) + if use_kwars_in_rule_call: + instance = kwargs.pop('instance') + rule(instance, **kwargs) + else: + rule(kwargs['instance']) else: def handler(sender, **kwargs): if kwargs.get('raw', False): return - rule(sender, kwargs['instance']) + if use_kwars_in_rule_call: + instance = kwargs.pop('instance') + rule(sender, instance, **kwargs) + else: + rule(sender, kwargs['instance']) return receiver(getattr(signals, signal), **receiver_kwargs)(handler) return _dec -def orm_signal_handler_with_kwargs(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): - def _dec(rule): - receiver_kwargs = kwargs - if model is not None: - receiver_kwargs['sender'] = getattr(models, model) - - def handler(sender, **kwargs): - if kwargs.get('raw', False): - return - instance = kwargs.pop('instance') - rule(instance, **kwargs) - else: - def handler(sender, **kwargs): - if kwargs.get('raw', False): - return - instance = kwargs.pop('instance') - rule(sender, instance, **kwargs) +def orm_signal_handler(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): + return _create_dec_func(signal, model, False, **kwargs) - return receiver(getattr(signals, signal), **receiver_kwargs)(handler) - return _dec +def orm_signal_handler_with_kwargs(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): + return _create_dec_func(signal, model, True, **kwargs) def disconnect_signal(signal: MODEL_SIGNAL, model_name: Optional[str] = None, dispatch_uid: Optional[Hashable] = None) -> bool: fetched_signal = getattr(signals, signal) From ac04d0a1b05f3f27f36bff97c1f7fd7e47dcfcfa Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 1 Apr 2024 15:54:39 -0500 Subject: [PATCH 70/99] unify orm_signal_handler --- .../businessrules/orm_signal_handler.py | 27 +++++++++---------- specifyweb/businessrules/rules/tree_rules.py | 7 +++-- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/specifyweb/businessrules/orm_signal_handler.py b/specifyweb/businessrules/orm_signal_handler.py index fbd4e391660..75dbb858677 100644 --- a/specifyweb/businessrules/orm_signal_handler.py +++ b/specifyweb/businessrules/orm_signal_handler.py @@ -1,3 +1,4 @@ +from inspect import getfullargspec from typing import Callable, Literal, Optional, Hashable from django.db.models import signals @@ -10,7 +11,7 @@ "post_save", "pre_delete", "post_delete", "m2m_changed"] -def _create_dec_func(signal: MODEL_SIGNAL, model: Optional[str] = None, use_kwars_in_rule_call: bool = False, **kwargs): +def orm_signal_handler(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): def _dec(rule): receiver_kwargs = kwargs if model is not None: @@ -21,29 +22,25 @@ def handler(sender, **kwargs): return # since the rule knows what model the signal comes from # the sender value is redundant. - if use_kwars_in_rule_call: - instance = kwargs.pop('instance') - rule(instance, **kwargs) - else: - rule(kwargs['instance']) + rule(kwargs['instance']) else: def handler(sender, **kwargs): if kwargs.get('raw', False): return - if use_kwars_in_rule_call: - instance = kwargs.pop('instance') - rule(sender, instance, **kwargs) + + instance = kwargs['instance'] + created = kwargs.get('created', None) + argspec = getfullargspec(rule) + rule_has_created = 'created' in argspec.args + + if created is not None and rule_has_created: + rule(sender, instance, created) else: - rule(sender, kwargs['instance']) + rule(sender, instance) return receiver(getattr(signals, signal), **receiver_kwargs)(handler) return _dec -def orm_signal_handler(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): - return _create_dec_func(signal, model, False, **kwargs) - -def orm_signal_handler_with_kwargs(signal: MODEL_SIGNAL, model: Optional[str] = None, **kwargs): - return _create_dec_func(signal, model, True, **kwargs) def disconnect_signal(signal: MODEL_SIGNAL, model_name: Optional[str] = None, dispatch_uid: Optional[Hashable] = None) -> bool: fetched_signal = getattr(signals, signal) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 09e9ccffed5..2992627f356 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -1,6 +1,6 @@ import logging -from specifyweb.businessrules.orm_signal_handler import orm_signal_handler, orm_signal_handler_with_kwargs +from specifyweb.businessrules.orm_signal_handler import orm_signal_handler from specifyweb.businessrules.exceptions import TreeBusinessRuleException from specifyweb.specify.tree_extras import is_instance_of_tree_def_item from specifyweb.specify.tree_ranks import * @@ -12,9 +12,8 @@ def pre_tree_rank_initiation_handler(sender, obj): if is_instance_of_tree_def_item(obj) and obj.pk is None: # is it a treedefitem and new object? pre_tree_rank_init(obj) -@orm_signal_handler_with_kwargs('post_save') -def post_tree_rank_initiation_handler(sender, obj, **kwargs): - created = kwargs.get('created', False) # Check if the object was just created +@orm_signal_handler('post_save') +def post_tree_rank_initiation_handler(sender, obj, created): if is_instance_of_tree_def_item(obj) and created: # is it a treedefitem? post_tree_rank_save(sender, obj) From 816337a349c6e6c1ad5602c8dd119c257571d781 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 1 Apr 2024 16:21:52 -0500 Subject: [PATCH 71/99] tree_extras models import change --- specifyweb/specify/tree_extras.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/specifyweb/specify/tree_extras.py b/specifyweb/specify/tree_extras.py index dd3e23a41a3..48f93da13c4 100644 --- a/specifyweb/specify/tree_extras.py +++ b/specifyweb/specify/tree_extras.py @@ -9,6 +9,7 @@ from django.conf import settings from specifyweb.businessrules.exceptions import TreeBusinessRuleException +import specifyweb.specify.models as spmodels from .auditcodes import TREE_MERGE, TREE_SYNONYMIZE, TREE_DESYNONYMIZE @@ -662,18 +663,11 @@ def renumber_tree(table): Sptasksemaphore.objects.filter(taskname__in=tasknames).update(islocked=False) def is_instance_of_tree_def_item(obj): - from specifyweb.specify.models import ( - Geographytreedefitem, - Geologictimeperiodtreedefitem, - Lithostrattreedefitem, - Storagetreedefitem, - Taxontreedefitem, - ) tree_def_item_classes = [ - Geographytreedefitem, - Geologictimeperiodtreedefitem, - Lithostrattreedefitem, - Storagetreedefitem, - Taxontreedefitem, + spmodels.Geographytreedefitem, + spmodels.Geologictimeperiodtreedefitem, + spmodels.Lithostrattreedefitem, + spmodels.Storagetreedefitem, + spmodels.Taxontreedefitem, ] return any(isinstance(obj, cls) for cls in tree_def_item_classes) \ No newline at end of file From 13a0d48fb4bbf58ae81bf9003d43e717914f827c Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 1 Apr 2024 17:00:27 -0500 Subject: [PATCH 72/99] pre_constraints_delete --- specifyweb/specify/api.py | 4 ++-- specifyweb/specify/build_models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index a7ff908aae6..4b49bea53ac 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -618,8 +618,8 @@ def delete_obj(collection, agent, obj, version=None, parent_obj=None) -> None: auditlog.remove(obj, agent, parent_obj) if version is not None: bump_version(obj, version) - if hasattr(obj, 'avant_delete'): - obj.avant_delete() + if hasattr(obj, 'pre_constraints_delete'): + obj.pre_constraints_delete() obj.delete() for dep in dependents_to_delete: diff --git a/specifyweb/specify/build_models.py b/specifyweb/specify/build_models.py index 32a7a75589d..b43635b5f86 100644 --- a/specifyweb/specify/build_models.py +++ b/specifyweb/specify/build_models.py @@ -65,7 +65,7 @@ def save(self, *args, **kwargs): except AbortSave: return - def avant_delete(self): + def pre_constraints_delete(self): # This function is to be called before the object is deleted, and before the pre_delete signal is sent. # It will manually send the pre_delete signal for the django model object. # The pre_delete function must contain logic that will prevent ForeignKey constraints from being violated. @@ -75,7 +75,7 @@ def avant_delete(self): attrs['save'] = save attrs['Meta'] = Meta if table.django_name in tree_def_item_tables: - attrs['avant_delete'] = avant_delete + attrs['pre_constraints_delete'] = pre_constraints_delete supercls = getattr(model_extras, table.django_name, models.Model) model = type(table.django_name, (supercls,), attrs) From 5906c01ca36c6e6f5697be549b3f6d9a8868d5ea Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 2 Apr 2024 10:38:02 -0500 Subject: [PATCH 73/99] verify_rank_parent_chain_integretity --- specifyweb/businessrules/rules/tree_rules.py | 9 ++++- specifyweb/specify/tree_ranks.py | 39 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 2992627f356..ba4d0ddc2fa 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -9,8 +9,12 @@ @orm_signal_handler('pre_save') def pre_tree_rank_initiation_handler(sender, obj): - if is_instance_of_tree_def_item(obj) and obj.pk is None: # is it a treedefitem and new object? - pre_tree_rank_init(obj) + if is_instance_of_tree_def_item(obj) and obj.pk is None: # is it a treedefitem? + if obj.pk is None: # is it a new object? + pre_tree_rank_init(obj) + verify_rank_parent_chain_integretity(obj, RankOperation.CREATED) + else: + verify_rank_parent_chain_integretity(obj, RankOperation.UPDATED) @orm_signal_handler('post_save') def post_tree_rank_initiation_handler(sender, obj, created): @@ -29,6 +33,7 @@ def cannot_delete_root_treedefitem(sender, obj): "id": obj.id }}) pre_tree_rank_deletion(sender, obj) + verify_rank_parent_chain_integretity(obj, RankOperation.DELETED) @orm_signal_handler('post_delete') def post_tree_rank_deletion_handler(sender, obj): diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 2d5dad23425..db49a5138a6 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -1,6 +1,7 @@ from functools import wraps from hmac import new from operator import ge +from enum import Enum from specifyweb.businessrules.exceptions import TreeBusinessRuleException from . import tree_extras @@ -278,3 +279,41 @@ def set_rank_id(new_rank): # Set the new rank id new_rank.rankid = new_rank_id + +class RankOperation(Enum): + CREATED = 'created' + DELETED = 'deleted' + UPDATED = 'updated' + +def verify_rank_parent_chain_integretity(rank, rank_operation: RankOperation): + """ + Verifies the parent chain integrity of the ranks. + """ + tree_def_item_model_name = rank.specify_model.name.lower().title() + tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) + + # Get all the ranks and their parent ranks + rank_id_to_parent_dict = {item.id: item.parent.id if item.parent is not None else None + for item in tree_def_item_model.objects.all()} + + # Edit the rank_id_to_parent_dict with the new rank, depending on the operation. + if rank_operation == RankOperation.CREATED or rank_operation == RankOperation.UPDATED: + rank_id_to_parent_dict[rank.id] = rank.parent.id if rank.parent is not None else None + elif rank_operation == RankOperation.DELETED: + rank_id_to_parent_dict.pop(rank.id, None) + else: + raise ValueError(f"Invalid rank operation: {rank_operation}") + + # Verify the parent chain integrity of the ranks. + # This is done by checking that each rank points to a valid parent rank, and that each parent only has one child. + parent_to_children_dict = {} + for rank_id, parent_id in rank_id_to_parent_dict.items(): + if parent_id is not None: + if parent_id not in rank_id_to_parent_dict.keys(): + raise TreeBusinessRuleException(f"Rank {rank_id} points to a non-existent parent rank {parent_id}") + if rank_id is not None: + parent_to_children_dict.setdefault(parent_id, []).append(rank_id) + + for parent_id, children in parent_to_children_dict.items(): + if len(children) > 1 and parent_id is not None: + raise TreeBusinessRuleException(f"Parent rank {parent_id} has more than one child rank") From d529551de54045d3ff2f6dd3b237893ad80366cc Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 2 Apr 2024 10:41:05 -0500 Subject: [PATCH 74/99] typo fix --- specifyweb/businessrules/rules/tree_rules.py | 6 +++--- specifyweb/specify/tree_ranks.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index ba4d0ddc2fa..162767f4116 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -12,9 +12,9 @@ def pre_tree_rank_initiation_handler(sender, obj): if is_instance_of_tree_def_item(obj) and obj.pk is None: # is it a treedefitem? if obj.pk is None: # is it a new object? pre_tree_rank_init(obj) - verify_rank_parent_chain_integretity(obj, RankOperation.CREATED) + verify_rank_parent_chain_integrity(obj, RankOperation.CREATED) else: - verify_rank_parent_chain_integretity(obj, RankOperation.UPDATED) + verify_rank_parent_chain_integrity(obj, RankOperation.UPDATED) @orm_signal_handler('post_save') def post_tree_rank_initiation_handler(sender, obj, created): @@ -33,7 +33,7 @@ def cannot_delete_root_treedefitem(sender, obj): "id": obj.id }}) pre_tree_rank_deletion(sender, obj) - verify_rank_parent_chain_integretity(obj, RankOperation.DELETED) + verify_rank_parent_chain_integrity(obj, RankOperation.DELETED) @orm_signal_handler('post_delete') def post_tree_rank_deletion_handler(sender, obj): diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index db49a5138a6..6f98f2e2b08 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -285,7 +285,7 @@ class RankOperation(Enum): DELETED = 'deleted' UPDATED = 'updated' -def verify_rank_parent_chain_integretity(rank, rank_operation: RankOperation): +def verify_rank_parent_chain_integrity(rank, rank_operation: RankOperation): """ Verifies the parent chain integrity of the ranks. """ From 175144cc60fbb4d5bb1a820ed8d798fcca7d34fb Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 13 May 2024 17:29:43 -0500 Subject: [PATCH 75/99] check rank item count --- specifyweb/businessrules/rules/tree_rules.py | 8 ++++++ specifyweb/specify/tree_ranks.py | 26 ++++++++++++++++++++ specifyweb/specify/tree_views.py | 9 +++++++ 3 files changed, 43 insertions(+) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 162767f4116..95050b637ea 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -32,6 +32,14 @@ def cannot_delete_root_treedefitem(sender, obj): "node": { "id": obj.id }}) + elif is_tree_rank_empty(sender, obj): + raise TreeBusinessRuleException( + "cannot delete tree rank containing items", + {"tree": obj.__class__.__name__, + "localizationKey": 'deletingNonEmptyTreeRank', + "node": { + "id": obj.id + }}) pre_tree_rank_deletion(sender, obj) verify_rank_parent_chain_integrity(obj, RankOperation.DELETED) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 6f98f2e2b08..b2e27a4cfd3 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -93,6 +93,32 @@ 'lithostrat': (LITHO_STRAT_RANKS, LITHO_STRAT_INCREMENT), } +TREE_RANK_TO_ITEM_MAP = { + 'Taxontreedefitem': 'Taxon', + 'Geographytreedefitem': 'Geography', + 'Storagetreedefitem': 'Storage', + 'Geologictimeperiodtreedefitem': 'Geologictimeperiod', + 'Lithostrattreedefitem': 'Lithostrat' +} + +def get_tree_item_model(tree_rank_model_name): + tree_item_model_name = TREE_RANK_TO_ITEM_MAP.get(tree_rank_model_name.title(), None) + if not tree_item_model_name: + return None + return getattr(spmodels, tree_item_model_name, None) + +def tree_rank_count(tree_rank_model_name, tree_rank_id) -> int: + tree_item_model = get_tree_item_model(tree_rank_model_name) + if not tree_item_model: + return 0 + return tree_item_model.objects.filter(definitionitem_id=tree_rank_id).count() + +def is_tree_rank_empty(tree_rank_model, tree_rank) -> bool: + tree_item_model = get_tree_item_model(tree_rank_model.__name__) + if not tree_item_model: + return False + return tree_item_model.objects.filter(definitionitem=tree_rank).count() == 0 + def post_tree_rank_save(tree_def_item_model, new_rank): tree_def = new_rank.treedef parent_rank = new_rank.parent diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 2e128012346..82b06c31888 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -9,6 +9,7 @@ from specifyweb.businessrules.exceptions import BusinessRuleException from specifyweb.permissions.permissions import PermissionTarget, \ PermissionTargetAction, check_permission_targets +from specifyweb.specify.tree_ranks import tree_rank_count from specifyweb.stored_queries import models from . import tree_extras from .api import get_object_or_404, obj_to_data, toJson @@ -290,6 +291,14 @@ def repair_tree(request, tree): tree_extras.renumber_tree(table) tree_extras.validate_tree_numbering(table) +@login_maybe_required +@require_GET +def tree_rank_item_count(request, tree, rankid): + """Returns the number of items in the tree rank with id .""" + tree_rank_model_name = tree if tree.endswith('treedefitem') else tree + 'treedefitem' + rank = get_object_or_404(tree_rank_model_name, id=rankid) + count = tree_rank_count(tree, rank.id) + return HttpResponse(toJson(count), content_type='application/json') class TaxonMutationPT(PermissionTarget): resource = "/tree/edit/taxon" From 3d3a7f633b385af920e7419f279193bf27002c05 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Wed, 15 May 2024 15:22:22 -0500 Subject: [PATCH 76/99] add to url.py file --- specifyweb/businessrules/rules/tree_rules.py | 2 +- specifyweb/specify/urls.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 95050b637ea..abf272c5be9 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -32,7 +32,7 @@ def cannot_delete_root_treedefitem(sender, obj): "node": { "id": obj.id }}) - elif is_tree_rank_empty(sender, obj): + elif not is_tree_rank_empty(sender, obj): raise TreeBusinessRuleException( "cannot delete tree rank containing items", {"tree": obj.__class__.__name__, diff --git a/specifyweb/specify/urls.py b/specifyweb/specify/urls.py index b8324cdffeb..b62fb637dbe 100644 --- a/specifyweb/specify/urls.py +++ b/specifyweb/specify/urls.py @@ -32,6 +32,7 @@ url(r'^(?P\d+)/move/$', tree_views.move), url(r'^(?P\d+)/synonymize/$', tree_views.synonymize), url(r'^(?P\d+)/desynonymize/$', tree_views.desynonymize), + url(r'^(?P\d+)/tree_rank_item_count/$', tree_views.tree_rank_item_count), url(r'^(?P\d+)/predict_fullname/$', tree_views.predict_fullname), url(r'^(?P\d+)/(?P\w+)/stats/$', tree_views.tree_stats), url(r'^(?P\d+)/(?P\w+)/(?P\w+)/$', tree_views.tree_view), From 1d76fa9924697e4e896fd102abdf4e8860b293bc Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Wed, 15 May 2024 14:16:42 -0700 Subject: [PATCH 77/99] Display Delete Blocker when delete used rank --- specifyweb/specify/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index 8e103d47d3d..dad3c6a9e6c 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -126,7 +126,7 @@ def delete_blockers(request, model, id): } ] for field, sub_objs in collector.delete_blockers ]) - filter_rank_deletion_exception(obj, result) + # filter_rank_deletion_exception(obj, result) return http.HttpResponse(api.toJson(result), content_type='application/json') From 83a9dd8b30daeb752ad21c0a1e18deadfe6487fa Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 08:16:12 -0700 Subject: [PATCH 78/99] Define ranks in lower case --- specifyweb/specify/tree_ranks.py | 114 +++++++++++++++---------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index b2e27a4cfd3..b2f879bbb7c 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -12,69 +12,69 @@ logger = logging.getLogger(__name__) TAXON_RANKS = { - 'TAXONOMY_ROOT': 0, - 'TAXONOMY ROOT': 0, - 'LIFE': 0, - 'KINGDOM': 10, - 'SUBKINGDOM': 20, - 'DIVISION': 30, - 'PHYLUM': 30, - 'SUBDIVISION': 40, - 'SUBPHYLUM': 40, - 'SUPERCLASS': 50, - 'CLASS': 60, - 'SUBCLASS': 70, - 'INFRACLASS': 80, - 'SUPERORDER': 90, - 'ORDER': 100, - 'SUBORDER': 110, - 'INFRAORDER': 120, - 'PARVORDER': 125, - 'SUPERFAMILY': 130, - 'FAMILY': 140, - 'SUBFAMILY': 150, - 'TRIBE': 160, - 'SUBTRIBE': 170, - 'GENUS': 180, - 'SUBGENUS': 190, - 'SECTION': 200, - 'SUBSECTION': 210, - 'SPECIES': 220, - 'SUBSPECIES': 230, - 'VARIETY': 240, - 'SUBVARIETY': 250, - 'FORMA': 260, - 'SUBFORMA': 270 + 'taxonomy_root': 0, + 'taxonomy root':0, + 'life': 0, + 'kingdom': 10, + 'subkingdom': 20, + 'division': 30, + 'phylum': 30, + 'subdivision': 40, + 'subphylum': 40, + 'superclass': 50, + 'class': 60, + 'subclass': 70, + 'infraclass': 80, + 'superorder': 90, + 'order': 100, + 'suborder': 110, + 'infraorder': 120, + 'parvorder': 125, + 'superfamilly': 130, + 'family': 140, + 'subfamily': 150, + 'tribe': 160, + 'subtribe': 170, + 'genus': 180, + 'subgenus': 190, + 'section': 200, + 'subsection': 210, + 'species': 220, + 'subspecies': 230, + 'variety': 240, + 'subvariety': 250, + 'forma': 260, + 'subforma': 270 } GEOGRAPHY_RANKS = { - 'CONTINENT': 100, - 'COUNTRY': 200, - 'STATE': 300, - 'COUNTY': 400 + 'continent': 100, + 'country': 200, + 'state': 300, + 'county': 400 } STORAGE_RANKS = { - 'BUILDING': 100, - 'COLLECTION': 150, - 'ROOM': 200, - 'AISLE': 250, - 'CABINET': 300, - 'SHELF': 350, - 'BOX': 400, - 'RACK': 450, - 'VIAL': 500 + 'building': 100, + 'collection': 150, + 'room': 200, + 'aisle': 250, + 'cabinet': 300, + 'shelf': 350, + 'box': 400, + 'rack': 450, + 'vial': 500 } GEOLOGIC_TIME_PERIOD_RANKS = { - 'ERA': 100, - 'PERIOD': 200, - 'EPOCH': 300, - 'AGE': 400 + 'era': 100, + 'period': 200, + 'epoch': 300, + 'age': 400 } LITHO_STRAT_RANKS = { - 'SUPERGROUP': 100, - 'GROUP': 200, - 'FORMATION': 300, - 'MEMBER': 400, - 'BED': 500 + 'supergroup': 100, + 'group': 200, + 'formation': 300, + 'member': 400, + 'bed': 500 } DEFAULT_RANK_INCREMENT = 100 @@ -254,7 +254,7 @@ def set_rank_id(new_rank): can_use_default_rank_id = ( use_default_rank_ids and default_tree_ranks is not None - and new_rank_name.upper() in default_tree_ranks + and new_rank_name.lower() in default_tree_ranks ) # Only use the the default rank id if the fhe following criteria is met: @@ -263,7 +263,7 @@ def set_rank_id(new_rank): # - the default rank is greater than the target rank # - the default rank is less than the current next rank from the target rank if can_use_default_rank_id: - default_rank_id = default_tree_ranks[new_rank_name.upper()] + default_rank_id = default_tree_ranks[new_rank_name.lower()] # Check if the default rank ID is not already used is_default_rank_id_unused = default_rank_id not in rank_ids From e5e850b85d74e607d8b488b21f80ba966cae5ab8 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 08:32:54 -0700 Subject: [PATCH 79/99] Simplify parent child rank --- specifyweb/specify/tree_ranks.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index b2f879bbb7c..a7e717764be 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -125,12 +125,7 @@ def post_tree_rank_save(tree_def_item_model, new_rank): new_rank_id = new_rank.rankid # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=parent_rank).exclude(rankid=new_rank_id) - if child_ranks.exists(): - # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update - for child_rank in child_ranks: - child_rank.parent = new_rank - child_rank.save() + child_ranks = tree_def_item_model.objects.filter(parent=parent_rank).exclude(rankid=new_rank_id).update(parent=new_rank) # Update the old root rank to point to the new root rank if the new rank is the new root rank if new_rank.parent is None: @@ -150,13 +145,7 @@ def pre_tree_rank_deletion(tree_def_item_model, rank): raise TreeBusinessRuleException("The Rank {rank.name} is not empty, cannot delete!") # Set the parent rank, that previously pointed to the old rank, to the target rank - child_ranks = tree_def_item_model.objects.filter(treedef=tree_def, parent=rank) - if child_ranks.exists(): - # Iterate through the child ranks, but there should only ever be 0 or 1 child ranks to update - for child_rank in child_ranks: - child_rank.parent = rank.parent - child_rank.save() - print(child_rank.name) + child_ranks = tree_def_item_model.objects.filter(parent=rank).update(parent=rank.parent) def post_tree_rank_deletion(rank): # Regenerate full names From 15a49acd2e908fcc8841e81497f1ab2f746e4d7e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 08:47:25 -0700 Subject: [PATCH 80/99] Remove unecessary code --- specifyweb/specify/tree_ranks.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index a7e717764be..3c54adebe91 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -127,13 +127,6 @@ def post_tree_rank_save(tree_def_item_model, new_rank): # Set the parent rank, that previously pointed to the target, to the new rank child_ranks = tree_def_item_model.objects.filter(parent=parent_rank).exclude(rankid=new_rank_id).update(parent=new_rank) - # Update the old root rank to point to the new root rank if the new rank is the new root rank - if new_rank.parent is None: - old_root_rank = tree_def_item_model.objects.exclude(id=new_rank.id).filter(treedef=tree_def, parent=None).first() - if old_root_rank is not None: - old_root_rank.parent = new_rank - old_root_rank.save() - # Regenerate full names tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) From 36585f410bd1eaf1653d67785eb37aacf909081e Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 08:53:51 -0700 Subject: [PATCH 81/99] Fix exclude id --- specifyweb/specify/tree_ranks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 3c54adebe91..15a2bec36c7 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -125,7 +125,7 @@ def post_tree_rank_save(tree_def_item_model, new_rank): new_rank_id = new_rank.rankid # Set the parent rank, that previously pointed to the target, to the new rank - child_ranks = tree_def_item_model.objects.filter(parent=parent_rank).exclude(rankid=new_rank_id).update(parent=new_rank) + child_ranks = tree_def_item_model.objects.filter(parent=parent_rank).exclude(id=new_rank.id).update(parent=new_rank) # Regenerate full names tree_extras.set_fullnames(tree_def, null_only=False, node_number_range=None) From db8124636be425396a149038b6c70db8746a5a71 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 08:54:56 -0700 Subject: [PATCH 82/99] Remove check fro str --- specifyweb/specify/tree_ranks.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 15a2bec36c7..0b5e97b1fe2 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -184,9 +184,6 @@ def set_rank_id(new_rank): # Check if the new rank already has a rank id if getattr(new_rank, 'rankid', None): new_rank_id = new_rank.rankid - if type(new_rank_id) is str: - new_rank_id = int(new_rank_id) - new_rank.rankid = new_rank_id if new_rank.parent and new_rank_id <= new_rank.parent.rankid: raise TreeBusinessRuleException( f"Rank ID {new_rank_id} must be greater than the parent rank ID {new_rank.parent.rankid}") From e482c7c0b13b485ea69840d89016038bc7b4f2e7 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 08:55:54 -0700 Subject: [PATCH 83/99] Remove unecessary treedef --- specifyweb/specify/tree_ranks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 0b5e97b1fe2..3194f35528f 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -187,7 +187,7 @@ def set_rank_id(new_rank): if new_rank.parent and new_rank_id <= new_rank.parent.rankid: raise TreeBusinessRuleException( f"Rank ID {new_rank_id} must be greater than the parent rank ID {new_rank.parent.rankid}") - child_rank = tree_def_item_model.objects.filter(treedef=new_rank.treedef, parent=new_rank.parent).exclude(id=new_rank.id) + child_rank = tree_def_item_model.objects.filter( parent=new_rank.parent).exclude(id=new_rank.id) if child_rank.exists() and new_rank_id >= child_rank.first().rankid: # Raising this exception causes many workbench tests to fail # raise TreeBusinessRuleException( From c1c56b8bdb6b48c1a50c54b342dfc20e5258986d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:23:27 -0700 Subject: [PATCH 84/99] Remove unecessary code --- specifyweb/specify/tree_ranks.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 3194f35528f..574cbc52b1c 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -198,11 +198,6 @@ def set_rank_id(new_rank): # Determine the new rank id parameters new_rank_id = getattr(new_rank, 'rankid', None) - tree_def = tree_def_model.objects.get(id=tree_id) - try: - tree_def = tree_def_model.objects.get(name=tree_name) - except tree_def_model.DoesNotExist: - pass parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() if parent_rank is None and parent_rank_name != 'root': raise TreeBusinessRuleException("Target rank name does not exist") From 6b751a3000fd74df7657a1444135b2e8efd92134 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:40:07 -0700 Subject: [PATCH 85/99] Remove unecessary default since expection raised above --- specifyweb/specify/tree_ranks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 574cbc52b1c..7d554d11808 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -218,7 +218,7 @@ def set_rank_id(new_rank): is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True # Set the default ranks and increments depending on the tree type - default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower(), (None, 100)) + default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower()) # In the future, add this as a function parameter to allow for more flexibility. # use_default_rank_ids can be set to false if you do not want to use the default rank ids. From de41d2706cd05a3fae74e05e5d847b7af5c36861 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:48:12 -0700 Subject: [PATCH 86/99] Raise warning when treedef none --- specifyweb/specify/tree_ranks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 7d554d11808..c617986f13b 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -158,7 +158,8 @@ def set_rank_id(new_rank): new_rank_name = getattr(new_rank, 'name', None) parent_rank_name = getattr(new_rank.parent, 'name', 'root') if getattr(new_rank, 'parent', None) else 'root' tree_name = getattr(new_rank.treedef, 'name', tree) if getattr(new_rank, 'treedef', None) else tree - tree_id = getattr(new_rank.treedef, 'id', 1) if getattr(new_rank, 'treedef', None) else 1 + if getattr(new_rank, 'treedef', None): + raise ValueError("new_rank.treedef is None") tree_def = getattr(new_rank, 'treedef', None) # Throw exceptions if the required parameters are not given correctly From 962be5db81bd3cdd8e18e01fd6fa80619f93e207 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:48:58 -0700 Subject: [PATCH 87/99] Chnge string --- specifyweb/specify/tree_ranks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index c617986f13b..265a9c89bc1 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -159,7 +159,7 @@ def set_rank_id(new_rank): parent_rank_name = getattr(new_rank.parent, 'name', 'root') if getattr(new_rank, 'parent', None) else 'root' tree_name = getattr(new_rank.treedef, 'name', tree) if getattr(new_rank, 'treedef', None) else tree if getattr(new_rank, 'treedef', None): - raise ValueError("new_rank.treedef is None") + raise ValueError("Expected tree definition, got None.") tree_def = getattr(new_rank, 'treedef', None) # Throw exceptions if the required parameters are not given correctly From 381d30ed82b8d83eacb7662ca62cd5b4e86e4d2f Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:49:53 -0700 Subject: [PATCH 88/99] Fix if statement --- specifyweb/specify/tree_ranks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 265a9c89bc1..c1b4c1948c3 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -158,7 +158,7 @@ def set_rank_id(new_rank): new_rank_name = getattr(new_rank, 'name', None) parent_rank_name = getattr(new_rank.parent, 'name', 'root') if getattr(new_rank, 'parent', None) else 'root' tree_name = getattr(new_rank.treedef, 'name', tree) if getattr(new_rank, 'treedef', None) else tree - if getattr(new_rank, 'treedef', None): + if getattr(new_rank, 'treedef', None) is not None: raise ValueError("Expected tree definition, got None.") tree_def = getattr(new_rank, 'treedef', None) From cd8ea3353b668d5e5b43823f177b50903c4c4a1d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:56:42 -0700 Subject: [PATCH 89/99] Remove unecessary check --- specifyweb/specify/tree_ranks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index c1b4c1948c3..797b7fff81a 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -158,8 +158,6 @@ def set_rank_id(new_rank): new_rank_name = getattr(new_rank, 'name', None) parent_rank_name = getattr(new_rank.parent, 'name', 'root') if getattr(new_rank, 'parent', None) else 'root' tree_name = getattr(new_rank.treedef, 'name', tree) if getattr(new_rank, 'treedef', None) else tree - if getattr(new_rank, 'treedef', None) is not None: - raise ValueError("Expected tree definition, got None.") tree_def = getattr(new_rank, 'treedef', None) # Throw exceptions if the required parameters are not given correctly From 59b88ea12a80cdd8be2465ecbb2384fbd50690ae Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 09:57:59 -0700 Subject: [PATCH 90/99] Remove checks --- specifyweb/specify/tree_ranks.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 797b7fff81a..ae66092ed0b 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -176,10 +176,6 @@ def set_rank_id(new_rank): tree_def_model = getattr(spmodels, tree_def_model_name.lower().title()) tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) - # Make sure the new rank has a tree definition set - if not hasattr(new_rank, 'treedef'): - new_rank.treedef = tree_def_model.objects.get(id=tree_id) - # Check if the new rank already has a rank id if getattr(new_rank, 'rankid', None): new_rank_id = new_rank.rankid From f8f973464e9b003b86ba2cdcfd1252df3e96718d Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 10:09:14 -0700 Subject: [PATCH 91/99] Remove unecessary warning raise --- specifyweb/businessrules/rules/tree_rules.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index abf272c5be9..162767f4116 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -32,14 +32,6 @@ def cannot_delete_root_treedefitem(sender, obj): "node": { "id": obj.id }}) - elif not is_tree_rank_empty(sender, obj): - raise TreeBusinessRuleException( - "cannot delete tree rank containing items", - {"tree": obj.__class__.__name__, - "localizationKey": 'deletingNonEmptyTreeRank', - "node": { - "id": obj.id - }}) pre_tree_rank_deletion(sender, obj) verify_rank_parent_chain_integrity(obj, RankOperation.DELETED) From 4a3b8b8d5d35ac420db27ccb14c79a77e9f47820 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 10:11:53 -0700 Subject: [PATCH 92/99] Raise expection when rank ids none --- specifyweb/specify/tree_ranks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index ae66092ed0b..90705503b3d 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -208,7 +208,9 @@ def set_rank_id(new_rank): raise TreeBusinessRuleException("Can't create rank ID less than 0") # Set conditions for rank ID creation - is_tree_def_items_empty = rank_ids is None or len(rank_ids) < 1 + if rank_ids is None: + raise ValueError("rank_ids should never be None.") + is_tree_def_items_empty = len(rank_ids) < 1 is_new_rank_first = parent_rank_id == -1 is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True From e4f8f2de06217c037844d2110bda4d18c9ddbd29 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Thu, 16 May 2024 10:47:04 -0700 Subject: [PATCH 93/99] Remove warning --- specifyweb/specify/tree_ranks.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 90705503b3d..eab56d7c9e5 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -208,8 +208,6 @@ def set_rank_id(new_rank): raise TreeBusinessRuleException("Can't create rank ID less than 0") # Set conditions for rank ID creation - if rank_ids is None: - raise ValueError("rank_ids should never be None.") is_tree_def_items_empty = len(rank_ids) < 1 is_new_rank_first = parent_rank_id == -1 is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True From c0d86e8e5953dc30a15bfc8df6c932ab96b20d9a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 17 May 2024 13:16:01 -0500 Subject: [PATCH 94/99] remove unused code --- specifyweb/specify/tree_ranks.py | 11 ++++------- specifyweb/specify/views.py | 14 -------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index eab56d7c9e5..412db82e2ae 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -155,9 +155,8 @@ def set_rank_id(new_rank): """ # Get parameter values from data tree = new_rank.specify_model.name.replace("TreeDefItem", "").lower() - new_rank_name = getattr(new_rank, 'name', None) - parent_rank_name = getattr(new_rank.parent, 'name', 'root') if getattr(new_rank, 'parent', None) else 'root' - tree_name = getattr(new_rank.treedef, 'name', tree) if getattr(new_rank, 'treedef', None) else tree + new_rank_name = new_rank.name + parent_rank_name = new_rank.parent.name if new_rank.parent else 'root' tree_def = getattr(new_rank, 'treedef', None) # Throw exceptions if the required parameters are not given correctly @@ -173,7 +172,6 @@ def set_rank_id(new_rank): # Get tree def item model tree_def_model_name = (tree + 'treedef').lower().title() tree_def_item_model_name = (tree + 'treedefitem').lower().title() - tree_def_model = getattr(spmodels, tree_def_model_name.lower().title()) tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) # Check if the new rank already has a rank id @@ -198,8 +196,8 @@ def set_rank_id(new_rank): raise TreeBusinessRuleException("Target rank name does not exist") parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 rank_ids = sorted(list(tree_def_item_model.objects.filter(treedef=tree_def).values_list('rankid', flat=True))) - parent_rank_idx = rank_ids.index(parent_rank_id) if rank_ids is not None and parent_rank_name != 'root' else -1 - next_rank_id = rank_ids[parent_rank_idx + 1] if rank_ids is not None and parent_rank_idx + 1 < len(rank_ids) else None + parent_rank_idx = rank_ids.index(parent_rank_id) if parent_rank_name != 'root' else -1 + next_rank_id = rank_ids[parent_rank_idx + 1] if parent_rank_idx + 1 < len(rank_ids) else None if next_rank_id is None and parent_rank_name != 'root': next_rank_id = maxsize @@ -222,7 +220,6 @@ def set_rank_id(new_rank): # Determine if the default rank ID can be used can_use_default_rank_id = ( use_default_rank_ids - and default_tree_ranks is not None and new_rank_name.lower() in default_tree_ranks ) diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index dad3c6a9e6c..345ab7ac656 100644 --- a/specifyweb/specify/views.py +++ b/specifyweb/specify/views.py @@ -92,19 +92,6 @@ def raise_error(request): raise Exception('This error is a test. You may now return to your regularly ' 'scheduled hacking.') -def filter_rank_deletion_exception(obj, delete_blockers): - # Check if the object is a tree rank - if not is_instance_of_tree_def_item(obj): - return - # Filter out blocker that is the child tree rank of tree rank being deleted. - # This is handled by the business rules when deleting a tree rank, the parent is set to the grandparent. - delete_blockers[:] = list( - filter( - lambda db: db['field'] != 'parent' and db['table'] == type(obj).__name__, - delete_blockers - ) - ) - @login_maybe_required @require_http_methods(['GET', 'HEAD']) def delete_blockers(request, model, id): @@ -126,7 +113,6 @@ def delete_blockers(request, model, id): } ] for field, sub_objs in collector.delete_blockers ]) - # filter_rank_deletion_exception(obj, result) return http.HttpResponse(api.toJson(result), content_type='application/json') From 05c5e73ae83210af932f8d0d1747093dbef546c7 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Fri, 17 May 2024 14:29:31 -0500 Subject: [PATCH 95/99] simplify tree rank code --- specifyweb/specify/tree_ranks.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py index 412db82e2ae..17bf9e829ba 100644 --- a/specifyweb/specify/tree_ranks.py +++ b/specifyweb/specify/tree_ranks.py @@ -170,7 +170,6 @@ def set_rank_id(new_rank): raise TreeBusinessRuleException("Invalid tree type") # Get tree def item model - tree_def_model_name = (tree + 'treedef').lower().title() tree_def_item_model_name = (tree + 'treedefitem').lower().title() tree_def_item_model = getattr(spmodels, tree_def_item_model_name.lower().title()) @@ -191,7 +190,7 @@ def set_rank_id(new_rank): # Determine the new rank id parameters new_rank_id = getattr(new_rank, 'rankid', None) - parent_rank = tree_def_item_model.objects.filter(treedef=tree_def, name=parent_rank_name).first() + parent_rank = new_rank.parent if parent_rank is None and parent_rank_name != 'root': raise TreeBusinessRuleException("Target rank name does not exist") parent_rank_id = parent_rank.rankid if parent_rank_name != 'root' else -1 @@ -208,7 +207,7 @@ def set_rank_id(new_rank): # Set conditions for rank ID creation is_tree_def_items_empty = len(rank_ids) < 1 is_new_rank_first = parent_rank_id == -1 - is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 if rank_ids is not None else True + is_new_rank_last = parent_rank_idx == len(rank_ids) - 1 # Set the default ranks and increments depending on the tree type default_tree_ranks, rank_increment = TREE_RANKS_MAPPING.get(tree.lower()) From 005b27175f157066dfb43c065ffcecf3f264f2d0 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 20 May 2024 09:09:47 -0500 Subject: [PATCH 96/99] remove leftover code --- specifyweb/specify/test_trees.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/specifyweb/specify/test_trees.py b/specifyweb/specify/test_trees.py index 858f315c337..46a144a72bb 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -213,9 +213,6 @@ def test_counts_correctness(self): ] class AddDeleteRankResourcesTest(ApiTests): - def setUp(self) -> None: - super().setUp() - def test_add_ranks_without_defaults(self): c = Client() c.force_login(self.specifyuser) From 9880e029d9bb1567ac7f91ac1fe857259b515848 Mon Sep 17 00:00:00 2001 From: alec_dev Date: Mon, 20 May 2024 15:00:39 +0000 Subject: [PATCH 97/99] Lint code with ESLint and Prettier Triggered by 605e2b696d5c1ba95c83b7a43fe710340e690286 on branch refs/heads/add_delete_tree_rank_api --- .../frontend/js_src/lib/components/WbUtils/Navigation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx b/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx index cd2e0f69eed..75cf78db3da 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/Navigation.tsx @@ -2,15 +2,15 @@ import React from 'react'; import type { LocalizedString } from 'typesafe-i18n'; import { useBooleanState } from '../../hooks/useBooleanState'; +import { StringToJsx } from '../../localization/utils'; import { wbText } from '../../localization/workbench'; +import { localized } from '../../utils/types'; import { Button } from '../Atoms/Button'; import { className } from '../Atoms/className'; import { icons } from '../Atoms/Icons'; import { ReadOnlyContext } from '../Core/Contexts'; import type { WbCellCounts } from '../WorkBench/CellMeta'; import type { WbUtils } from './Utils'; -import { StringToJsx } from '../../localization/utils'; -import { localized } from '../../utils/types'; export function Navigation({ name, From 20e66acb3e8f576cbaed968e75196ed88f0d2a7a Mon Sep 17 00:00:00 2001 From: alec_dev Date: Tue, 21 May 2024 12:07:51 -0500 Subject: [PATCH 98/99] create abstract TreeRank model for save and delete logic override --- specifyweb/specify/build_models.py | 14 +++++----- specifyweb/specify/model_extras.py | 22 ++++++++++++++- specifyweb/specify/tree_extras.py | 44 +++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/specifyweb/specify/build_models.py b/specifyweb/specify/build_models.py index b43635b5f86..7e191c7fa80 100644 --- a/specifyweb/specify/build_models.py +++ b/specifyweb/specify/build_models.py @@ -74,7 +74,7 @@ def pre_constraints_delete(self): attrs['save'] = save attrs['Meta'] = Meta - if table.django_name in tree_def_item_tables: + if table.django_name in tables_with_pre_constraints_delete: attrs['pre_constraints_delete'] = pre_constraints_delete supercls = getattr(model_extras, table.django_name, models.Model) @@ -289,12 +289,12 @@ def make_args(cls, fld): 'java.lang.Boolean': make_boolean_field, } -tree_def_item_tables = [ - 'Geographytreedefitem', - 'Geologictimeperiodtreedefitem', - 'Lithostrattreedefitem', - 'Storagetreedefitem', - 'Taxontreedefitem', +tables_with_pre_constraints_delete = [ + # 'Geographytreedefitem', + # 'Geologictimeperiodtreedefitem', + # 'Lithostrattreedefitem', + # 'Storagetreedefitem', + # 'Taxontreedefitem', ] def build_models(module, datamodel): diff --git a/specifyweb/specify/model_extras.py b/specifyweb/specify/model_extras.py index ede943c99dc..e27f1b0c0bd 100644 --- a/specifyweb/specify/model_extras.py +++ b/specifyweb/specify/model_extras.py @@ -4,7 +4,7 @@ from django.contrib.auth.base_user import BaseUserManager from django.conf import settings -from .tree_extras import Tree +from .tree_extras import Tree, TreeRank if settings.AUTH_LDAP_SERVER_URI is not None: from . import ldap_extras @@ -157,3 +157,23 @@ class Meta: class Lithostrat(Tree): class Meta: abstract = True + +class Geographytreedefitem(TreeRank): + class Meta: + abstract = True + +class Geologictimeperiodtreedefitem(TreeRank): + class Meta: + abstract = True + +class Lithostrattreedefitem(TreeRank): + class Meta: + abstract = True + +class Storagetreedefitem(TreeRank): + class Meta: + abstract = True + +class Taxontreedefitem(TreeRank): + class Meta: + abstract = True diff --git a/specifyweb/specify/tree_extras.py b/specifyweb/specify/tree_extras.py index 48f93da13c4..fcf2dfae93d 100644 --- a/specifyweb/specify/tree_extras.py +++ b/specifyweb/specify/tree_extras.py @@ -1,6 +1,9 @@ import re from contextlib import contextmanager import logging + +from specifyweb.specify.tree_ranks import RankOperation, post_tree_rank_save, pre_tree_rank_deletion, \ + verify_rank_parent_chain_integrity, pre_tree_rank_init, post_tree_rank_deletion logger = logging.getLogger(__name__) @@ -670,4 +673,43 @@ def is_instance_of_tree_def_item(obj): spmodels.Storagetreedefitem, spmodels.Taxontreedefitem, ] - return any(isinstance(obj, cls) for cls in tree_def_item_classes) \ No newline at end of file + return any(isinstance(obj, cls) for cls in tree_def_item_classes) + +class TreeRank(models.Model): + class Meta: + abstract = True + + def save(self, *args, **kwargs): + # pre_save + if hasattr(self, 'isaccepted'): + self.isaccepted = self.accepted_id == None + if self.pk is None: # is it a new object? + pre_tree_rank_init(self) + verify_rank_parent_chain_integrity(self, RankOperation.CREATED) + else: + verify_rank_parent_chain_integrity(self, RankOperation.UPDATED) + + # save + super(TreeRank, self).save(*args, **kwargs) + + # post_save + post_tree_rank_save(self.__class__, self) + + def delete(self, *args, **kwargs): + # pre_delete + if self.__class__.objects.get(id=self.id).parent is None: + raise TreeBusinessRuleException( + "cannot delete root level tree definition item", + {"tree": self.__class__.__name__, + "localizationKey": 'deletingTreeRoot', + "node": { + "id": self.id + }}) + pre_tree_rank_deletion(self.__class__, self) + verify_rank_parent_chain_integrity(self, RankOperation.DELETED) + + # delete + super(TreeRank, self).delete(*args, **kwargs) + + # post_delete + post_tree_rank_deletion(self) From cb7b862267bfdb37d938cf370fff7dd743e552a3 Mon Sep 17 00:00:00 2001 From: Caroline D <108160931+CarolineDenis@users.noreply.github.com> Date: Tue, 21 May 2024 18:13:47 +0000 Subject: [PATCH 99/99] Lint code with ESLint and Prettier Triggered by 87182f049c391b8c3e0085d742a93b9e07e32dec on branch refs/heads/add_delete_tree_rank_api --- .../lib/components/FormSliders/IntegratedRecordSelector.tsx | 6 ++---- .../lib/components/Interactions/InteractionDialog.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx index bbebabfb3ac..8e594d2a0a6 100644 --- a/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx +++ b/specifyweb/frontend/js_src/lib/components/FormSliders/IntegratedRecordSelector.tsx @@ -21,10 +21,8 @@ import type { FormType } from '../FormParse'; import type { SubViewSortField } from '../FormParse/cells'; import { augmentMode, ResourceView } from '../Forms/ResourceView'; import { useFirstFocus } from '../Forms/SpecifyForm'; -import { - interactionPrepTables, - InteractionWithPreps, -} from '../Interactions/helpers'; +import type { InteractionWithPreps } from '../Interactions/helpers'; +import { interactionPrepTables } from '../Interactions/helpers'; import { InteractionDialog } from '../Interactions/InteractionDialog'; import { hasTablePermission } from '../Permissions/helpers'; import { relationshipIsToMany } from '../WbPlanView/mappingHelpers'; diff --git a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index 5225ca9b784..4d96cb5a456 100644 --- a/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx +++ b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx @@ -26,8 +26,8 @@ import { Button } from '../Atoms/Button'; import { Link } from '../Atoms/Link'; import { LoadingContext, ReadOnlyContext } from '../Core/Contexts'; import type { - AnySchema, AnyInteractionPreparation, + AnySchema, SerializedResource, } from '../DataModel/helperTypes'; import type { SpecifyResource } from '../DataModel/legacyTypes';