diff --git a/core/concepts/custom_validators.py b/core/concepts/custom_validators.py index c4151eae..e6ed0165 100644 --- a/core/concepts/custom_validators.py +++ b/core/concepts/custom_validators.py @@ -14,6 +14,7 @@ LOCALES_SEARCH_INDEX_TERM, INDEX_TERM, FULLY_SPECIFIED, SHORT, OPENMRS_CONCEPT_EXTERNAL_ID_ERROR, OPENMRS_EXTERNAL_ID_LENGTH, OPENMRS_NAME_EXTERNAL_ID_ERROR, OPENMRS_DESCRIPTION_EXTERNAL_ID_ERROR) +from core.concepts.models import ConceptName from core.concepts.validators import BaseConceptValidator, message_with_name_details @@ -76,7 +77,7 @@ def preferred_name_should_be_unique_for_source_and_locale(self, concept): concept=concept, attribute='locale_preferred', error_message=OPENMRS_PREFERRED_NAME_UNIQUE_PER_SOURCE_LOCALE, - filters={'names__locale_preferred': True} + filters={'locale_preferred': True} ) def fully_specified_name_should_be_unique_for_source_and_locale(self, concept): @@ -103,14 +104,20 @@ def no_other_record_has_same_name(self, name, versioned_object_id, filters=None) if not filters: filters = {} - return not self.repo.concepts_set.exclude( - versioned_object_id=versioned_object_id + # Query the localized text row directly so all name constraints apply to the same related record. + return not ConceptName.objects.exclude( + concept__versioned_object_id=versioned_object_id ).exclude( - names__type__in=(*LOCALES_SHORT, *LOCALES_SEARCH_INDEX_TERM, '', None) + type__in=(*LOCALES_SHORT, *LOCALES_SEARCH_INDEX_TERM, '', None) ).exclude( - names__type__isnull=True + type__isnull=True ).filter( - is_active=True, retired=False, is_latest_version=True, names__locale=name.locale, names__name=name.name, + concept__parent=self.repo, + concept__is_active=True, + concept__retired=False, + concept__is_latest_version=True, + locale=name.locale, + name__iexact=name.name, **filters ).exists() diff --git a/core/concepts/tests/tests.py b/core/concepts/tests/tests.py index 56d1cc06..669cc163 100644 --- a/core/concepts/tests/tests.py +++ b/core/concepts/tests/tests.py @@ -1800,6 +1800,51 @@ def test_concepts_should_have_unique_fully_specified_name_per_locale(self): } ) + def test_duplicate_fully_specified_name_per_source_should_fail_case_insensitively_even_with_null_typed_name(self): + """Regression for #2406: duplicate FSNs must be rejected case-insensitively even with NULL-typed names.""" + source = OrganizationSourceFactory(custom_validation_schema=OPENMRS_VALIDATION_SCHEMA, version=HEAD) + + concept1 = Concept.persist_new( + { + 'mnemonic': 'cerebral-malaria-existing', + 'version': HEAD, + 'parent': source, + 'concept_class': 'Diagnosis', + 'datatype': 'None', + 'names': [ + ConceptNameFactory.build( + name='Cerebral malaria', locale='en', locale_preferred=True, type='Fully Specified' + ), + ConceptNameFactory.build( + name='Unrelated synonym with NULL type', locale='en', locale_preferred=False, type=None + ), + ] + } + ) + concept2 = Concept.persist_new( + { + 'mnemonic': 'cerebral-malaria-duplicate', + 'version': HEAD, + 'parent': source, + 'concept_class': 'Diagnosis', + 'datatype': 'None', + 'names': [ + ConceptNameFactory.build( + name='cerebral malaria', locale='en', locale_preferred=True, type='Fully Specified' + ), + ] + } + ) + + self.assertEqual(concept1.errors, {}) + self.assertEqual( + concept2.errors, + { + 'names': [OPENMRS_FULLY_SPECIFIED_NAME_UNIQUE_PER_SOURCE_LOCALE + + ': cerebral malaria (locale: en, preferred: yes)'] + } + ) + def test_at_least_one_fully_specified_name_per_concept_negative(self): source = OrganizationSourceFactory(custom_validation_schema=OPENMRS_VALIDATION_SCHEMA, version=HEAD)