From 4d96d2c645a2711b523b6e1b5413f3448f18afdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 1 Jul 2020 22:29:07 +0200 Subject: [PATCH] Autocompletion refactoring + generic view --- bda/views.py | 2 +- bds/autocomplete.py | 28 +++++- bds/templates/bds/search_results.html | 90 ++++------------- bds/urls.py | 2 +- bds/views.py | 16 +-- cof/urls.py | 3 +- gestioncof/autocomplete.py | 48 ++++----- .../templates/gestioncof/search_results.html | 75 ++++---------- gestioncof/tests/test_views.py | 26 ++++- gestioncof/views.py | 12 ++- kfet/autocomplete.py | 75 +++++++------- kfet/static/kfet/css/index.css | 4 +- kfet/tests/test_views.py | 19 +++- kfet/urls.py | 6 +- kfet/views.py | 21 ++++ shared/{views => }/autocomplete.py | 98 ++++++++++++++----- shared/templates/shared/search_results.html | 17 ++++ .../shared/search_results_section.html | 25 +++++ shared/templatetags/search_utils.py | 21 +--- shared/tests/mixins.py | 2 +- shared/views.py | 57 +++++++++++ 21 files changed, 379 insertions(+), 268 deletions(-) rename shared/{views => }/autocomplete.py (60%) create mode 100644 shared/templates/shared/search_results.html create mode 100644 shared/templates/shared/search_results_section.html create mode 100644 shared/views.py diff --git a/bda/views.py b/bda/views.py index f799360d..25fa97e2 100644 --- a/bda/views.py +++ b/bda/views.py @@ -42,7 +42,7 @@ from bda.models import ( Tirage, ) from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required -from shared.views.autocomplete import Select2QuerySetView +from shared.views import Select2QuerySetView @cof_required diff --git a/bds/autocomplete.py b/bds/autocomplete.py index 0a240cea..9c9d8e59 100644 --- a/bds/autocomplete.py +++ b/bds/autocomplete.py @@ -1,7 +1,8 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.utils.translation import gettext_lazy as _ -from shared.views import autocomplete +from shared import autocomplete User = get_user_model() @@ -9,28 +10,47 @@ User = get_user_model() class BDSMemberSearch(autocomplete.ModelSearch): model = User search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Membres du BDS") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(bds__is_member=True) return qset_filter + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return "#TODO" + class BDSOthersSearch(autocomplete.ModelSearch): model = User search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Non-membres du BDS") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(bds__isnull=True) | Q(bds__is_member=False) return qset_filter + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return "#TODO" + + +class BDSLDAPSearch(autocomplete.LDAPSearch): + def result_link(self, clipper): + return "#TODO" + class BDSSearch(autocomplete.Compose): search_units = [ - ("members", "username", BDSMemberSearch), - ("others", "username", BDSOthersSearch), - ("clippers", "clipper", autocomplete.LDAPSearch), + ("members", BDSMemberSearch()), + ("others", BDSOthersSearch()), + ("clippers", BDSLDAPSearch()), ] diff --git a/bds/templates/bds/search_results.html b/bds/templates/bds/search_results.html index b8d5e241..4c5471ff 100644 --- a/bds/templates/bds/search_results.html +++ b/bds/templates/bds/search_results.html @@ -1,75 +1,21 @@ +{% extends "shared/search_results.html" %} {% load i18n %} -{% load search_utils %} - +
  • + + {% trans "Créer un compte" %} + +
  • +{% endblock %} diff --git a/bds/urls.py b/bds/urls.py index fbddccc6..b067a18f 100644 --- a/bds/urls.py +++ b/bds/urls.py @@ -5,5 +5,5 @@ from bds import views app_name = "bds" urlpatterns = [ path("", views.Home.as_view(), name="home"), - path("autocomplete", views.AutocompleteView.as_view(), name="autocomplete"), + path("autocomplete", views.BDSAutocompleteView.as_view(), name="autocomplete"), ] diff --git a/bds/views.py b/bds/views.py index a2ba3a2c..c612d3a2 100644 --- a/bds/views.py +++ b/bds/views.py @@ -1,23 +1,13 @@ -from django.http import Http404 from django.views.generic import TemplateView from bds.autocomplete import bds_search from bds.mixins import StaffRequiredMixin +from shared.views import AutocompleteView -class AutocompleteView(StaffRequiredMixin, TemplateView): +class BDSAutocompleteView(StaffRequiredMixin, AutocompleteView): template_name = "bds/search_results.html" - - def get_context_data(self, *args, **kwargs): - ctx = super().get_context_data(*args, **kwargs) - if "q" not in self.request.GET: - raise Http404 - q = self.request.GET["q"] - ctx["q"] = q - results = bds_search.search(q.split()) - ctx.update(results) - ctx["total"] = sum((len(r) for r in results.values())) - return ctx + search_composer = bds_search class Home(StaffRequiredMixin, TemplateView): diff --git a/cof/urls.py b/cof/urls.py index 12cf4f5a..7980a336 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -21,7 +21,6 @@ urlpatterns = [ if "gestioncof" in settings.INSTALLED_APPS: from gestioncof import csv_views, views as gestioncof_views - from gestioncof.autocomplete import autocomplete from gestioncof.urls import ( calendar_patterns, clubs_patterns, @@ -109,7 +108,7 @@ if "gestioncof" in settings.INSTALLED_APPS: # Autocompletion path( "autocomplete/registration", - autocomplete, + gestioncof_views.registration_autocomplete, name="cof.registration.autocomplete", ), path( diff --git a/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index 239317f8..9dae05db 100644 --- a/gestioncof/autocomplete.py +++ b/gestioncof/autocomplete.py @@ -1,10 +1,9 @@ from django.contrib.auth import get_user_model from django.db.models import Q -from django.http import Http404 -from django.views.generic import TemplateView +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ -from gestioncof.decorators import buro_required -from shared.views import autocomplete +from shared import autocomplete User = get_user_model() @@ -12,45 +11,48 @@ User = get_user_model() class COFMemberSearch(autocomplete.ModelSearch): model = User search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Membres du COF") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(profile__is_cof=True) return qset_filter + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return reverse("user-registration", args=(user.username,)) + class COFOthersSearch(autocomplete.ModelSearch): model = User search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Non-membres du COF") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(profile__is_cof=False) return qset_filter + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return reverse("user-registration", args=(user.username,)) + + +class COFLDAPSearch(autocomplete.LDAPSearch): + def result_link(self, clipper): + return reverse("clipper-registration", args=(clipper.clipper, clipper.fullname)) + class COFSearch(autocomplete.Compose): search_units = [ - ("members", "username", COFMemberSearch), - ("others", "username", COFOthersSearch), - ("clippers", "clipper", autocomplete.LDAPSearch), + ("members", COFMemberSearch()), + ("others", COFOthersSearch()), + ("clippers", COFLDAPSearch()), ] cof_search = COFSearch() - - -class AutocompleteView(TemplateView): - template_name = "gestioncof/search_results.html" - - def get_context_data(self, *args, **kwargs): - ctx = super().get_context_data(*args, **kwargs) - if "q" not in self.request.GET: - raise Http404 - q = self.request.GET["q"] - ctx["q"] = q - ctx.update(cof_search.search(q.split())) - return ctx - - -autocomplete = buro_required(AutocompleteView.as_view()) diff --git a/gestioncof/templates/gestioncof/search_results.html b/gestioncof/templates/gestioncof/search_results.html index 126649b6..fdcd36ce 100644 --- a/gestioncof/templates/gestioncof/search_results.html +++ b/gestioncof/templates/gestioncof/search_results.html @@ -1,56 +1,21 @@ -{% load search_utils %} +{% extends "shared/search_results.html" %} +{% load i18n %} - +{% block extra_section %} +
  • + {% if not results %} + + {% trans "Aucune correspondance trouvée" %} + + {% else %} + + {% trans "Pas dans la liste ?" %} + + {% endif %} +
  • +
  • + + {% trans "Créer un compte" %} + +
  • +{% endblock %} diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index 09e86860..0107b6a1 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -16,8 +16,8 @@ from django.urls import reverse from bda.models import Salle, Tirage from gestioncof.models import CalendarSubscription, Club, Event, Survey, SurveyAnswer from gestioncof.tests.mixins import MegaHelperMixin, ViewTestCaseMixin +from shared.autocomplete import Clipper, LDAPSearch from shared.tests.mixins import CSVResponseMixin, ICalMixin, MockLDAPMixin -from shared.views.autocomplete import Clipper from .utils import create_member, create_root, create_user @@ -290,14 +290,30 @@ class RegistrationAutocompleteViewTests(MockLDAPMixin, ViewTestCaseMixin, TestCa self.assertEqual(r.status_code, 200) + def extract(section): + return [r.verbose_name for r in section.entries] + + others = [] + members = [] + clippers = [] + for section in r.context["results"]: + if section.name == "others": + others = extract(section) + elif section.name == "members": + members = extract(section) + elif section.name == "clippers": + clippers = extract(section) + else: + raise ValueError("Unexpected section name: {}".format(section.name)) + self.assertQuerysetEqual( - r.context["others"], map(repr, expected_others), ordered=False + others, map(str, expected_others), ordered=False, transform=str ) self.assertQuerysetEqual( - r.context["members"], map(repr, expected_members), ordered=False, + members, map(str, expected_members), ordered=False, transform=str ) - self.assertCountEqual( - map(str, r.context["clippers"]), map(str, expected_clippers) + self.assertSetEqual( + set(clippers), set(map(LDAPSearch().result_verbose_name, expected_clippers)) ) def test_username(self): diff --git a/gestioncof/views.py b/gestioncof/views.py index 07a0ae03..5966d2cd 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -25,6 +25,7 @@ from django_cas_ng.views import LogoutView as CasLogoutView from icalendar import Calendar, Event as Vevent from bda.models import Spectacle, Tirage +from gestioncof.autocomplete import cof_search from gestioncof.decorators import buro_required, cof_required from gestioncof.forms import ( CalendarForm, @@ -58,7 +59,7 @@ from gestioncof.models import ( SurveyQuestion, SurveyQuestionAnswer, ) -from shared.views.autocomplete import Select2QuerySetView +from shared.views import AutocompleteView, Select2QuerySetView class HomeView(LoginRequiredMixin, TemplateView): @@ -942,9 +943,18 @@ class ConfigUpdate(FormView): ## +# For the admin site class UserAutocomplete(Select2QuerySetView): model = User search_fields = ("username", "first_name", "last_name") user_autocomplete = buro_required(UserAutocomplete.as_view()) + + +class COFAutocompleteView(AutocompleteView): + template_name = "gestioncof/search_results.html" + search_composer = cof_search + + +registration_autocomplete = buro_required(COFAutocompleteView.as_view()) diff --git a/kfet/autocomplete.py b/kfet/autocomplete.py index c4e7a766..326c2796 100644 --- a/kfet/autocomplete.py +++ b/kfet/autocomplete.py @@ -1,10 +1,9 @@ from django.contrib.auth import get_user_model -from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Q -from django.http import Http404 -from django.views.generic import TemplateView +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ -from shared.views import autocomplete +from shared import autocomplete User = get_user_model() @@ -17,73 +16,75 @@ class KfetAccountSearch(autocomplete.ModelSearch): "last_name", "profile__account_kfet__trigramme", ] + verbose_name = _("Comptes existants") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(profile__account_kfet__isnull=False) return qset_filter + def result_verbose_name(self, user): + return "{} ({})".format(user, user.profile.account_kfet.trigramme) + + def result_uuid(self, user): + return user.username + class COFMemberSearch(autocomplete.ModelSearch): model = User search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Membres du COF") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(profile__account_kfet__isnull=True) & Q(profile__is_cof=True) return qset_filter + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return reverse("kfet.account.create.fromuser", args=(user.username,)) + class OthersSearch(autocomplete.ModelSearch): model = User search_fields = ["username", "first_name", "last_name"] + verbose_name = _("Non-membres du COF") def get_queryset_filter(self, *args, **kwargs): qset_filter = super().get_queryset_filter(*args, **kwargs) qset_filter &= Q(profile__account_kfet__isnull=True) & Q(profile__is_cof=False) return qset_filter + def result_uuid(self, user): + return user.username + + def result_link(self, user): + return reverse("kfet.account.create.fromuser", args=(user.username,)) + + +class KfetLDAPSearch(autocomplete.LDAPSearch): + def result_link(self, clipper): + return reverse( + "kfet.account.create.fromclipper", args=(clipper.clipper, clipper.fullname) + ) + class KfetAutocomplete(autocomplete.Compose): search_units = [ - ("kfet", "username", KfetAccountSearch), - ("users_cof", "username", COFMemberSearch), - ("users_notcof", "username", OthersSearch), - ("clippers", "clipper", autocomplete.LDAPSearch), + ("kfet", KfetAccountSearch()), + ("users_cof", COFMemberSearch()), + ("users_notcof", OthersSearch()), + ("clippers", KfetLDAPSearch()), ] kfet_autocomplete = KfetAutocomplete() -class AccountCreateAutocompleteView(PermissionRequiredMixin, TemplateView): - template_name = "kfet/account_create_autocomplete.html" - permission_required = "kfet.is_team" - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - if "q" not in self.request.GET: - raise Http404 - q = self.request.GET["q"] - ctx["q"] = q - results = kfet_autocomplete.search(q.split()) - ctx["options"] = sum((len(res) for res in results.values())) - ctx.update(results) - return ctx +class KfetAccountOnlyAutocomplete(autocomplete.Compose): + search_units = [("kfet", KfetAccountSearch())] -class AccountSearchAutocompleteView(PermissionRequiredMixin, TemplateView): - template_name = "kfet/account_search_autocomplete.html" - permission_required = "kfet.is_team" - - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) - if "q" not in self.request.GET: - raise Http404 - q = self.request.GET["q"] - ctx["q"] = q - ctx["accounts"] = [ - (user.profile.account_kfet.trigramme, user.get_full_name()) - for user in KfetAccountSearch().search(q.split()) - ] - return ctx +kfet_account_only_autocomplete = KfetAccountOnlyAutocomplete() diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index fdb86aff..cfaf2d74 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -159,7 +159,7 @@ background:rgba(255,255,255,0.9); } -#search_results ul li.user_category { +#search_results ul li.autocomplete-header { font-weight:bold; background:#c8102e; color:#fff; @@ -178,7 +178,7 @@ text-decoration:none; } -#search_results ul li span.text { +#search_results ul li span.autocomplete-item { display:block; padding:5px 20px; } diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index e411bd8d..e08575a3 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -181,9 +181,15 @@ class AccountCreateAutocompleteViewTests(ViewTestCaseMixin, TestCase): def test_ok(self): r = self.client.get(self.url, {"q": "first"}) self.assertEqual(r.status_code, 200) - self.assertEqual(len(r.context["users_notcof"]), 0) - self.assertEqual(len(r.context["users_cof"]), 0) - self.assertSetEqual(set(r.context["kfet"]), set([self.users["user"]])) + self.assertEqual(len(r.context["results"]), 1) + (res,) = r.context["results"] + self.assertEqual(res.name, "kfet") + + u = self.users["user"] + self.assertSetEqual( + {e.verbose_name for e in res.entries}, + {"{} ({})".format(u, u.profile.account_kfet.trigramme)}, + ) class AccountSearchViewTests(ViewTestCaseMixin, TestCase): @@ -196,7 +202,12 @@ class AccountSearchViewTests(ViewTestCaseMixin, TestCase): def test_ok(self): r = self.client.get(self.url, {"q": "first"}) self.assertEqual(r.status_code, 200) - self.assertSetEqual(set(r.context["accounts"]), set([("000", "first last")])) + + u = self.users["user"] + self.assertSetEqual( + {e.verbose_name for e in r.context["results"][0].entries}, + {"{} ({})".format(u, u.profile.account_kfet.trigramme)}, + ) class AccountReadViewTests(ViewTestCaseMixin, TestCase): diff --git a/kfet/urls.py b/kfet/urls.py index a4ce450c..2548e77e 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -1,7 +1,7 @@ from django.contrib.auth.decorators import permission_required from django.urls import include, path, register_converter -from kfet import autocomplete, converters, views +from kfet import converters, views from kfet.decorators import teamkfet_required register_converter(converters.TrigrammeConverter, "trigramme") @@ -38,13 +38,13 @@ urlpatterns = [ ), path( "autocomplete/account_new", - autocomplete.AccountCreateAutocompleteView.as_view(), + views.AccountCreateAutocompleteView.as_view(), name="kfet.account.create.autocomplete", ), # Account - Search path( "autocomplete/account_search", - autocomplete.AccountSearchAutocompleteView.as_view(), + views.AccountSearchAutocompleteView.as_view(), name="kfet.account.search.autocomplete", ), # Account - Read diff --git a/kfet/views.py b/kfet/views.py index b6c49f72..17c40e36 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -27,6 +27,11 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView from gestioncof.models import CofProfile from kfet import KFET_DELETED_TRIGRAMME, consumers from kfet.auth.decorators import kfet_password_auth +from kfet.autocomplete import ( + KfetAccountSearch, + kfet_account_only_autocomplete, + kfet_autocomplete, +) from kfet.config import kfet_config from kfet.decorators import teamkfet_required from kfet.forms import ( @@ -78,6 +83,7 @@ from kfet.models import ( TransferGroup, ) from kfet.statistic import DayScale, MonthScale, ScaleMixin, WeekScale, scale_url_params +from shared.views import AutocompleteView from .auth import KFET_GENERIC_TRIGRAMME from .auth.views import ( # noqa @@ -2586,3 +2592,18 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): }, ] return context + + +# --- +# Autocompletion +# --- + + +class AccountCreateAutocompleteView(PermissionRequiredMixin, AutocompleteView): + permission_required = "kfet.is_team" + search_composer = kfet_autocomplete + + +class AccountSearchAutocompleteView(PermissionRequiredMixin, AutocompleteView): + permission_required = "kfet.is_team" + search_composer = kfet_account_only_autocomplete diff --git a/shared/views/autocomplete.py b/shared/autocomplete.py similarity index 60% rename from shared/views/autocomplete.py rename to shared/autocomplete.py index 50d0d2c2..6e13d720 100644 --- a/shared/views/autocomplete.py +++ b/shared/autocomplete.py @@ -1,9 +1,9 @@ import logging from collections import namedtuple -from dal import autocomplete from django.conf import settings from django.db.models import Q +from django.utils.translation import gettext_lazy as _ if getattr(settings, "LDAP_SERVER_URL", None): import ldap @@ -21,14 +21,53 @@ class SearchUnit: A search unit should implement a `search` method taking a list of keywords as argument and returning an iterable of search results. + + It might optionally implement the following methods and attributes: + + - verbose_name (attribute): a nice name to refer to the results of this search unit + in templates. Examples: "COF Members", "K-Fêt accounts", etc. + + - result_verbose_name (method): a callable that takes one search result as an input + and returns a nice name to refer to this particular result in templates. + Example: `lambda user: user.get_full_name()` + + - result_link (method): a callable that takes one search result and returns a url + to make this particular search result clickable on the search page. For instance + this can be a link to a detail view of the object. + + - result_uuid (method): a callable that takes one result as an input and returns an + identifier that is globally unique across search units for this object. + This is used to compare results coming from different search units in the + `Compose` class. For instance, if the same user can be returned by the LDAP + search and a model search instance, using the clipper login as a UUID in both + units avoids this user to be returned twice by `Compose`. + Returning `None` means that the object should be considered unique. """ + # Mandatory method + def search(self, _keywords): raise NotImplementedError( - "Class implementing the SeachUnit interface should implement the search " + "Class implementing the SearchUnit interface should implement the search " "method" ) + # Optional attributes and methods + + verbose_name = None + + def result_verbose_name(self, result): + """Hook to customize the way results are displayed.""" + return str(result) + + def result_link(self, result): + """Hook to add a link on individual results on the search page.""" + return None + + def result_uuid(self, result): + """A universal unique identifier for the search results.""" + return None + # --- # Model-based search @@ -57,6 +96,10 @@ class ModelSearch(SearchUnit): model = None search_fields = [] + def __init__(self): + if self.verbose_name is None: + self.verbose_name = "{} search".format(self.model.Meta.verbose_name) + def get_queryset_filter(self, keywords): filter_q = Q() @@ -82,14 +125,6 @@ class ModelSearch(SearchUnit): return self.model.objects.filter(self.get_queryset_filter(keywords)) -class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView): - """Compatibility layer between ModelSearch and Select2QuerySetView.""" - - def get_queryset(self): - keywords = self.q.split() - return super().search(keywords) - - # --- # LDAP search # --- @@ -102,6 +137,8 @@ class LDAPSearch(SearchUnit): domain_component = "dc=spi,dc=ens,dc=fr" search_fields = ["cn", "uid"] + verbose_name = _("Comptes clippers") + def get_ldap_query(self, keywords): """Return a search query with the following semantics: @@ -148,6 +185,12 @@ class LDAPSearch(SearchUnit): django_logger.error("An LDAP error occurred", exc_info=err) return [] + def result_verbose_name(self, clipper): + return "{} ({})".format(clipper.fullname, clipper.clipper) + + def result_uuid(self, clipper): + return clipper.clipper + # --- # Composition of autocomplete units @@ -157,18 +200,13 @@ class LDAPSearch(SearchUnit): class Compose: """Search with several units and remove duplicate results. - The `search_units` class attribute should be a list of tuples of the form `(name, - uniq_key, search_unit)`. + The `search_units` class attribute should be a list of pairs of the form `(name, + search_unit)`. The `search` method produces a dictionary whose keys are the `name`s given in `search_units` and whose values are iterables produced by the different search units. - The `uniq_key`s are used to remove duplicates: for instance, say that search unit - 1 has `uniq_key = "username"` and search unit 2 has `uniq_key = "clipper"`, then - search results from unit 2 whose `.clipper` attribute is equal to the - `.username` attribute of some result from unit 1 are omitted. - Typical Example: >>> from django.contrib.auth.models import User @@ -176,11 +214,17 @@ class Compose: >>> class UserSearch(ModelSearch): ... model = User ... search_fields = ["username", "first_name", "last_name"] + ... + ... def result_uuid(self, user): + ... # Assuming that `.username` stores the clipper login of already + ... # registered this avoids showing the same user twice (here and in the + ... # ldap unit). + ... return user.username >>> >>> class UserAndClipperSearch(Compose): ... search_units = [ - ... ("users", "username", UserSearch), - ... ("clippers", "clipper", LDAPSearch), + ... ("users", UserSearch()), + ... ("clippers", LDAPSearch()), ... ] In this example, clipper accounts that already have an associated user (i.e. with a @@ -190,11 +234,15 @@ class Compose: search_units = [] def search(self, keywords): - uniq_results = set() + seen_uuids = set() results = {} - for name, uniq_key, search_unit in self.search_units: - res = search_unit().search(keywords) - res = [r for r in res if getattr(r, uniq_key) not in uniq_results] - uniq_results |= set((getattr(r, uniq_key) for r in res)) - results[name] = res + for name, search_unit in self.search_units: + uniq_res = [] + for r in search_unit.search(keywords): + uuid = search_unit.result_uuid(r) + if uuid is None or uuid not in seen_uuids: + uniq_res.append(r) + if uuid is not None: + seen_uuids.add(uuid) + results[name] = uniq_res return results diff --git a/shared/templates/shared/search_results.html b/shared/templates/shared/search_results.html new file mode 100644 index 00000000..66661e8c --- /dev/null +++ b/shared/templates/shared/search_results.html @@ -0,0 +1,17 @@ +{% load i18n %} + + diff --git a/shared/templates/shared/search_results_section.html b/shared/templates/shared/search_results_section.html new file mode 100644 index 00000000..800d6557 --- /dev/null +++ b/shared/templates/shared/search_results_section.html @@ -0,0 +1,25 @@ +{% load search_utils %} + +
  • + {{ section.verbose_name }} +
  • + +{% for entry in section.entries %} + {% if forloop.counter < 5 %} +
  • + {% if entry.link %} + + {{ entry.verbose_name | highlight:q }} + + {% else %} + + {{ entry.verbose_name | highlight:q }} + + {% endif %} +
  • + {% elif forloop.counter == 5 %} +
  • + ... +
  • + {% endif %} +{% endfor %} diff --git a/shared/templatetags/search_utils.py b/shared/templatetags/search_utils.py index 28851248..a98c36e5 100644 --- a/shared/templatetags/search_utils.py +++ b/shared/templatetags/search_utils.py @@ -6,27 +6,10 @@ from django.utils.safestring import mark_safe register = template.Library() -def highlight_text(text, q): +@register.filter +def highlight(text, q): q2 = "|".join(re.escape(word) for word in q.split()) pattern = re.compile(r"(?P%s)" % q2, re.IGNORECASE) return mark_safe( re.sub(pattern, r"\g", text) ) - - -@register.filter -def highlight_user(user, q): - if user.first_name and user.last_name: - text = "%s %s (%s)" % (user.first_name, user.last_name, user.username) - else: - text = user.username - return highlight_text(text, q) - - -@register.filter -def highlight_clipper(clipper, q): - if clipper.fullname: - text = "%s (%s)" % (clipper.fullname, clipper.clipper) - else: - text = clipper.clipper - return highlight_text(text, q) diff --git a/shared/tests/mixins.py b/shared/tests/mixins.py index 030b3d5c..7b5edf6a 100644 --- a/shared/tests/mixins.py +++ b/shared/tests/mixins.py @@ -44,7 +44,7 @@ class MockLDAPMixin: # Mock ldap module whose `initialize_method` always return the same ldap object. mock_ldap_module = self.MockLDAPModule(mock_ldap_obj) - patcher = mock.patch("shared.views.autocomplete.ldap", new=mock_ldap_module) + patcher = mock.patch("shared.autocomplete.ldap", new=mock_ldap_module) patcher.start() self.addCleanup(patcher.stop) diff --git a/shared/views.py b/shared/views.py new file mode 100644 index 00000000..31523bad --- /dev/null +++ b/shared/views.py @@ -0,0 +1,57 @@ +from collections import namedtuple + +from dal import autocomplete +from django.core.exceptions import ImproperlyConfigured +from django.http import Http404 +from django.views.generic import TemplateView + +from shared.autocomplete import ModelSearch + + +class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView): + """Compatibility layer between ModelSearch and Select2QuerySetView.""" + + def get_queryset(self): + keywords = self.q.split() + return super().search(keywords) + + +Section = namedtuple("Section", ("name", "verbose_name", "entries")) +Entry = namedtuple("Entry", ("verbose_name", "link")) + + +class AutocompleteView(TemplateView): + template_name = "shared/search_results.html" + search_composer = None + + def get_search_composer(self): + if self.search_composer is None: + raise ImproperlyConfigured("Please specify a search composer") + return self.search_composer + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if "q" not in self.request.GET: + raise Http404 + q = self.request.GET["q"] + ctx["q"] = q + ctx["results"] = self.search(keywords=q.split()) + return ctx + + def search(self, keywords): + search_composer = self.get_search_composer() + raw_results = search_composer.search(keywords) + sections = [] + for name, unit in search_composer.search_units: + entries = [ + Entry( + verbose_name=unit.result_verbose_name(res), + link=unit.result_link(res), + ) + for res in raw_results[name] + ] + if entries: + sections.append( + Section(name=name, verbose_name=unit.verbose_name, entries=entries) + ) + return sections