Skip to content

Commit 439fb3e

Browse files
olewoyjanno42niklasmohrinrichardebeling
authored
Merge suggestions (#1968)
* Merge suggestions * Add some examples to test data --------- Co-authored-by: Johannes Wolf <[email protected]> Co-authored-by: Niklas Mohrin <[email protected]> Co-authored-by: Richard Ebeling <[email protected]>
1 parent d220638 commit 439fb3e

File tree

5 files changed

+179
-19
lines changed

5 files changed

+179
-19
lines changed

evap/development/fixtures/test_data.json

+76-4
Original file line numberDiff line numberDiff line change
@@ -132980,7 +132980,7 @@
132980132980
"title": "",
132981132981
"first_name_given": "",
132982132982
"first_name_chosen": "",
132983-
"last_name": "",
132983+
"last_name": "reviewer",
132984132984
"language": "",
132985132985
"is_proxy_user": false,
132986132986
"login_key": null,
@@ -133008,7 +133008,7 @@
133008133008
"title": "",
133009133009
"first_name_given": "",
133010133010
"first_name_chosen": "",
133011-
"last_name": "",
133011+
"last_name": "proxy",
133012133012
"language": "",
133013133013
"is_proxy_user": true,
133014133014
"login_key": null,
@@ -133043,7 +133043,7 @@
133043133043
"title": "",
133044133044
"first_name_given": "",
133045133045
"first_name_chosen": "",
133046-
"last_name": "",
133046+
"last_name": "proxy_delegate",
133047133047
"language": "",
133048133048
"is_proxy_user": false,
133049133049
"login_key": null,
@@ -133071,7 +133071,79 @@
133071133071
"title": "",
133072133072
"first_name_given": "",
133073133073
"first_name_chosen": "",
133074-
"last_name": "",
133074+
"last_name": "proxy_delegate_2",
133075+
"language": "",
133076+
"is_proxy_user": false,
133077+
"login_key": null,
133078+
"login_key_valid_until": null,
133079+
"is_active": true,
133080+
"notes": "",
133081+
"startpage": "DE",
133082+
"groups": [],
133083+
"user_permissions": [],
133084+
"delegates": [],
133085+
"cc_users": []
133086+
}
133087+
},
133088+
{
133089+
"model": "evaluation.userprofile",
133090+
"fields": {
133091+
"password": "eZAyFmtqHydCIFtGdbevAxiVjiRpqMtmaVUCrmkcfXdoJDigmGWPVNHeoYYyRojokKUJjsgPSPvZkjiiIHSIQlBfOKtQFDbZlPEyKnrQRrHdPtEhUYHqJauIlyIkYpBM",
133092+
"last_login": null,
133093+
"is_superuser": false,
133094+
"email": "[email protected]",
133095+
"title": "",
133096+
"first_name_given": "Vincenzo Alfredo",
133097+
"first_name_chosen": "",
133098+
"last_name": "Boston",
133099+
"language": "",
133100+
"is_proxy_user": false,
133101+
"login_key": null,
133102+
"login_key_valid_until": null,
133103+
"is_active": true,
133104+
"notes": "",
133105+
"startpage": "DE",
133106+
"groups": [],
133107+
"user_permissions": [],
133108+
"delegates": [],
133109+
"cc_users": []
133110+
}
133111+
},
133112+
{
133113+
"model": "evaluation.userprofile",
133114+
"fields": {
133115+
"password": "utAhMBbTpirVqtaoPpadEHdamaehnXWbEsliMMSnwDBYJcTnHluinAxkTeEupPoBzpuDBMYeXbpwmockMtQNYegbMuxkUBEBKqWGkOEFAWxzUFjdxevtIwYzvAgHCAwD",
133116+
"last_login": null,
133117+
"is_superuser": false,
133118+
"email": "[email protected]",
133119+
"title": "",
133120+
"first_name_given": "Bud",
133121+
"first_name_chosen": "",
133122+
"last_name": "LedBetter",
133123+
"language": "",
133124+
"is_proxy_user": false,
133125+
"login_key": null,
133126+
"login_key_valid_until": null,
133127+
"is_active": true,
133128+
"notes": "",
133129+
"startpage": "DE",
133130+
"groups": [],
133131+
"user_permissions": [],
133132+
"delegates": [],
133133+
"cc_users": []
133134+
}
133135+
},
133136+
{
133137+
"model": "evaluation.userprofile",
133138+
"fields": {
133139+
"password": "naFmzOVrFhXrVVLsIGFYceDAarTGwDRFZKGJwBvKhNFCpupezBrwhorUHsyQSpUxLFKSQuOurcIyoBBYRjARXjzcJCbqYRiKRMOwvdTqwNjAbYDhUKbopBPDYhANXUkI",
133140+
"last_login": null,
133141+
"is_superuser": false,
133142+
"email": "[email protected]",
133143+
"title": "",
133144+
"first_name_given": "Melody",
133145+
"first_name_chosen": "",
133146+
"last_name": "Large",
133075133147
"language": "",
133076133148
"is_proxy_user": false,
133077133149
"login_key": null,

evap/settings.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767

6868
# email domains for the internal users of the hosting institution used to
6969
# figure out who is an internal user
70-
INSTITUTION_EMAIL_DOMAINS = ["institution.example.com"]
70+
INSTITUTION_EMAIL_DOMAINS = ["institution.example.com", "student.institution.example.com"]
7171

7272
# List of tuples defining email domains that should be replaced on saving UserProfiles.
7373
# Emails ending on the first value will have this part replaced by the second value.

evap/staff/templates/staff_user_merge_selection.html

+42-12
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,50 @@
88

99
{% block content %}
1010
{{ block.super }}
11-
<h3>{% trans 'Merge users' %}</h3>
12-
13-
<form id="user-selection-form" method="POST" class="form-horizontal">
14-
{% csrf_token %}
15-
<div class="card mb-3">
16-
<div class="card-body">
17-
<p>{% trans 'Select the users you want to merge.' %}</p>
18-
{% include 'bootstrap_form.html' with form=form wide=True %}
11+
<div class="row row-cols-1 row-cols-xl-2">
12+
<div class="col">
13+
<div class="card">
14+
<div class="card-body">
15+
<h4 class="card-title">{% trans 'Merge users' %}</h4>
16+
<form id="user-selection-form" method="POST" class="form-horizontal">
17+
{% csrf_token %}
18+
<p>{% trans 'Select the users you want to merge.' %}</p>
19+
{% include 'bootstrap_form.html' with form=form wide=True %}
20+
</form>
21+
</div>
22+
<div class="card-footer text-center card-submit-area d-flex">
23+
<button type="submit" form="user-selection-form" class="btn btn-light mx-auto my-auto">{% trans 'Show merge info' %}</button>
24+
</div>
1925
</div>
2026
</div>
21-
<div class="card card-submit-area text-center mb-3">
22-
<div class="card-body">
23-
<button type="submit" class="btn btn-primary">{% trans 'Show merge info' %}</button>
27+
<div class="col">
28+
<div class="card">
29+
<div class="card-body">
30+
<h4 class="card-title">{% trans 'Merge suggestions' %}</h4>
31+
<table class="table table-striped table-narrow table-vertically-aligned">
32+
<tbody>
33+
{% for main_user, merge_candidate in suggested_merges %}
34+
<tr>
35+
<td>
36+
<b>{{ main_user.full_name }}</b> <br />
37+
<b>{{ merge_candidate.full_name }}</b>
38+
</td>
39+
<td>
40+
{{ main_user.email }} <br />
41+
{{ merge_candidate.email }}
42+
</td>
43+
<td class="text-end">
44+
<a href="{% url 'staff:user_merge' main_user.id merge_candidate.id %}" class="btn btn-sm btn-light" data-bs-toggle="tooltip" data-bs-placement="top" title="{% trans 'Show merge info' %}">
45+
<span class="fas fa-object-group"></span>
46+
</a>
47+
</td>
48+
</tr>
49+
{% endfor %}
50+
</tbody>
51+
</table>
52+
</div>
2453
</div>
2554
</div>
26-
</form>
55+
</div>
56+
2757
{% endblock %}

evap/staff/tests/test_views.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ def get_post_params(cls):
339339

340340

341341
class TestUserMergeSelectionView(WebTestStaffMode):
342-
url = "/staff/user/merge"
342+
url = reverse("staff:user_merge_selection")
343343

344344
@classmethod
345345
def setUpTestData(cls):
@@ -348,6 +348,10 @@ def setUpTestData(cls):
348348
cls.main_user = baker.make(UserProfile, _fill_optional=["email"])
349349
cls.other_user = baker.make(UserProfile, _fill_optional=["email"])
350350

351+
# The merge candidate is created first, so the account is older.
352+
cls.suggested_merge_candidate = baker.make(UserProfile, email="[email protected]")
353+
cls.suggested_main_user = baker.make(UserProfile, email="[email protected]")
354+
351355
def test_redirection_user_merge_view(self):
352356
page = self.app.get(self.url, user=self.manager)
353357

@@ -360,6 +364,19 @@ def test_redirection_user_merge_view(self):
360364
self.assertContains(page, self.main_user.email)
361365
self.assertContains(page, self.other_user.email)
362366

367+
def test_suggested_merge(self):
368+
page = self.app.get(self.url, user=self.manager)
369+
370+
expected_url = reverse(
371+
"staff:user_merge", args=[self.suggested_main_user.id, self.suggested_merge_candidate.id]
372+
)
373+
unexpected_url = reverse(
374+
"staff:user_merge", args=[self.suggested_merge_candidate.id, self.suggested_main_user.id]
375+
)
376+
377+
self.assertContains(page, f'<a href="{expected_url}"')
378+
self.assertNotContains(page, f'<a href="{unexpected_url}"')
379+
363380

364381
class TestUserMergeView(WebTestStaffModeWith200Check):
365382
@classmethod

evap/staff/views.py

+42-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,19 @@
1212
from django.contrib.messages.views import SuccessMessageMixin
1313
from django.core.exceptions import PermissionDenied, SuspiciousOperation
1414
from django.db import IntegrityError, transaction
15-
from django.db.models import BooleanField, Case, Count, ExpressionWrapper, IntegerField, Prefetch, Q, Sum, When
15+
from django.db.models import (
16+
BooleanField,
17+
Case,
18+
Count,
19+
ExpressionWrapper,
20+
Func,
21+
IntegerField,
22+
OuterRef,
23+
Prefetch,
24+
Q,
25+
Sum,
26+
When,
27+
)
1628
from django.dispatch import receiver
1729
from django.forms import BaseForm, formset_factory
1830
from django.forms.models import inlineformset_factory, modelformset_factory
@@ -2294,6 +2306,35 @@ class UserMergeSelectionView(FormView):
22942306
form_class = UserMergeSelectionForm
22952307
template_name = "staff_user_merge_selection.html"
22962308

2309+
def get_context_data(self, **kwargs) -> dict[str, Any]:
2310+
context = super().get_context_data(**kwargs)
2311+
2312+
class UserNameFromEmail(Func):
2313+
# django docs support our usage here:
2314+
# https://docs.djangoproject.com/en/5.0/ref/models/expressions/#func-expressions
2315+
# pylint: disable=abstract-method
2316+
template = "split_part(%(expressions)s, '@', 1)"
2317+
2318+
query = UserProfile.objects.annotate(username_part_of_email=UserNameFromEmail("email"))
2319+
2320+
users_with_merge_candidates = query.annotate(
2321+
merge_candidate_pk=query.filter(username_part_of_email=UserNameFromEmail(OuterRef("email")))
2322+
.filter(pk__lt=OuterRef("pk"))
2323+
.values("pk")[:1]
2324+
).exclude(merge_candidate_pk=None)
2325+
2326+
merge_candidate_ids = [user.merge_candidate_pk for user in users_with_merge_candidates]
2327+
merge_candidates_by_id = {user.pk: user for user in UserProfile.objects.filter(pk__in=merge_candidate_ids)}
2328+
2329+
suggested_merges = [
2330+
(user, merge_candidates_by_id[user.merge_candidate_pk])
2331+
for user in users_with_merge_candidates
2332+
if not user.is_external and not merge_candidates_by_id[user.merge_candidate_pk].is_external
2333+
]
2334+
2335+
context["suggested_merges"] = suggested_merges
2336+
return context
2337+
22972338
def form_valid(self, form: UserMergeSelectionForm) -> HttpResponse:
22982339
return redirect(
22992340
"staff:user_merge",

0 commit comments

Comments
 (0)