diff --git a/specifyweb/specify/management/commands/run_key_migration_functions.py b/specifyweb/specify/management/commands/run_key_migration_functions.py index 0d45633bd51..cebc00cc023 100644 --- a/specifyweb/specify/management/commands/run_key_migration_functions.py +++ b/specifyweb/specify/management/commands/run_key_migration_functions.py @@ -21,7 +21,7 @@ from specifyweb.specify.migration_utils.migration_helpers.helper_0003_cotype_picklist import create_cotype_splocalecontaineritem, create_cotype_picklist from specifyweb.specify.migration_utils.migration_helpers.helper_0004_stratigraphy_age import create_agetype_picklist, create_strat_table_schema_config_with_defaults from specifyweb.specify.migration_utils.migration_helpers.helper_0007_schema_config_update import create_cogtype_picklist -from specifyweb.specify.migration_utils.migration_helpers.helper_0008_schema_config_update import update_relative_age_fields +from specifyweb.specify.migration_utils.migration_helpers.helper_0008_ageCitations_fix import update_relative_age_fields from specifyweb.specify.migration_utils.migration_helpers.helper_0012_add_cojo_to_schema_config import add_cojo_to_schema_config from specifyweb.specify.migration_utils.migration_helpers.helper_0013_collectionobjectgroup_parentcog import update_cog_schema_config from specifyweb.specify.migration_utils.migration_helpers.helper_0015_add_version_to_ages import update_age_schema_config @@ -31,7 +31,7 @@ from specifyweb.specify.migration_utils.migration_helpers.helper_0040_components import create_table_schema_config_with_defaults, remove_componentparent_item from specifyweb.specify.migration_utils.migration_helpers.helper_0042_discipline_type_picklist import create_discipline_type_picklist from specifyweb.specify.migration_utils.router import use_migration_connection -from specifyweb.specify.migration_utils.misc_migrations import make_selectseries_false +from specifyweb.specify.migration_utils.migration_helpers.helper_0031_add_default_for_selectseries import make_selectseries_false from specifyweb.specify.migration_utils.tectonic_ranks import create_default_tectonic_ranks, create_root_tectonic_node, fix_tectonic_unit_treedef_discipline_links from specifyweb.backend.patches.migration_utils import apply_migrations as apply_patches @@ -268,4 +268,4 @@ def handle(self, *args, **options): self.stdout.write(self.style.SUCCESS(f"Applied {func_name}")) except Exception: logger.exception("An error occurred while running key migrations") - raise + raise \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/__init__.py b/specifyweb/specify/management/commands/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/specifyweb/specify/management/commands/tests/app_resource_tests.py b/specifyweb/specify/management/commands/tests/app_resource_tests.py new file mode 100644 index 00000000000..7ae3570957e --- /dev/null +++ b/specifyweb/specify/management/commands/tests/app_resource_tests.py @@ -0,0 +1,76 @@ +from unittest.mock import Mock, patch, sentinel, call +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.management.commands.tests.test_migration_base import MigrationCommandTestCase + + +class AppResourceTests(MigrationCommandTestCase): + def test_create_missing_app_resource_dirs_writes_summary(self): + stdout = Mock() + + ensure_dirs_path = ( + "specifyweb.backend.setup_tool.app_resource_defaults." + "ensure_all_discipline_resource_dirs" + ) + with patch( + ensure_dirs_path, + return_value={"total_disciplines": 4, "created": 2, "updated": 1}, + ) as ensure_dirs: + rkm.create_missing_app_resource_dirs(stdout, sentinel.apps) + + ensure_dirs.assert_called_once_with() + stdout.assert_called_once_with( + "Ensured discipline app resource directories: total=4, created=2, updated=1" + ) + + def test_create_missing_app_resource_dirs_without_stdout_writes_nothing(self): + ensure_dirs_path = ( + "specifyweb.backend.setup_tool.app_resource_defaults." + "ensure_all_discipline_resource_dirs" + ) + with patch( + ensure_dirs_path, + return_value={"total_disciplines": 4, "created": 2, "updated": 1}, + ) as ensure_dirs: + rkm.create_missing_app_resource_dirs(None, sentinel.apps) + + ensure_dirs.assert_called_once_with() + + def test_fix_app_resource_dirs_runs_creation_then_deduplication(self): + calls = [] + stdout = Mock() + + def create_missing_app_resource_dirs(stdout_arg, apps): + calls.append(("create_missing_app_resource_dirs", stdout_arg, apps)) + + def deduplicate_discipline_resource_dirs(apps): + calls.append(("deduplicate_discipline_resource_dirs", apps)) + + with ( + patch.object(rkm, "apps", sentinel.apps), + patch.object( + rkm, + "create_missing_app_resource_dirs", + create_missing_app_resource_dirs, + ), + patch.object( + rkm, + "deduplicate_discipline_resource_dirs", + deduplicate_discipline_resource_dirs, + ), + ): + rkm.fix_app_resource_dirs(stdout) + + self.assertEqual( + calls, + [ + ("create_missing_app_resource_dirs", stdout, sentinel.apps), + ("deduplicate_discipline_resource_dirs", sentinel.apps), + ], + ) + self.assertEqual( + stdout.call_args_list, + [ + call("Running ..."), + call("Running deduplicate_discipline_resource_dirs..."), + ], + ) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/business_rules_tests.py b/specifyweb/specify/management/commands/tests/business_rules_tests.py new file mode 100644 index 00000000000..a3609e08774 --- /dev/null +++ b/specifyweb/specify/management/commands/tests/business_rules_tests.py @@ -0,0 +1,134 @@ +from unittest.mock import patch +from types import SimpleNamespace + +from django.apps import apps as django_apps +from django.test import TestCase + +from specifyweb.backend.businessrules.models import UniquenessRule, UniquenessRuleField +from specifyweb.specify.models import ( + Datatype, + Discipline, + Division, + Geographytreedef, + Geologictimeperiodtreedef, + Institution, +) +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.management.commands.tests.test_migration_base import MigrationCommandTestCase + + +class BusinessRulesMigrationTests(MigrationCommandTestCase): + def test_fix_business_rules_runs_migrations_in_order(self): + names = [ + "apply_default_uniqueness_rules_to_disciplines", + "catnum_rule_editable", + "fix_global_default_rules", + ] + self._assert_section_calls( + rkm.fix_business_rules, + [(rkm, name) for name in names], + names, + ) + + def test_apply_default_uniqueness_rules_skips_existing_db_constraints(self): + discipline_without_constraint = SimpleNamespace(id=1) + discipline_with_constraint = SimpleNamespace(id=2) + + class FakeDiscipline: + objects = SimpleNamespace( + all=lambda: [discipline_without_constraint, discipline_with_constraint] + ) + + class FakeUniquenessRuleManager: + def filter(self, discipline, isDatabaseConstraint): + self.last_is_database_constraint = isDatabaseConstraint + return SimpleNamespace( + exists=lambda: discipline is discipline_with_constraint + ) + + fake_uniqueness_rule_manager = FakeUniquenessRuleManager() + + class FakeUniquenessRule: + objects = fake_uniqueness_rule_manager + + class FakeApps: + def get_model(self, app_label, model_name): + return { + ("specify", "Discipline"): FakeDiscipline, + ("businessrules", "UniquenessRule"): FakeUniquenessRule, + }[(app_label, model_name)] + + fake_apps = FakeApps() + + with patch.object(rkm, "apply_default_uniqueness_rules") as apply_rules: + rkm.apply_default_uniqueness_rules_to_disciplines(fake_apps) + + apply_rules.assert_called_once_with( + discipline_without_constraint, + registry=fake_apps, + ) + self.assertIs(fake_uniqueness_rule_manager.last_is_database_constraint, True) + + +class BusinessRulesDatabaseTests(TestCase): + def test_catnum_rule_editable_only_updates_matching_catalog_number_rule(self): + institution = Institution.objects.create( + name="Test Institution", + isaccessionsglobal=True, + issecurityon=False, + isserverbased=False, + issharinglocalities=True, + issinglegeographytree=True, + ) + division = Division.objects.create(institution=institution, name="Test Division") + geologictimeperiodtreedef = Geologictimeperiodtreedef.objects.create( + name="Test gtptd" + ) + geographytreedef = Geographytreedef.objects.create(name="Test gtd") + geographytreedef.treedefitems.create(name="Planet", rankid="0") + datatype = Datatype.objects.create(name="Test datatype") + discipline = Discipline.objects.create( + name="Test Discipline", + division=division, + datatype=datatype, + geographytreedef=geographytreedef, + geologictimeperiodtreedef=geologictimeperiodtreedef, + type="paleobotany", + ) + matching_rule = UniquenessRule.objects.create( + modelName="Collectionobject", + discipline=discipline, + isDatabaseConstraint=True, + ) + UniquenessRuleField.objects.create( + uniquenessrule=matching_rule, + fieldPath="catalogNumber", + isScope=False, + ) + UniquenessRuleField.objects.create( + uniquenessrule=matching_rule, + fieldPath="collection", + isScope=True, + ) + nonmatching_rule = UniquenessRule.objects.create( + modelName="Collectionobject", + discipline=discipline, + isDatabaseConstraint=True, + ) + UniquenessRuleField.objects.create( + uniquenessrule=nonmatching_rule, + fieldPath="catalogNumber", + isScope=False, + ) + UniquenessRuleField.objects.create( + uniquenessrule=nonmatching_rule, + fieldPath="discipline", + isScope=True, + ) + + rkm.catnum_rule_editable(django_apps) + + matching_rule.refresh_from_db() + nonmatching_rule.refresh_from_db() + self.assertFalse(matching_rule.isDatabaseConstraint) + self.assertTrue(nonmatching_rule.isDatabaseConstraint) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/command_tests.py b/specifyweb/specify/management/commands/tests/command_tests.py new file mode 100644 index 00000000000..c668de443c3 --- /dev/null +++ b/specifyweb/specify/management/commands/tests/command_tests.py @@ -0,0 +1,66 @@ +from io import StringIO +from unittest.mock import Mock, patch, sentinel + +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.management.commands.tests.test_migration_base import MigrationCommandTestCase + + +class KeyMigrationCommandTests(MigrationCommandTestCase): + def test_full_pipeline_dispatches_sections_in_order_with_verbose_stdout(self): + calls = [] + + def section(name): + def wrapped(stdout): + calls.append((name, stdout is not None)) + return wrapped + + command = self._command() + command.funcs = {name: section(name) for name in self.section_names} + + command.handle(functions=[], verbose=True) + + self.assertEqual(calls, [(name, True) for name in self.section_names]) + + def test_selected_sections_run_in_requested_order_without_verbose_stdout(self): + calls = [] + + def section(name): + def wrapped(stdout): + calls.append((name, stdout)) + return wrapped + + command = self._command() + command.funcs = {name: section(name) for name in self.section_names} + + command.handle( + functions=["fix_misc", "fix_cots", "fix_permissions"], + verbose=False, + ) + + self.assertEqual( + calls, + [ + ("fix_misc", None), + ("fix_cots", None), + ("fix_permissions", None), + ], + ) + + def test_apply_patches_dispatch_passes_apps_registry_not_stdout(self): + command = self._command() + + with patch.object(rkm, "apps", sentinel.apps), patch.object(rkm, "apply_patches") as apply_patches: + command.handle(functions=["apply_patches"], verbose=True) + + apply_patches.assert_called_once_with(sentinel.apps) + + def test_unknown_function_writes_error_and_dispatches_nothing(self): + stdout = StringIO() + stderr = StringIO() + command = rkm.Command(stdout=stdout, stderr=stderr) + command.funcs = {"known": Mock()} + + command.handle(functions=["unknown"], verbose=True) + + command.funcs["known"].assert_not_called() + self.assertIn("Unknown function: unknown", stderr.getvalue()) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/cots_tests.py b/specifyweb/specify/management/commands/tests/cots_tests.py new file mode 100644 index 00000000000..46b029488db --- /dev/null +++ b/specifyweb/specify/management/commands/tests/cots_tests.py @@ -0,0 +1,94 @@ +from django.apps import apps as django_apps +from specifyweb.specify import models +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.management.commands.tests.test_migration_base import ( + MigrationCommandTestCase, + MigrationDatabaseTestCase, +) + +class CotsMigrationTests(MigrationCommandTestCase): + def test_fix_cots_runs_migrations_in_order(self): + names = [ + "create_default_collection_types", + "create_default_discipline_for_tree_defs", + "create_cogtype_type_picklist", + "set_discipline_for_taxon_treedefs", + "fix_taxon_treedef_discipline_links", + "create_cotype_picklist" + ] + + self._assert_section_calls( + rkm.fix_cots, + [(rkm, name) for name in names], + names, + ) + + +class CotsDatabaseTests(MigrationDatabaseTestCase): + def test_set_discipline_for_taxon_treedefs_uses_collection_discipline(self): + taxon_tree_def = models.Taxontreedef.objects.create( + name=f"Unlinked Taxon Tree {self.collection.id}", + ) + self.collectionobjecttype.taxontreedef = taxon_tree_def + self.collectionobjecttype.save() + + rkm.set_discipline_for_taxon_treedefs(django_apps) + + taxon_tree_def.refresh_from_db() + self.assertEqual(taxon_tree_def.discipline_id, self.discipline.id) + + def test_create_cotype_picklist_creates_readonly_system_picklist_idempotently(self): + new_collection = models.Collection.objects.create( + catalognumformatname="test", + collectionname=f"TestCollection{self.collection.id}", + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + models.Picklist.objects.filter( + collection__in=[self.collection, new_collection], + name="CollectionObjectType", + ).delete() + + rkm.create_cotype_picklist(django_apps) + rkm.create_cotype_picklist(django_apps) + + for collection in [self.collection, new_collection]: + picklists = models.Picklist.objects.filter( + collection=collection, + name="CollectionObjectType", + ) + self.assertEqual(picklists.count(), 1) + picklist = picklists.get() + self.assertTrue(picklist.issystem) + self.assertTrue(picklist.readonly) + self.assertEqual(picklist.type, 1) + self.assertEqual(picklist.tablename, "collectionobjecttype") + self.assertEqual(picklist.sizelimit, -1) + self.assertEqual(picklist.sorttype, 1) + self.assertEqual(picklist.formatter, "CollectionObjectType") + + def test_create_cogtype_type_picklist_creates_default_items_idempotently(self): + models.Picklist.objects.filter( + collection=self.collection, + name="SystemCOGTypes", + ).delete() + + rkm.create_cogtype_type_picklist(django_apps) + rkm.create_cogtype_type_picklist(django_apps) + + picklist = models.Picklist.objects.get( + collection=self.collection, + name="SystemCOGTypes", + ) + self.assertFalse(picklist.issystem) + self.assertFalse(picklist.readonly) + self.assertEqual(picklist.type, 0) + self.assertEqual( + set(picklist.picklistitems.values_list("title", "value")), + { + ("Discrete", "Discrete"), + ("Consolidated", "Consolidated"), + ("Drill Core", "Drill Core"), + }, + ) + self.assertEqual(picklist.picklistitems.count(), 3) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/permissions_tests.py b/specifyweb/specify/management/commands/tests/permissions_tests.py new file mode 100644 index 00000000000..d2241614e01 --- /dev/null +++ b/specifyweb/specify/management/commands/tests/permissions_tests.py @@ -0,0 +1,28 @@ +from unittest.mock import patch, sentinel + +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.management.commands.tests.test_migration_base import MigrationCommandTestCase + + +class PermissionsMigrationTests(MigrationCommandTestCase): + def test_fix_permissions_runs_migrations_in_order(self): + names = [ + "initialize_permissions", + "add_permission", + "add_stats_edit_permission", + ] + self._assert_section_calls( + rkm.fix_permissions, + [(rkm, name) for name in names], + names, + ) + + def test_initialize_permissions_passes_expected_options(self): + with patch.object(rkm, "initialize") as initialize: + rkm.initialize_permissions(sentinel.apps) + + initialize.assert_called_once_with( + False, + sentinel.apps, + migrate_sp6_users=False, + ) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/schema_config_tests.py b/specifyweb/specify/management/commands/tests/schema_config_tests.py new file mode 100644 index 00000000000..5fdf485e94f --- /dev/null +++ b/specifyweb/specify/management/commands/tests/schema_config_tests.py @@ -0,0 +1,240 @@ +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.management.commands.tests.test_migration_base import MigrationCommandTestCase +from types import SimpleNamespace + +from django.apps import apps as django_apps + +from specifyweb.specify import models +from specifyweb.specify.migration_utils.deduplication import deduplicate_containeritems_and_strings +from specifyweb.specify.migration_utils.schema_reader import bulk_create_splocaleitemstr_idempotent +from specifyweb.specify.tests.test_api import ApiTests + +class SchemaConfigTests(MigrationCommandTestCase): + def test_fix_schema_config_runs_migrations_and_schema_defaults_in_order(self): + calls = [] + stdout = Mock() + discipline_1 = SimpleNamespace(id=11, type="botany") + discipline_2 = SimpleNamespace(id=12, type="paleobotany") + + class FakeDiscipline: + objects = SimpleNamespace(all=lambda: [discipline_1, discipline_2]) + + class FakeApps: + def get_model(self, app_label, model_name): + self.model_request = (app_label, model_name) + return FakeDiscipline + + def apply_schema_defaults(args): + calls.append(("apply_schema_defaults_task.apply", args)) + + names = [ + # Ordered per fix_schema_config implementation + "create_geo_table_schema_config_with_defaults", + "create_cotype_splocalecontaineritem", + "create_strat_table_schema_config_with_defaults", + "create_agetype_picklist", + "create_cogtype_picklist", + "update_relative_age_fields", + "add_cojo_to_schema_config", + "update_cog_schema_config", + "update_age_schema_config", + "add_tectonicunit_to_pc_in_schema_config", + "update_storage_unique_id_fields", + "update_loan_and_gift_agent_fields", + "remove_componentparent_item", + "create_table_schema_config_with_defaults", + "create_discipline_type_picklist", + # "apply_schema_overrides_for_all_disciplines", + # "deduplicate_schema_config_orm", + ] + fake_apps = FakeApps() + + with ExitStack() as stack: + stack.enter_context(patch.object(rkm, "apps", fake_apps)) + self._patch_recorders(stack, [(rkm, name) for name in names], calls) + schema_defaults_apply_path = ( + "specifyweb.backend.setup_tool.schema_defaults." + "apply_schema_defaults_task.apply" + ) + stack.enter_context( + patch( + schema_defaults_apply_path, + apply_schema_defaults, + ) + ) + + rkm.fix_schema_config(stdout) + + self.assertEqual(fake_apps.model_request, ("specify", "Discipline")) + self.assertEqual( + calls, + [(name, fake_apps) for name in names[:-1]] + + [ + ("apply_schema_defaults_task.apply", [discipline_1.id]), + ("apply_schema_defaults_task.apply", [discipline_2.id]), + (names[-1], fake_apps), + ], + ) + stdout.assert_any_call("Running apply_schema_overrides_for_all_disciplines...") + stdout.assert_any_call( + "Applying schema defaults/overrides for discipline 11 (botany)..." + ) + stdout.assert_any_call( + "Applying schema defaults/overrides for discipline 12 (paleobotany)..." + ) + +class KeyMigrationSelectedHelperDatabaseTests(ApiTests): + def _make_schema_container(self, name, **kwargs): + return models.Splocalecontainer.objects.create( + name=name, + discipline=self.discipline, + schematype=0, + **kwargs, + ) + + def test_bulk_create_splocaleitemstr_idempotent_updates_and_dedupes(self): + container = self._make_schema_container( + f"bulkitemstr{self.collection.id}", + ) + item = models.Splocalecontaineritem.objects.create( + container=container, + name="field1", + ) + keeper = models.Splocaleitemstr.objects.create( + itemname=item, + language="en", + text="Old Name", + ) + duplicate = models.Splocaleitemstr.objects.create( + itemname=item, + language="en", + text="Duplicate Name", + ) + + created_count = bulk_create_splocaleitemstr_idempotent( + models.Splocaleitemstr, + [ + { + "itemname": item, + "language": "en", + "version": 0, + "text": "Updated Name", + }, + { + "itemdesc": item, + "language": "en", + "version": 0, + "text": "Created Description", + }, + ], + ) + + keeper.refresh_from_db() + + self.assertEqual(created_count, 1) + + self.assertEqual( + models.Splocaleitemstr.objects.filter( + itemname=item, + language="en", + ).count(), + 1, + ) + + self.assertFalse( + models.Splocaleitemstr.objects.filter(id=duplicate.id).exists() + ) + + self.assertEqual( + list( + models.Splocaleitemstr.objects.filter( + itemdesc=item, + language="en", + ).values_list("text", flat=True) + ), + ["Created Description"], + ) + + def test_deduplicate_containeritems_and_strings_repoints_unique_strings(self): + container = self._make_schema_container( + f"dedupeitems{self.collection.id}", + ) + + keeper = models.Splocalecontaineritem.objects.create( + container=container, + name="field1", + ) + + duplicate = models.Splocalecontaineritem.objects.create( + container=container, + name="field1", + ) + + # keeper string + models.Splocaleitemstr.objects.create( + itemname=keeper, + language="en", + text="Keeper Name", + ) + + # duplicate strings + duplicate_name = models.Splocaleitemstr.objects.create( + itemname=duplicate, + language="es", + text="Duplicate Name ES", + ) + + duplicate_desc = models.Splocaleitemstr.objects.create( + itemdesc=duplicate, + language="en", + text="Duplicate Desc", + ) + + duplicate_conflicting_name = models.Splocaleitemstr.objects.create( + itemname=duplicate, + language="en", + text="Duplicate Name EN", + ) + + # run migration/helper + with patch("builtins.print"): + deduplicate_containeritems_and_strings(django_apps) + + # duplicate container item must be removed + self.assertFalse( + models.Splocalecontaineritem.objects.filter(id=duplicate.id).exists() + ) + + # keeper still exists + keeper.refresh_from_db() + + self.assertFalse( + models.Splocaleitemstr.objects.filter( + itemname_id=duplicate.id + ).exists() + ) + + self.assertFalse( + models.Splocaleitemstr.objects.filter( + itemdesc_id=duplicate.id + ).exists() + ) + + # conflicting duplicate string should not survive + self.assertFalse( + models.Splocaleitemstr.objects.filter( + id=duplicate_conflicting_name.id + ).exists() + ) + + # keeper should still retain its original string + self.assertTrue( + models.Splocaleitemstr.objects.filter( + itemname_id=keeper.id, + language="en", + text="Keeper Name", + ).exists() + ) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/tectonic_tests.py b/specifyweb/specify/management/commands/tests/tectonic_tests.py new file mode 100644 index 00000000000..285cc1b806f --- /dev/null +++ b/specifyweb/specify/management/commands/tests/tectonic_tests.py @@ -0,0 +1,63 @@ +from .test_migration_base import MigrationDatabaseTestCase +from specifyweb.specify import models +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from django.apps import apps as django_apps + +class TectonicDatabaseTests(MigrationDatabaseTestCase): + def test_create_default_tectonic_ranks_creates_chain_and_assigns_discipline(self): + self.discipline.tectonicunittreedef = None + self.discipline.save() + models.Tectonicunittreedef.objects.filter( + discipline=self.discipline, + ).delete() + + rkm.create_default_tectonic_ranks(django_apps) + + self.discipline.refresh_from_db() + tree_def = self.discipline.tectonicunittreedef + self.assertIsNotNone(tree_def) + items = list( + models.Tectonicunittreedefitem.objects.filter( + treedef=tree_def, + ).order_by("rankid") + ) + self.assertEqual( + [(item.name, item.rankid) for item in items], + [ + ("Root", 0), + ("Superstructure", 10), + ("Tectonic Domain", 20), + ("Tectonic Subdomain", 30), + ("Tectonic Unit", 40), + ("Tectonic Subunit", 50), + ], + ) + self.assertIsNone(items[0].parent) + for parent, child in zip(items, items[1:]): + self.assertEqual(child.parent_id, parent.id) + + def test_create_root_tectonic_node_is_idempotent(self): + tree_def = models.Tectonicunittreedef.objects.create( + name="Tectonic Unit", + discipline=self.discipline, + ) + models.Tectonicunittreedefitem.objects.create( + name="Root", + title="Root", + rankid=0, + treedef=tree_def, + ) + + rkm.create_root_tectonic_node(django_apps) + rkm.create_root_tectonic_node(django_apps) + + roots = models.Tectonicunit.objects.filter( + name="Root", + definition=tree_def, + ) + self.assertEqual(roots.count(), 1) + root = roots.get() + self.assertEqual(root.fullname, "Root") + self.assertEqual(root.rankid, 0) + self.assertIsNone(root.parent) + self.assertTrue(root.isaccepted) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/test_deduplicate_discipline.py b/specifyweb/specify/management/commands/tests/test_deduplicate_discipline.py new file mode 100644 index 00000000000..1322f9f1ad6 --- /dev/null +++ b/specifyweb/specify/management/commands/tests/test_deduplicate_discipline.py @@ -0,0 +1,112 @@ +from datetime import timedelta + +from django.apps import apps as django_apps +from django.utils import timezone + +from specifyweb.specify import models +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.tests.test_api import ApiTests + +class KeyMigrationAppResourceDirDatabaseTests(ApiTests): + def test_deduplicate_discipline_resource_dirs_deletes_only_empty_duplicates(self): + base_time = timezone.now() - timedelta(days=1) + keep_oldest = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + timestampcreated=base_time, + ) + empty_duplicate = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + timestampcreated=base_time + timedelta(minutes=1), + ) + duplicate_with_resource = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + timestampcreated=base_time + timedelta(minutes=2), + ) + models.Spappresource.objects.create( + spappresourcedir=duplicate_with_resource, + name="PreservedResource", + level=0, + specifyuser=self.specifyuser, + ) + collection_scoped_duplicate = models.Spappresourcedir.objects.create( + collection=self.collection, + discipline=self.discipline, + ispersonal=False, + timestampcreated=base_time + timedelta(minutes=3), + ) + + rkm.deduplicate_discipline_resource_dirs(django_apps) + + self.assertTrue( + models.Spappresourcedir.objects.filter(id=keep_oldest.id).exists() + ) + self.assertFalse( + models.Spappresourcedir.objects.filter(id=empty_duplicate.id).exists() + ) + self.assertTrue( + models.Spappresourcedir.objects.filter( + id=duplicate_with_resource.id + ).exists() + ) + self.assertTrue( + models.Spappresourcedir.objects.filter( + id=collection_scoped_duplicate.id + ).exists() + ) + + def test_deduplicate_discipline_resource_dirs_equal_timestamps_are_preserved(self): + timestamp = timezone.now() + + first = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + timestampcreated=timestamp, + ) + second = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + timestampcreated=timestamp, + ) + + rkm.deduplicate_discipline_resource_dirs(django_apps) + + self.assertTrue( + models.Spappresourcedir.objects.filter(id=first.id).exists() + ) + self.assertTrue( + models.Spappresourcedir.objects.filter(id=second.id).exists() + ) + + def test_deduplicate_discipline_resource_dirs_preserves_scoped_dirs(self): + base_time = timezone.now() + unscoped = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + timestampcreated=base_time, + ) + personal = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=True, + timestampcreated=base_time + timedelta(minutes=1), + ) + usertype_scoped = models.Spappresourcedir.objects.create( + discipline=self.discipline, + ispersonal=False, + usertype="Manager", + timestampcreated=base_time + timedelta(minutes=2), + ) + + rkm.deduplicate_discipline_resource_dirs(django_apps) + + self.assertTrue( + models.Spappresourcedir.objects.filter(id=unscoped.id).exists() + ) + self.assertTrue( + models.Spappresourcedir.objects.filter(id=personal.id).exists() + ) + self.assertTrue( + models.Spappresourcedir.objects.filter(id=usertype_scoped.id).exists() + ) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/test_migration_base.py b/specifyweb/specify/management/commands/tests/test_migration_base.py new file mode 100644 index 00000000000..2468e3ad956 --- /dev/null +++ b/specifyweb/specify/management/commands/tests/test_migration_base.py @@ -0,0 +1,64 @@ +from contextlib import ExitStack +from unittest.mock import Mock, sentinel, patch, call + +from django.test import TestCase + +from specifyweb.specify.management.commands import run_key_migration_functions as rkm +from specifyweb.specify.tests.test_api import ApiTests + + +class MigrationCommandTestCase(TestCase): + """Base class for migration command tests""" + + section_names = ( + "apply_patches", + "fix_cots", + "fix_permissions", + "fix_business_rules", + "fix_schema_config", + "fix_app_resource_dirs", + "fix_tectonic_ranks", + "fix_misc", + ) + + def _command(self): + return rkm.Command(stdout=self._stdout(), stderr=self._stderr()) + + def _stdout(self): + return Mock() + + def _stderr(self): + return Mock() + + def _recorder(self, name, calls): + def func(apps): + calls.append((name, apps)) + + func.__name__ = name + return func + + def _patch_recorders(self, stack, patch_targets, calls): + for target, attr in patch_targets: + stack.enter_context( + patch.object(target, attr, self._recorder(attr, calls)) + ) + + def _assert_section_calls(self, section, patch_targets, expected_names): + calls = [] + stdout = Mock() + + with ExitStack() as stack: + stack.enter_context(patch.object(rkm, "apps", sentinel.apps)) + self._patch_recorders(stack, patch_targets, calls) + section(stdout) + + self.assertEqual(calls, [(name, sentinel.apps) for name in expected_names]) + self.assertEqual( + stdout.call_args_list, + [call(f"Running {name}...") for name in expected_names], + ) + +class MigrationDatabaseTestCase(ApiTests): + """Base class for database-backed migration tests""" + + pass \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/test_run_key_migration_functions.py b/specifyweb/specify/management/commands/tests/test_run_key_migration_functions.py new file mode 100644 index 00000000000..a4d91258bf4 --- /dev/null +++ b/specifyweb/specify/management/commands/tests/test_run_key_migration_functions.py @@ -0,0 +1,414 @@ +from io import StringIO + +from django.core.management import call_command +from django.test import TransactionTestCase + +from specifyweb.backend.businessrules.models import ( + UniquenessRule, + UniquenessRuleField, +) +from specifyweb.backend.permissions.models import ( + LibraryRole, + LibraryRolePolicy, + Role, + RolePolicy, + UserPolicy, + UserRole, +) +from specifyweb.specify import models +from specifyweb.specify.tests.test_api import ApiTests + +from unittest.mock import patch + +from specifyweb.backend.businessrules.rules.cogtype_rules import SYSTEM_COGTYPES_PICKLIST + + +TRACKED_MODELS = { + "Collectionobjecttype": models.Collectionobjecttype, + "Collectionobjectgrouptype": models.Collectionobjectgrouptype, + "Picklist": models.Picklist, + "Picklistitem": models.Picklistitem, + "Splocalecontainer": models.Splocalecontainer, + "Splocalecontaineritem": models.Splocalecontaineritem, + "Splocaleitemstr": models.Splocaleitemstr, + "UniquenessRule": UniquenessRule, + "UniquenessRuleField": UniquenessRuleField, + "LibraryRole": LibraryRole, + "LibraryRolePolicy": LibraryRolePolicy, + "Role": Role, + "RolePolicy": RolePolicy, + "UserRole": UserRole, + "UserPolicy": UserPolicy, + "Spappresourcedir": models.Spappresourcedir, + "Tectonicunittreedef": models.Tectonicunittreedef, + "Tectonicunittreedefitem": models.Tectonicunittreedefitem, + "Tectonicunit": models.Tectonicunit, +} + + +def record_counts(): + return { + name: model.objects.count() + for name, model in TRACKED_MODELS.items() + } + + +def count_diff(before, after): + return { + name: after_count - before[name] + for name, after_count in after.items() + if after_count != before[name] + } + + +class RunKeyMigrationFunctionsTests(ApiTests, TransactionTestCase): + databases = {"default", "migrations"} + + def setUp(self): + super().setUp() + self.discipline.name = "Test Discipline" + self.discipline.taxontreedef = self.taxontreedef + self.discipline.save(update_fields=["name", "taxontreedef"]) + self.cogtypes_picklist = models.Picklist.objects.create( + name=SYSTEM_COGTYPES_PICKLIST, + type=0, + collection=self.collection, + ) + + models.Picklistitem.objects.create( + picklist=self.cogtypes_picklist, + title="Discrete", + value="Discrete", + ordinal=0, + ) + + def tearDown(self): + super().tearDown() + + def simulate_specify7_usage( + self, suffix, *, include_collection_object_group_type=True + ): + picklist = models.Picklist.objects.create( + name=f"Test Picklist {suffix}", + type=0, + collection=self.collection, + ) + models.Picklistitem.objects.create( + picklist=picklist, + title=f"Test Picklist Item {suffix}", + value=f"test-picklist-item-{suffix}", + ordinal=0, + ) + + collection_object_type = models.Collectionobjecttype.objects.create( + name=f"Test Collection Object Type {suffix}", + collection=self.collection, + taxontreedef=self.taxontreedef, + ) + + collection_object = models.Collectionobject.objects.create( + collection=self.collection, + catalognumber=f"cat-{suffix}", + collectionobjecttype=collection_object_type, + ) + + collection_object_group_type = ( + models.Collectionobjectgrouptype.objects.create( + name=f"Test Collection Object Group Type {suffix}", + type="Discrete", + collection=self.collection, + ) + if include_collection_object_group_type + else None + ) + + role = Role.objects.create( + collection=self.collection, + name=f"Test Role {suffix}", + description="User-created role", + ) + + RolePolicy.objects.create( + role=role, + resource=f"/test/resource/{suffix}", + action="read", + ) + + UserRole.objects.create( + specifyuser=self.specifyuser, + role=role, + ) + + UserPolicy.objects.create( + collection=self.collection, + specifyuser=self.specifyuser, + resource=f"/test/user-policy/{suffix}", + action="read", + ) + + library_role = LibraryRole.objects.create( + name=f"Test Library Role {suffix}", + description="User-created library role", + ) + + LibraryRolePolicy.objects.create( + role=library_role, + resource=f"/test/library-resource/{suffix}", + action="read", + ) + + app_resource_dir = models.Spappresourcedir.objects.create( + collection=self.collection, + ispersonal=False, + ) + + app_resource = models.Spappresource.objects.create( + spappresourcedir=app_resource_dir, + specifyuser=self.specifyuser, + level=0, + name=f"Test App Resource {suffix}", + ) + + models.Spappresourcedata.objects.create( + spappresource=app_resource, + data=f"test app resource data {suffix}".encode(), + ) + + schema_container = models.Splocalecontainer.objects.create( + name=f"test_schema_container_{suffix.replace('-', '_')}", + schematype=0, + discipline=self.discipline, + ) + + schema_item = models.Splocalecontaineritem.objects.create( + name=f"test_schema_item_{suffix.replace('-', '_')}", + container=schema_container, + ) + + models.Splocaleitemstr.objects.create( + itemname=schema_item, + language="en", + text=f"Test Schema Item {suffix}", + ) + + return { + "app_resource_dir_id": app_resource_dir.id, + "app_resource_id": app_resource.id, + "collection_object_group_type_id": ( + None + if collection_object_group_type is None + else collection_object_group_type.id + ), + "collection_object_id": collection_object.id, + "collection_object_type_id": collection_object_type.id, + "library_role_id": library_role.id, + "picklist_id": picklist.id, + "role_id": role.id, + "schema_container_id": schema_container.id, + "schema_item_id": schema_item.id, + "suffix": suffix, + } + + def assert_simulated_specify7_usage_preserved(self, usage): + self.assertEqual( + models.Picklist.objects.filter( + id=usage["picklist_id"], + name=f"Test Picklist {usage['suffix']}", + collection=self.collection, + ).count(), + 1, + ) + + self.assertEqual( + models.Picklistitem.objects.filter( + picklist_id=usage["picklist_id"], + value=f"test-picklist-item-{usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + models.Collectionobjecttype.objects.filter( + id=usage["collection_object_type_id"], + name=f"Test Collection Object Type {usage['suffix']}", + collection=self.collection, + ).count(), + 1, + ) + + self.assertEqual( + models.Collectionobject.objects.filter( + id=usage["collection_object_id"], + collectionobjecttype_id=usage["collection_object_type_id"], + ).count(), + 1, + ) + + if usage["collection_object_group_type_id"] is not None: + self.assertEqual( + models.Collectionobjectgrouptype.objects.filter( + id=usage["collection_object_group_type_id"], + name=f"Test Collection Object Group Type {usage['suffix']}", + collection=self.collection, + ).count(), + 1, + ) + + self.assertEqual( + Role.objects.filter( + id=usage["role_id"], + collection=self.collection, + name=f"Test Role {usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + RolePolicy.objects.filter( + role_id=usage["role_id"], + resource=f"/test/resource/{usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + UserRole.objects.filter( + specifyuser=self.specifyuser, + role_id=usage["role_id"], + ).count(), + 1, + ) + + self.assertEqual( + UserPolicy.objects.filter( + collection=self.collection, + specifyuser=self.specifyuser, + resource=f"/test/user-policy/{usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + LibraryRole.objects.filter( + id=usage["library_role_id"], + name=f"Test Library Role {usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + LibraryRolePolicy.objects.filter( + role_id=usage["library_role_id"], + resource=f"/test/library-resource/{usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + models.Spappresourcedir.objects.filter( + id=usage["app_resource_dir_id"], + collection=self.collection, + ).count(), + 1, + ) + + self.assertEqual( + models.Spappresource.objects.filter( + id=usage["app_resource_id"], + spappresourcedir_id=usage["app_resource_dir_id"], + specifyuser=self.specifyuser, + name=f"Test App Resource {usage['suffix']}", + ).count(), + 1, + ) + + self.assertEqual( + models.Spappresourcedata.objects.filter( + spappresource_id=usage["app_resource_id"], + ).count(), + 1, + ) + + self.assertEqual( + models.Splocalecontainer.objects.filter( + id=usage["schema_container_id"], + discipline=self.discipline, + ).count(), + 1, + ) + + self.assertEqual( + models.Splocalecontaineritem.objects.filter( + id=usage["schema_item_id"], + container_id=usage["schema_container_id"], + ).count(), + 1, + ) + + self.assertEqual( + models.Splocaleitemstr.objects.filter( + itemname_id=usage["schema_item_id"], + language="en", + text=f"Test Schema Item {usage['suffix']}", + ).count(), + 1, + ) + + def run_key_migration_functions(self, *args): + out = StringIO() + call_command("run_key_migration_functions", *args, stdout=out) + return out.getvalue() + + @patch( + "specifyweb.specify.management.commands.run_key_migration_functions.set_discipline_for_taxon_treedefs" + ) + def test_second_run_does_not_create_duplicate_records(self, mock_set_discipline_for_taxon_treedefs): + # First dataset + self.simulate_specify7_usage( + "before-first-run", + include_collection_object_group_type=False, + ) + + before_first_run = record_counts() + + self.run_key_migration_functions( + "fix_schema_config", + "fix_app_resource_dirs", + "fix_permissions", + "fix_business_rules", + "fix_tectonic_ranks", + "fix_misc", + ) + + after_first_run = record_counts() + first_run_diff = count_diff(before_first_run, after_first_run) + self.assertTrue( + first_run_diff, + "First run should create or backfill expected migration records", + ) + + # Second dataset inserted between runs + between_run_usage = self.simulate_specify7_usage("between-runs") + + before_second_run = record_counts() + + self.run_key_migration_functions( + "fix_schema_config", + "fix_app_resource_dirs", + "fix_permissions", + "fix_business_rules", + "fix_tectonic_ranks", + "fix_misc", + ) + after_second_run = record_counts() + second_run_diff = count_diff(before_second_run, after_second_run) + + self.assertEqual( + second_run_diff, + {}, + f"Second run created or removed tracked records: {second_run_diff}", + ) + + self.assert_simulated_specify7_usage_preserved( + between_run_usage + ) \ No newline at end of file diff --git a/specifyweb/specify/management/commands/tests/test_run_key_migration_functions_order.py b/specifyweb/specify/management/commands/tests/test_run_key_migration_functions_order.py new file mode 100644 index 00000000000..a487e8ec9ac --- /dev/null +++ b/specifyweb/specify/management/commands/tests/test_run_key_migration_functions_order.py @@ -0,0 +1,43 @@ +from unittest.mock import patch, sentinel + +from specifyweb.specify.management.commands import run_key_migration_functions as rkm + +from specifyweb.specify.management.commands.tests.test_migration_base import MigrationCommandTestCase + + +class KeyMigrationSectionTests(MigrationCommandTestCase): + + def test_log_and_run_without_stdout_still_calls_each_function(self): + calls = [] + funcs = [ + self._recorder("first", calls), + self._recorder("second", calls), + ] + + with patch.object(rkm, "apps", sentinel.apps): + rkm.log_and_run(funcs, stdout=None) + + self.assertEqual(calls, [("first", sentinel.apps), ("second", sentinel.apps)]) + + def test_fix_tectonic_ranks_runs_migrations_in_order(self): + names = [ + "create_default_tectonic_ranks", + "create_root_tectonic_node", + "fix_tectonic_unit_treedef_discipline_links", + ] + + self._assert_section_calls( + rkm.fix_tectonic_ranks, + [(rkm, name) for name in names], + names, + ) + + def test_fix_misc_runs_migrations_in_order(self): + names = ["make_selectseries_false"] + + self._assert_section_calls( + rkm.fix_misc, + [(rkm, name) for name in names], + names, + ) + diff --git a/specifyweb/specify/migration_utils/migration_helpers/__init__.py b/specifyweb/specify/migration_utils/migration_helpers/__init__.py index f6a08eeff89..b5e27f9dc8e 100644 --- a/specifyweb/specify/migration_utils/migration_helpers/__init__.py +++ b/specifyweb/specify/migration_utils/migration_helpers/__init__.py @@ -1,7 +1,7 @@ from .helper_0002_schema_config_update import MIGRATION_0002_TABLES from .helper_0004_stratigraphy_age import MIGRATION_0004_FIELDS, MIGRATION_0004_TABLES from .helper_0007_schema_config_update import MIGRATION_0007_FIELDS -from .helper_0008_schema_config_update import MIGRATION_0008_FIELDS +from .helper_0008_ageCitations_fix import MIGRATION_0008_FIELDS from .helper_0012_add_cojo_to_schema_config import MIGRATION_0012_FIELDS from .helper_0013_collectionobjectgroup_parentcog import MIGRATION_0013_FIELDS from .helper_0020_add_tectonicunit_to_pc_in_schema_config import MIGRATION_0020_FIELDS diff --git a/specifyweb/specify/migration_utils/migration_helpers/helper_0003_cotype_picklist.py b/specifyweb/specify/migration_utils/migration_helpers/helper_0003_cotype_picklist.py index f24704aa305..c4870ac1344 100644 --- a/specifyweb/specify/migration_utils/migration_helpers/helper_0003_cotype_picklist.py +++ b/specifyweb/specify/migration_utils/migration_helpers/helper_0003_cotype_picklist.py @@ -1,4 +1,3 @@ - # ########################################## # Used in 0003_cotype_picklist.py # ########################################## diff --git a/specifyweb/specify/migration_utils/migration_helpers/helper_0008_schema_config_update.py b/specifyweb/specify/migration_utils/migration_helpers/helper_0008_ageCitations_fix.py similarity index 100% rename from specifyweb/specify/migration_utils/migration_helpers/helper_0008_schema_config_update.py rename to specifyweb/specify/migration_utils/migration_helpers/helper_0008_ageCitations_fix.py diff --git a/specifyweb/specify/migration_utils/migration_helpers/helper_0017_schemaconfig_fixes.py b/specifyweb/specify/migration_utils/migration_helpers/helper_0017_schemaconfig_fixes.py index 8dd06e45807..01f0c2d10b2 100644 --- a/specifyweb/specify/migration_utils/migration_helpers/helper_0017_schemaconfig_fixes.py +++ b/specifyweb/specify/migration_utils/migration_helpers/helper_0017_schemaconfig_fixes.py @@ -6,7 +6,7 @@ from specifyweb.specify.migration_utils.migration_helpers.helper_0002_schema_config_update import MIGRATION_0002_TABLES from specifyweb.specify.migration_utils.migration_helpers.helper_0004_stratigraphy_age import MIGRATION_0004_FIELDS, MIGRATION_0004_TABLES from specifyweb.specify.migration_utils.migration_helpers.helper_0007_schema_config_update import MIGRATION_0007_FIELDS -from specifyweb.specify.migration_utils.migration_helpers.helper_0008_schema_config_update import MIGRATION_0008_FIELDS +from specifyweb.specify.migration_utils.migration_helpers.helper_0008_ageCitations_fix import MIGRATION_0008_FIELDS from specifyweb.specify.migration_utils.migration_helpers.helper_0012_add_cojo_to_schema_config import MIGRATION_0012_FIELDS from specifyweb.specify.migration_utils.migration_helpers.helper_0013_collectionobjectgroup_parentcog import MIGRATION_0013_FIELDS # ########################################## diff --git a/specifyweb/specify/migration_utils/misc_migrations.py b/specifyweb/specify/migration_utils/migration_helpers/helper_0031_add_default_for_selectseries.py similarity index 100% rename from specifyweb/specify/migration_utils/misc_migrations.py rename to specifyweb/specify/migration_utils/migration_helpers/helper_0031_add_default_for_selectseries.py diff --git a/specifyweb/specify/migration_utils/tests/__init__.py b/specifyweb/specify/migration_utils/tests/__init__.py new file mode 100644 index 00000000000..270cf5b0223 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/__init__.py @@ -0,0 +1 @@ +# Initialize tests package \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_bulk_create.py b/specifyweb/specify/migration_utils/tests/test_bulk_create.py new file mode 100644 index 00000000000..2663348f4b6 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_bulk_create.py @@ -0,0 +1,92 @@ +import unittest +from unittest.mock import MagicMock + +from specifyweb.specify.migration_utils.schema_reader import ( + bulk_create_splocaleitemstr_idempotent, +) + + +class BulkCreateSplocaleitemstrIdempotentTest(unittest.TestCase): + + def test_bulk_create_splocaleitemstr_idempotent(self): + # ----------------------- + # Mock model + manager + # ----------------------- + class FakeSplocaleitemstr: + objects = MagicMock() + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + mock_model = FakeSplocaleitemstr + mock_manager = mock_model.objects + mock_model.objects = mock_manager + + # ----------------------- + # Mock queryset chain + # ----------------------- + mock_qs = MagicMock() + mock_qs.filter.return_value = mock_qs + mock_qs.order_by.return_value = [] + mock_manager.filter.return_value = mock_qs + + # ----------------------- + # Mock bulk_create + # ----------------------- + mock_manager.bulk_create = MagicMock() + + # ----------------------- + # Input data + # ----------------------- + item1 = MagicMock() + item1.pk = 1 + + item2 = MagicMock() + item2.pk = 2 + + rows = [ + {"itemname": item1, "text": "Test1", "language": "en"}, + {"itemdesc": item2, "text": "Test2", "language": "es"}, + ] + + # ----------------------- + # Execute + # ----------------------- + result = bulk_create_splocaleitemstr_idempotent(mock_model, rows) + + # ----------------------- + # Assert result + # ----------------------- + self.assertEqual(result, 2) + + # ----------------------- + # Assert ORM interaction + # ----------------------- + self.assertTrue(mock_manager.filter.called) + self.assertTrue(mock_manager.bulk_create.called) + + # ----------------------- + # IMPORTANT: validate call structure + # ----------------------- + self.assertEqual(mock_manager.bulk_create.call_count, 2) + + # First call (itemname) + first_args, _ = mock_manager.bulk_create.call_args_list[0] + first_batch = first_args[0] + + self.assertEqual(len(first_batch), 1) + self.assertEqual(first_batch[0].text, "Test1") + self.assertEqual(first_batch[0].language, "en") + + # Second call (itemdesc) + second_args, _ = mock_manager.bulk_create.call_args_list[1] + second_batch = second_args[0] + + self.assertEqual(len(second_batch), 1) + self.assertEqual(second_batch[0].text, "Test2") + self.assertEqual(second_batch[0].language, "es") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_deduplication.py b/specifyweb/specify/migration_utils/tests/test_deduplication.py new file mode 100644 index 00000000000..07027b397b8 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_deduplication.py @@ -0,0 +1,224 @@ +import unittest +from unittest.mock import MagicMock, Mock, call, patch + +from specifyweb.specify.migration_utils.deduplication import ( + deduplicate_schema_config_sql, + deduplicate_splocalecontainers, + deduplicate_containeritems_and_strings, + deduplicate_schema_config_orm, +) + + +class DeduplicateSchemaConfigSqlTests(unittest.TestCase): + + @patch( + "specifyweb.specify.migration_utils.deduplication.connection.cursor" + ) + def test_executes_sql_and_closes_cursor(self, mock_cursor_factory): + mock_cursor = MagicMock() + mock_cursor_factory.return_value = mock_cursor + + deduplicate_schema_config_sql() + + mock_cursor.execute.assert_called_once() + + sql = mock_cursor.execute.call_args[0][0] + + self.assertIn( + "CREATE TEMPORARY TABLE container_items_to_delete", + sql, + ) + self.assertIn( + "DELETE FROM splocalecontaineritem", + sql, + ) + + mock_cursor.close.assert_called_once() + + +class DeduplicateSpLocaleContainersTests(unittest.TestCase): + + @patch( + "specifyweb.specify.migration_utils.deduplication.transaction.atomic" + ) + def test_removes_duplicate_containers_and_related_records( + self, + mock_atomic, + ): + apps = Mock() + + Container = MagicMock() + ContainerItem = MagicMock() + ItemStr = MagicMock() + + apps.get_model.side_effect = [ + Container, + ContainerItem, + ItemStr, + ] + + duplicate_containers = MagicMock(name="duplicate_containers") + duplicate_items = MagicMock(name="duplicate_items") + + ( + Container.objects.filter.return_value + .annotate.return_value + .filter.return_value + ) = duplicate_containers + + ContainerItem.objects.filter.return_value = duplicate_items + + deduplicate_splocalecontainers(apps) + + Container.objects.filter.assert_any_call( + schematype=0 + ) + + ContainerItem.objects.filter.assert_called_once_with( + container__in=duplicate_containers + ) + + self.assertEqual( + ItemStr.objects.filter.call_args_list, + [ + call(itemname__in=duplicate_items), + call(itemdesc__in=duplicate_items), + call(containername__in=duplicate_containers), + call(containerdesc__in=duplicate_containers), + ], + ) + + duplicate_items.delete.assert_called_once() + duplicate_containers.delete.assert_called_once() + + self.assertEqual( + ItemStr.objects.filter.return_value.delete.call_count, + 4, + ) + + +class DeduplicateContainerItemsAndStringsTests(unittest.TestCase): + + @patch( + "specifyweb.specify.migration_utils.deduplication.transaction.atomic" + ) + @patch( + "specifyweb.specify.migration_utils.deduplication.print" + ) + def test_deletes_duplicate_items_and_strings( + self, + mock_print, + mock_atomic, + ): + apps = Mock() + + ContainerItem = MagicMock() + ItemStr = MagicMock() + + apps.get_model.side_effect = [ + ContainerItem, + ItemStr, + ] + + item1 = Mock(id=1, rn=1) + item2 = Mock(id=2, rn=2) + item3 = Mock(id=3, rn=3) + + ContainerItem.objects.filter.return_value.annotate.return_value = [ + item1, + item2, + item3, + ] + + deduplicate_containeritems_and_strings(apps) + + ItemStr.objects.filter.assert_any_call( + itemname_id__in=[2, 3] + ) + ItemStr.objects.filter.assert_any_call( + itemdesc_id__in=[2, 3] + ) + + ContainerItem.objects.filter.assert_any_call( + id__in=[2, 3] + ) + + self.assertEqual( + ItemStr.objects.filter.return_value.delete.call_count, + 2, + ) + + ContainerItem.objects.filter.return_value.delete.assert_called_once() + + mock_print.assert_called_once_with( + "Successfully deleted 2 duplicate schema items." + ) + + @patch( + "specifyweb.specify.migration_utils.deduplication.transaction.atomic" + ) + @patch( + "specifyweb.specify.migration_utils.deduplication.print" + ) + def test_no_duplicates_found( + self, + mock_print, + mock_atomic, + ): + apps = Mock() + + ContainerItem = MagicMock() + ItemStr = MagicMock() + + apps.get_model.side_effect = [ + ContainerItem, + ItemStr, + ] + + ContainerItem.objects.filter.return_value.annotate.return_value = [ + Mock(id=1, rn=1), + ] + + deduplicate_containeritems_and_strings(apps) + + self.assertFalse( + any( + kwargs.get("id__in") is not None + for _, kwargs + in ContainerItem.objects.filter.call_args_list + ) + ) + + self.assertEqual( + ItemStr.objects.filter.return_value.delete.call_count, + 0, + ) + + mock_print.assert_called_once_with( + "No duplicates found." + ) + + +class DeduplicateSchemaConfigOrmTests(unittest.TestCase): + + @patch( + "specifyweb.specify.migration_utils.deduplication.transaction.atomic" + ) + @patch( + "specifyweb.specify.migration_utils.deduplication.deduplicate_containeritems_and_strings" + ) + @patch( + "specifyweb.specify.migration_utils.deduplication.deduplicate_splocalecontainers" + ) + def test_calls_both_dedupe_steps( + self, + mock_container_dedupe, + mock_item_dedupe, + mock_atomic, + ): + apps = Mock() + + deduplicate_schema_config_orm(apps) + + mock_container_dedupe.assert_called_once_with(apps) + mock_item_dedupe.assert_called_once_with(apps) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_default_cots.py b/specifyweb/specify/migration_utils/tests/test_default_cots.py new file mode 100644 index 00000000000..501bb4b5a0c --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_default_cots.py @@ -0,0 +1,299 @@ +import unittest +from unittest.mock import MagicMock + +from specifyweb.specify.migration_utils.migration_helpers.helper_0002_schema_config_update import DEFAULT_COG_TYPES, create_cogtype_type_picklist, create_default_discipline_for_tree_defs, set_discipline_for_taxon_treedefs +from specifyweb.specify.migration_utils.migration_helpers.helper_0003_cotype_picklist import COT_PICKLIST_NAME, create_cotype_picklist + +from ..default_cots import ( + create_default_collection_types, + fix_taxon_treedef_discipline_links, +) + + +class CreateDefaultCollectionTypesTests(unittest.TestCase): + def test_create_default_collection_types(self): + apps = MagicMock() + + Collection = MagicMock() + Collectionobject = MagicMock() + Collectionobjecttype = MagicMock() + + apps.get_model.side_effect = [ + Collection, + Collectionobject, + Collectionobjecttype, + ] + + discipline = MagicMock() + discipline.name = "Botany" + discipline.taxontreedef_id = 42 + + collection = MagicMock() + collection.discipline = discipline + + Collection.objects.filter.return_value = [collection] + + cot = MagicMock() + Collectionobjecttype.objects.get_or_create.return_value = (cot, True) + + create_default_collection_types(apps) + + Collectionobjecttype.objects.get_or_create.assert_called_once_with( + name="Botany", + collection=collection, + taxontreedef_id=42, + ) + + Collectionobject.objects.filter.assert_called_once_with( + collection=collection + ) + + Collectionobject.objects.filter.return_value.update.assert_called_once_with( + collectionobjecttype=cot + ) + + self.assertEqual(collection.collectionobjecttype, cot) + collection.save.assert_called_once() + + +class CreateDefaultDisciplineForTreeDefsTests(unittest.TestCase): + def test_assigns_missing_discipline_and_institution_links(self): + apps = MagicMock() + + Discipline = MagicMock() + Institution = MagicMock() + + apps.get_model.side_effect = [ + Discipline, + Institution, + ] + + geography = MagicMock(discipline_id=None) + geology = MagicMock(discipline_id=None) + litho = MagicMock(discipline_id=None) + taxon = MagicMock(discipline_id=None) + + discipline = MagicMock() + discipline.geographytreedef = geography + discipline.geologictimeperiodtreedef = geology + discipline.lithostrattreedef = litho + discipline.taxontreedef = taxon + + Discipline.objects.all.return_value = [discipline] + + storage = MagicMock(institution_id=None) + + institution = MagicMock() + institution.storagetreedef = storage + + Institution.objects.all.return_value = [institution] + + create_default_discipline_for_tree_defs(apps) + + self.assertEqual(geography.discipline, discipline) + self.assertEqual(geology.discipline, discipline) + self.assertEqual(litho.discipline, discipline) + self.assertEqual(taxon.discipline, discipline) + self.assertEqual(storage.institution, institution) + + geography.save.assert_called_once() + geology.save.assert_called_once() + litho.save.assert_called_once() + taxon.save.assert_called_once() + storage.save.assert_called_once() + + def test_skips_existing_links(self): + apps = MagicMock() + + Discipline = MagicMock() + Institution = MagicMock() + + apps.get_model.side_effect = [ + Discipline, + Institution, + ] + + geography = MagicMock(discipline_id=1) + + discipline = MagicMock() + discipline.geographytreedef = geography + discipline.geologictimeperiodtreedef = None + discipline.lithostrattreedef = None + discipline.taxontreedef = None + + Discipline.objects.all.return_value = [discipline] + + storage = MagicMock(institution_id=1) + + institution = MagicMock() + institution.storagetreedef = storage + + Institution.objects.all.return_value = [institution] + + create_default_discipline_for_tree_defs(apps) + + geography.save.assert_not_called() + storage.save.assert_not_called() + + +class CreateCogtypeTypePicklistTests(unittest.TestCase): + def test_creates_default_picklist_items_when_picklist_created(self): + apps = MagicMock() + + Collection = MagicMock() + Picklist = MagicMock() + Picklistitem = MagicMock() + + apps.get_model.side_effect = [ + Collection, + Picklist, + Picklistitem, + ] + + collection = MagicMock() + Collection.objects.all.return_value = [collection] + + picklist = MagicMock() + Picklist.objects.get_or_create.return_value = (picklist, True) + + create_cogtype_type_picklist(apps) + + Picklist.objects.get_or_create.assert_called_once() + + self.assertEqual( + Picklistitem.objects.get_or_create.call_count, + len(DEFAULT_COG_TYPES), + ) + + for cog_type in DEFAULT_COG_TYPES: + Picklistitem.objects.get_or_create.assert_any_call( + title=cog_type, + value=cog_type, + picklist=picklist, + ) + + def test_does_not_create_items_when_picklist_exists(self): + apps = MagicMock() + + Collection = MagicMock() + Picklist = MagicMock() + Picklistitem = MagicMock() + + apps.get_model.side_effect = [ + Collection, + Picklist, + Picklistitem, + ] + + Collection.objects.all.return_value = [MagicMock()] + + Picklist.objects.get_or_create.return_value = ( + MagicMock(), + False, + ) + + create_cogtype_type_picklist(apps) + + Picklistitem.objects.get_or_create.assert_not_called() + + +class CreateCotypePicklistTests(unittest.TestCase): + def test_creates_picklist_for_each_collection(self): + apps = MagicMock() + + Collection = MagicMock() + Picklist = MagicMock() + + apps.get_model.side_effect = [ + Collection, + Picklist, + ] + + collections = [MagicMock(), MagicMock()] + Collection.objects.all.return_value = collections + + create_cotype_picklist(apps) + + self.assertEqual( + Picklist.objects.get_or_create.call_count, + 2, + ) + + for collection in collections: + Picklist.objects.get_or_create.assert_any_call( + name=COT_PICKLIST_NAME, + type=1, + tablename="collectionobjecttype", + collection=collection, + defaults={ + "issystem": True, + "readonly": True, + "sizelimit": -1, + "sorttype": 1, + "formatter": COT_PICKLIST_NAME, + }, + ) + + +class SetDisciplineForTaxonTreedefsTests(unittest.TestCase): + def test_updates_null_disciplines(self): + apps = MagicMock() + + Collectionobjecttype = MagicMock() + Taxontreedef = MagicMock() + + apps.get_model.side_effect = [ + Collectionobjecttype, + Taxontreedef, + ] + + qs = MagicMock() + Taxontreedef.objects.filter.return_value = qs + + create_subquery_qs = MagicMock() + Collectionobjecttype.objects.filter.return_value = create_subquery_qs + + ( + create_subquery_qs.order_by.return_value + .values.return_value.__getitem__.return_value + ) = MagicMock() + + set_discipline_for_taxon_treedefs(apps) + + Taxontreedef.objects.filter.assert_called_once_with( + discipline__isnull=True + ) + + qs.update.assert_called_once() + + +class FixTaxonTreedefDisciplineLinksTests(unittest.TestCase): + def test_updates_null_taxon_treedef_disciplines(self): + apps = MagicMock() + + Discipline = MagicMock() + Taxontreedef = MagicMock() + + apps.get_model.side_effect = [ + Discipline, + Taxontreedef, + ] + + qs = MagicMock() + Taxontreedef.objects.filter.return_value = qs + + discipline_qs = MagicMock() + Discipline.objects.filter.return_value = discipline_qs + + ( + discipline_qs.order_by.return_value + .values.return_value.__getitem__.return_value + ) = MagicMock() + + fix_taxon_treedef_discipline_links(apps) + + Taxontreedef.objects.filter.assert_called_once_with( + discipline__isnull=True + ) + + qs.update.assert_called_once() \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0003_cotype_picklist.py b/specifyweb/specify/migration_utils/tests/test_helper_0003_cotype_picklist.py new file mode 100644 index 00000000000..db1d68b5636 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0003_cotype_picklist.py @@ -0,0 +1,71 @@ +from django.apps import apps + +from specifyweb.specify.models import ( + Collection, + Picklist, + Splocalecontainer, + Splocalecontaineritem, + Splocaleitemstr, +) +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0003_cotype_picklist + + +class Helper0003CotypePicklistTest(ApiTests): + + def setUp(self): + super().setUp() + self.other_collection = Collection.objects.create( + catalognumformatname='test2', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + self.collectionobject_container = Splocalecontainer.objects.create( + name='collectionobject', + schematype=0, + discipline=self.discipline, + aggregator='', + defaultui='', + format='', + ishidden=False, + issystem=False, + ) + + def test_create_cotype_picklist_creates_picklists_for_each_collection(self): + helper_0003_cotype_picklist.create_cotype_picklist(apps) + + self.assertEqual( + Picklist.objects.filter( + name=helper_0003_cotype_picklist.COT_PICKLIST_NAME, + tablename='collectionobjecttype', + type=1, + ).count(), + Collection.objects.count(), + ) + + def test_create_cotype_splocalecontaineritem_creates_schema_items_and_strings(self): + helper_0003_cotype_picklist.create_cotype_splocalecontaineritem(apps) + + schema_item = Splocalecontaineritem.objects.get( + container=self.collectionobject_container, + name=helper_0003_cotype_picklist.COT_FIELD_NAME, + ) + self.assertEqual( + schema_item.picklistname, + helper_0003_cotype_picklist.COT_PICKLIST_NAME, + ) + self.assertEqual(schema_item.type, 'ManyToOne') + self.assertTrue(schema_item.isrequired) + self.assertTrue( + Splocaleitemstr.objects.filter( + itemname=schema_item, + text=helper_0003_cotype_picklist.COT_TEXT, + ).exists() + ) + self.assertTrue( + Splocaleitemstr.objects.filter( + itemdesc=schema_item, + text=helper_0003_cotype_picklist.COT_TEXT, + ).exists() + ) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0004_stratigraphy_age.py b/specifyweb/specify/migration_utils/tests/test_helper_0004_stratigraphy_age.py new file mode 100644 index 00000000000..99995103902 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0004_stratigraphy_age.py @@ -0,0 +1,130 @@ +from unittest.mock import patch, MagicMock + +from django.apps import apps +from django.db.models import Prefetch +from django.test import TestCase + +from specifyweb.specify.models import Picklist, Picklistitem, Collection +from specifyweb.specify.migration_utils.migration_helpers import helper_0004_stratigraphy_age +from specifyweb.specify.tests.test_api import ApiTests + +class CreateAgetypePicklistTests(ApiTests): + + def setUp(self): + super().setUp() + self.other_collection = Collection.objects.create( + catalognumformatname='test', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + + def test_create_agetype_picklist_creates_items_for_new_picklist(self): + helper_0004_stratigraphy_age.create_agetype_picklist(apps) + + picklists = Picklist.objects.filter( + name=helper_0004_stratigraphy_age.AGETYPE_PICKLIST_NAME + ).prefetch_related( + Prefetch( + "picklistitems", + queryset=Picklistitem.objects.order_by("value"), + to_attr="testitems" + ) + ) + + collection_count = Collection.objects.all().count() + + self.assertEqual( + picklists.count(), + collection_count + ) + + ordered_age_types = tuple( + (val, val) for val in + sorted(helper_0004_stratigraphy_age.DEFAULT_AGE_TYPES) + ) + for picklist in picklists: + picklist_item_values = tuple( + (item.title, item.value) + for item in picklist.testitems + ) + + self.assertEqual( + ordered_age_types, + picklist_item_values + ) + +class CreateStratSchemaConfigTests(TestCase): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0004_stratigraphy_age.update_table_schema_config_with_defaults" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0004_stratigraphy_age.update_table_field_schema_config_with_defaults" + ) + def test_create_strat_table_schema_config_with_defaults( + self, + mock_field_update, + mock_table_update, + ): + mock_apps = MagicMock() + + discipline_model = MagicMock() + discipline_model.objects.all.return_value = [ + MagicMock(id=1), + MagicMock(id=2), + ] + + mock_apps.get_model.return_value = discipline_model + + helper_0004_stratigraphy_age.create_strat_table_schema_config_with_defaults( + mock_apps + ) + + self.assertEqual( + mock_table_update.call_count, + len(helper_0004_stratigraphy_age.MIGRATION_0004_TABLES) * 2, + ) + + expected_field_calls = ( + sum( + len(fields) + for fields in helper_0004_stratigraphy_age.MIGRATION_0004_FIELDS.values() + ) + * 2 + ) + + self.assertEqual( + mock_field_update.call_count, + expected_field_calls, + ) + +class RevertStratSchemaConfigTests(TestCase): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0004_stratigraphy_age.revert_table_schema_config" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0004_stratigraphy_age.revert_table_field_schema_config" + ) + def test_revert_strat_table_schema_config_with_defaults( + self, + mock_revert_field, + mock_revert_table, + ): + helper_0004_stratigraphy_age.revert_strat_table_schema_config_with_defaults( + MagicMock() + ) + + self.assertEqual( + mock_revert_table.call_count, + len(helper_0004_stratigraphy_age.MIGRATION_0004_TABLES), + ) + + self.assertEqual( + mock_revert_field.call_count, + sum( + len(fields) + for fields in helper_0004_stratigraphy_age.MIGRATION_0004_FIELDS.values() + ), + ) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0007_schema_config_update.py b/specifyweb/specify/migration_utils/tests/test_helper_0007_schema_config_update.py new file mode 100644 index 00000000000..749d04bfc3b --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0007_schema_config_update.py @@ -0,0 +1,205 @@ +from unittest.mock import patch + +from django.apps import apps +from specifyweb.specify.models import ( + Collection, + Picklist, + Splocalecontainer, + Splocalecontaineritem, + Splocaleitemstr, +) +from specifyweb.specify.migration_utils.migration_helpers import helper_0007_schema_config_update +from specifyweb.specify.tests.test_api import ApiTests + + +class UpdateCogTypeFieldsTests(ApiTests): + + def setUp(self): + super().setUp() + self.cog_container = Splocalecontainer.objects.create( + name="collectionobject", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + self.cog_item = Splocalecontaineritem.objects.create( + container=self.cog_container, + name="collectionObjectType", + ishidden=False, + issystem=False, + ) + self.cog_item_name = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-type-name", + itemname=self.cog_item, + ) + self.cog_item_desc = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-type-desc", + itemdesc=self.cog_item, + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0007_schema_config_update.revert_table_field_schema_config" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0007_schema_config_update.update_table_field_schema_config_with_defaults" + ) + def test_update_cog_type_fields( + self, + mock_update, + mock_revert, + ): + helper_0007_schema_config_update.update_cog_type_fields(apps) + + mock_revert.assert_any_call( + "CollectionObjectGroup", + "children", + apps, + ) + + mock_revert.assert_any_call( + "CollectionObjectGroup", + "cojo", + apps, + ) + + self.assertEqual( + mock_update.call_count, + sum( + len(fields) + for fields in helper_0007_schema_config_update.MIGRATION_0007_FIELDS.values() + ), + ) + + self.assertFalse( + Splocaleitemstr.objects.filter( + id__in=[self.cog_item_name.id, self.cog_item_desc.id] + ).exists() + ) + self.assertFalse( + Splocalecontaineritem.objects.filter(id=self.cog_item.id).exists() + ) + + +class CreateCogTypePicklistTests(ApiTests): + + def setUp(self): + super().setUp() + self.other_collection = Collection.objects.create( + catalognumformatname='test', + collectionname='OtherCollection', + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + + def test_create_cogtype_picklist(self): + helper_0007_schema_config_update.create_cogtype_picklist(apps) + + picklists = Picklist.objects.filter( + name=helper_0007_schema_config_update.COG_PICKLIST_NAME, + tablename="collectionobjectgrouptype", + formatter="CollectionObjectGroupType", + type=1, + ) + self.assertEqual( + picklists.count(), + Collection.objects.all().count(), + ) + + +class RevertCogTypePicklistTests(ApiTests): + + def setUp(self): + super().setUp() + self.target_picklist = Picklist.objects.create( + name=helper_0007_schema_config_update.COG_PICKLIST_NAME, + type=1, + tablename="collectionobjectgrouptype", + formatter="CollectionObjectGroupType", + collection=self.collection, + issystem=False, + readonly=False, + sizelimit=-1, + sorttype=1, + ) + + def test_revert_cogtype_picklist(self): + helper_0007_schema_config_update.revert_cogtype_picklist(apps) + + self.assertFalse( + Picklist.objects.filter(name=helper_0007_schema_config_update.COG_PICKLIST_NAME).exists() + ) + + +class UpdateCogTypeSplocaleContainerItemTests(ApiTests): + + def setUp(self): + super().setUp() + self.cog_container = Splocalecontainer.objects.create( + name="collectionobjectgroup", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + self.cog_type_item = Splocalecontaineritem.objects.create( + container=self.cog_container, + name=helper_0007_schema_config_update.COGTYPE_FIELD_NAME, + picklistname=None, + type="", + isrequired=False, + ishidden=False, + issystem=False, + ) + + def test_update_cogtype_splocalecontaineritem(self): + helper_0007_schema_config_update.update_cogtype_splocalecontaineritem(apps) + + self.cog_type_item.refresh_from_db() + self.assertEqual( + self.cog_type_item.picklistname, + helper_0007_schema_config_update.COG_PICKLIST_NAME, + ) + self.assertEqual(self.cog_type_item.type, "ManyToOne") + self.assertTrue(self.cog_type_item.isrequired) + + +class UpdateSystemCogTypesPicklistTests(ApiTests): + + def setUp(self): + super().setUp() + self.system_picklist = Picklist.objects.create( + name="Default Collection Object Group Types", + type=1, + tablename="collectionobjectgrouptype", + formatter="foo", + collection=self.collection, + issystem=False, + readonly=False, + sizelimit=0, + sorttype=1, + ) + + def test_update_systemcogtypes_picklist(self): + helper_0007_schema_config_update.update_systemcogtypes_picklist(apps) + + self.system_picklist.refresh_from_db() + self.assertEqual( + self.system_picklist.name, + helper_0007_schema_config_update.HISTORICAL_COGTYPES_PICKLIST, + ) + self.assertEqual(self.system_picklist.type, 0) + self.assertTrue(self.system_picklist.issystem) + self.assertTrue(self.system_picklist.readonly) + self.assertEqual(self.system_picklist.sizelimit, 3) + self.assertIsNone(self.system_picklist.tablename) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0008_ageCitations_fix.py b/specifyweb/specify/migration_utils/tests/test_helper_0008_ageCitations_fix.py new file mode 100644 index 00000000000..c14e6ef6f6c --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0008_ageCitations_fix.py @@ -0,0 +1,39 @@ +from django.apps import apps +from unittest.mock import patch, MagicMock + +from specifyweb.specify.migration_utils.migration_helpers import helper_0008_ageCitations_fix +from specifyweb.specify.models import Discipline +from specifyweb.specify.tests.test_api import ApiTests +# + +class RelativeAgeFieldTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0008_ageCitations_fix.update_table_field_schema_config_with_defaults" + ) + def test_update_relative_age_fields(self, mock_update): + helper_0008_ageCitations_fix.update_relative_age_fields(apps) + + expected = ( + Discipline.objects.count() + * sum( + len(fields) + for fields in helper_0008_ageCitations_fix.MIGRATION_0008_FIELDS.values() + ) + ) + + self.assertEqual(mock_update.call_count, expected) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0008_ageCitations_fix.revert_table_field_schema_config" + ) + def test_revert_relative_age_fields(self, mock_revert): + helper_0008_ageCitations_fix.revert_relative_age_fields(MagicMock()) + + self.assertEqual( + mock_revert.call_count, + sum( + len(fields) + for fields in helper_0008_ageCitations_fix.MIGRATION_0008_FIELDS.values() + ), + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0012_add_cojo_to_schema_config.py b/specifyweb/specify/migration_utils/tests/test_helper_0012_add_cojo_to_schema_config.py new file mode 100644 index 00000000000..6d2ac885df7 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0012_add_cojo_to_schema_config.py @@ -0,0 +1,25 @@ +from django.test import TestCase +from unittest.mock import patch, MagicMock +from django.apps import apps + +from specifyweb.specify.migration_utils.migration_helpers import helper_0012_add_cojo_to_schema_config +from specifyweb.specify.models import Discipline +from specifyweb.specify.tests.test_api import ApiTests + +class CojoSchemaConfigTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0012_add_cojo_to_schema_config.update_table_field_schema_config_with_defaults" + ) + def test_add_cojo_to_schema_config(self, mock_update): + helper_0012_add_cojo_to_schema_config.add_cojo_to_schema_config(apps) + + expected = ( + Discipline.objects.count() + * sum( + len(fields) + for fields in helper_0012_add_cojo_to_schema_config.MIGRATION_0012_FIELDS.values() + ) + ) + + self.assertEqual(mock_update.call_count, expected) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0013_collectionobjectgroup_parentcog.py b/specifyweb/specify/migration_utils/tests/test_helper_0013_collectionobjectgroup_parentcog.py new file mode 100644 index 00000000000..92317dc48c8 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0013_collectionobjectgroup_parentcog.py @@ -0,0 +1,84 @@ +from unittest.mock import call, patch +from django.apps import apps + +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0013_collectionobjectgroup_parentcog, +) + +class UpdateCogSchemaConfigTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0013_collectionobjectgroup_parentcog.revert_table_field_schema_config" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0013_collectionobjectgroup_parentcog.update_table_field_schema_config_with_defaults" + ) + def test_update_cog_schema_config( + self, + mock_update, + mock_revert, + ): + helper_0013_collectionobjectgroup_parentcog.update_cog_schema_config( + apps + ) + + mock_revert.assert_has_calls( + [ + call( + "CollectionObjectGroup", + "parentCojo", + apps, + ), + call( + "CollectionObjectGroup", + "parentCog", + apps, + ), + ] + ) + + expected_update_calls = ( + self.discipline.__class__.objects.count() + * sum( + len(fields) + for fields in helper_0013_collectionobjectgroup_parentcog.MIGRATION_0013_FIELDS.values() + ) + ) + + self.assertEqual( + mock_update.call_count, + expected_update_calls, + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0013_collectionobjectgroup_parentcog.revert_table_field_schema_config" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0013_collectionobjectgroup_parentcog.update_table_field_schema_config_with_defaults" + ) + def test_revert_update_cog_schema_config( + self, + mock_update, + mock_revert, + ): + helper_0013_collectionobjectgroup_parentcog.revert_update_cog_schema_config( + apps + ) + + expected_revert_calls = sum( + len(fields) + for fields in helper_0013_collectionobjectgroup_parentcog.MIGRATION_0013_FIELDS.values() + ) + + self.assertEqual( + mock_revert.call_count, + expected_revert_calls, + ) + + mock_update.assert_any_call( + "CollectionObjectGroup", + self.discipline.id, + "parentCojo", + apps, + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0015_add_version_to_ages.py b/specifyweb/specify/migration_utils/tests/test_helper_0015_add_version_to_ages.py new file mode 100644 index 00000000000..bf0fc71da87 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0015_add_version_to_ages.py @@ -0,0 +1,41 @@ +from unittest.mock import call, patch + +from django.apps import apps +from django.test import TestCase + +from specifyweb.specify.models import Discipline +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0015_add_version_to_ages + + +class AddVersionToAgesTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0015_add_version_to_ages.update_table_field_schema_config_with_defaults" + ) + def test_update_age_schema_config(self, mock_update): + helper_0015_add_version_to_ages.update_age_schema_config(apps) + + self.assertEqual(mock_update.call_count, Discipline.objects.count() * 2) + mock_update.assert_has_calls( + [ + call("AbsoluteAge", self.discipline.id, "version", apps), + call("RelativeAge", self.discipline.id, "version", apps), + ], + any_order=True, + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0015_add_version_to_ages.revert_table_field_schema_config" + ) + def test_revert_update_age_schema_config(self, mock_revert): + helper_0015_add_version_to_ages.revert_update_age_schema_config(apps) + + mock_revert.assert_has_calls( + [ + call("AbsoluteAge", "version", apps), + call("RelativeAge", "version", apps), + ], + any_order=True, + ) + self.assertEqual(mock_revert.call_count, 2) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0017_schemaconfig_fixes.py b/specifyweb/specify/migration_utils/tests/test_helper_0017_schemaconfig_fixes.py new file mode 100644 index 00000000000..e4f44511c66 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0017_schemaconfig_fixes.py @@ -0,0 +1,28 @@ +from django.test import TestCase +from unittest.mock import patch, MagicMock + +from specifyweb.specify.migration_utils.migration_helpers import helper_0017_schemaconfig_fixes + +class SchemaConfigFixesTests(TestCase): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0017_schemaconfig_fixes.fix_table_captions" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0017_schemaconfig_fixes.fix_item_types" + ) + def test_schemaconfig_fixes( + self, + mock_fix_item_types, + mock_fix_table_captions, + ): + mock_apps = MagicMock() + + helper_0017_schemaconfig_fixes.schemaconfig_fixes(mock_apps) + + mock_fix_table_captions.assert_called_once_with( + mock_apps + ) + mock_fix_item_types.assert_called_once_with( + mock_apps + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0018_cot_catnum_schema.py b/specifyweb/specify/migration_utils/tests/test_helper_0018_cot_catnum_schema.py new file mode 100644 index 00000000000..c7bc25fec6d --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0018_cot_catnum_schema.py @@ -0,0 +1,108 @@ +from django.apps import apps +from unittest.mock import MagicMock, patch + +from specifyweb.specify.models import ( + Splocalecontainer, + Splocalecontaineritem, + Splocaleitemstr, +) +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0018_cot_catnum_schema + + +class AddCotCatnumToSchemaTests(ApiTests): + + def setUp(self): + super().setUp() + self.collectionobjecttype_container = Splocalecontainer.objects.create( + name='collectionobjecttype', + schematype=0, + discipline=self.discipline, + aggregator='', + defaultui='', + format='', + ishidden=False, + issystem=False, + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0018_cot_catnum_schema.datamodel" + ) + def test_add_cot_catnum_to_schema(self, mock_datamodel): + field = MagicMock() + field.name = "catalogNumberFormatName" + field.type = "text" + field.required = True + + table = MagicMock() + table.get_field_strict.return_value = field + + mock_datamodel.get_table_strict.return_value = table + + helper_0018_cot_catnum_schema.add_cot_catnum_to_schema(apps) + + schema_item = Splocalecontaineritem.objects.get( + container=self.collectionobjecttype_container, + name="catalogNumberFormatName", + ) + self.assertEqual(schema_item.version, 0) + self.assertTrue(schema_item.isrequired) + self.assertEqual(schema_item.type, "text") + self.assertTrue( + Splocaleitemstr.objects.filter( + itemname=schema_item, + text="Catalog Number Format Name", + ).exists() + ) + self.assertTrue( + Splocaleitemstr.objects.filter( + itemdesc=schema_item, + text="Catalog Number Format Name", + ).exists() + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0018_cot_catnum_schema.datamodel" + ) + def test_remove_cot_catnum_from_schema(self, mock_datamodel): + field = MagicMock() + field.name = "catalogNumberFormatName" + field.type = "text" + field.required = True + + table = MagicMock() + table.get_field_strict.return_value = field + + mock_datamodel.get_table_strict.return_value = table + + schema_item = Splocalecontaineritem.objects.create( + container=self.collectionobjecttype_container, + name="catalogNumberFormatName", + type="text", + ishidden=False, + issystem=False, + ) + Splocaleitemstr.objects.create( + language='en', + country='US', + text='Catalog Number Format Name', + itemname=schema_item, + ) + Splocaleitemstr.objects.create( + language='en', + country='US', + text='Catalog Number Format Name', + itemdesc=schema_item, + ) + + helper_0018_cot_catnum_schema.remove_cot_catnum_from_schema(apps) + + self.assertFalse( + Splocalecontaineritem.objects.filter(id=schema_item.id).exists() + ) + self.assertFalse( + Splocaleitemstr.objects.filter(itemname=schema_item).exists() + ) + self.assertFalse( + Splocaleitemstr.objects.filter(itemdesc=schema_item).exists() + ) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0020_add_tectonicunit_to_pc_in_schema_config.py b/specifyweb/specify/migration_utils/tests/test_helper_0020_add_tectonicunit_to_pc_in_schema_config.py new file mode 100644 index 00000000000..d386e4ab76e --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0020_add_tectonicunit_to_pc_in_schema_config.py @@ -0,0 +1,49 @@ +from unittest.mock import patch + +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0020_add_tectonicunit_to_pc_in_schema_config, +) +from django.apps import apps + + +class AddTectonicUnitTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0020_add_tectonicunit_to_pc_in_schema_config.update_table_field_schema_config_with_defaults" + ) + def test_add_tectonicunit_to_pc_in_schema_config( + self, + mock_update, + ): + helper_0020_add_tectonicunit_to_pc_in_schema_config.add_tectonicunit_to_pc_in_schema_config( + apps + ) + + expected = ( + self.discipline.__class__.objects.count() + * len( + helper_0020_add_tectonicunit_to_pc_in_schema_config.MIGRATION_0020_FIELDS[ + "PaleoContext" + ] + ) + ) + + self.assertEqual(mock_update.call_count, expected) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0020_add_tectonicunit_to_pc_in_schema_config.revert_table_field_schema_config" + ) + def test_remove_tectonicunit_from_pc_schema_config( + self, + mock_revert, + ): + helper_0020_add_tectonicunit_to_pc_in_schema_config.remove_tectonicunit_from_pc_schema_config( + apps + ) + + mock_revert.assert_called_once_with( + "PaleoContext", + "tectonicUnit", + apps, + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0021_update_hidden_geo_tables.py b/specifyweb/specify/migration_utils/tests/test_helper_0021_update_hidden_geo_tables.py new file mode 100644 index 00000000000..1e8bc91fdb2 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0021_update_hidden_geo_tables.py @@ -0,0 +1,109 @@ +from django.apps import apps + +from specifyweb.specify.models import Discipline, Splocalecontainer, Splocalecontaineritem +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0021_update_hidden_geo_tables + + +class FixHiddenGeoPropTests(ApiTests): + + def setUp(self): + super().setUp() + self.other_discipline = Discipline.objects.create( + geologictimeperiodtreedef=self.geologictimeperiodtreedef, + geographytreedef=self.geographytreedef, + division=self.division, + datatype=self.datatype, + type='botany', + ) + self.container = Splocalecontainer.objects.create( + name='collectionobject', + schematype=0, + discipline=self.other_discipline, + aggregator='', + defaultui='', + format='', + ishidden=False, + issystem=False, + ) + self.relative_item = Splocalecontaineritem.objects.create( + container=self.container, + name='relativeAges', + ishidden=False, + issystem=False, + ) + self.absolute_item = Splocalecontaineritem.objects.create( + container=self.container, + name='absoluteAges', + ishidden=False, + issystem=False, + ) + self.cojo_item = Splocalecontaineritem.objects.create( + container=self.container, + name='cojo', + ishidden=False, + issystem=False, + ) + + def test_fix_hidden_geo_prop(self): + helper_0021_update_hidden_geo_tables.fix_hidden_geo_prop(apps) + + self.relative_item.refresh_from_db() + self.absolute_item.refresh_from_db() + self.cojo_item.refresh_from_db() + + self.assertTrue(self.relative_item.ishidden) + self.assertTrue(self.absolute_item.ishidden) + self.assertTrue(self.cojo_item.ishidden) + + +class ReverseFixHiddenGeoPropTests(ApiTests): + + def setUp(self): + super().setUp() + self.other_discipline = Discipline.objects.create( + geologictimeperiodtreedef=self.geologictimeperiodtreedef, + geographytreedef=self.geographytreedef, + division=self.division, + datatype=self.datatype, + type='botany', + ) + self.container = Splocalecontainer.objects.create( + name='collectionobject', + schematype=0, + discipline=self.other_discipline, + aggregator='', + defaultui='', + format='', + ishidden=False, + issystem=False, + ) + self.relative_item = Splocalecontaineritem.objects.create( + container=self.container, + name='relativeAges', + ishidden=True, + issystem=False, + ) + self.absolute_item = Splocalecontaineritem.objects.create( + container=self.container, + name='absoluteAges', + ishidden=True, + issystem=False, + ) + self.cojo_item = Splocalecontaineritem.objects.create( + container=self.container, + name='cojo', + ishidden=True, + issystem=False, + ) + + def test_reverse_fix_hidden_geo_prop(self): + helper_0021_update_hidden_geo_tables.reverse_fix_hidden_geo_prop(apps) + + self.relative_item.refresh_from_db() + self.absolute_item.refresh_from_db() + self.cojo_item.refresh_from_db() + + self.assertFalse(self.relative_item.ishidden) + self.assertFalse(self.absolute_item.ishidden) + self.assertFalse(self.cojo_item.ishidden) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0023_update_schema_config_text.py b/specifyweb/specify/migration_utils/tests/test_helper_0023_update_schema_config_text.py new file mode 100644 index 00000000000..23207860ef9 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0023_update_schema_config_text.py @@ -0,0 +1,173 @@ +from unittest.mock import patch + +from django.apps import apps + +from specifyweb.specify.models import Splocalecontainer, Splocalecontaineritem, Splocaleitemstr +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0023_update_schema_config_text + + +class UpdateSchemaConfigTextTests(ApiTests): + + def setUp(self): + super().setUp() + + self.container = Splocalecontainer.objects.create( + name="collectionobjectgroup", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + + self.item = Splocalecontaineritem.objects.create( + container=self.container, + name="guid", + ishidden=False, + issystem=False, + ) + + self.desc = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-desc", + itemdesc=self.item, + ) + + self.name = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-name", + itemname=self.item, + ) + + self.absolute_container = Splocalecontainer.objects.create( + name="absoluteage", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + + self.yesno2 = Splocalecontaineritem.objects.create( + container=self.absolute_container, + name="yesno2", + ishidden=False, + issystem=False, + ) + self.date1 = Splocalecontaineritem.objects.create( + container=self.absolute_container, + name="date1", + ishidden=True, + issystem=False, + ) + self.date2 = Splocalecontaineritem.objects.create( + container=self.absolute_container, + name="date2", + ishidden=False, + issystem=False, + ) + + self.duplicate_keep = Splocalecontaineritem.objects.create( + container=self.absolute_container, + name="dupfield", + ishidden=False, + issystem=False, + ) + self.duplicate_delete = Splocalecontaineritem.objects.create( + container=self.absolute_container, + name="dupfield", + ishidden=False, + issystem=False, + ) + + self.duplicate_desc = Splocaleitemstr.objects.create( + language="en", + country="US", + text="dup-desc", + itemdesc=self.duplicate_delete, + ) + self.duplicate_name = Splocaleitemstr.objects.create( + language="en", + country="US", + text="dup-name", + itemname=self.duplicate_delete, + ) + + def test_update_schema_config_field_desc(self): + helper_0023_update_schema_config_text.update_schema_config_field_desc(apps) + + self.desc.refresh_from_db() + self.name.refresh_from_db() + self.assertEqual(self.desc.text, "GUID") + self.assertEqual(self.name.text, "GUID") + + def test_reverse_update_schema_config_field_desc(self): + helper_0023_update_schema_config_text.reverse_update_schema_config_field_desc(apps) + + self.desc.refresh_from_db() + self.name.refresh_from_db() + self.assertEqual(self.desc.text, "guid") + self.assertEqual(self.name.text, "guid") + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0023_update_schema_config_text._schema_override_hidden_values_for_discipline" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0023_update_schema_config_text._fields_without_explicit_hidden_override" + ) + def test_update_hidden_prop_hides_and_reconciles_duplicates( + self, + mock_fields_without_override, + mock_schema_override, + ): + mock_schema_override.return_value = { + "absoluteage": { + "yesno2": True, + "date1": False, + } + } + mock_fields_without_override.return_value = ["date2"] + + helper_0023_update_schema_config_text.update_hidden_prop(apps) + + self.yesno2.refresh_from_db() + self.date1.refresh_from_db() + self.date2.refresh_from_db() + + self.assertTrue(self.yesno2.ishidden) + self.assertFalse(self.date1.ishidden) + self.assertTrue(self.date2.ishidden) + + self.assertEqual( + Splocalecontaineritem.objects.filter( + container=self.absolute_container, + name="dupfield", + ).count(), + 1, + ) + + self.duplicate_desc.refresh_from_db() + self.duplicate_name.refresh_from_db() + + self.assertEqual(self.duplicate_desc.itemdesc_id, self.duplicate_keep.id) + self.assertEqual(self.duplicate_name.itemname_id, self.duplicate_keep.id) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0023_update_schema_config_text._fields_without_explicit_hidden_override" + ) + def test_reverse_update_hidden_prop_unhides_fields(self, mock_fields_without_override): + self.date2.ishidden = True + self.date2.save() + mock_fields_without_override.return_value = ["date2"] + + helper_0023_update_schema_config_text.reverse_update_hidden_prop(apps) + + self.date2.refresh_from_db() + self.assertFalse(self.date2.ishidden) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0024_add_uniqueIdentifier_storage.py b/specifyweb/specify/migration_utils/tests/test_helper_0024_add_uniqueIdentifier_storage.py new file mode 100644 index 00000000000..3df201e2b98 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0024_add_uniqueIdentifier_storage.py @@ -0,0 +1,49 @@ +from unittest.mock import patch + +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0024_add_uniqueIdentifier_storage, +) +from django.apps import apps + + +class StorageUniqueIdentifierTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0024_add_uniqueIdentifier_storage.update_table_field_schema_config_with_defaults" + ) + def test_update_storage_unique_id_fields( + self, + mock_update, + ): + helper_0024_add_uniqueIdentifier_storage.update_storage_unique_id_fields( + apps + ) + + expected = ( + self.discipline.__class__.objects.count() + * len( + helper_0024_add_uniqueIdentifier_storage.MIGRATION_0024_FIELDS[ + "Storage" + ] + ) + ) + + self.assertEqual(mock_update.call_count, expected) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0024_add_uniqueIdentifier_storage.revert_table_field_schema_config" + ) + def test_revert_storage_unique_id_fields( + self, + mock_revert, + ): + helper_0024_add_uniqueIdentifier_storage.revert_storage_unique_id_fields( + apps + ) + + mock_revert.assert_called_once_with( + "Storage", + "uniqueIdentifier", + apps, + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0027_CO_children.py b/specifyweb/specify/migration_utils/tests/test_helper_0027_CO_children.py new file mode 100644 index 00000000000..94f1163dd49 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0027_CO_children.py @@ -0,0 +1,46 @@ +from unittest.mock import patch + +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0027_CO_children, +) +from django.apps import apps + + +class COChildrenTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0027_CO_children.update_table_field_schema_config_with_defaults" + ) + def test_update_co_children_fields( + self, + mock_update, + ): + helper_0027_CO_children.update_co_children_fields(apps) + + expected = ( + self.discipline.__class__.objects.count() + * sum( + len(fields) + for fields in helper_0027_CO_children.MIGRATION_0027_FIELDS.values() + ) + ) + + self.assertGreater(mock_update.call_count, 0) + self.assertEqual(mock_update.call_count, expected) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0027_CO_children.revert_table_field_schema_config" + ) + def test_revert_co_children_fields( + self, + mock_revert, + ): + helper_0027_CO_children.revert_co_children_fields(apps) + + expected = sum( + len(fields) + for fields in helper_0027_CO_children.MIGRATION_0027_FIELDS.values() + ) + + self.assertEqual(mock_revert.call_count, expected) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0031_add_default_for_selectseries.py b/specifyweb/specify/migration_utils/tests/test_helper_0031_add_default_for_selectseries.py new file mode 100644 index 00000000000..595cfa7a909 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0031_add_default_for_selectseries.py @@ -0,0 +1,38 @@ +from django.apps import apps as django_apps +from django.test import TestCase +from specifyweb.specify import models +from specifyweb.specify.migration_utils.migration_helpers.helper_0031_add_default_for_selectseries import make_selectseries_false +from specifyweb.specify.tests.test_api import ApiTests + +class MakeSelectSeriesFalseTests(ApiTests): + def test_make_selectseries_false_updates_only_null_smushed_values(self): + null_query = models.Spquery.objects.create( + name=f"Null Smushed {self.collection.id}", + contextname="Collectionobject", + contexttableid=models.Collectionobject.specify_model.tableId, + specifyuser=self.specifyuser, + smushed=None, + ) + false_query = models.Spquery.objects.create( + name=f"False Smushed {self.collection.id}", + contextname="Collectionobject", + contexttableid=models.Collectionobject.specify_model.tableId, + specifyuser=self.specifyuser, + smushed=False, + ) + true_query = models.Spquery.objects.create( + name=f"True Smushed {self.collection.id}", + contextname="Collectionobject", + contexttableid=models.Collectionobject.specify_model.tableId, + specifyuser=self.specifyuser, + smushed=True, + ) + + make_selectseries_false(django_apps) + + null_query.refresh_from_db() + false_query.refresh_from_db() + true_query.refresh_from_db() + self.assertFalse(null_query.smushed) + self.assertFalse(false_query.smushed) + self.assertTrue(true_query.smushed) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0032_add_quantities_gift.py b/specifyweb/specify/migration_utils/tests/test_helper_0032_add_quantities_gift.py new file mode 100644 index 00000000000..43dea7e28f1 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0032_add_quantities_gift.py @@ -0,0 +1,34 @@ +from specifyweb.specify.tests.test_api import ApiTests +from unittest.mock import patch + +from django.apps import apps + +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0032_add_quantities_gift, +) + +class AddQuantitiesGiftTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0032_add_quantities_gift.update_table_field_schema_config_with_defaults" + ) + def test_add_quantities_gift( + self, + mock_update, + ): + helper_0032_add_quantities_gift.add_quantities_gift( + apps + ) + + expected = ( + self.discipline.__class__.objects.count() + * sum( + len(fields) + for fields in helper_0032_add_quantities_gift.MIGRATION_0032_FIELDS.values() + ) + ) + + self.assertEqual( + mock_update.call_count, + expected, + ) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0033_update_paleo_desc.py b/specifyweb/specify/migration_utils/tests/test_helper_0033_update_paleo_desc.py new file mode 100644 index 00000000000..c7fead86789 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0033_update_paleo_desc.py @@ -0,0 +1,45 @@ +from django.apps import apps + +from specifyweb.specify.models import ( + Splocalecontainer, + Splocalecontaineritem, + Splocaleitemstr, +) +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0033_update_paleo_desc + + +class UpdatePaleoDescTests(ApiTests): + + def setUp(self): + super().setUp() + self.paleo_container = Splocalecontainer.objects.create( + name='paleocontext', + schematype=0, + discipline=self.discipline, + aggregator='', + defaultui='', + format='', + ishidden=False, + issystem=False, + ) + self.paleo_item = Splocalecontaineritem.objects.create( + container=self.paleo_container, + name='paleoDescItem', + ishidden=False, + issystem=False, + ) + self.paleo_desc = Splocaleitemstr.objects.create( + language='en', + country='US', + text='old-description', + containerdesc=self.paleo_container, + itemdesc=self.paleo_item, + ) + + def test_update_paleo_desc(self): + helper_0033_update_paleo_desc.update_paleo_desc(apps) + + self.paleo_desc.refresh_from_db() + expected_desc = helper_0033_update_paleo_desc.MIGRATION_0033_TABLES[0][1] + self.assertEqual(self.paleo_desc.text, expected_desc) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0034_accession_date_fields.py b/specifyweb/specify/migration_utils/tests/test_helper_0034_accession_date_fields.py new file mode 100644 index 00000000000..c16a3a4f1ad --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0034_accession_date_fields.py @@ -0,0 +1,81 @@ +from unittest.mock import patch + +from django.apps import apps + +from specifyweb.specify.models import Splocalecontainer, Splocalecontaineritem, Splocaleitemstr +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0034_accession_date_fields + + +class AccessionDateFieldsTests(ApiTests): + + def setUp(self): + super().setUp() + + self.container = Splocalecontainer.objects.create( + name="accession", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + + self.item = Splocalecontaineritem.objects.create( + container=self.container, + name="dateAccessionedPrecision", + ishidden=False, + issystem=False, + ) + + self.desc = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-desc", + itemdesc=self.item, + ) + + self.name = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-name", + itemname=self.item, + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0034_accession_date_fields.update_table_field_schema_config_with_defaults" + ) + def test_update_accession_date_fields_calls_schema_config_update(self, mock_update): + helper_0034_accession_date_fields.update_accession_date_fields(apps) + + expected = len(helper_0034_accession_date_fields.MIGRATION_0034_FIELDS["Accession"]) + self.assertEqual(mock_update.call_count, expected) + mock_update.assert_any_call("Accession", self.discipline.id, "dateAccessionedPrecision", apps) + mock_update.assert_any_call("Accession", self.discipline.id, "date2Precision", apps) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0034_accession_date_fields.update_table_field_schema_config_with_defaults" + ) + def test_update_accession_date_fields_updates_field_descriptions(self, mock_update): + helper_0034_accession_date_fields.update_accession_date_fields(apps) + + self.item.refresh_from_db() + self.desc.refresh_from_db() + self.name.refresh_from_db() + + self.assertTrue(self.item.ishidden) + self.assertEqual(self.desc.text, "Date Accessioned Precision") + self.assertEqual(self.name.text, "Date Accessioned Precision") + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0034_accession_date_fields.revert_table_field_schema_config" + ) + def test_revert_update_accession_date_fields_reverts_field_config(self, mock_revert): + helper_0034_accession_date_fields.revert_update_accession_date_fields(apps) + + expected = len(helper_0034_accession_date_fields.MIGRATION_0034_FIELDS["Accession"]) + self.assertEqual(mock_revert.call_count, expected) + mock_revert.assert_any_call("Accession", "date1", apps) + mock_revert.assert_any_call("Accession", "date2Precision", apps) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0035_version_required.py b/specifyweb/specify/migration_utils/tests/test_helper_0035_version_required.py new file mode 100644 index 00000000000..2c4e1495d4a --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0035_version_required.py @@ -0,0 +1,48 @@ +from unittest.mock import patch + +from specifyweb.specify.tests.test_api import ApiTests +from django.apps import apps + +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0035_version_required, +) + + +class VersionRequiredTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0035_version_required.update_table_field_schema_config_params" + ) + def test_update_version_required( + self, + mock_update, + ): + helper_0035_version_required.update_version_required( + apps + ) + + expected = ( + self.discipline.__class__.objects.count() + * sum( + len(fields) + for fields in helper_0035_version_required.MIGRATION_0035_FIELDS.values() + ) + ) + + self.assertEqual(mock_update.call_count, expected) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0035_version_required.update_table_field_schema_config_params" + ) + def test_revert_version_required( + self, + mock_update, + ): + helper_0035_version_required.revert_version_required( + apps + ) + + self.assertTrue(mock_update.called) + + args = mock_update.call_args[0] + self.assertEqual(args[3]["isrequired"], True) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0039_agent_fields_for_loan_and_gift.py b/specifyweb/specify/migration_utils/tests/test_helper_0039_agent_fields_for_loan_and_gift.py new file mode 100644 index 00000000000..6bc49c24044 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0039_agent_fields_for_loan_and_gift.py @@ -0,0 +1,21 @@ +from unittest.mock import patch, MagicMock +from django.apps import apps as django_apps + +from specifyweb.specify.tests.test_api import ApiTests +import specifyweb.specify.migration_utils.migration_helpers.helper_0039_agent_fields_for_loan_and_gift as helper + + +class UpdateLoanAndGiftAgentFieldsTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers." + "helper_0039_agent_fields_for_loan_and_gift.update_table_field_schema_config_with_defaults" + ) + def test_calls_schema_writer_for_all_fields(self, mock_update): + + helper.update_loan_and_gift_agent_fields(django_apps) + + self.assertEqual(mock_update.call_count, 10) + + for _, kwargs in mock_update.call_args_list: + self.assertEqual(kwargs["defaults"], {"ishidden": True}) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0040_components.py b/specifyweb/specify/migration_utils/tests/test_helper_0040_components.py new file mode 100644 index 00000000000..b33830c3a51 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0040_components.py @@ -0,0 +1,202 @@ +from unittest.mock import patch + +from django.apps import apps + +from specifyweb.specify.models import Splocalecontainer, Splocalecontaineritem, Splocaleitemstr +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.migration_utils.migration_helpers import helper_0040_components + + +class ComponentMigrationTests(ApiTests): + + def setUp(self): + super().setUp() + + self.component_container = Splocalecontainer.objects.create( + name="component", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + + self.component_type_item = Splocalecontaineritem.objects.create( + container=self.component_container, + name="type", + ishidden=False, + issystem=False, + ) + + self.component_name_item = Splocalecontaineritem.objects.create( + container=self.component_container, + name="name", + ishidden=False, + issystem=False, + ) + + self.component_desc = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-type-desc", + itemdesc=self.component_type_item, + ) + + self.component_name = Splocaleitemstr.objects.create( + language="en", + country="US", + text="old-type-name", + itemname=self.component_type_item, + ) + + self.collectionobject_container = Splocalecontainer.objects.create( + name="collectionobject", + schematype=0, + discipline=self.discipline, + aggregator="", + defaultui="", + format="", + ishidden=False, + issystem=False, + ) + + self.component_parent_item = Splocalecontaineritem.objects.create( + container=self.collectionobject_container, + name="componentParent", + ishidden=False, + issystem=False, + ) + + self.components_item = Splocalecontaineritem.objects.create( + container=self.collectionobject_container, + name="components", + ishidden=False, + issystem=False, + ) + + self.removed_desc = Splocaleitemstr.objects.create( + language="en", + country="US", + text="remove-desc", + itemdesc=self.component_parent_item, + ) + + self.removed_name = Splocaleitemstr.objects.create( + language="en", + country="US", + text="remove-name", + itemname=self.components_item, + ) + + self.hidden_item = Splocalecontaineritem.objects.create( + container=self.collectionobject_container, + name="components", + ishidden=False, + issystem=False, + ) + + self.hidden_field = Splocalecontaineritem.objects.create( + container=self.component_container, + name="type", + ishidden=True, + issystem=False, + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0040_components.update_table_schema_config_with_defaults" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0040_components.update_table_field_schema_config_with_defaults" + ) + def test_create_table_schema_config_with_defaults(self, mock_field_update, mock_table_update): + helper_0040_components.create_table_schema_config_with_defaults(apps) + + self.assertEqual( + mock_table_update.call_count, + len(helper_0040_components.MIGRATION_0040_TABLES), + ) + + expected_field_calls = ( + self.discipline.__class__.objects.count() + * sum( + len(fields) + for fields in helper_0040_components.MIGRATION_0040_FIELDS.values() + ) + ) + self.assertEqual(mock_field_update.call_count, expected_field_calls) + + def test_update_schema_config_field_desc_for_components(self): + helper_0040_components.update_schema_config_field_desc_for_components(apps) + + self.component_desc.refresh_from_db() + self.component_name.refresh_from_db() + + expected_desc = helper_0040_components.MIGRATION_0040_UPDATE_FIELDS["Component"][0][2] + expected_name = helper_0040_components.MIGRATION_0040_UPDATE_FIELDS["Component"][0][1] + + self.assertEqual(self.component_desc.text, expected_desc) + self.assertEqual(self.component_name.text, expected_name) + + def test_update_hidden_prop_for_components(self): + helper_0040_components.update_hidden_prop_for_components(apps) + + self.hidden_item.refresh_from_db() + self.assertTrue(self.hidden_item.ishidden) + + def test_create_cotype_splocalecontaineritem_for_components(self): + self.component_type_item.picklistname = "" + self.component_type_item.isrequired = False + self.component_type_item.type = "" + self.component_type_item.save() + + helper_0040_components.create_cotype_splocalecontaineritem_for_components(apps) + + self.component_type_item.refresh_from_db() + self.assertEqual(self.component_type_item.picklistname, "CollectionObjectType") + self.assertTrue(self.component_type_item.isrequired) + self.assertEqual(self.component_type_item.type, "ManyToOne") + + def test_remove_0029_schema_config_fields(self): + helper_0040_components.remove_0029_schema_config_fields(apps) + + self.assertFalse( + Splocalecontaineritem.objects.filter(id=self.component_parent_item.id).exists() + ) + self.assertFalse( + Splocalecontaineritem.objects.filter(id=self.components_item.id).exists() + ) + self.assertFalse( + Splocaleitemstr.objects.filter(id=self.removed_desc.id).exists() + ) + self.assertFalse( + Splocaleitemstr.objects.filter(id=self.removed_name.id).exists() + ) + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0040_components.revert_table_schema_config" + ) + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0040_components.revert_table_field_schema_config" + ) + def test_revert_table_schema_config_with_defaults(self, mock_revert_field, mock_revert_table): + helper_0040_components.revert_table_schema_config_with_defaults(apps) + + self.assertEqual( + mock_revert_table.call_count, + len(helper_0040_components.MIGRATION_0040_TABLES), + ) + self.assertEqual( + mock_revert_field.call_count, + sum(len(fields) for fields in helper_0040_components.MIGRATION_0040_FIELDS.values()), + ) + + def test_reverse_hide_component_fields(self): + self.hidden_field.ishidden = True + self.hidden_field.save() + + helper_0040_components.reverse_hide_component_fields(apps) + + self.hidden_field.refresh_from_db() + self.assertTrue(self.hidden_field.ishidden) diff --git a/specifyweb/specify/migration_utils/tests/test_helper_0042_discipline_type_picklist.py b/specifyweb/specify/migration_utils/tests/test_helper_0042_discipline_type_picklist.py new file mode 100644 index 00000000000..d8c7f0fa9d6 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_helper_0042_discipline_type_picklist.py @@ -0,0 +1,157 @@ +from unittest.mock import patch + +from django.apps import apps +from django.db.models import Prefetch + +from specifyweb.specify.tests.test_api import ApiTests +from specifyweb.specify.models import ( + Picklist, + Picklistitem, + Collection, + Splocalecontainer, + Splocalecontaineritem, +) + +from specifyweb.specify.migration_utils.migration_helpers import ( + helper_0042_discipline_type_picklist, +) + + +class CreateDisciplineTypePicklistTests(ApiTests): + + @patch( + "specifyweb.specify.migration_utils.migration_helpers.helper_0042_discipline_type_picklist.batch_query" + ) + def test_create_discipline_type_picklist_creates_picklists_and_items( + self, + mock_batch_query, + ): + self.other_collection = Collection.objects.create( + catalognumformatname="test2", + collectionname="OtherCollection", + isembeddedcollectingevent=False, + discipline=self.discipline, + ) + + all_ids = list(Collection.objects.values_list("id", flat=True)) + mock_batch_query.return_value = [all_ids] + + helper_0042_discipline_type_picklist.create_discipline_type_picklist( + apps + ) + + picklists = Picklist.objects.filter( + name=helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME, + type=0, + ) + + self.assertEqual( + picklists.count(), + len(all_ids), + ) + + expected_item_count = ( + len(all_ids) + * len(helper_0042_discipline_type_picklist.DISCIPLINE_NAMES) + ) + + self.assertEqual( + Picklistitem.objects.filter( + picklist__in=picklists + ).count(), + expected_item_count, + ) + + def test_create_discipline_type_picklist_is_idempotent(self): + helper_0042_discipline_type_picklist.create_discipline_type_picklist( + apps + ) + + first_count = Picklist.objects.filter( + name=helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME + ).count() + + helper_0042_discipline_type_picklist.create_discipline_type_picklist( + apps + ) + + second_count = Picklist.objects.filter( + name=helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME + ).count() + + self.assertEqual(first_count, second_count) + + +class DisciplineTypePicklistRevertTests(ApiTests): + + def test_revert_deletes_picklists(self): + Picklist.objects.create( + collection=self.collection, + name=helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME, + type=0, + issystem=True, + readonly=True, + sizelimit=-1, + sorttype=1, + ) + + self.assertTrue( + Picklist.objects.filter( + name=helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME + ).exists() + ) + + helper_0042_discipline_type_picklist.revert_discipline_type_picklist( + apps + ) + + self.assertFalse( + Picklist.objects.filter( + name=helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME + ).exists() + ) + + +class DisciplineTypeSplocaleContainerItemTests(ApiTests): + + def test_update_splocalecontaineritem_sets_picklist(self): + container = Splocalecontainer.objects.create( + name="discipline", + schematype=0, + discipline=self.discipline + ) + item = Splocalecontaineritem.objects.create( + container=container, + name="type" + ) + + helper_0042_discipline_type_picklist.update_discipline_type_splocalecontaineritem(apps) + + item.refresh_from_db() + self.assertEqual(item.picklistname, helper_0042_discipline_type_picklist.DISCIPLINE_TYPE_PICKLIST_NAME) + self.assertTrue(item.isrequired) + + +class DisciplineTypeSplocaleContainerItemRevertTests(ApiTests): + + def test_revert_splocalecontaineritem(self): + container = Splocalecontainer.objects.create( + name="discipline", + schematype=0, + discipline=self.discipline + ) + item = Splocalecontaineritem.objects.create( + container=container, + name="type" + ) + + helper_0042_discipline_type_picklist.update_discipline_type_splocalecontaineritem( + apps + ) + + helper_0042_discipline_type_picklist.revert_discipline_type_splocalecontaineritem( + apps + ) + + item.refresh_from_db() + self.assertIsNone(item.picklistname) \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_schema_reader.py b/specifyweb/specify/migration_utils/tests/test_schema_reader.py new file mode 100644 index 00000000000..466f836d9cf --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_schema_reader.py @@ -0,0 +1,163 @@ +import unittest +from unittest.mock import patch, MagicMock +from types import SimpleNamespace +from collections import defaultdict + +from ..schema_reader import ( + _has_explicit_hidden_override, + _schema_override_hidden_values_for_discipline, + _schema_override_hidden_fields_for_discipline, + _fields_without_explicit_hidden_override, + datamodel_type_to_schematype, + camel_to_spaced_title_case, + uncapitilize, + find_missing_schema_config_fields, +) + + +class SchemaReaderTests(unittest.TestCase): + def setUp(self): + _schema_override_hidden_values_for_discipline.cache_clear() + + def tearDown(self): + _schema_override_hidden_values_for_discipline.cache_clear() + + def test_has_explicit_hidden_override(self): + self.assertTrue(_has_explicit_hidden_override({"isHidden": True})) + self.assertTrue(_has_explicit_hidden_override({"ISHIDDEN": False})) + self.assertFalse(_has_explicit_hidden_override({"other": "value"})) + + @patch('specifyweb.specify.migration_utils.schema_reader.logger') + @patch('specifyweb.specify.migration_utils.schema_reader.json') + @patch('specifyweb.specify.migration_utils.schema_reader.Path') + @patch('specifyweb.specify.migration_utils.schema_reader.settings') + def test_schema_override_hidden_values_for_discipline( + self, + mock_settings, + mock_path, + mock_json, + mock_logger, + ): + mock_settings.SPECIFY_CONFIG_DIR = "/config" + + fake_json_data = { + "collectionobject": { + "items": [ + { + "catalogNumber": {"isHidden": True}, + "availability": {"otherSetting": "value"} + } + ] + } + } + + mock_path_instance = MagicMock() + mock_path.return_value = mock_path_instance + mock_path_instance.__truediv__.return_value = mock_path_instance + mock_path_instance.exists.return_value = True + + with patch( + "specifyweb.specify.migration_utils.schema_reader.json.load", + return_value=fake_json_data, + ): + result = _schema_override_hidden_values_for_discipline("bird") + + self.assertEqual(result, { + "collectionobject": {"catalognumber": True} + }) + + def test_schema_override_hidden_fields_for_discipline(self): + with patch( + 'specifyweb.specify.migration_utils.schema_reader._schema_override_hidden_values_for_discipline' + ) as mock_hidden_values: + + mock_hidden_values.return_value = { + "table1": {"field1": True, "field2": False}, + "table2": {"field3": True} + } + + result = _schema_override_hidden_fields_for_discipline("biology") + + self.assertEqual(result, { + "table1": {"field1", "field2"}, + "table2": {"field3"} + }) + + def test_fields_without_explicit_hidden_override(self): + with patch( + 'specifyweb.specify.migration_utils.schema_reader._schema_override_hidden_fields_for_discipline' + ) as mock_hidden_fields: + + mock_hidden_fields.return_value = { + "collectionobject": {"catalognumber", "availability"} + } + + result = _fields_without_explicit_hidden_override( + "CollectionObject", + ["catalogNumber", "field1", "field2"], + "bird" + ) + + self.assertEqual(result, ["field1", "field2"]) + + def test_datamodel_type_to_schematype(self): + self.assertEqual(datamodel_type_to_schematype("many-to-one"), "ManyToOne") + self.assertEqual(datamodel_type_to_schematype("one-to-many"), "OneToMany") + self.assertEqual(datamodel_type_to_schematype("many-to-many"), "ManyToMany") + + def test_camel_to_spaced_title_case(self): + self.assertEqual(camel_to_spaced_title_case("catalogNumber"), "Catalog Number") + self.assertEqual(camel_to_spaced_title_case("modifiedByAgent"), "Modified By Agent") + self.assertEqual(camel_to_spaced_title_case("yesNo6"), "Yes No6") + self.assertEqual(camel_to_spaced_title_case("cojo"), "Cojo") + + def test_uncapitilize(self): + self.assertEqual(uncapitilize("Test"), "test") + self.assertEqual(uncapitilize("tEST"), "tEST") + self.assertEqual(uncapitilize("A"), "a") + self.assertEqual(uncapitilize("AB"), "aB") + + #TODO + # @patch('specifyweb.specify.migration_utils.schema_reader.global_apps') + # @patch('specifyweb.specify.migration_utils.schema_reader.datamodel') + # def test_find_missing_schema_config_fields(self, mock_datamodel, mock_apps): + # MockContainer = MagicMock() + # MockContainerItem = MagicMock() + # mock_apps.get_model.side_effect = [MockContainer, MockContainerItem] + + # # Setup container query - return ALL container names being checked + # mock_containers_qs = MagicMock() + # MockContainer.objects.filter.return_value = mock_containers_qs + # mock_containers_qs.values_list.return_value = [('CollectionObject',)] + + # # Setup items query to return existing fields for CollectionObject + # mock_items_qs = MagicMock() + # MockContainerItem.objects.filter.return_value = mock_items_qs + + # mock_items_qs.values_list.return_value = [ + # 'catalogNumber', + # 'fieldNumber' + # ] + + # # Setup test table + # mock_table = MagicMock() + # mock_table.name = "CollectionObject" + # mock_table._all_fields.return_value = [ + # SimpleNamespace(name="catalogNumber"), + # SimpleNamespace(name="fieldNumber"), + # SimpleNamespace(name="date1"), + # SimpleNamespace(name="date2") + # ] + # mock_datamodel.tables = [mock_table] + + # missing_tables, missing_fields = find_missing_schema_config_fields(1) + + # # Assertions + # self.assertEqual(missing_tables, []) + # self.assertEqual(missing_fields, { + # "CollectionObject": ["date1", "date2"] + # }) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_schema_writer.py b/specifyweb/specify/migration_utils/tests/test_schema_writer.py new file mode 100644 index 00000000000..e8566a7f891 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_schema_writer.py @@ -0,0 +1,86 @@ +from django.test import TestCase +from unittest.mock import patch, MagicMock + +from specifyweb.specify.migration_utils.schema_writer import ( + update_table_schema_config_with_defaults, + revert_table_field_schema_config, +) + + +class SchemaWriterTests(TestCase): + + @patch("specifyweb.specify.migration_utils.schema_writer.bulk_create_splocaleitemstr_idempotent") + def test_update_table_schema_config_with_defaults(self, mock_bulk_create): + mock_apps = MagicMock() + # ----------------------- + # Mock models via apps.get_model + # ----------------------- + mock_container = MagicMock() + mock_containeritem = MagicMock() + mock_itemstr = MagicMock() + + def get_model(app_label, model_name): + if model_name == "Splocalecontainer": + return mock_container + if model_name == "Splocalecontaineritem": + return mock_containeritem + if model_name == "Splocaleitemstr": + return mock_itemstr + + mock_apps.get_model.side_effect = get_model + + # ----------------------- + # Query behavior + # ----------------------- + mock_container.objects.filter.return_value.order_by.return_value.first.return_value = None + mock_containeritem.objects.filter.return_value.exists.return_value = False + + mock_table = MagicMock() + mock_table.name = "TestTable" + mock_table.system = False + mock_table._all_fields.return_value = [] + + with patch("specifyweb.specify.migration_utils.schema_writer.datamodel") as mock_datamodel: + mock_datamodel.get_table.return_value = mock_table + + update_table_schema_config_with_defaults("TestTable", 1, apps=mock_apps) + + mock_bulk_create.assert_called_once() + + def test_revert_table_field_schema_config(self): + mock_apps = MagicMock() + + mock_container = MagicMock() + mock_itemstr = MagicMock() + mock_containeritem = MagicMock() + + def get_model(app_label, model_name): + if model_name == "Splocalecontainer": + return mock_container + if model_name == "Splocaleitemstr": + return mock_itemstr + if model_name == "Splocalecontaineritem": + return mock_containeritem + + mock_apps.get_model.side_effect = get_model + + # --- queryset mocks + container_qs = MagicMock() + itemstr_qs = MagicMock() + containeritem_qs = MagicMock() + + mock_container.objects.filter.return_value = container_qs + mock_itemstr.objects.filter.return_value = itemstr_qs + mock_containeritem.objects.filter.return_value = containeritem_qs + + # --- execute --- + revert_table_field_schema_config("TestTable", "field", apps=mock_apps) + + # --- assert filters were called --- + mock_container.objects.filter.assert_called_once() + mock_itemstr.objects.filter.assert_called_once() + mock_containeritem.objects.filter.assert_called_once() + + # --- assert deletes --- + itemstr_qs.delete.assert_called_once() + containeritem_qs.delete.assert_called_once() \ No newline at end of file diff --git a/specifyweb/specify/migration_utils/tests/test_tectonic_ranks.py b/specifyweb/specify/migration_utils/tests/test_tectonic_ranks.py new file mode 100644 index 00000000000..d99849527c2 --- /dev/null +++ b/specifyweb/specify/migration_utils/tests/test_tectonic_ranks.py @@ -0,0 +1,304 @@ +import unittest +from unittest.mock import MagicMock, call, patch + +from ..tectonic_ranks import ( + DEFAULT_RANKS, + create_default_tectonic_ranks, + create_root_tectonic_node, + _create_tectonic_unit_for_discipline, + fix_tectonic_unit_treedef_discipline_links, +) + + +class CreateDefaultTectonicRanksTests(unittest.TestCase): + @patch( + "specifyweb.specify.migration_utils.tectonic_ranks._create_tectonic_unit_for_discipline" + ) + def test_creates_default_ranks_for_trees_missing_ranks( + self, mock_create_tectonic_units + ): + apps = MagicMock() + + TectonicUnitTreeDefItem = MagicMock() + TectonicTreeDef = MagicMock() + Discipline = MagicMock() + + apps.get_model.side_effect = [ + TectonicUnitTreeDefItem, + TectonicTreeDef, + Discipline, + ] + + tree1 = MagicMock() + tree2 = MagicMock() + + TectonicTreeDef.objects.filter.return_value = [tree1, tree2] + + parent_node = MagicMock() + TectonicUnitTreeDefItem.objects.get_or_create.return_value = ( + parent_node, + True, + ) + + create_default_tectonic_ranks(apps) + + mock_create_tectonic_units.assert_called_once_with( + Discipline_Model=Discipline, + Tectonicunittreedef_Model=TectonicTreeDef, + ) + + expected_calls = [] + for tree in [tree1, tree2]: + parent = None + for rank in DEFAULT_RANKS: + expected_calls.append( + call( + rankid=rank["rankid"], + parent=parent, + treedef=tree, + defaults={ + "name": rank["name"], + "title": rank["name"], + **rank.get("attrs", {}), + }, + ) + ) + parent = parent_node + + self.assertEqual( + TectonicUnitTreeDefItem.objects.get_or_create.call_count, + len(DEFAULT_RANKS) * 2, + ) + TectonicUnitTreeDefItem.objects.get_or_create.assert_has_calls( + expected_calls, + any_order=False, + ) + + @patch( + "specifyweb.specify.migration_utils.tectonic_ranks._create_tectonic_unit_for_discipline" + ) + def test_no_rank_creation_when_no_trees_missing_ranks( + self, mock_create_tectonic_units + ): + apps = MagicMock() + + TectonicUnitTreeDefItem = MagicMock() + TectonicTreeDef = MagicMock() + Discipline = MagicMock() + + apps.get_model.side_effect = [ + TectonicUnitTreeDefItem, + TectonicTreeDef, + Discipline, + ] + + TectonicTreeDef.objects.filter.return_value = [] + + create_default_tectonic_ranks(apps) + + TectonicUnitTreeDefItem.objects.get_or_create.assert_not_called() + + +class CreateRootTectonicNodeTests(unittest.TestCase): + @patch( + "specifyweb.specify.migration_utils.tectonic_ranks.logger" + ) + def test_creates_root_node_for_missing_trees(self, mock_logger): + apps = MagicMock() + + TectonicUnit = MagicMock() + TectonicUnitTreeDefItem = MagicMock() + TectonicUnitTreeDef = MagicMock() + + apps.get_model.side_effect = [ + TectonicUnit, + TectonicUnitTreeDefItem, + TectonicUnitTreeDef, + ] + + tree1 = MagicMock() + tree1.discipline_id = 101 + + tree2 = MagicMock() + tree2.discipline_id = 202 + + annotated_qs = MagicMock() + annotated_qs.filter.return_value = [tree1, tree2] + + TectonicUnitTreeDef.objects.annotate.return_value = annotated_qs + + root_rank = MagicMock() + TectonicUnitTreeDefItem.objects.get_or_create.return_value = ( + root_rank, + True, + ) + + create_root_tectonic_node(apps) + + self.assertEqual( + TectonicUnitTreeDefItem.objects.get_or_create.call_count, + 2, + ) + + self.assertEqual( + TectonicUnit.objects.create.call_count, + 2, + ) + + TectonicUnit.objects.create.assert_any_call( + name="Root", + fullname="Root", + isaccepted=1, + nodenumber=1, + rankid=0, + parent=None, + definition=tree1, + definitionitem=root_rank, + ) + + TectonicUnit.objects.create.assert_any_call( + name="Root", + fullname="Root", + isaccepted=1, + nodenumber=1, + rankid=0, + parent=None, + definition=tree2, + definitionitem=root_rank, + ) + + self.assertEqual(mock_logger.info.call_count, 2) + + TectonicUnitTreeDefItem.objects.filter.assert_called_once_with( + parent=None, + rankid=0, + isenforced__isnull=True, + ) + + TectonicUnitTreeDefItem.objects.filter.return_value.update.assert_called_once_with( + isenforced=True + ) + + def test_no_missing_root_nodes_still_updates_isenforced(self): + apps = MagicMock() + + TectonicUnit = MagicMock() + TectonicUnitTreeDefItem = MagicMock() + TectonicUnitTreeDef = MagicMock() + + apps.get_model.side_effect = [ + TectonicUnit, + TectonicUnitTreeDefItem, + TectonicUnitTreeDef, + ] + + annotated_qs = MagicMock() + annotated_qs.filter.return_value = [] + + TectonicUnitTreeDef.objects.annotate.return_value = annotated_qs + + create_root_tectonic_node(apps) + + TectonicUnit.objects.create.assert_not_called() + + TectonicUnitTreeDefItem.objects.filter.return_value.update.assert_called_once_with( + isenforced=True + ) + + +class CreateTectonicUnitForDisciplineTests(unittest.TestCase): + def test_creates_missing_tree_defs_and_updates_discipline_links(self): + Discipline_Model = MagicMock() + TectonicTreeDef_Model = MagicMock() + + missing_disciplines = [1, 2, 3] + + first_filter = MagicMock() + first_filter.values_list.return_value = missing_disciplines + + second_filter = MagicMock() + + Discipline_Model.objects.filter.side_effect = [ + first_filter, + second_filter, + ] + + _create_tectonic_unit_for_discipline( + Discipline_Model=Discipline_Model, + Tectonicunittreedef_Model=TectonicTreeDef_Model, + ) + + TectonicTreeDef_Model.objects.bulk_create.assert_called_once() + + bulk_create_args = ( + TectonicTreeDef_Model.objects.bulk_create.call_args.args[0] + ) + + self.assertEqual(len(bulk_create_args), 3) + + TectonicTreeDef_Model.objects.bulk_create.assert_called_once_with( + bulk_create_args, + batch_size=1000, + ) + + second_filter.update.assert_called_once() + + def test_handles_no_missing_disciplines(self): + Discipline_Model = MagicMock() + TectonicTreeDef_Model = MagicMock() + + first_filter = MagicMock() + first_filter.values_list.return_value = [] + + second_filter = MagicMock() + + Discipline_Model.objects.filter.side_effect = [ + first_filter, + second_filter, + ] + + _create_tectonic_unit_for_discipline( + Discipline_Model=Discipline_Model, + Tectonicunittreedef_Model=TectonicTreeDef_Model, + ) + + TectonicTreeDef_Model.objects.bulk_create.assert_called_once() + + created_objects = ( + TectonicTreeDef_Model.objects.bulk_create.call_args.args[0] + ) + + self.assertEqual(created_objects, []) + + +class FixTectonicUnitTreeDefDisciplineLinksTests(unittest.TestCase): + @patch( + "specifyweb.specify.migration_utils.tectonic_ranks._create_tectonic_unit_for_discipline" + ) + def test_updates_null_discipline_links(self, mock_create): + apps = MagicMock() + + Discipline = MagicMock() + TectonicTreeDef = MagicMock() + + apps.get_model.side_effect = [ + Discipline, + TectonicTreeDef, + ] + + qs = MagicMock() + TectonicTreeDef.objects.filter.return_value = qs + + fix_tectonic_unit_treedef_discipline_links(apps) + + mock_create.assert_called_once_with( + Discipline_Model=Discipline, + Tectonicunittreedef_Model=TectonicTreeDef, + ) + + TectonicTreeDef.objects.filter.assert_called_once_with( + discipline__isnull=True, + disciplines__isnull=False, + ) + + qs.update.assert_called_once() \ No newline at end of file diff --git a/specifyweb/specify/migrations/0008_ageCitations_fix.py b/specifyweb/specify/migrations/0008_ageCitations_fix.py index ea2d0ee28a4..d08e3984907 100644 --- a/specifyweb/specify/migrations/0008_ageCitations_fix.py +++ b/specifyweb/specify/migrations/0008_ageCitations_fix.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.15 on 2024-10-23 14:31 from django.db import migrations, models -from specifyweb.specify.migration_utils.migration_helpers.helper_0008_schema_config_update import revert_relative_age_fields, update_relative_age_fields +from specifyweb.specify.migration_utils.migration_helpers.helper_0008_ageCitations_fix import revert_relative_age_fields, update_relative_age_fields import specifyweb.specify.models from specifyweb.specify.migration_utils import migration_helpers as usc diff --git a/specifyweb/specify/migrations/0031_add_default_for_selectseries.py b/specifyweb/specify/migrations/0031_add_default_for_selectseries.py index 41482df6491..90a4e44f4ce 100644 --- a/specifyweb/specify/migrations/0031_add_default_for_selectseries.py +++ b/specifyweb/specify/migrations/0031_add_default_for_selectseries.py @@ -7,7 +7,7 @@ from django.db import migrations, models -from specifyweb.specify.migration_utils.misc_migrations import make_selectseries_false +from specifyweb.specify.migration_utils.migration_helpers.helper_0031_add_default_for_selectseries import make_selectseries_false # def make_selectseries_false(apps):