diff --git a/specifyweb/businessrules/orm_signal_handler.py b/specifyweb/businessrules/orm_signal_handler.py index 040da44a816..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 @@ -26,7 +27,16 @@ def handler(sender, **kwargs): def handler(sender, **kwargs): if kwargs.get('raw', False): return - rule(sender, kwargs['instance']) + + 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, instance) return receiver(getattr(signals, signal), **receiver_kwargs)(handler) return _dec diff --git a/specifyweb/businessrules/rules/tree_rules.py b/specifyweb/businessrules/rules/tree_rules.py index 38be2d13784..162767f4116 100644 --- a/specifyweb/businessrules/rules/tree_rules.py +++ b/specifyweb/businessrules/rules/tree_rules.py @@ -2,13 +2,28 @@ 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 * 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) 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_integrity(obj, RankOperation.CREATED) + else: + verify_rank_parent_chain_integrity(obj, RankOperation.UPDATED) + +@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) @orm_signal_handler('pre_delete') def cannot_delete_root_treedefitem(sender, obj): - if hasattr(obj, 'treedef'): # is it a treedefitem? + if is_instance_of_tree_def_item(obj): # is it a treedefitem? if sender.objects.get(id=obj.id).parent is None: raise TreeBusinessRuleException( "cannot delete root level tree definition item", @@ -17,9 +32,18 @@ def cannot_delete_root_treedefitem(sender, obj): "node": { "id": obj.id }}) + pre_tree_rank_deletion(sender, obj) + verify_rank_parent_chain_integrity(obj, RankOperation.DELETED) +@orm_signal_handler('post_delete') +def post_tree_rank_deletion_handler(sender, obj): + if is_instance_of_tree_def_item(obj): # 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 + + + \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts b/specifyweb/frontend/js_src/lib/components/DataModel/schemaExtras.ts index 119a486b567..6fbde1459ac 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 @@ -384,9 +359,4 @@ export const schemaExtras: { }), ], ], - GeographyTreeDefItem: treeDefItem, - StorageTreeDefItem: treeDefItem, - TaxonTreeDefItem: treeDefItem, - GeologicTimePeriodTreeDefItem: treeDefItem, - LithoStratTreeDefItem: treeDefItem, }; 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/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 958a8f20db5..002f09e62a3 100644 --- a/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx +++ b/specifyweb/frontend/js_src/lib/components/Forms/ResourceView.tsx @@ -37,15 +37,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/Interactions/InteractionDialog.tsx b/specifyweb/frontend/js_src/lib/components/Interactions/InteractionDialog.tsx index b5110ed62f8..9f19bfa7597 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'; 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..4c48014c5b5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/TreeView/AddRank.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import { commonText } from '../../localization/common'; +import { interactionsText } from '../../localization/interactions'; +import { treeText } from '../../localization/tree'; +import type { RA } from '../../utils/types'; +import { Button } from '../Atoms/Button'; +import { Form, Label, Select } from '../Atoms/Form'; +import type { + FilterTablesByEndsWith, + SerializedResource, +} from '../DataModel/helperTypes'; +import { tables } from '../DataModel/tables'; +import { ResourceView } from '../Forms/ResourceView'; +import { Dialog } from '../Molecules/Dialog'; +import { Submit } from '../Atoms/Submit'; +import { useId } from '../../hooks/useId'; + +export function AddRank({ + treeDefinitionItems, +}: { + readonly treeDefinitionItems: RA< + SerializedResource> + >; +}): JSX.Element { + const [state, setState] = React.useState<'add' | 'initial' | 'parent'>( + 'initial' + ); + + const [parentRank, setParentRank] = React.useState( + treeDefinitionItems[0].resource_uri + ); + + const treeDef = treeDefinitionItems[0].treeDef; + + const treeResource = React.useMemo(() => { + const resource = new tables[treeDefinitionItems[0]._tableName].Resource(); + resource.set('treeDef', treeDef); + return resource; + }, [treeDef]); + const id = useId('add-rank'); + + return ( + <> + setState('parent')} + /> + {state === 'parent' && ( + + {commonText.cancel()} + { + treeResource.set('parent', parentRank); + setState('add'); + }} + form={id('form')} + > + {interactionsText.continue()} + + + } + header={treeText.chooseParentRank()} + onClose={() => setState('initial')} + > +
+ + {treeText.chooseParentRank()} + + +
+
+ )} + {state === 'add' && ( + setState('initial')} + onDeleted={undefined} + onSaved={() => { + globalThis.location.reload(); + }} + /> + )} + + ); +} diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx index 1143bb4d740..ff2b3941dc4 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/Tree.tsx @@ -17,8 +17,10 @@ import type { import { deserializeResource } from '../DataModel/serializers'; import { ResourceView } from '../Forms/ResourceView'; import { getPref } from '../InitialContext/remotePrefs'; +import { hasTablePermission } from '../Permissions/helpers'; import { useHighContrast } from '../Preferences/Hooks'; import { userPreferences } from '../Preferences/userPreferences'; +import { AddRank } from './AddRank'; import type { Conformations, Row, Stats } from './helpers'; import { fetchStats } from './helpers'; import { TreeRow } from './Row'; @@ -47,7 +49,7 @@ export function Tree({ searchBoxRef, baseUrl, setLastFocusedTree, - handleToggleEditingRanks, + onToggleEditingRanks: handleToggleEditingRanks, }: { readonly treeDefinitionItems: RA< SerializedResource> @@ -66,7 +68,7 @@ export function Tree({ readonly searchBoxRef: React.RefObject; readonly baseUrl: string; readonly setLastFocusedTree: () => void; - readonly handleToggleEditingRanks: () => void; + readonly onToggleEditingRanks: () => void; }): JSX.Element { const highContrast = useHighContrast(); @@ -151,12 +153,21 @@ export function Tree({ role="columnheader" > {index === 0 ? ( - + <> + + {isEditingRanks && + hasTablePermission( + treeDefinitionItems[0]._tableName, + 'create' + ) ? ( + + ) : null} + ) : null} globalThis.location.reload()} onSaved={(): void => globalThis.location.reload()} /> ) : null} diff --git a/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx b/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx index b135e508c92..d8fc7123d47 100644 --- a/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx +++ b/specifyweb/frontend/js_src/lib/components/TreeView/index.tsx @@ -202,7 +202,6 @@ function TreeView({ focusPath={states[type].focusPath} focusRef={toolbarButtonRef} getRows={getRows} - handleToggleEditingRanks={handleToggleEditingRanks} hideEmptyNodes={hideEmptyNodes} isEditingRanks={isEditingRanks} ranks={rankIds} @@ -212,6 +211,7 @@ function TreeView({ setLastFocusedTree={() => setLastFocusedTree(type)} tableName={tableName} treeDefinitionItems={treeDefinitionItems} + onToggleEditingRanks={handleToggleEditingRanks} /> ); 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, diff --git a/specifyweb/frontend/js_src/lib/localization/tree.ts b/specifyweb/frontend/js_src/lib/localization/tree.ts index 4dbb55aa4e4..50f259e0c91 100644 --- a/specifyweb/frontend/js_src/lib/localization/tree.ts +++ b/specifyweb/frontend/js_src/lib/localization/tree.ts @@ -524,6 +524,12 @@ export const treeText = createDictionary({ 'uk-ua': 'Синхронізувати', 'ru-ru': 'Синхронизировать', }, + addNewRank: { + 'en-us': 'Add New Rank', + }, + chooseParentRank: { + 'en-us': 'Choose Parent Rank', + }, moveItems: { 'en-us': 'Move Items', 'de-ch': 'Elemente verschieben', diff --git a/specifyweb/specify/api.py b/specifyweb/specify/api.py index bba00c75040..7aa0cb94fd3 100644 --- a/specifyweb/specify/api.py +++ b/specifyweb/specify/api.py @@ -621,6 +621,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, '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 b780f9b328a..38e9dced080 100644 --- a/specifyweb/specify/build_models.py +++ b/specifyweb/specify/build_models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models.signals import pre_delete from model_utils import FieldTracker from requests import get @@ -66,6 +67,13 @@ def save(self, *args, **kwargs): return super(model, self).save(*args, **kwargs) except AbortSave: return + + 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. + # This is needed because database constraints are checked before pre_delete signals are sent. + pre_delete.send(sender=self.__class__, instance=self) def save_timestamped(self, *args, **kwargs): timestamp_override = kwargs.pop('timestamp_override', False) @@ -86,6 +94,8 @@ def save_timestamped(self, *args, **kwargs): attrs[field] = models.DateTimeField(db_column=field) # default=timezone.now is handled in pre_save_auto_timestamp_field_with_override attrs['Meta'] = Meta + if table.django_name in tables_with_pre_constraints_delete: + attrs['pre_constraints_delete'] = pre_constraints_delete if has_timestamp_fields: attrs['save'] = save_timestamped @@ -127,6 +137,12 @@ def protect(collector, field, sub_objs, using): 'Spappresourcedata.spappresource': models.CASCADE, 'Spappresourcedata.spviewsetobj': models.CASCADE, 'Spreport.appresource': models.CASCADE, + + 'Geographytreedefitem.parent': models.DO_NOTHING, + 'Geologictimeperiodtreedefitem.parent': models.DO_NOTHING, + 'Lithostrattreedefitem.parent': models.DO_NOTHING, + 'Storagetreedefitem.parent': models.DO_NOTHING, + 'Taxontreedefitem.parent': models.DO_NOTHING, } def make_relationship(modelname, rel, datamodel): @@ -303,6 +319,14 @@ def make_args(cls, fld): 'java.lang.Boolean': make_boolean_field, } +tables_with_pre_constraints_delete = [ + # 'Geographytreedefitem', + # 'Geologictimeperiodtreedefitem', + # 'Lithostrattreedefitem', + # 'Storagetreedefitem', + # 'Taxontreedefitem', +] + def build_models(module, datamodel): return { model.specify_model.tableId: model for table in datamodel.tables diff --git a/specifyweb/specify/load_datamodel.py b/specifyweb/specify/load_datamodel.py index 243a1bea5f3..b5be209c42b 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', + 'Geographytreedef.treedefitems', + 'Geologictimeperiodtreedef.treedefitems', 'Gift.addressofrecord', 'Gift.giftagents', 'Gift.giftpreparations', 'Gift.shipments', 'Latlonpolygon.points', + 'lithostrattreedef.treedefitems', 'Loan.addressofrecord', 'Loan.loanagents', 'Loan.loanpreparations', @@ -347,9 +350,11 @@ def flag_system_tables(datamodel: Datamodel) -> None: 'Repositoryagreement.repositoryagreementagents', 'Repositoryagreement.repositoryagreementauthorizations', 'Spquery.fields', + 'Storagetreedef.treedefitems', 'Taxon.commonnames', 'Taxon.taxoncitations', 'Taxon.taxonattribute', + 'Taxontreedef.treedefitems', 'Workbench.workbenchtemplate', 'Workbenchtemplate.workbenchtemplatemappingitems', } diff --git a/specifyweb/specify/model_extras.py b/specifyweb/specify/model_extras.py index 7463e05a23e..aee4abcecdd 100644 --- a/specifyweb/specify/model_extras.py +++ b/specifyweb/specify/model_extras.py @@ -6,7 +6,7 @@ from django.utils import timezone from .model_timestamp import SpTimestampedModel, pre_save_auto_timestamp_field_with_override -from .tree_extras import Tree +from .tree_extras import Tree, TreeRank if settings.AUTH_LDAP_SERVER_URI is not None: from . import ldap_extras @@ -160,3 +160,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/test_trees.py b/specifyweb/specify/test_trees.py index 50646bfdcc5..46a144a72bb 100644 --- a/specifyweb/specify/test_trees.py +++ b/specifyweb/specify/test_trees.py @@ -1,7 +1,11 @@ -from specifyweb.specify import models +import json +from django.test import Client +from specifyweb.businessrules.exceptions import TreeBusinessRuleException +from specifyweb.specify import api, models 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: @@ -208,4 +212,198 @@ def test_counts_correctness(self): for parent_id, correct in correct_results.items() ] +class AddDeleteRankResourcesTest(ApiTests): + def test_add_ranks_without_defaults(self): + c = Client() + c.force_login(self.specifyuser) + treedef_geo = models.Geographytreedef.objects.create(name='GeographyTest') + + # Test adding non-default rank on empty heirarchy + data = { + 'name': 'Universe', + 'parent': None, + 'treedef': treedef_geo, + 'rankid': 100 + } + universe_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(100, models.Geographytreedefitem.objects.get(name='Universe').rankid) + + # Test adding non-default rank to the end of the heirarchy + data = { + 'name': 'Galaxy', + 'parent': api.uri_for_model(models.Geographytreedefitem, universe_rank.id), + 'treedef': treedef_geo, + 'rankid': 200 + } + galaxy_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(200, models.Geographytreedefitem.objects.get(name='Galaxy').rankid) + + # Test adding non-default rank to the front of the heirarchy + data = { + 'name': 'Multiverse', + 'parent': None, + 'treedef': treedef_geo, + 'rankid': 50 + } + multiverse_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(50, models.Geographytreedefitem.objects.get(name='Multiverse').rankid) + + # Test adding non-default rank in the middle of the heirarchy + data = { + 'name': 'Dimension', + 'parent': api.uri_for_model(models.Geographytreedefitem, universe_rank.id), + 'treedef': treedef_geo, + 'rankid': 150 + } + dimersion_rank = api.create_obj(self.collection, self.agent, 'geographytreedefitem', data) + self.assertEqual(150, models.Geographytreedefitem.objects.get(name='Dimension').rankid) + + # 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) + + # 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): + c = Client() + c.force_login(self.specifyuser) + + for obj in models.Taxontreedefitem.objects.all(): + obj.delete() + + treedef_taxon = models.Taxontreedef.objects.create(name='TaxonTest') + + # Test adding default rank on empty heirarchy + data = { + 'name': 'Taxonomy Root', + 'parent': None, + 'treedef': treedef_taxon + } + taxon_root_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) + self.assertEqual(0, models.Taxontreedefitem.objects.get(name='Taxonomy Root').rankid) + + # Test adding non-default rank in front of rank 0 + data = { + 'name': 'Invalid', + 'parent': None, + 'treedef': treedef_taxon + } + with self.assertRaises(TreeBusinessRuleException): + api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) + self.assertEqual(0, models.Taxontreedefitem.objects.filter(name='Invalid').count()) + + # Test adding default rank to the end of the heirarchy + data = { + 'name': 'Division', + 'parent': api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), + 'treedef': treedef_taxon + } + division_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) + self.assertEqual(30, models.Taxontreedefitem.objects.get(name='Division').rankid) + + # Test adding default rank to the middle of the heirarchy + data = { + 'name': 'Kingdom', + 'parent': api.uri_for_model(models.Taxontreedefitem, taxon_root_rank.id), + 'treedef': treedef_taxon + } + kingdom_rank = api.create_obj(self.collection, self.agent, 'taxontreedefitem', data) + 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.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) + + treedef_geotimeperiod = models.Geologictimeperiodtreedef.objects.create(name='GeographyTimePeriodTest') + era_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Era', + rankid=100, + treedef=treedef_geotimeperiod + ) + period_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Period', + rankid=200, + treedef=treedef_geotimeperiod, + parent=era_rank + ) + epoch_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Epoch', + rankid=300, + treedef=treedef_geotimeperiod, + parent=period_rank + ) + age_rank = models.Geologictimeperiodtreedefitem.objects.create( + name='Age', + rankid=400, + treedef=treedef_geotimeperiod, + parent=epoch_rank + ) + + # Test deleting a rank in the middle of the heirarchy + api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', epoch_rank.id, epoch_rank.version) + 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 + api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', age_rank.id, age_rank.version) + self.assertEqual(None, models.Geologictimeperiodtreedefitem.objects.filter(name='Age').first()) + + # Test deleting a rank at the head of the heirarchy + with self.assertRaises(TreeBusinessRuleException): + api.delete_resource(self.collection, self.agent, 'Geologictimeperiodtreedefitem', era_rank.id, era_rank.version) + \ No newline at end of file diff --git a/specifyweb/specify/tree_extras.py b/specifyweb/specify/tree_extras.py index 1d280f60cd4..fe2a232b0cb 100644 --- a/specifyweb/specify/tree_extras.py +++ b/specifyweb/specify/tree_extras.py @@ -2,6 +2,8 @@ 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 from specifyweb.specify.model_timestamp import pre_save_auto_timestamp_field_with_override logger = logging.getLogger(__name__) @@ -11,6 +13,7 @@ from django.conf import settings from specifyweb.businessrules.exceptions import TreeBusinessRuleException +import specifyweb.specify.models as spmodels from .auditcodes import TREE_BULK_MOVE, TREE_MERGE, TREE_SYNONYMIZE, TREE_DESYNONYMIZE @@ -680,4 +683,53 @@ 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): + tree_def_item_classes = [ + spmodels.Geographytreedefitem, + spmodels.Geologictimeperiodtreedefitem, + spmodels.Lithostrattreedefitem, + spmodels.Storagetreedefitem, + spmodels.Taxontreedefitem, + ] + 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) diff --git a/specifyweb/specify/tree_ranks.py b/specifyweb/specify/tree_ranks.py new file mode 100644 index 00000000000..17bf9e829ba --- /dev/null +++ b/specifyweb/specify/tree_ranks.py @@ -0,0 +1,310 @@ +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 +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, + '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 +} +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), +} + +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 + 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(id=new_rank.id).update(parent=new_rank) + + # 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 + if tree_def_item_model.objects.filter(parent=rank).count() > 1: + 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(parent=rank).update(parent=rank.parent) + +def post_tree_rank_deletion(rank): + # Regenerate full names + tree_extras.set_fullnames(rank.treedef, null_only=False, node_number_range=None) + +def pre_tree_rank_init(new_rank): + set_rank_id(new_rank) + +def set_rank_id(new_rank): + """ + Sets the new rank to the specified tree when adding a new rank. + Expects at least the name, parent, and tree_def of the rank to be set. + All the other parameters are optional. + """ + # Get parameter values from data + tree = new_rank.specify_model.name.replace("TreeDefItem", "").lower() + 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 + if new_rank_name is None: + raise TreeBusinessRuleException("Rank name is not given") + if parent_rank_name is None: + raise TreeBusinessRuleException("Parent rank name is not given") + if tree_def is None: + raise TreeBusinessRuleException("Tree definition is not given") + if tree is None or tree.lower() not in TREE_RANKS_MAPPING.keys(): + raise TreeBusinessRuleException("Invalid tree type") + + # Get tree def item model + tree_def_item_model_name = (tree + 'treedefitem').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 + if getattr(new_rank, 'rankid', None): + new_rank_id = new_rank.rankid + 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( 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( + # f"Rank ID {new_rank_id} must be less than the child rank ID {child_rank.first().rankid}") + new_rank_id = None + if new_rank_id: + return + + # Determine the new rank id parameters + new_rank_id = getattr(new_rank, 'rankid', None) + 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 + 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 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 + + # Don't allow rank IDs less than 0, but really shouldn't be less than 2 + if new_rank_id is not None and next_rank_id is not None and next_rank_id < 0: + raise TreeBusinessRuleException("Can't create rank ID less than 0") + + # 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 + + # Set the default ranks and increments depending on the tree type + 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. + use_default_rank_ids = True + + # Determine if the default rank ID can be used + can_use_default_rank_id = ( + use_default_rank_ids + and new_rank_name.lower() 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.lower()] + + # 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 TreeBusinessRuleException(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 + + # 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 TreeBusinessRuleException(f"Can't add rank id between {new_rank_id} and {parent_rank_id}") + + # 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_integrity(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") diff --git a/specifyweb/specify/tree_views.py b/specifyweb/specify/tree_views.py index 2cb995b34b3..9bf78957513 100644 --- a/specifyweb/specify/tree_views.py +++ b/specifyweb/specify/tree_views.py @@ -10,6 +10,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 @@ -349,6 +350,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" @@ -403,4 +412,4 @@ def perm_target(tree): 'storage': StorageMutationPT, 'geologictimeperiod': GeologictimeperiodMutationPT, 'lithostrat': LithostratMutationPT, - }[tree] \ No newline at end of file + }[tree] diff --git a/specifyweb/specify/urls.py b/specifyweb/specify/urls.py index aeab8cd4372..aa142071550 100644 --- a/specifyweb/specify/urls.py +++ b/specifyweb/specify/urls.py @@ -33,6 +33,7 @@ url(r'^(?P\d+)/bulk_move/$', tree_views.bulk_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), diff --git a/specifyweb/specify/views.py b/specifyweb/specify/views.py index 94ab17a982d..345ab7ac656 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,7 +92,6 @@ def raise_error(request): raise Exception('This error is a test. You may now return to your regularly ' 'scheduled hacking.') - @login_maybe_required @require_http_methods(['GET', 'HEAD']) def delete_blockers(request, model, id):