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 %}
-
- {% if members %}
-
- {% for user in members %}
- {% if forloop.counter < 5 %}
- -
-
- {{ user|highlight_user:q }}
-
-
- {% elif forloop.counter == 5 %}
- -
- ...
-
- {% endif %}
- {% endfor %}
- {% endif %}
-
- {% if others %}
-
- {% for user in others %}
- {% if forloop.counter < 5 %}
- -
-
- {{ user|highlight_user:q }}
-
-
- {% elif forloop.counter == 5 %}
- -
- ...
-
- {% endif %}
- {% endfor %}
- {% endif %}
-
- {% if clippers %}
-
- {% for clipper in clippers %}
- {% if forloop.counter < 5 %}
- -
-
- {{ clipper|highlight_clipper:q }}
-
-
- {% elif forloop.counter == 5 %}
- -
- ...
-
- {% endif %}
- {% endfor %}
- {% endif %}
-
- {% if total %}
-
- {% else %}
-
- {% endif %}
-
- -
- {% trans "Créer un compte" %}
+{% block extra_section %}
+
-
+
+
+ {% 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 %}
-
- {% if members %}
-
- {% for user in members %}
- {% if forloop.counter < 5 %}
- -
-
- {{ user|highlight_user:q }}
-
-
- {% elif forloop.counter == 5 %}
- - ...
- {% endif %}
- {% endfor %}
- {% endif %}
-
- {% if others %}
-
- {% for user in others %}
- {% if forloop.counter < 5 %}
- -
-
- {{ user|highlight_user:q }}
-
-
- {% elif forloop.counter == 5 %}
- - ...
- {% endif %}
- {% endfor %}
- {% endif %}
-
- {% if clippers %}
-
- {% for clipper in clippers %}
- {% if forloop.counter < 5 %}
- -
-
- {{ clipper|highlight_clipper:q }}
-
-
- {% elif forloop.counter == 5 %}
- - ...
- {% endif %}
- {% endfor %}
- {% endif %}
-
- {% if total %}
-
- {% else %}
-
- {% endif %}
-
- - Créer un compte
-
+{% block extra_section %}
+
+
+
+ {% 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 %}
+
+
+ {% for section in results %}
+ {% include "shared/search_results_section.html" with section=section %}
+ {% endfor %}
+
+ {% block extra_section %}
+ {% if not results %}
+
+ {% endif %}
+ {% endblock %}
+
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 %}
+
+
+
+{% 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