Repository: carpentries/amy
Issue: IntegrityError when merging persons with tasks in common and "combine" strategy

Example: merging person A and person B, both have similar task "learner" in "XYZ" event.


Changed files:
workshops/util.py, workshops/test/test_person.py, workshops/views.py

diff --git a/workshops/test/test_person.py b/workshops/test/test_person.py
index a352d92b..d6b42534 100644
--- a/workshops/test/test_person.py
+++ b/workshops/test/test_person.py
@@ -705,3 +705,17 @@ class TestPersonMerging(TestBase):
         for key, value in assertions.items():
             self.assertEqual(set(getattr(self.person_b, key).all()), value,
                              key)
+
+    def test_merging_m2m_with_similar_attributes(self):
+        """Regression test: merging people with the same M2M objects, e.g. when
+        both people have task 'learner' in event 'ABCD', would result in unique
+        constraint violation and cause IntegrityError."""
+        self.person_b.task_set.create(
+            event=Event.objects.get(slug='ends-tomorrow-ongoing'),
+            role=Role.objects.get(name='instructor'),
+        )
+
+        self.strategy['task_set'] = 'combine'
+
+        rv = self.client.post(self.url, data=self.strategy)
+        self.assertEqual(rv.status_code, 302)
diff --git a/workshops/util.py b/workshops/util.py
index 6bfeb854..e2311551 100644
--- a/workshops/util.py
+++ b/workshops/util.py
@@ -797,6 +797,11 @@ def merge_objects(object_a, object_b, easy_fields, difficult_fields,
         base_obj = object_b
         merging_obj = object_a
 
+    # used to catch all IntegrityErrors caused by violated database constraints
+    # when adding two similar entries by the manager (see below for more
+    # details)
+    integrity_errors = []
+
     with transaction.atomic():
         for attr in easy_fields:
             value = choices.get(attr)
@@ -847,10 +852,18 @@ def merge_objects(object_a, object_b, easy_fields, difficult_fields,
                 manager.set(list(related_b.all()))
 
             elif value == 'combine':
-                # remove duplicates
-                manager.add(*list(related_a.all() |
-                                  related_b.all()))
+                summed = related_a.all() | related_b.all()
+
+                # some entries may cause IntegrityError (violation of
+                # uniqueness constraint) because they are duplicates *after*
+                # being added by the manager
+                for element in summed:
+                    try:
+                        with transaction.atomic():
+                            manager.add(element)
+                    except IntegrityError as e:
+                        integrity_errors.append(str(e))
 
         merging_obj.delete()
 
-        return base_obj.save()
+        return base_obj.save(), integrity_errors
diff --git a/workshops/views.py b/workshops/views.py
index 0524a53b..2a8166df 100644
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -939,8 +939,14 @@ def persons_merge(request):
                          'task_set')
 
             try:
-                merge_objects(obj_a, obj_b, easy, difficult, choices=data,
-                              base_a=base_a)
+                _, integrity_errors = merge_objects(obj_a, obj_b, easy,
+                                                    difficult, choices=data,
+                                                    base_a=base_a)
+
+                if integrity_errors:
+                    msg = ('There were integrity errors when merging related '
+                           'objects:\\n' '\\n'.join(integrity_errors))
+                    messages.warning(request, msg)
 
             except ProtectedError as e:
                 return failed_to_delete(request, object=merging_obj,
@@ -1294,15 +1300,21 @@ def events_merge(request):
                 'administrator', 'url', 'reg_key', 'admin_fee',
                 'invoice_status', 'attendance', 'contact', 'country', 'venue',
                 'address', 'latitude', 'longitude', 'learners_pre',
-                'learners_post',  'instructors_pre', 'instructors_post',
+                'learners_post', 'instructors_pre', 'instructors_post',
                 'learners_longterm', 'notes',
             )
             # M2M relationships
             difficult = ('tags', 'task_set', 'todoitem_set')
 
             try:
-                merge_objects(obj_a, obj_b, easy, difficult, choices=data,
-                              base_a=base_a)
+                _, integrity_errors = merge_objects(obj_a, obj_b, easy,
+                                                    difficult, choices=data,
+                                                    base_a=base_a)
+
+                if integrity_errors:
+                    msg = ('There were integrity errors when merging related '
+                           'objects:\\n' '\\n'.join(integrity_errors))
+                    messages.warning(request, msg)
 
             except ProtectedError as e:
                 return failed_to_delete(request, object=merging_obj,