diff --git a/specifyweb/backend/context/app_resource.py b/specifyweb/backend/context/app_resource.py index 53be974152a..947d0da802c 100644 --- a/specifyweb/backend/context/app_resource.py +++ b/specifyweb/backend/context/app_resource.py @@ -68,42 +68,87 @@ def get_app_resource(collection, user, resource_name, additional_default=False): if resource_name == 'DataViewQueries': return get_data_view_queries_resource(collection, user) - # Handling for DataObjFormatters to support fallback to defaults - if resource_name == 'DataObjFormatters': + # UIFormatters + additional_default: return Backstop only (same contract as + # TypeSearches with additionaldefault=true). Do not merge custom resources. + if resource_name == 'UIFormatters' and additional_default: + return load_resource_at_level(collection, user, 'Backstop', resource_name) + + # Handling for DataObjFormatters / UIFormatters to support fallback to defaults. + # Custom resources at a more-specific level would otherwise fully replace + # backstop definitions (see #6243, #8676). + if resource_name in ('DataObjFormatters', 'UIFormatters'): custom_formatter = None - default_formatter = None - - # Try to search for the the custom formatter first + custom_level = None + + # Try to search for the custom formatter first for level in DIR_LEVELS: # First look in the database. from_db = get_app_resource_from_db(collection, user, level, resource_name) if from_db is not None: custom_formatter = from_db + custom_level = level break # If resource was not found, look on the filesystem. from_fs = load_resource_at_level(collection, user, level, resource_name) if from_fs is not None: custom_formatter = from_fs + custom_level = level break - + # If custom formatter not found, try to load the default formatter from the backstop level default_formatter = load_resource_at_level(collection, user, 'Backstop', resource_name) - - # If both custom and default formatters are found, merge them + + if resource_name == 'UIFormatters': + # Merge by format name: many UIFormatters share the same class + # (e.g. several CatalogNumber* formats on CollectionObject). + # Order: most-specific custom → Common → Backstop so overlapping + # names keep the most-specific definition (#8676). + if custom_formatter is not None: + merged_xml = custom_formatter[0] + + # When custom is above Common, also pull Common definitions. + if custom_level not in ('Common', 'Backstop'): + common_formatter = get_app_resource_from_db( + collection, user, 'Common', resource_name + ) + if common_formatter is None: + common_formatter = load_resource_at_level( + collection, user, 'Common', resource_name + ) + if common_formatter is not None: + merged_xml = merge_ui_formatters( + merged_xml, common_formatter[0] + ) + + if default_formatter is not None: + merged_xml = merge_ui_formatters( + merged_xml, default_formatter[0] + ) + + return merged_xml, custom_formatter[1], custom_formatter[2] + + if default_formatter is not None: + return default_formatter + + return None + + # DataObjFormatters: merge by class with Backstop only. if custom_formatter is not None and default_formatter is not None: - merged_formatter = merge_formatters(custom_formatter[0], default_formatter[0]) + merged_formatter = merge_formatters( + custom_formatter[0], default_formatter[0] + ) return merged_formatter, custom_formatter[1], custom_formatter[2] - + # Return the custom formatter if it exists, otherwise return the default if custom_formatter is not None: return custom_formatter - + if default_formatter is not None: return default_formatter - + return None - + if not additional_default: # Traverse the hierarchy. for level in DIR_LEVELS: @@ -323,15 +368,15 @@ def get_app_resource_dirs_for_level(collection, user, level): return Spappresourcedir.objects.filter(**filters) def merge_formatters(custom_xml, default_xml): - """Merge custom and default XML formatters. - This function takes two XML strings, one for custom formatters and one for default formatters, - and merges them by adding any formatters from the default XML that are not already present in - the custom XML. It returns the merged XML as a string. + """Merge custom and default XML DataObjFormatters by ``class``. + + Adds any formatters from the default XML whose ``class`` is not already + present in the custom XML. Returns the merged XML as a string. """ - + custom_root = ElementTree.fromstring(custom_xml) default_root = ElementTree.fromstring(default_xml) - + # Create a set of existing classes in the custom XML to avoid duplicates custom_classes = set() for format_elem in custom_root.findall('format'): @@ -344,6 +389,33 @@ def merge_formatters(custom_xml, default_xml): class_attr = format_elem.get('class') if class_attr and class_attr not in custom_classes: custom_root.append(format_elem) - + # Convert the merged XML tree back to a string return ElementTree.tostring(custom_root, encoding='utf-8').decode('utf-8') + + +def merge_ui_formatters(custom_xml, default_xml): + """Merge custom and default UIFormatters by format ``name``. + + Unlike DataObjFormatters (merged by ``class``), several UIFormatters can + share the same datamodel class (e.g. multiple catalog-number formats on + CollectionObject). Custom entries with the same name win; missing names + are filled from the default/backstop resource. + """ + + custom_root = ElementTree.fromstring(custom_xml) + default_root = ElementTree.fromstring(default_xml) + + custom_names = { + format_elem.get('name') + for format_elem in custom_root.findall('format') + if format_elem.get('name') + } + + for format_elem in default_root.findall('format'): + name = format_elem.get('name') + if name and name not in custom_names: + custom_root.append(format_elem) + custom_names.add(name) + + return ElementTree.tostring(custom_root, encoding='utf-8').decode('utf-8') diff --git a/specifyweb/backend/context/tests/test_app_resource.py b/specifyweb/backend/context/tests/test_app_resource.py index 900576db564..b506f710978 100644 --- a/specifyweb/backend/context/tests/test_app_resource.py +++ b/specifyweb/backend/context/tests/test_app_resource.py @@ -1,13 +1,62 @@ +from xml.etree import ElementTree + from django.test import Client from specifyweb.specify.tests.test_api import ApiTests from unittest.mock import Mock, patch -from specifyweb.backend.context.app_resource import get_data_view_queries_resource +from specifyweb.backend.context.app_resource import ( + get_app_resource, + get_data_view_queries_resource, + merge_ui_formatters, +) FOUND_RESOURCE = ('"Value"', "text/xml", 4) +CUSTOM_UI_FORMATTERS = """ + + + + + + + + +""" + +COMMON_UI_FORMATTERS = """ + + + + + + + + + + + + +""" + +BACKSTOP_UI_FORMATTERS = """ + + + + + + + + + edu.ku.brc.specify.ui.CatalogNumberUIFieldFormatter + + + edu.ku.brc.specify.ui.CatalogNumberStringUIFieldFormatter + + +""" + class TestAppResource(ApiTests): @@ -90,3 +139,142 @@ def test_not_found_quiet(self, get_app_resource: Mock): response = c.get("/context/app.resource?name='simple'&quiet='true'") self._assertStatusCodeEqual(response, 204) + + def test_merge_ui_formatters_by_name(self): + merged = merge_ui_formatters(CUSTOM_UI_FORMATTERS, BACKSTOP_UI_FORMATTERS) + root = ElementTree.fromstring(merged) + names = [fmt.get('name') for fmt in root.findall('format')] + + self.assertEqual( + names, + [ + 'GridRefMGRS', + 'CatalogNumber', + 'AccessionNumber', + 'CatalogNumberNumeric', + 'CatalogNumberString', + ], + ) + # Custom CatalogNumber wins over backstop (keeps numeric part, not year). + catalog_number = root.find("./format[@name='CatalogNumber']") + self.assertIsNotNone(catalog_number) + self.assertIsNotNone(catalog_number.find("./field[@type='numeric']")) + self.assertIsNone(catalog_number.find("./field[@type='year']")) + + @patch("specifyweb.backend.context.app_resource.load_resource_at_level") + @patch("specifyweb.backend.context.app_resource.get_app_resource_from_db") + def test_uiformatters_merge_custom_with_backstop( + self, get_from_db: Mock, load_from_filesystem: Mock + ): + get_from_db.side_effect = lambda _collection, _user, level, _name: ( + (CUSTOM_UI_FORMATTERS, 'text/xml', 23) if level == 'Collection' else None + ) + load_from_filesystem.side_effect = lambda _collection, _user, level, _name: ( + (BACKSTOP_UI_FORMATTERS, 'text/xml', None) if level == 'Backstop' else None + ) + + result = get_app_resource(self.collection, self.specifyuser, 'UIFormatters') + + self.assertIsNotNone(result) + data, mimetype, resource_id = result + self.assertEqual(mimetype, 'text/xml') + self.assertEqual(resource_id, 23) + + names = { + fmt.get('name') + for fmt in ElementTree.fromstring(data).findall('format') + } + self.assertEqual( + names, + { + 'GridRefMGRS', + 'CatalogNumber', + 'AccessionNumber', + 'CatalogNumberNumeric', + 'CatalogNumberString', + }, + ) + + @patch("specifyweb.backend.context.app_resource.load_resource_at_level") + @patch("specifyweb.backend.context.app_resource.get_app_resource_from_db") + def test_uiformatters_merge_common_before_backstop( + self, get_from_db: Mock, load_from_filesystem: Mock + ): + get_from_db.side_effect = lambda _collection, _user, level, _name: { + 'Collection': (CUSTOM_UI_FORMATTERS, 'text/xml', 23), + 'Common': (COMMON_UI_FORMATTERS, 'text/xml', 11), + }.get(level) + load_from_filesystem.side_effect = lambda _collection, _user, level, _name: ( + (BACKSTOP_UI_FORMATTERS, 'text/xml', None) if level == 'Backstop' else None + ) + + result = get_app_resource(self.collection, self.specifyuser, 'UIFormatters') + + self.assertIsNotNone(result) + data, mimetype, resource_id = result + self.assertEqual(mimetype, 'text/xml') + self.assertEqual(resource_id, 23) + + root = ElementTree.fromstring(data) + names = [fmt.get('name') for fmt in root.findall('format')] + self.assertEqual( + names, + [ + 'GridRefMGRS', + 'CatalogNumber', + 'AccessionNumber', + 'CatalogNumberNumeric', + 'CatalogNumberString', + ], + ) + # Collection CatalogNumber wins over Common and Backstop. + catalog_number = root.find("./format[@name='CatalogNumber']") + self.assertIsNotNone(catalog_number.find("./field[@type='numeric']")) + self.assertIsNone(catalog_number.find("./field[@type='regex']")) + self.assertIsNone(catalog_number.find("./field[@type='year']")) + # AccessionNumber is only in Common/Backstop: Common wins over Backstop. + accession_number = root.find("./format[@name='AccessionNumber']") + self.assertIsNotNone(accession_number.find("./field[@type='year']")) + self.assertIsNone(accession_number.find("./field[@type='regex']")) + + @patch("specifyweb.backend.context.app_resource.load_resource_at_level") + @patch("specifyweb.backend.context.app_resource.get_app_resource_from_db") + def test_uiformatters_additional_default_skips_custom( + self, get_from_db: Mock, load_from_filesystem: Mock + ): + get_from_db.return_value = (CUSTOM_UI_FORMATTERS, 'text/xml', 23) + load_from_filesystem.return_value = ( + BACKSTOP_UI_FORMATTERS, + 'text/xml', + None, + ) + + result = get_app_resource( + self.collection, + self.specifyuser, + 'UIFormatters', + additional_default=True, + ) + + self.assertIsNotNone(result) + data, mimetype, resource_id = result + self.assertEqual(mimetype, 'text/xml') + self.assertIsNone(resource_id) + names = { + fmt.get('name') + for fmt in ElementTree.fromstring(data).findall('format') + } + self.assertEqual( + names, + { + 'CatalogNumber', + 'AccessionNumber', + 'CatalogNumberNumeric', + 'CatalogNumberString', + }, + ) + self.assertNotIn('GridRefMGRS', names) + get_from_db.assert_not_called() + load_from_filesystem.assert_called_once_with( + self.collection, self.specifyuser, 'Backstop', 'UIFormatters' + )