Skip to content

Commit 63296fb

Browse files
committed
OpenConceptLab/ocl_issues#2381 | Concept PUT/PATCH with mappings
1 parent 6374ae4 commit 63296fb

File tree

3 files changed

+339
-3
lines changed

3 files changed

+339
-3
lines changed

core/concepts/models.py

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,10 @@ def create_new_version_for(
530530
cls, instance, data, user, create_parent_version=True, add_prev_version_children=True,
531531
_hierarchy_processing=False, is_patch=False
532532
): # pylint: disable=too-many-arguments
533+
mappings_payload = data.pop('mappings_payload', None)
534+
prev_latest = Concept.objects.filter(
535+
mnemonic=instance.mnemonic, parent_id=instance.parent_id, is_latest_version=True
536+
).first()
533537
instance.id = None # Clear id so it is persisted as a new object
534538
instance.version = data.get('version', None)
535539
instance.concept_class = data.get('concept_class', instance.concept_class)
@@ -557,14 +561,25 @@ def create_new_version_for(
557561
if not parent_concept_uris and has_parent_concept_uris_attr:
558562
parent_concept_uris = []
559563

560-
return instance.save_as_new_version(
564+
errors = instance.save_as_new_version(
561565
user=user,
562566
create_parent_version=create_parent_version,
563567
parent_concept_uris=parent_concept_uris,
564568
add_prev_version_children=add_prev_version_children,
565569
_hierarchy_processing=_hierarchy_processing
566570
)
567571

572+
if errors or mappings_payload is None:
573+
return errors
574+
mappings_result, has_mapping_errors = instance.upsert_or_delete_mappings(
575+
mappings_payload, user
576+
)
577+
if has_mapping_errors:
578+
instance.rollback_latest_version_to(prev_latest)
579+
errors['mappings'] = instance._get_errors_from_mappings(mappings_result)
580+
581+
return errors
582+
568583
def set_parent_concepts_from_uris(self, create_parent_version=True):
569584
parent_concepts = get(self, '_parent_concepts', [])
570585
if create_parent_version:
@@ -655,14 +670,140 @@ def create_mappings(self, mappings):
655670

656671
def _create_mapping_from_self(self, mapping_data, user):
657672
from core.mappings.models import Mapping
658-
data = {key: value for key, value in mapping_data.items() if not key.startswith('from_')}
673+
data = {
674+
key: value for key, value in mapping_data.items()
675+
if not key.startswith('from_') and key not in ['id', 'uuid', 'action']
676+
}
659677
data['from_concept_url'] = drop_version(self.uri)
660678
if data.get('to_concept') == '__parent_concept':
661679
data['to_concept_url'] = self.uri
662680
data.pop('to_concept')
663681

664682
return Mapping.persist_new({**data, 'parent_id': self.parent_id}, user)
665683

684+
def _validate_mapping_create_from_self(self, mapping_data, user):
685+
from core.mappings.models import Mapping
686+
data = {
687+
key: value for key, value in mapping_data.items()
688+
if not key.startswith('from_') and key not in ['id', 'uuid', 'action']
689+
}
690+
data['from_concept_url'] = drop_version(self.uri)
691+
if data.get('to_concept') == '__parent_concept':
692+
data['to_concept_url'] = self.uri
693+
data.pop('to_concept')
694+
data['parent_id'] = self.parent_id
695+
696+
related_fields = ['from_concept_url', 'to_concept_url', 'to_source_url', 'from_source_url']
697+
field_data = {k: v for k, v in data.items() if k not in related_fields}
698+
url_params = {k: v for k, v in data.items() if k in related_fields}
699+
candidate = Mapping(**field_data, created_by=user, updated_by=user)
700+
candidate.populate_fields_from_relations(url_params)
701+
candidate.full_clean()
702+
703+
@staticmethod
704+
def _rollback_mapping_operations(applied_operations):
705+
from core.mappings.models import Mapping
706+
for operation in reversed(applied_operations):
707+
op_type = operation.get('__action')
708+
mapping_id = operation['versioned_object_id']
709+
if not mapping_id or not op_type:
710+
continue
711+
if op_type == 'create' and mapping_id:
712+
Mapping.objects.filter(versioned_object_id=mapping_id).delete()
713+
continue
714+
latest_version = Mapping.objects.filter(versioned_object_id=mapping_id, is_latest_version=True).first()
715+
prev_latest = latest_version.prev_version
716+
prev_latest.mark_latest_version(True, latest_version.parent)
717+
prev_latest.update_versioned_object()
718+
latest_version.delete()
719+
720+
def rollback_latest_version_to(self, prev_latest):
721+
if not prev_latest:
722+
return
723+
latest = Concept.objects.filter(
724+
mnemonic=self.mnemonic, parent_id=self.parent_id, is_latest_version=True
725+
).first()
726+
if latest and latest.id != prev_latest.id:
727+
latest.remove_locales()
728+
latest.delete()
729+
prev_latest.mark_latest_version(True, self.parent)
730+
prev_latest.update_versioned_object()
731+
732+
def find_direct_mapping(self, mapping_id):
733+
return self.get_unidirectional_mappings().filter(mnemonic=str(mapping_id)).first() if mapping_id else None
734+
735+
def upsert_or_delete_mappings(self, mappings_payload, user):
736+
from core.mappings.models import Mapping
737+
results = []
738+
any_with_errors = False
739+
applied_operations = []
740+
741+
for mapping_data in mappings_payload or []:
742+
mapping_data = mapping_data.copy()
743+
mapping_id = mapping_data.get('id')
744+
action = mapping_data.get('action')
745+
errors = {}
746+
serializer = None
747+
created = None
748+
749+
try:
750+
if action == '__delete' and not mapping_id:
751+
raise ValidationError({'id': ['Mapping id is required when action is __delete.']})
752+
753+
if mapping_id:
754+
mapping = self.find_direct_mapping(mapping_id)
755+
if not mapping:
756+
raise ValidationError({'id': [f"Mapping '{mapping_id}' not found for this concept."]})
757+
758+
if action == '__delete':
759+
errors = mapping.retire(
760+
user, mapping_data.get('update_comment') or mapping_data.get('comment')
761+
)
762+
if not errors:
763+
applied_operations.append({
764+
'action': '__delete', 'versioned_object_id': mapping.versioned_object_id})
765+
created = mapping
766+
else:
767+
updates = {
768+
k: v for k, v in mapping_data.items() if k not in ['id', 'uuid', 'action', 'checksums']}
769+
if updates.get('to_concept') == '__parent_concept':
770+
updates['to_concept_url'] = self.uri
771+
updates.pop('to_concept')
772+
errors = Mapping.create_new_version_for(mapping.clone(user=user), updates, user)
773+
if not errors:
774+
applied_operations.append({
775+
'__action': 'update', 'versioned_object_id': mapping.versioned_object_id})
776+
created = mapping
777+
else:
778+
created = self._create_mapping_from_self(mapping_data, user)
779+
errors = created.errors
780+
if not errors and not created.id:
781+
errors['__all__'] = ['Something bad happened while creating the mapping.']
782+
if not errors and created.id:
783+
applied_operations.append({'__action': 'create', 'id': created.versioned_object_id})
784+
except Exception as ex: # pylint: disable=broad-except
785+
error_dict = get(ex, 'message_dict') or get(ex, 'error_dict')
786+
if error_dict:
787+
errors = error_dict
788+
else:
789+
errors['__all__'] = [str(ex)]
790+
any_with_errors = True
791+
792+
if errors:
793+
any_with_errors = True
794+
795+
results.append({
796+
'mapping': mapping_data,
797+
'instance': created,
798+
'serializer': serializer,
799+
'errors': errors
800+
})
801+
802+
if any_with_errors and applied_operations:
803+
self._rollback_mapping_operations(applied_operations)
804+
805+
return results, any_with_errors
806+
666807
@staticmethod
667808
def _remove_mappings_just_created(mappings_results):
668809
for mapping in mappings_results:

core/concepts/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,10 @@ def update(self, request, *args, **kwargs):
356356
status=status.HTTP_400_BAD_REQUEST
357357
)
358358
self.object = self.object.clone()
359+
data = request.data.copy()
360+
data['mappings_payload'] = data.pop('mappings', [])
359361
serializer = self.get_serializer(
360-
self.object, data=request.data, partial=partial, is_patch=real_partial)
362+
self.object, data=data, partial=partial, is_patch=real_partial)
361363
success_status_code = status.HTTP_200_OK
362364

363365
if serializer.is_valid():

core/integration_tests/tests_concepts.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,199 @@ def test_put_200(self): # pylint: disable=too-many-statements
487487
self.assertEqual(response.data['display_name'], prev_version.display_name)
488488
self.assertEqual(concept.datatype, "N/A")
489489

490+
def test_put_200_with_mappings(self): # pylint: disable=too-many-statements
491+
concept = ConceptFactory(parent=self.source, datatype="N/A")
492+
self.assertEqual(concept.versions.count(), 1)
493+
concepts_url = f"/orgs/{self.organization.mnemonic}/sources/{self.source.mnemonic}/concepts/{concept.mnemonic}/"
494+
random_concept = ConceptFactory()
495+
496+
mappings = [
497+
{
498+
'from_concept': '__parent_concept',
499+
'to_concept_url': '/orgs/random-org/sources/random-source/concepts/target-concept/',
500+
'map_type': 'Same As'
501+
},
502+
{
503+
'from_concept_url': concepts_url,
504+
'to_concept_url': '/orgs/random-org/sources/random-source/concepts/target-concept/',
505+
'map_type': 'BROADER-THAN'
506+
},
507+
{
508+
'from_concept_url': concepts_url,
509+
'to_concept_url': random_concept.url,
510+
'map_type': 'NARROWER-THAN'
511+
},
512+
{
513+
'to_concept_url': concepts_url,
514+
'map_type': 'Same As'
515+
},
516+
]
517+
518+
response = self.client.put(
519+
concepts_url,
520+
{
521+
**self.concept_payload,
522+
'datatype': 'None', 'update_comment': 'Updated datatype', 'mappings': mappings
523+
},
524+
HTTP_AUTHORIZATION='Token ' + self.token,
525+
format='json'
526+
)
527+
self.assertEqual(response.status_code, 200)
528+
self.assertListEqual(
529+
sorted(list(response.data.keys())),
530+
sorted(['uuid',
531+
'id',
532+
'external_id',
533+
'concept_class',
534+
'datatype',
535+
'url',
536+
'retired',
537+
'source',
538+
'owner',
539+
'owner_type',
540+
'owner_url',
541+
'display_name',
542+
'display_locale',
543+
'names',
544+
'descriptions',
545+
'created_on',
546+
'updated_on',
547+
'versions_url',
548+
'version',
549+
'extras',
550+
'type',
551+
'update_comment',
552+
'version_url',
553+
'updated_by',
554+
'created_by',
555+
'public_can_view',
556+
'checksums',
557+
'property',
558+
'latest_source_version',
559+
'versioned_object_id'])
560+
)
561+
concept.refresh_from_db()
562+
latest_version = concept.get_latest_version()
563+
self.assertEqual(concept.datatype, 'None')
564+
self.assertEqual(latest_version.datatype, 'None')
565+
self.assertEqual(latest_version.prev_version.datatype, 'N/A')
566+
self.assertEqual(latest_version.get_bidirectional_mappings().count(), 4)
567+
self.assertEqual(concept.get_bidirectional_mappings().count(), 4)
568+
self.assertEqual(concept.parent.get_mappings_queryset().count(), 4)
569+
self.assertEqual(self.source.get_mappings_queryset().count(), 4)
570+
571+
def test_put_200_with_mappings_upsert_and_delete(self):
572+
concept = ConceptFactory(parent=self.source)
573+
concepts_url = f"/orgs/{self.organization.mnemonic}/sources/{self.source.mnemonic}/concepts/{concept.mnemonic}/"
574+
update_target = ConceptFactory(parent=self.source)
575+
update_target_new = ConceptFactory(parent=self.source)
576+
delete_target = ConceptFactory(parent=self.source)
577+
new_target = ConceptFactory(parent=self.source)
578+
579+
mapping_to_update = MappingFactory(
580+
parent=self.source, from_concept=concept, to_concept=update_target, map_type='Same As'
581+
).versioned_object
582+
mapping_to_delete = MappingFactory(
583+
parent=self.source, from_concept=concept, to_concept=delete_target, map_type='BROADER-THAN'
584+
).versioned_object
585+
586+
response = self.client.put(
587+
concepts_url,
588+
{
589+
**self.concept_payload,
590+
'datatype': 'None',
591+
'update_comment': 'Updated concept with mapping operations',
592+
'mappings': [
593+
{
594+
'id': mapping_to_update.mnemonic,
595+
'map_type': 'NARROWER-THAN',
596+
'update_comment': 'updated map type',
597+
'to_concept_url': update_target_new.url
598+
},
599+
{
600+
'to_concept_url': new_target.url,
601+
'map_type': 'Same As'
602+
},
603+
{
604+
'id': mapping_to_delete.mnemonic,
605+
'action': '__delete',
606+
'update_comment': 'Deleted from concept update'
607+
}
608+
]
609+
},
610+
HTTP_AUTHORIZATION='Token ' + self.token,
611+
format='json'
612+
)
613+
614+
self.assertEqual(response.status_code, 200)
615+
mapping_to_update.refresh_from_db()
616+
mapping_to_delete.refresh_from_db()
617+
concept.refresh_from_db()
618+
self.assertEqual(mapping_to_update.map_type, 'NARROWER-THAN')
619+
self.assertEqual(mapping_to_update.to_concept_id, update_target_new.id)
620+
self.assertEqual(mapping_to_update.versions.count(), 2)
621+
self.assertTrue(mapping_to_delete.retired)
622+
self.assertTrue(mapping_to_delete.get_latest_version().retired)
623+
self.assertFalse(mapping_to_delete.get_latest_version().prev_version.retired)
624+
self.assertEqual(mapping_to_delete.versions.count(), 2)
625+
self.assertTrue(
626+
concept.get_unidirectional_mappings().filter(
627+
to_concept_id=new_target.id,
628+
map_type='Same As',
629+
retired=False
630+
).exists()
631+
)
632+
self.assertEqual(concept.get_unidirectional_mappings().filter(retired=False).count(), 2)
633+
634+
def test_put_400_with_mappings_everything_or_nothing(self):
635+
concept = ConceptFactory(parent=self.source)
636+
concepts_url = f"/orgs/{self.organization.mnemonic}/sources/{self.source.mnemonic}/concepts/{concept.mnemonic}/"
637+
existing_target = ConceptFactory(parent=self.source)
638+
new_target = ConceptFactory(parent=self.source)
639+
existing_mapping = MappingFactory(
640+
parent=self.source, from_concept=concept, to_concept=existing_target, map_type='Same As'
641+
).versioned_object
642+
643+
initial_versions_count = concept.versions.count()
644+
initial_datatype = concept.datatype
645+
initial_active_mappings_count = concept.get_bidirectional_mappings().filter(retired=False).count()
646+
647+
response = self.client.put(
648+
concepts_url,
649+
{
650+
**self.concept_payload,
651+
'datatype': 'None',
652+
'update_comment': 'Should fail and rollback all',
653+
'mappings': [
654+
{
655+
'id': existing_mapping.mnemonic,
656+
'to_concept_url': new_target.url,
657+
'map_type': 'NARROWER-THAN'
658+
},
659+
{
660+
'action': '__delete'
661+
}
662+
]
663+
},
664+
HTTP_AUTHORIZATION='Token ' + self.token,
665+
format='json'
666+
)
667+
668+
self.assertEqual(response.status_code, 400)
669+
self.assertIn('mappings', response.data)
670+
671+
concept.refresh_from_db()
672+
existing_mapping.refresh_from_db()
673+
self.assertEqual(concept.versions.count(), initial_versions_count)
674+
self.assertEqual(concept.datatype, initial_datatype)
675+
self.assertEqual(existing_mapping.to_concept_id, existing_target.id)
676+
self.assertEqual(existing_mapping.map_type, 'Same As')
677+
self.assertFalse(existing_mapping.retired)
678+
self.assertEqual(
679+
concept.get_bidirectional_mappings().filter(retired=False).count(),
680+
initial_active_mappings_count
681+
)
682+
490683
def test_put_200_openmrs_schema(self): # pylint: disable=too-many-statements
491684
self.create_lookup_concept_classes()
492685
source = OrganizationSourceFactory(custom_validation_schema=OPENMRS_VALIDATION_SCHEMA)

0 commit comments

Comments
 (0)