Repository: comic/grand-challenge.org

Issue:
Improve the user search and selection form
In #1684 the user selection for groups was consolidated, so now it would be good to improve it. The form uses a ModelSelect2 widget (https://github.com/comic/grand-challenge.org/blob/master/app/grandchallenge/groups/forms.py#L19) that calls an autocomplete view that only allows filtering by username (https://github.com/comic/grand-challenge.org/blob/master/app/grandchallenge/groups/views.py#L67). 

Top priority is to extend the filter:
- Switch to filtering with `icontains` rather than `istartswith`
- Allow filtering on the users username, email address, full name, OR verification email address. To do this use Django `Q` objects (https://docs.djangoproject.com/en/3.1/topics/db/queries/#complex-lookups-with-q-objects).

Next, is to improve the look of the widget, at the moment you just get a string of the username:
![image](https://user-images.githubusercontent.com/12661555/105184656-e1834880-5b2f-11eb-9ac7-36c9e2fcff22.png)

On github it's much nicer
![image](https://user-images.githubusercontent.com/12661555/105184861-214a3000-5b30-11eb-9fe5-24aa1c4613a4.png)

Extend this to
- Include the users full name in the string
- Show whether the user is verified or not in the string, and if so, what is the domain of their verification address (see https://github.com/comic/grand-challenge.org/blob/master/app/grandchallenge/profiles/templatetags/profiles.py#L16)
- Stretch goal: Show the users mugshot



Discussion:

Resolving patch:
diff --git a/app/grandchallenge/groups/forms.py b/app/grandchallenge/groups/forms.py
--- a/app/grandchallenge/groups/forms.py
+++ b/app/grandchallenge/groups/forms.py
@@ -22,6 +22,7 @@ class UserGroupForm(SaveFormInitMixin, Form):
                 "data-placeholder": "Search for a user ...",
                 "data-minimum-input-length": 3,
                 "data-theme": settings.CRISPY_TEMPLATE_PACK,
+                "data-html": True,
             },
         ),
     )
diff --git a/app/grandchallenge/groups/views.py b/app/grandchallenge/groups/views.py
--- a/app/grandchallenge/groups/views.py
+++ b/app/grandchallenge/groups/views.py
@@ -3,6 +3,9 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth.mixins import UserPassesTestMixin
 from django.contrib.messages.views import SuccessMessageMixin
+from django.db.models import CharField, Q, Value
+from django.db.models.functions import Concat
+from django.utils.html import format_html
 from django.views.generic import FormView
 from guardian.mixins import (
     LoginRequiredMixin,
@@ -10,6 +13,8 @@
 )
 from guardian.shortcuts import get_objects_for_user
 
+from grandchallenge.verifications.models import Verification
+
 
 class UserGroupUpdateMixin(
     LoginRequiredMixin,
@@ -51,6 +56,7 @@ def test_func(self):
             "workstations.change_workstation",
             "algorithms.change_job",
         ]
+        # TODO reduce number of queries
         return any(
             get_objects_for_user(user=self.request.user, perms=perm,).exists()
             for perm in allowed_perms
@@ -61,9 +67,50 @@ def get_queryset(self):
             get_user_model()
             .objects.order_by("username")
             .exclude(username=settings.ANONYMOUS_USER_NAME)
+            .annotate(
+                full_name=Concat(
+                    "first_name",
+                    Value(" "),
+                    "last_name",
+                    output_field=CharField(),
+                )
+            )
+            .select_related("verification", "user_profile")
         )
 
         if self.q:
-            qs = qs.filter(username__istartswith=self.q)
+            qs = qs.filter(
+                Q(username__icontains=self.q)
+                | Q(email__icontains=self.q)
+                | Q(full_name__icontains=self.q)
+                | Q(verification__email__icontains=self.q)
+            )
 
         return qs
+
+    def get_result_label(self, result):
+
+        try:
+            is_verified = result.verification.is_verified
+        except Verification.DoesNotExist:
+            is_verified = False
+
+        if is_verified:
+            return format_html(
+                '<img src="{}" width ="20" height ="20" style="vertical-align:top"> '
+                "&nbsp; <b>{}</b> &nbsp; {} &nbsp;"
+                '<i class="fas fa-user-check text-success" '
+                'title="Verified email address at {}">',
+                result.user_profile.get_mugshot_url(),
+                result.get_username(),
+                result.get_full_name().title(),
+                result.verification.email.split("@")[1],
+            )
+        else:
+            return format_html(
+                '<img src="{}" width ="20" height ="20" style="vertical-align:top"> '
+                "&nbsp; <b>{}</b> &nbsp; {}",
+                result.user_profile.get_mugshot_url(),
+                result.get_username(),
+                result.get_full_name().title(),
+            )


Test patch:
diff --git a/app/tests/groups_tests/test_views.py b/app/tests/groups_tests/test_views.py
--- a/app/tests/groups_tests/test_views.py
+++ b/app/tests/groups_tests/test_views.py
@@ -1,11 +1,20 @@
 import pytest
+from django.core.management import call_command
+from lxml.html.diff import html_escape
+from userena.models import UserenaSignup
 
+from grandchallenge.subdomains.utils import reverse
 from tests.algorithms_tests.factories import AlgorithmFactory
 from tests.archives_tests.factories import ArchiveFactory
-from tests.factories import UserFactory, WorkstationFactory
+from tests.factories import (
+    SUPER_SECURE_TEST_PASSWORD,
+    UserFactory,
+    WorkstationFactory,
+)
 from tests.organizations_tests.factories import OrganizationFactory
 from tests.reader_studies_tests.factories import ReaderStudyFactory
-from tests.utils import get_view_for_user
+from tests.utils import get_http_host, get_view_for_user
+from tests.verification_tests.factories import VerificationFactory
 
 
 @pytest.mark.django_db
@@ -52,13 +61,13 @@ def get_user_autocomplete():
 
         response = get_user_autocomplete()
         assert response.status_code == 200
-        assert response.json()["results"] == [
-            {
-                "id": str(u.pk),
-                "text": str(u.username),
-                "selected_text": str(u.username),
-            }
-        ]
+        assert str(u.pk) in response.json()["results"][0]["id"]
+        assert (
+            html_escape(str(u.user_profile.get_mugshot_url()))
+            in response.json()["results"][0]["text"]
+        )
+        assert u.username in response.json()["results"][0]["text"]
+        assert u.get_full_name() in response.json()["results"][0]["text"]
 
         response = get_view_for_user(
             client=client,
@@ -81,3 +90,106 @@ def get_user_autocomplete():
         )
         assert response.status_code == 302
         assert not group.user_set.filter(pk=u.pk).exists()
+
+
+@pytest.mark.django_db
+class TestAutocompleteViews:
+    @pytest.mark.parametrize(
+        "is_verified", (False, True),
+    )
+    @pytest.mark.parametrize(
+        "filter", (("username"), ("email"), ("full_name"),),
+    )
+    def test_autocomplete_filter_options(self, client, filter, is_verified):
+        archive = ArchiveFactory()
+
+        admin = UserFactory()
+        archive.add_editor(admin)
+        first_name = "Jane"
+        last_name = "Doe"
+
+        if is_verified:
+            call_command("check_permissions")
+            u = UserenaSignup.objects.create_user(
+                "userena", "userena@test.com", "testpassword", active=True
+            )
+            VerificationFactory(user=u, is_verified=True)
+            u.first_name = first_name
+            u.last_name = last_name
+            u.save()
+
+        else:
+            u = UserFactory(first_name=first_name, last_name=last_name)
+
+        u.full_name = u.get_full_name().title()
+        filter_criterion = getattr(u, filter)
+
+        def get_user_autocomplete():
+            return get_view_for_user(
+                client=client,
+                viewname="users-autocomplete",
+                user=admin,
+                data={"q": filter_criterion},
+            )
+
+        response = get_user_autocomplete()
+        assert response.status_code == 200
+
+        assert str(u.pk) in response.json()["results"][0]["id"]
+        assert (
+            html_escape(str(u.user_profile.get_mugshot_url()))
+            in response.json()["results"][0]["text"]
+        )
+        assert u.username in response.json()["results"][0]["text"]
+        assert u.get_full_name() in response.json()["results"][0]["text"]
+        if is_verified:
+            assert (
+                u.verification.email.split("@")[1]
+                in response.json()["results"][0]["text"]
+            )
+
+    def test_autocomplete_for_verified_email(self, client):
+        archive = ArchiveFactory()
+        admin = UserFactory()
+        archive.add_editor(admin)
+
+        call_command("check_permissions")
+        user = UserenaSignup.objects.create_user(
+            "userena", "userena@test.com", "testpassword", active=True
+        )
+        VerificationFactory(user=user, is_verified=True)
+
+        response = get_view_for_user(
+            client=client,
+            viewname="users-autocomplete",
+            user=admin,
+            data={"q": user.verification.email},
+        )
+        assert response.status_code == 200
+
+        assert str(user.pk) in response.json()["results"][0]["id"]
+
+    def test_autocomplete_num_queries(self, client, django_assert_num_queries):
+
+        archive = ArchiveFactory()
+        admin = UserFactory()
+        archive.add_editor(admin)
+
+        user = UserFactory()
+
+        url = reverse("users-autocomplete", kwargs={})
+
+        client.login(
+            username=user.username, password=SUPER_SECURE_TEST_PASSWORD
+        )
+
+        method = client.get
+
+        url, kwargs = get_http_host(
+            url=url, kwargs={"data": {"q": user.username}}
+        )
+        try:
+            with django_assert_num_queries(20) as _:
+                method(url, **kwargs)
+        finally:
+            client.logout()