From 4cb6e7d9b4b5d442ba58830b62f868c9b01a330d Mon Sep 17 00:00:00 2001
From: "J.-S. Caux" <J.S.Caux@uva.nl>
Date: Sat, 17 Nov 2018 19:00:01 +0100
Subject: [PATCH] Improve merging Contributors

---
 profiles/forms.py                             | 18 ++++++++--------
 profiles/models.py                            |  9 ++++----
 .../templates/profiles/profile_merge.html     |  2 +-
 scipost/forms.py                              | 20 +++++++++++++++++-
 .../0018_contributor_duplicate_of.py          | 21 +++++++++++++++++++
 scipost/models.py                             | 13 +++++++++++-
 scipost/views.py                              | 13 +++++++++---
 ...contributor_duplicate_accounts_merged.html | 18 ++++++++++++++++
 ...contributor_duplicate_accounts_merged.json |  8 +++++++
 9 files changed, 103 insertions(+), 19 deletions(-)
 create mode 100644 scipost/migrations/0018_contributor_duplicate_of.py
 create mode 100644 templates/email/contributors/inform_contributor_duplicate_accounts_merged.html
 create mode 100644 templates/email/contributors/inform_contributor_duplicate_accounts_merged.json

diff --git a/profiles/forms.py b/profiles/forms.py
index 3b182359a..ff35d4d39 100644
--- a/profiles/forms.py
+++ b/profiles/forms.py
@@ -98,10 +98,11 @@ class ProfileMergeForm(forms.Form):
         data = super().clean()
         if self.cleaned_data['to_merge'] == self.cleaned_data['to_merge_into']:
             self.add_error(None, 'A Profile cannot be merged into itself.')
-        if self.cleaned_data['to_merge'].has_contributor and \
-           self.cleaned_data['to_merge_into'].has_contributor:
-            self.add_error(None, 'Each of these two Profiles has a Contributor. '
-                           'Cannot merge. If these are distinct people or if two separate '
+        if self.cleaned_data['to_merge'].has_active_contributor and \
+           self.cleaned_data['to_merge_into'].has_active_contributor:
+            self.add_error(None, 'Each of these two Profiles has an active Contributor. '
+                           'Merge the Contributors first.\n'
+                           'If these are distinct people or if two separate '
                            'accounts are needed, a ProfileNonDuplicate instance should be created; '
                            'contact techsupport.')
         return data
@@ -114,13 +115,15 @@ class ProfileMergeForm(forms.Form):
         profile = self.cleaned_data['to_merge_into']
         profile_old = self.cleaned_data['to_merge']
 
-        # Merge scientific information from old Profile to the new Profile.
+        # Merge information from old to new Profile.
         profile.expertises = list(
             set(profile_old.expertises or []) | set(profile.expertises or []))
         if profile.orcid_id is None:
             profile.orcid_id = profile_old.orcid_id
         if profile.webpage is None:
             profile.webpage = profile_old.webpage
+        if profile_old.has_active_contributor and not profile.has_active_contributor:
+            profile.contributor = profile_old.contributor
         profile.save()  # Save all the field updates.
 
         profile.topics.add(*profile_old.topics.all())
@@ -128,13 +131,10 @@ class ProfileMergeForm(forms.Form):
         if hasattr(profile_old, 'unregisteredauthor') and profile_old.unregisteredauthor:
             profile.unregisteredauthor.merge(profile_old.unregisteredauthor)
 
-        # Merge email and Contributor information
+        # Merge email
         profile_old.emails.exclude(
             email__in=profile.emails.values_list('email', flat=True)).update(
             primary=False, profile=profile)
-        if hasattr(profile_old, 'contributor') and profile_old.contributor:
-            profile.contributor = profile_old.contributor
-            profile.contributor.save()
 
         # Move all invitations to the "new" profile.
         profile_old.refereeinvitation_set.all().update(profile=profile)
diff --git a/profiles/models.py b/profiles/models.py
index f840a6ca0..3d1d9d69b 100644
--- a/profiles/models.py
+++ b/profiles/models.py
@@ -77,13 +77,14 @@ class Profile(models.Model):
         return getattr(self.emails.filter(primary=True).first(), 'email', '')
 
     @property
-    def has_contributor(self):
-        has_contributor = False
+    def has_active_contributor(self):
+        has_active_contributor = False
         try:
-            has_contributor = (self.contributor is not None)
+            has_active_contributor = (self.contributor is not None and
+                                      self.contributor.is_active)
         except Contributor.DoesNotExist:
             pass
-        return has_contributor
+        return has_active_contributor
 
     def get_absolute_url(self):
         return reverse('profiles:profile_detail', kwargs={'pk': self.id})
diff --git a/profiles/templates/profiles/profile_merge.html b/profiles/templates/profiles/profile_merge.html
index b58a65b3c..20dd5aea6 100644
--- a/profiles/templates/profiles/profile_merge.html
+++ b/profiles/templates/profiles/profile_merge.html
@@ -16,7 +16,7 @@
 <div class="row">
   <div class="col-12">
     <h1 class="highlight">Merge Profiles {{ profile_to_merge.id }} and {{ profile_to_merge_into.id }}</h1>
-    {% if profile_to_merge.contributor and profile_to_merge.contributor.user.is_active and profile_to_merge_into.contributor and not profile_to_merge_into.contributor.user.is_active %}
+    {% if profile_to_merge.has_active_contributor and not profile_to_merge_into.has_active_contributor %}
     <h3 class="text-danger">Warning: the Profile to merge is associated to an active Contributor, while the one to merge into is not</h3>
     <p>Consider <a href="{% url 'profiles:merge' %}?to_merge={{ profile_to_merge_into.id }}&to_merge_into={{ profile_to_merge.id }}" method="get">merging the other way around</a></p>
     {% endif %}
diff --git a/scipost/forms.py b/scipost/forms.py
index 6c9e89a65..0528e0312 100644
--- a/scipost/forms.py
+++ b/scipost/forms.py
@@ -41,6 +41,7 @@ from commentaries.models import Commentary
 from comments.models import Comment
 from funders.models import Grant
 from journals.models import PublicationAuthorsTable, Publication
+from mails.utils import DirectMailUtil
 from submissions.models import Submission, EditorialAssignment, RefereeInvitation, Report, \
     EditorialCommunication, EICRecommendation
 from theses.models import ThesisLink
@@ -439,20 +440,31 @@ class ContributorMergeForm(forms.Form):
         contrib_from = self.cleaned_data['to_merge']
         contrib_into = self.cleaned_data['to_merge_into']
 
+        both_contribs_active = contrib_from.is_active and contrib_info.is_active
+
         contrib_from_qs = Contributor.objects.filter(pk=contrib_from.id)
         contrib_into_qs = Contributor.objects.filter(pk=contrib_into.id)
 
         # Step 1: update all fields within Contributor
         if contrib_from.profile and not contrib_into.profile:
-            contrib_into_qs.update(profile=contrib_from.profile)
+            profile = contrib_from.profile
+            contrib_from_qs.update(profile=None)
+            contrib_into_qs.update(profile=profile)
         User.objects.filter(pk=contrib_from.user.id).update(is_active=False)
         User.objects.filter(pk=contrib_into.user.id).update(is_active=True)
+        if contrib_from.invitation_key and not contrib_into.invitation_key:
+            contrib_into_qs.update(invitation_key=contrib_into.invitation_key)
+        if contrib_from.activation_key and not contrib_into.activation_key:
+            contrib_into_qs.update(activation_key=contrib_into.activation_key)
         contrib_from_qs.update(status=DOUBLE_ACCOUNT)
         if contrib_from.orcid_id and not contrib_into.orcid_id:
             contrib_into_qs.update(orcid_id=contrib_from.orcid_id)
         if contrib_from.personalwebpage and not contrib_into.personalwebpage:
             contrib_into_qs.update(personalwebpage=contrib_from.personalwebpage)
 
+        # Specify duplicate_of for deactivated Contributor
+        contrib_from_qs.update(duplicate_of=contrib_into)
+
         # Step 2: update all ForeignKey relations
         Affiliation.objects.filter(contributor=contrib_from).update(contributor=contrib_into)
         Fellowship.objects.filter(contributor=contrib_from).update(contributor=contrib_into)
@@ -597,6 +609,12 @@ class ContributorMergeForm(forms.Form):
         for nom in motions:
             nom.in_disagreement.remove(contrib_from)
             nom.in_disagreement.add(contrib_into)
+        # If both accounts were active, inform the Contributor of the merge
+        if both_contribs_active or True:
+            mail_sender = DirectMailUtil(
+                mail_code='contributors/inform_contributor_duplicate_accounts_merged',
+                contrib_from=contrib_from)
+            mail_sender.send()
         return Contributor.objects.get(id=contrib_into.id)
 
 
diff --git a/scipost/migrations/0018_contributor_duplicate_of.py b/scipost/migrations/0018_contributor_duplicate_of.py
new file mode 100644
index 000000000..cbaa585ff
--- /dev/null
+++ b/scipost/migrations/0018_contributor_duplicate_of.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2018-11-17 17:01
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('scipost', '0017_auto_20181115_2150'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='contributor',
+            name='duplicate_of',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='duplicates', to='scipost.Contributor'),
+        ),
+    ]
diff --git a/scipost/models.py b/scipost/models.py
index d55050e0b..960c721d2 100644
--- a/scipost/models.py
+++ b/scipost/models.py
@@ -16,7 +16,7 @@ from django.utils import timezone
 
 from .behaviors import TimeStampedModel, orcid_validator
 from .constants import (
-    SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, subject_areas_dict, DISABLED,
+    SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, subject_areas_dict, NORMAL_CONTRIBUTOR, DISABLED,
     TITLE_CHOICES, INVITATION_STYLE, INVITATION_TYPE, INVITATION_CONTRIBUTOR, INVITATION_FORMAL,
     AUTHORSHIP_CLAIM_PENDING, AUTHORSHIP_CLAIM_STATUS, CONTRIBUTOR_STATUSES, NEWLY_REGISTERED)
 from .fields import ChoiceArrayField
@@ -63,6 +63,9 @@ class Contributor(models.Model):
                                   related_name="contrib_vetted_by", blank=True, null=True)
     accepts_SciPost_emails = models.BooleanField(
         default=True, verbose_name="I accept to receive SciPost emails")
+    # If this Contributor is merged into another, then this field is set to point to the new one:
+    duplicate_of = models.ForeignKey('scipost.Contributor', on_delete=models.SET_NULL,
+                                     null=True, blank=True, related_name='duplicates')
 
     objects = ContributorQuerySet.as_manager()
 
@@ -82,6 +85,14 @@ class Contributor(models.Model):
         """Return public information page url."""
         return reverse('scipost:contributor_info', args=(self.id,))
 
+    @property
+    def is_active(self):
+        """
+        Checks if the Contributor is registered, vetted,
+        and has not been deactivated for any reason.
+        """
+        return self.user.is_active and self.status == NORMAL_CONTRIBUTOR
+
     @property
     def is_currently_available(self):
         """Check if Contributor is currently not marked as unavailable."""
diff --git a/scipost/views.py b/scipost/views.py
index f6f07c3e8..53ad7d488 100644
--- a/scipost/views.py
+++ b/scipost/views.py
@@ -964,6 +964,10 @@ def contributor_info(request, contributor_id):
 class ContributorDuplicateListView(PermissionsMixin, PaginationMixin, ListView):
     """
     List Contributors with potential (not yet handled) duplicates.
+    Two sources of duplicates are separately considered:
+    - duplicate full names (last name + first name)
+    - duplicate email addresses.
+
     """
     permission_required = 'scipost.can_vet_registration_requests'
     model = Contributor
@@ -996,10 +1000,13 @@ class ContributorDuplicateListView(PermissionsMixin, PaginationMixin, ListView):
 def contributor_merge(request):
     """
     Handles the merging of data from one Contributor instance to another,
-    to solve multiple registration issues.
+    to solve one person - multiple registrations issues.
+
+    Both instances are preserved, but the merge_from instance's
+    status is set to DOUBLE_ACCOUNT and its User is set to inactive.
 
-    Both instances are preserved, but the merge_from instance is set to inactive,
-    and its status is set to DOUBLE_ACCOUNT.
+    If both Contributor instances were active, then the account owner
+    is emailed with information about the merge.
     """
     merge_form = ContributorMergeForm(request.POST or None, initial=request.GET)
     context = {'merge_form': merge_form}
diff --git a/templates/email/contributors/inform_contributor_duplicate_accounts_merged.html b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.html
new file mode 100644
index 000000000..fe79c1490
--- /dev/null
+++ b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.html
@@ -0,0 +1,18 @@
+<p>
+  Dear {{ contrib_from.duplicate_of.get_title_display }} {{ contrib_from.duplicate_of.user.last_name }},
+</p>
+<p>
+  We noticed that you had two separate registrations at SciPost, and have consolidated your two accounts into a single active one, namely your account with username <strong>{{ contrib_from.duplicate_of.user.username }}</strong>.
+</p>
+<p>
+  Your alternate account with username <strong>{{ contrib_from.user.username }}</strong> has been deactivated, but all the data associated to it has been transferred to your active account.
+</p>
+<p>
+  Please get in touch with us at <a href="mailto:techsupport@scipost.org">SciPost techsupport</a> if you have any questions.
+</p>
+<p>
+  Many thanks,
+  <br><br>
+  The SciPost Team
+</p>
+{% include 'email/_footer.html' %}
diff --git a/templates/email/contributors/inform_contributor_duplicate_accounts_merged.json b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.json
new file mode 100644
index 000000000..f7987957d
--- /dev/null
+++ b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.json
@@ -0,0 +1,8 @@
+{
+    "subject": "SciPost: duplicate accounts merged",
+    "to_address": "user.email",
+    "bcc_to": "admin@scipost.org",
+    "from_address_name": "SciPost Admin",
+    "from_address": "admin@scipost.org",
+    "context_object": "contrib_from"
+}
-- 
GitLab