Repository: carpentries/amy

Issue:
Bulk upload persons - change link to *not* use existing person 
With bulk upload of persons, if a possible match exists, we can use that person.  This is useful.

Once we have matched the new person to an existing person the display changes to

![image](https://user-images.githubusercontent.com/829690/30820611-6bba228c-a1f0-11e7-8453-c161b5485a20.png)

It's noted that an existing person will be used, but the link to use the existing person still displays.  This link should be to *not* use the existing person.



Discussion:

Resolving patch:
diff --git a/workshops/urls.py b/workshops/urls.py
--- a/workshops/urls.py
+++ b/workshops/urls.py
@@ -47,6 +47,7 @@
         url(r'^confirm/$',views.person_bulk_add_confirmation, name='person_bulk_add_confirmation'),
         url(r'^(?P<entry_id>\d+)/remove/$',views.person_bulk_add_remove_entry, name='person_bulk_add_remove_entry'),
         url(r'^(?P<entry_id>\d+)/match_person/(?P<person_id>\d+)$',views.person_bulk_add_match_person, name='person_bulk_add_match_person'),
+        url(r'^(?P<entry_id>\d+)/match_person/$',views.person_bulk_add_match_person, name='person_bulk_add_match_person'),
     ])),
 
     url(r'^persons/', include([
diff --git a/workshops/util.py b/workshops/util.py
--- a/workshops/util.py
+++ b/workshops/util.py
@@ -99,10 +99,12 @@ def upload_person_task_csv(stream):
     return result, list(empty_fields)
 
 
-def verify_upload_person_task(data):
+def verify_upload_person_task(data, match=False):
     """
-    Verify that uploaded data is correct.  Show errors by populating ``errors``
-    dictionary item.  This function changes ``data`` in place.
+    Verify that uploaded data is correct.  Show errors by populating `errors`
+    dictionary item.  This function changes `data` in place.
+
+    If `match` provided, it will try to match with first similar person.
     """
 
     errors_occur = False
@@ -116,7 +118,10 @@ def verify_upload_person_task(data):
             try:
                 existing_event = Event.objects.get(slug=event)
             except Event.DoesNotExist:
-                errors.append('Event with slug {0} does not exist.'
+                errors.append('Event with slug "{0}" does not exist.'
+                              .format(event))
+            except Event.MultipleObjectsReturned:
+                errors.append('More than one event named "{0}" exists.'
                               .format(event))
 
         role = item.get('role', None)
@@ -125,10 +130,10 @@ def verify_upload_person_task(data):
             try:
                 existing_role = Role.objects.get(name=role)
             except Role.DoesNotExist:
-                errors.append('Role with name {0} does not exist.'
+                errors.append('Role with name "{0}" does not exist.'
                               .format(role))
             except Role.MultipleObjectsReturned:
-                errors.append('More than one role named {0} exists.'
+                errors.append('More than one role named "{0}" exists.'
                               .format(role))
 
         # check if the user exists, and if so: check if existing user's
@@ -139,7 +144,17 @@ def verify_upload_person_task(data):
         person_id = item.get('existing_person_id', None)
         person = None
 
-        if person_id:
+        # try to match with first similar person
+        if match is True:
+            try:
+                person = Person.objects.get(email=email)
+            except Person.DoesNotExist:
+                person = None
+            else:
+                info.append('Existing record for person will be used.')
+                person_id = person.pk
+
+        elif person_id:
             try:
                 person = Person.objects.get(id=int(person_id))
             except (ValueError, TypeError, Person.DoesNotExist):
@@ -149,6 +164,22 @@ def verify_upload_person_task(data):
             else:
                 info.append('Existing record for person will be used.')
 
+        elif not person_id:
+            try:
+                Person.objects.get(email=email)
+            except Person.DoesNotExist:
+                pass
+            else:
+                errors.append('Person with this email address already exists.')
+
+            try:
+                if item.get('username'):
+                    Person.objects.get(username=item.get('username'))
+            except Person.DoesNotExist:
+                pass
+            else:
+                errors.append('Person with this username already exists.')
+
         if not email and not person:
             info.append('It\'s highly recommended to add an email address.')
 
@@ -158,6 +189,7 @@ def verify_upload_person_task(data):
             item['family'] = family = person.family
             item['email'] = email = person.email
             item['username'] = person.username
+            item['existing_person_id'] = person_id
             item['person_exists'] = True
         else:
             # force a newly created username
@@ -173,9 +205,12 @@ def verify_upload_person_task(data):
             Q(email=email) & ~Q(email='') & Q(email__isnull=False)
         )
         # need to cast to list, otherwise it won't JSON-ify
-        item['similar_persons'] = list(similar_persons.values(
-            'id', 'personal', 'middle', 'family', 'email', 'username',
-        ))
+        item['similar_persons'] = list(
+            zip(
+                similar_persons.values_list('id', flat=True),
+                map(lambda x: str(x), similar_persons)
+            )
+        )
 
         if existing_event and person and existing_role:
             # person, their role and a corresponding event exist, so
@@ -203,12 +238,11 @@ def verify_upload_person_task(data):
         if not event:
             errors.append('Must have an event.')
 
+        item['errors'] = errors
         if errors:
             errors_occur = True
-            item['errors'] = errors
 
-        if info:
-            item['info'] = info
+        item['info'] = info
 
     return errors_occur
 
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -484,7 +484,8 @@ def person_bulk_add_template(request):
 
 
 @admin_required
-@permission_required('workshops.add_person', raise_exception=True)
+@permission_required(['workshops.add_person', 'workshops.change_person'],
+                     raise_exception=True)
 def person_bulk_add(request):
     if request.method == 'POST':
         form = PersonBulkAddForm(request.POST, request.FILES)
@@ -496,7 +497,7 @@ def person_bulk_add(request):
             except csv.Error as e:
                 messages.add_message(
                     request, messages.ERROR,
-                    "Error processing uploaded .CSV file: {}".format(e))
+                    "Error processing uploaded CSV file: {}".format(e))
             except UnicodeDecodeError as e:
                 messages.add_message(
                     request, messages.ERROR,
@@ -513,6 +514,8 @@ def person_bulk_add(request):
                     # then redirect to confirmation page which in turn saves
                     # the data
                     request.session['bulk-add-people'] = persons_tasks
+                    # request match
+                    request.session['bulk-add-people-match'] = True
                     return redirect('person_bulk_add_confirmation')
 
     else:
@@ -528,16 +531,18 @@ def person_bulk_add(request):
 
 
 @admin_required
-@permission_required('workshops.add_person', raise_exception=True)
+@permission_required(['workshops.add_person', 'workshops.change_person'],
+                     raise_exception=True)
 def person_bulk_add_confirmation(request):
     """
     This view allows for manipulating and saving session-stored upload data.
     """
     persons_tasks = request.session.get('bulk-add-people')
+    match = request.session.get('bulk-add-people-match', False)
 
     # if the session is empty, add message and redirect
     if not persons_tasks:
-        messages.warning(request, "Could not locate CSV data, please try the "
+        messages.warning(request, "Could not locate CSV data, please "
                                   "upload again.")
         return redirect('person_bulk_add')
 
@@ -613,7 +618,9 @@ def person_bulk_add_confirmation(request):
 
     else:
         # alters persons_tasks via reference
-        any_errors = verify_upload_person_task(persons_tasks)
+        any_errors = verify_upload_person_task(persons_tasks,
+                                               match=bool(match))
+        request.session['bulk-add-people-match'] = False
 
     roles = Role.objects.all().values_list('name', flat=True)
 
@@ -628,7 +635,8 @@ def person_bulk_add_confirmation(request):
 
 
 @admin_required
-@permission_required('workshops.add_person', raise_exception=True)
+@permission_required(['workshops.add_person', 'workshops.change_person'],
+                     raise_exception=True)
 def person_bulk_add_remove_entry(request, entry_id):
     "Remove specific entry from the session-saved list of people to be added."
     persons_tasks = request.session.get('bulk-add-people')
@@ -652,29 +660,57 @@ def person_bulk_add_remove_entry(request, entry_id):
 
 
 @admin_required
-@permission_required('workshops.add_person', raise_exception=True)
-def person_bulk_add_match_person(request, entry_id, person_id):
+@permission_required(['workshops.add_person', 'workshops.change_person'],
+                     raise_exception=True)
+def person_bulk_add_match_person(request, entry_id, person_id=None):
     """Save information about matched person in the session-saved data."""
     persons_tasks = request.session.get('bulk-add-people')
+    if not persons_tasks:
+        messages.warning(request, 'Could not locate CSV data, please try the '
+                                  'upload again.')
+        return redirect('person_bulk_add')
 
-    if persons_tasks:
-        entry_id = int(entry_id)
-        person_id = int(person_id)
 
+    if person_id is None:
+        # unmatch
         try:
-            persons_tasks[entry_id]['existing_person_id'] = person_id
+            entry_id = int(entry_id)
+
+            persons_tasks[entry_id]['existing_person_id'] = 0
             request.session['bulk-add-people'] = persons_tasks
 
+        except ValueError:
+            # catches invalid argument for int()
+            messages.warning(request, 'Invalid entry ID ({}) or person ID '
+                                      '({}).'.format(entry_id, person_id))
+
         except IndexError:
+            # catches index out of bound
             messages.warning(request, 'Could not find specified entry #{}'
                                       .format(entry_id))
 
         return redirect(person_bulk_add_confirmation)
 
     else:
-        messages.warning(request, 'Could not locate CSV data, please try the '
-                                  'upload again.')
-        return redirect('person_bulk_add')
+        # match
+        try:
+            entry_id = int(entry_id)
+            person_id = int(person_id)
+
+            persons_tasks[entry_id]['existing_person_id'] = person_id
+            request.session['bulk-add-people'] = persons_tasks
+
+        except ValueError:
+            # catches invalid argument for int()
+            messages.warning(request, 'Invalid entry ID ({}) or person ID '
+                                      '({}).'.format(entry_id, person_id))
+
+        except IndexError:
+            # catches index out of bound
+            messages.warning(request, 'Could not find specified entry #{}'
+                                      .format(entry_id))
+
+        return redirect(person_bulk_add_confirmation)
 
 
 class PersonCreate(OnlyForAdminsMixin, PermissionRequiredMixin,


Test patch:
diff --git a/workshops/test/test_bulk_add.py b/workshops/test/test_bulk_add.py
--- a/workshops/test/test_bulk_add.py
+++ b/workshops/test/test_bulk_add.py
@@ -140,26 +140,27 @@ class VerifyUploadPersonTask(CSVBulkUploadTestBase):
 
     def test_verify_with_good_data(self):
         good_data = self.make_data()
-        has_errors = verify_upload_person_task(good_data)
+        has_errors = verify_upload_person_task(good_data, match=True)
         self.assertFalse(has_errors)
         # make sure 'errors' wasn't set
-        self.assertIsNone(good_data[0]['errors'])
+        # 'errors' may be an empty list, which evaluates to False
+        self.assertFalse(good_data[0]['errors'])
 
     def test_verify_event_doesnt_exist(self):
         bad_data = self.make_data()
         bad_data[0]['event'] = 'no-such-event'
-        has_errors = verify_upload_person_task(bad_data)
+        has_errors = verify_upload_person_task(bad_data, match=True)
         self.assertTrue(has_errors)
 
         errors = bad_data[0]['errors']
-        self.assertTrue(len(errors) == 1)
+        self.assertEqual(len(errors), 1)
         self.assertTrue('Event with slug' in errors[0])
 
     def test_verify_role_doesnt_exist(self):
         bad_data = self.make_data()
         bad_data[0]['role'] = 'foobar'
 
-        has_errors = verify_upload_person_task(bad_data)
+        has_errors = verify_upload_person_task(bad_data, match=True)
         self.assertTrue(has_errors)
 
         errors = bad_data[0]['errors']
@@ -174,8 +175,8 @@ def test_verify_email_caseinsensitive_matches(self):
             bad_data[0]['personal'] = 'Harry'
             bad_data[0]['family'] = 'Potter'
 
-            has_errors = verify_upload_person_task(bad_data)
-            self.assertFalse(has_errors)
+            has_errors = verify_upload_person_task(bad_data, match=True)
+            self.assertFalse(has_errors, 'Bad email: {}'.format(email))
 
     def test_verify_existing_user_has_workshop_role_provided(self):
         bad_data = [
@@ -187,7 +188,7 @@ def test_verify_existing_user_has_workshop_role_provided(self):
                 'role': '',
             }
         ]
-        has_errors = verify_upload_person_task(bad_data)
+        has_errors = verify_upload_person_task(bad_data, match=True)
         self.assertTrue(has_errors)
         errors = bad_data[0]['errors']
         self.assertEqual(len(errors), 2)
@@ -253,10 +254,23 @@ def test_matched_similar_persons(self):
         self.assertEqual(len(data[0]['similar_persons']), 1)
         self.assertEqual(len(data[1]['similar_persons']), 1)
 
-        self.assertEqual(data[0]['similar_persons'][0]['username'],
-                         'potter_harry')
-        self.assertEqual(data[1]['similar_persons'][0]['username'],
-                         'weasley_ron')
+        self.assertEqual(data[0]['similar_persons'][0][0],
+                         self.harry.pk)
+        self.assertEqual(data[1]['similar_persons'][0][0],
+                         self.ron.pk)
+
+    def test_duplicate_errors(self):
+        """Ensure errors about duplicate person in the database are present."""
+        data = self.make_data()
+        data[0]['personal'] = 'Harry'
+        data[0]['family'] = 'Potter'
+        data[0]['email'] = 'harry@hogwarts.edu'
+        verify_upload_person_task(data)
+        self.assertEqual(len(data[0]['errors']), 2)
+        self.assertIn('Person with this email address already exists.',
+                      data[0]['errors'])
+        self.assertIn('Person with this username already exists.',
+                      data[0]['errors'])
 
 
 class BulkUploadUsersViewTestCase(CSVBulkUploadTestBase):