Skip to content

Commit 6a67ea5

Browse files
authored
OpenConceptLab/ocl_issues#2295 | POST concepts with mappings (#818)
* OpenConceptLab/ocl_issues#2295 | POST concepts with mappings * OpenConceptLab/ocl_issues#2295 | assuming from concept is self
1 parent dadf90c commit 6a67ea5

4 files changed

Lines changed: 260 additions & 5 deletions

File tree

core/concepts/models.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,71 @@ def create_new_versions_for_removed_parents(self, uris):
616616
child = Concept.objects.filter(uri=uri).first().get_latest_version()
617617
child.parent_concepts.add(new_latest_version)
618618

619+
def create_mappings(self, mappings):
620+
from core.mappings.serializers import MappingDetailSerializer
621+
results = []
622+
any_with_errors = False
623+
if mappings and self.id:
624+
user = self.created_by
625+
for mapping_data in mappings:
626+
errors = {}
627+
try:
628+
created = self._create_mapping_from_self(mapping_data.copy(), user)
629+
except Exception as ex:
630+
error_dict = get(ex, 'message_dict') or get(ex, 'error_dict')
631+
if error_dict:
632+
errors = error_dict
633+
else:
634+
errors['__all__'] = [str(ex)]
635+
any_with_errors = True
636+
created = False
637+
serializer = None
638+
if created:
639+
serializer = MappingDetailSerializer(created)
640+
errors = created.errors
641+
if not errors and not created.id:
642+
errors['__all__'] = ['Something bad happened while creating the mapping.']
643+
if errors:
644+
serializer._errors = errors # pylint: disable=protected-access
645+
any_with_errors = True
646+
647+
results.append({
648+
'mapping': mapping_data,
649+
'instance': created,
650+
'serializer': serializer,
651+
'errors': errors
652+
})
653+
654+
return results, any_with_errors
655+
656+
def _create_mapping_from_self(self, mapping_data, user):
657+
from core.mappings.models import Mapping
658+
data = {key: value for key, value in mapping_data.items() if not key.startswith('from_')}
659+
data['from_concept_url'] = drop_version(self.uri)
660+
if data.get('to_concept') == '__parent_concept':
661+
data['to_concept_url'] = self.uri
662+
data.pop('to_concept')
663+
664+
return Mapping.persist_new({**data, 'parent_id': self.parent_id}, user)
665+
666+
@staticmethod
667+
def _remove_mappings_just_created(mappings_results):
668+
for mapping in mappings_results:
669+
if get(mapping, 'instance.id', None):
670+
try:
671+
mapping['instance'].delete()
672+
except IntegrityError:
673+
pass
674+
675+
@staticmethod
676+
def _get_errors_from_mappings(mappings_result):
677+
return [
678+
{
679+
**mapping['mapping'],
680+
'errors': get(mapping, 'serializer.errors') or mapping.get('errors')
681+
} for mapping in mappings_result if mapping.get('errors')
682+
]
683+
619684
def set_locales(self, locales, locale_klass):
620685
if not self.id:
621686
return # pragma: no cover
@@ -783,6 +848,10 @@ def persist_new(cls, data, user=None, create_initial_version=True, create_parent
783848
names = data.pop('names', []) or []
784849
descriptions = data.pop('descriptions', []) or []
785850
parent_concept_uris = data.pop('parent_concept_urls', None)
851+
mappings_payload = data.pop('mappings_payload', None) or data.pop('mappings', None) or []
852+
mappings_result = []
853+
has_mapping_errors = False
854+
initial_version = None
786855
concept = Concept(**data)
787856
temp_version = generate_temp_version()
788857
concept.version = temp_version
@@ -832,6 +901,12 @@ def persist_new(cls, data, user=None, create_initial_version=True, create_parent
832901
initial_version.sources.set([parent])
833902

834903
concept.sources.set([parent])
904+
905+
if mappings_payload:
906+
mappings_result, has_mapping_errors = concept.create_mappings(mappings_payload)
907+
if has_mapping_errors:
908+
raise ValidationError('Error(s) occurred while creating mappings.')
909+
835910
if get(settings, 'TEST_MODE', False):
836911
update_mappings_concept(concept.id)
837912
else:
@@ -850,13 +925,25 @@ def persist_new(cls, data, user=None, create_initial_version=True, create_parent
850925
parent.update_concepts_count()
851926
concept.set_checksums(sync=sync_checksum)
852927
except ValidationError as ex:
928+
if mappings_result:
929+
concept._remove_mappings_just_created(mappings_result)
930+
if get(initial_version, 'id'):
931+
initial_version.delete()
853932
if concept.id:
854933
concept.delete()
855934
concept.errors.update(get(ex, 'message_dict', {}) or get(ex, 'error_dict', {}))
935+
if has_mapping_errors:
936+
concept.errors['mappings'] = concept._get_errors_from_mappings(mappings_result)
856937
except (IntegrityError, ValueError) as ex:
938+
if mappings_result:
939+
concept._remove_mappings_just_created(mappings_result)
940+
if get(initial_version, 'id'):
941+
initial_version.delete()
857942
if concept.id:
858943
concept.delete()
859944
concept.errors.update({'__all__': ex.args})
945+
if has_mapping_errors:
946+
concept.errors['mappings'] = concept._get_errors_from_mappings(mappings_result)
860947

861948
return concept
862949

core/concepts/serializers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def to_representation(self, instance): # used to be to_native
112112
class ConceptAbstractSerializer(AbstractResourceSerializer):
113113
uuid = CharField(source='id', read_only=True)
114114
mappings = SerializerMethodField()
115+
mappings_payload = ListField(child=JSONField(), write_only=True, required=False, allow_empty=True)
115116
parent_concepts = SerializerMethodField()
116117
child_concepts = SerializerMethodField()
117118
hierarchy_path = SerializerMethodField()
@@ -126,7 +127,7 @@ class Meta:
126127
abstract = True
127128
fields = AbstractResourceSerializer.Meta.fields + (
128129
'uuid', 'parent_concept_urls', 'child_concept_urls', 'parent_concepts', 'child_concepts', 'hierarchy_path',
129-
'mappings', 'extras', 'summary', 'references', 'has_children', 'checksums'
130+
'mappings', 'extras', 'summary', 'references', 'has_children', 'checksums', 'mappings_payload'
130131
)
131132

132133
def __init__(self, *args, **kwargs): # pylint: disable=too-many-branches

core/concepts/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,9 @@ def post(self, request, **_):
244244
if not self.parent_resource or isinstance(request.data, list):
245245
raise Http404()
246246
concept_id = get(request.data, 'id') or generate_temp_version()
247-
serializer = self.get_serializer(
248-
data={**request.data, 'parent_id': self.parent_resource.id, 'id': concept_id, 'name': concept_id}
249-
)
247+
data = {**request.data, 'parent_id': self.parent_resource.id, 'id': concept_id, 'name': concept_id}
248+
data['mappings_payload'] = data.pop('mappings', [])
249+
serializer = self.get_serializer(data=data)
250250
if serializer.is_valid():
251251
serializer.save()
252252
if serializer.is_valid():

core/integration_tests/tests_concepts.py

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from core.concepts.documents import ConceptDocument
1313
from core.concepts.models import Concept
1414
from core.concepts.tests.factories import ConceptFactory, ConceptNameFactory, ConceptDescriptionFactory
15+
from core.mappings.models import Mapping
1516
from core.mappings.tests.factories import MappingFactory
1617
from core.orgs.models import Organization
1718
from core.sources.tests.factories import OrganizationSourceFactory, UserSourceFactory
@@ -143,6 +144,173 @@ def test_post_201(self):
143144
self.assertEqual(response.status_code, 400)
144145
self.assertEqual(response.data, {'__all__': ['Concept ID must be unique within a source.']})
145146

147+
def test_post_201_with_mappings(self):
148+
concepts_url = f"/orgs/{self.organization.mnemonic}/sources/{self.source.mnemonic}/concepts/"
149+
random_concept = ConceptFactory()
150+
151+
mappings = [
152+
# 1 to target concept that doesnt exists with __parent_concept as substitution
153+
{
154+
'from_concept': '__parent_concept',
155+
'to_concept_url': '/orgs/random-org/sources/random-source/concepts/target-concept/',
156+
'map_type': 'Same As'
157+
},
158+
# 2 to target concept that doesnt exists with parent concept as direct url
159+
{
160+
'from_concept_url': concepts_url + 'c1/',
161+
'to_concept_url': '/orgs/random-org/sources/random-source/concepts/target-concept/',
162+
'map_type': 'BROADER-THAN'
163+
},
164+
# 3 to target concept that exists
165+
{
166+
'from_concept_url': concepts_url + 'c1/',
167+
'to_concept_url': random_concept.url,
168+
'map_type': 'NARROWER-THAN'
169+
},
170+
# 4 self mapping without from_concept
171+
{
172+
'to_concept_url': concepts_url + 'c1/',
173+
'map_type': 'Same As'
174+
},
175+
]
176+
177+
response = self.client.post(
178+
concepts_url,
179+
{**self.concept_payload, 'mappings': mappings},
180+
HTTP_AUTHORIZATION='Token ' + self.token,
181+
format='json'
182+
)
183+
184+
self.assertEqual(response.status_code, 201)
185+
self.assertListEqual(
186+
sorted(list(response.data.keys())),
187+
sorted([
188+
'uuid',
189+
'id',
190+
'external_id',
191+
'concept_class',
192+
'datatype',
193+
'url',
194+
'retired',
195+
'source',
196+
'owner',
197+
'owner_type',
198+
'owner_url',
199+
'display_name',
200+
'display_locale',
201+
'names',
202+
'descriptions',
203+
'created_on',
204+
'updated_on',
205+
'versions_url',
206+
'version',
207+
'extras',
208+
'type',
209+
'update_comment',
210+
'version_url',
211+
'updated_by',
212+
'created_by',
213+
'public_can_view',
214+
'checksums',
215+
'property',
216+
'versioned_object_id',
217+
'latest_source_version'
218+
])
219+
)
220+
221+
concept = Concept.objects.filter(mnemonic='c1').first().versioned_object
222+
latest_version = concept.get_latest_version()
223+
224+
self.assertFalse(latest_version.is_versioned_object)
225+
self.assertTrue(latest_version.is_latest_version)
226+
227+
self.assertTrue(concept.is_versioned_object)
228+
self.assertFalse(concept.is_latest_version)
229+
230+
self.assertEqual(concept.versions.count(), 1)
231+
self.assertEqual(response.data['uuid'], str(concept.id))
232+
self.assertEqual(response.data['datatype'], 'Coded')
233+
self.assertEqual(response.data['concept_class'], 'Procedure')
234+
self.assertEqual(response.data['url'], concept.uri)
235+
self.assertFalse(response.data['retired'])
236+
self.assertEqual(response.data['source'], self.source.mnemonic)
237+
self.assertEqual(response.data['owner'], self.organization.mnemonic)
238+
self.assertEqual(response.data['owner_type'], "Organization")
239+
self.assertEqual(response.data['owner_url'], self.organization.uri)
240+
self.assertEqual(response.data['display_name'], 'c1 name')
241+
self.assertEqual(response.data['display_locale'], 'en')
242+
self.assertEqual(response.data['versions_url'], concept.uri + 'versions/')
243+
self.assertEqual(response.data['version'], str(concept.id))
244+
self.assertEqual(response.data['extras'], {'foo': 'bar'})
245+
self.assertEqual(response.data['type'], 'Concept')
246+
self.assertEqual(response.data['version_url'], latest_version.uri)
247+
self.assertEqual(latest_version.get_bidirectional_mappings().count(), 4)
248+
self.assertEqual(concept.get_bidirectional_mappings().count(), 4)
249+
self.assertEqual(concept.parent.get_mappings_queryset().count(), 4)
250+
self.assertEqual(self.source.get_mappings_queryset().count(), 4)
251+
252+
def test_post_400_with_mappings_everything_or_nothing(self):
253+
concepts_url = f"/orgs/{self.organization.mnemonic}/sources/{self.source.mnemonic}/concepts/"
254+
random_concept = ConceptFactory()
255+
256+
mappings = [
257+
# 1 to target concept that doesnt exists with __parent_concept as substitution
258+
{
259+
'to_concept_url': '/orgs/random-org/sources/random-source/concepts/target-concept/',
260+
'map_type': 'Same As'
261+
},
262+
# 2 to target concept that doesnt exists with parent concept as direct url -- must fail for duplicate
263+
{
264+
'from_concept_url': concepts_url + 'c1/',
265+
'to_concept_url': '/orgs/random-org/sources/random-source/concepts/target-concept/',
266+
'map_type': 'Same As'
267+
},
268+
# 3 to target concept that exists
269+
{
270+
'from_concept_url': concepts_url + 'c1/',
271+
'to_concept_url': random_concept.url,
272+
'map_type': 'NARROWER-THAN'
273+
},
274+
# 4 parent concept not involved - must pass and from_concept_url is ignored and set to parent concept url
275+
{
276+
'from_concept_url': concepts_url + 'c2/',
277+
'to_concept_url': random_concept.url,
278+
'map_type': 'Same-As'
279+
},
280+
# 5 parent concept not involved - must fail for parent concept not from concept
281+
{
282+
'from_concept_url': random_concept.url,
283+
'to_concept_url': concepts_url + 'c1/',
284+
'map_type': 'Same-As'
285+
},
286+
]
287+
288+
response = self.client.post(
289+
concepts_url,
290+
{**self.concept_payload, 'mappings': mappings},
291+
HTTP_AUTHORIZATION='Token ' + self.token,
292+
format='json'
293+
)
294+
295+
self.assertEqual(response.status_code, 400)
296+
self.assertEqual(
297+
response.data,
298+
{
299+
'mappings': [
300+
{
301+
**mappings[1],
302+
'errors': {
303+
'__all__': ['Parent, map_type, from_concept, to_source, to_concept_code must be unique.']
304+
}
305+
}
306+
]
307+
}
308+
)
309+
self.assertFalse(Concept.objects.filter(mnemonic='c1').exists())
310+
self.assertFalse(self.source.get_concepts_queryset().exists())
311+
self.assertFalse(self.source.get_mappings_queryset().exists())
312+
self.assertEqual(Mapping.objects.count(), 0)
313+
146314
def test_post_400(self):
147315
concepts_url = f"/orgs/{self.organization.mnemonic}/sources/{self.source.mnemonic}/concepts/"
148316

@@ -319,7 +487,6 @@ def test_put_200(self): # pylint: disable=too-many-statements
319487
self.assertEqual(response.data['display_name'], prev_version.display_name)
320488
self.assertEqual(concept.datatype, "N/A")
321489

322-
323490
def test_put_200_openmrs_schema(self): # pylint: disable=too-many-statements
324491
self.create_lookup_concept_classes()
325492
source = OrganizationSourceFactory(custom_validation_schema=OPENMRS_VALIDATION_SCHEMA)

0 commit comments

Comments
 (0)