Repository: e-valuation/EvaP

Issue:
User profile merge suggestions
On the user merge page, two user profiles can be selected and then be merged. The view should be extended by a second part (placed in a second card on the right, the two cards should be placed like those on the user profile index page) which lists user profiles that should probably be merged.

The suggestion list should show all user profile pairs where
- both users are internal and
- the users' email addresses first part (before the `@`) matches.

For each of the entries, a button should be shown which links to the user profile merge preview page of the suggested merge of the two user profiles. The main user profile to be kept is the one with the higher ID.


Discussion:

Resolving patch:
diff --git a/evap/settings.py b/evap/settings.py
--- a/evap/settings.py
+++ b/evap/settings.py
@@ -67,7 +67,7 @@
 
 # email domains for the internal users of the hosting institution used to
 # figure out who is an internal user
-INSTITUTION_EMAIL_DOMAINS = ["institution.example.com"]
+INSTITUTION_EMAIL_DOMAINS = ["institution.example.com", "student.institution.example.com"]
 
 # List of tuples defining email domains that should be replaced on saving UserProfiles.
 # Emails ending on the first value will have this part replaced by the second value.
diff --git a/evap/staff/views.py b/evap/staff/views.py
--- a/evap/staff/views.py
+++ b/evap/staff/views.py
@@ -12,7 +12,19 @@
 from django.contrib.messages.views import SuccessMessageMixin
 from django.core.exceptions import PermissionDenied, SuspiciousOperation
 from django.db import IntegrityError, transaction
-from django.db.models import BooleanField, Case, Count, ExpressionWrapper, IntegerField, Prefetch, Q, Sum, When
+from django.db.models import (
+    BooleanField,
+    Case,
+    Count,
+    ExpressionWrapper,
+    Func,
+    IntegerField,
+    OuterRef,
+    Prefetch,
+    Q,
+    Sum,
+    When,
+)
 from django.dispatch import receiver
 from django.forms import BaseForm, formset_factory
 from django.forms.models import inlineformset_factory, modelformset_factory
@@ -2294,6 +2306,35 @@ class UserMergeSelectionView(FormView):
     form_class = UserMergeSelectionForm
     template_name = "staff_user_merge_selection.html"
 
+    def get_context_data(self, **kwargs) -> dict[str, Any]:
+        context = super().get_context_data(**kwargs)
+
+        class UserNameFromEmail(Func):
+            # django docs support our usage here:
+            # https://docs.djangoproject.com/en/5.0/ref/models/expressions/#func-expressions
+            # pylint: disable=abstract-method
+            template = "split_part(%(expressions)s, '@', 1)"
+
+        query = UserProfile.objects.annotate(username_part_of_email=UserNameFromEmail("email"))
+
+        users_with_merge_candidates = query.annotate(
+            merge_candidate_pk=query.filter(username_part_of_email=UserNameFromEmail(OuterRef("email")))
+            .filter(pk__lt=OuterRef("pk"))
+            .values("pk")[:1]
+        ).exclude(merge_candidate_pk=None)
+
+        merge_candidate_ids = [user.merge_candidate_pk for user in users_with_merge_candidates]
+        merge_candidates_by_id = {user.pk: user for user in UserProfile.objects.filter(pk__in=merge_candidate_ids)}
+
+        suggested_merges = [
+            (user, merge_candidates_by_id[user.merge_candidate_pk])
+            for user in users_with_merge_candidates
+            if not user.is_external and not merge_candidates_by_id[user.merge_candidate_pk].is_external
+        ]
+
+        context["suggested_merges"] = suggested_merges
+        return context
+
     def form_valid(self, form: UserMergeSelectionForm) -> HttpResponse:
         return redirect(
             "staff:user_merge",


Test patch:
diff --git a/evap/development/fixtures/test_data.json b/evap/development/fixtures/test_data.json
--- a/evap/development/fixtures/test_data.json
+++ b/evap/development/fixtures/test_data.json
@@ -132980,7 +132980,7 @@
     "title": "",
     "first_name_given": "",
     "first_name_chosen": "",
-    "last_name": "",
+    "last_name": "reviewer",
     "language": "",
     "is_proxy_user": false,
     "login_key": null,
@@ -133008,7 +133008,7 @@
     "title": "",
     "first_name_given": "",
     "first_name_chosen": "",
-    "last_name": "",
+    "last_name": "proxy",
     "language": "",
     "is_proxy_user": true,
     "login_key": null,
@@ -133043,7 +133043,7 @@
     "title": "",
     "first_name_given": "",
     "first_name_chosen": "",
-    "last_name": "",
+    "last_name": "proxy_delegate",
     "language": "",
     "is_proxy_user": false,
     "login_key": null,
@@ -133071,7 +133071,79 @@
     "title": "",
     "first_name_given": "",
     "first_name_chosen": "",
-    "last_name": "",
+    "last_name": "proxy_delegate_2",
+    "language": "",
+    "is_proxy_user": false,
+    "login_key": null,
+    "login_key_valid_until": null,
+    "is_active": true,
+    "notes": "",
+    "startpage": "DE",
+    "groups": [],
+    "user_permissions": [],
+    "delegates": [],
+    "cc_users": []
+  }
+},
+{
+  "model": "evaluation.userprofile",
+  "fields": {
+    "password": "eZAyFmtqHydCIFtGdbevAxiVjiRpqMtmaVUCrmkcfXdoJDigmGWPVNHeoYYyRojokKUJjsgPSPvZkjiiIHSIQlBfOKtQFDbZlPEyKnrQRrHdPtEhUYHqJauIlyIkYpBM",
+    "last_login": null,
+    "is_superuser": false,
+    "email": "vincenzo.boston@student.institution.example.com",
+    "title": "",
+    "first_name_given": "Vincenzo Alfredo",
+    "first_name_chosen": "",
+    "last_name": "Boston",
+    "language": "",
+    "is_proxy_user": false,
+    "login_key": null,
+    "login_key_valid_until": null,
+    "is_active": true,
+    "notes": "",
+    "startpage": "DE",
+    "groups": [],
+    "user_permissions": [],
+    "delegates": [],
+    "cc_users": []
+  }
+},
+{
+  "model": "evaluation.userprofile",
+  "fields": {
+    "password": "utAhMBbTpirVqtaoPpadEHdamaehnXWbEsliMMSnwDBYJcTnHluinAxkTeEupPoBzpuDBMYeXbpwmockMtQNYegbMuxkUBEBKqWGkOEFAWxzUFjdxevtIwYzvAgHCAwD",
+    "last_login": null,
+    "is_superuser": false,
+    "email": "bud.ledbetter@student.institution.example.com",
+    "title": "",
+    "first_name_given": "Bud",
+    "first_name_chosen": "",
+    "last_name": "LedBetter",
+    "language": "",
+    "is_proxy_user": false,
+    "login_key": null,
+    "login_key_valid_until": null,
+    "is_active": true,
+    "notes": "",
+    "startpage": "DE",
+    "groups": [],
+    "user_permissions": [],
+    "delegates": [],
+    "cc_users": []
+  }
+},
+{
+  "model": "evaluation.userprofile",
+  "fields": {
+    "password": "naFmzOVrFhXrVVLsIGFYceDAarTGwDRFZKGJwBvKhNFCpupezBrwhorUHsyQSpUxLFKSQuOurcIyoBBYRjARXjzcJCbqYRiKRMOwvdTqwNjAbYDhUKbopBPDYhANXUkI",
+    "last_login": null,
+    "is_superuser": false,
+    "email": "melody.large@student.institution.example.com",
+    "title": "",
+    "first_name_given": "Melody",
+    "first_name_chosen": "",
+    "last_name": "Large",
     "language": "",
     "is_proxy_user": false,
     "login_key": null,
diff --git a/evap/staff/tests/test_views.py b/evap/staff/tests/test_views.py
--- a/evap/staff/tests/test_views.py
+++ b/evap/staff/tests/test_views.py
@@ -339,7 +339,7 @@ def get_post_params(cls):
 
 
 class TestUserMergeSelectionView(WebTestStaffMode):
-    url = "/staff/user/merge"
+    url = reverse("staff:user_merge_selection")
 
     @classmethod
     def setUpTestData(cls):
@@ -348,6 +348,10 @@ def setUpTestData(cls):
         cls.main_user = baker.make(UserProfile, _fill_optional=["email"])
         cls.other_user = baker.make(UserProfile, _fill_optional=["email"])
 
+        # The merge candidate is created first, so the account is older.
+        cls.suggested_merge_candidate = baker.make(UserProfile, email="user@student.institution.example.com")
+        cls.suggested_main_user = baker.make(UserProfile, email="user@institution.example.com")
+
     def test_redirection_user_merge_view(self):
         page = self.app.get(self.url, user=self.manager)
 
@@ -360,6 +364,19 @@ def test_redirection_user_merge_view(self):
         self.assertContains(page, self.main_user.email)
         self.assertContains(page, self.other_user.email)
 
+    def test_suggested_merge(self):
+        page = self.app.get(self.url, user=self.manager)
+
+        expected_url = reverse(
+            "staff:user_merge", args=[self.suggested_main_user.id, self.suggested_merge_candidate.id]
+        )
+        unexpected_url = reverse(
+            "staff:user_merge", args=[self.suggested_merge_candidate.id, self.suggested_main_user.id]
+        )
+
+        self.assertContains(page, f'<a href="{expected_url}"')
+        self.assertNotContains(page, f'<a href="{unexpected_url}"')
+
 
 class TestUserMergeView(WebTestStaffModeWith200Check):
     @classmethod