diff --git a/bds/autocomplete.py b/bds/autocomplete.py index 7fec4cea..9c9d8e59 100644 --- a/bds/autocomplete.py +++ b/bds/autocomplete.py @@ -1,5 +1,6 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.utils.translation import gettext_lazy as _ from shared import autocomplete @@ -9,6 +10,7 @@ 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) @@ -18,10 +20,14 @@ class BDSMemberSearch(autocomplete.ModelSearch): 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) @@ -31,12 +37,20 @@ class BDSOthersSearch(autocomplete.ModelSearch): 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", BDSMemberSearch()), ("others", BDSOthersSearch()), - ("clippers", autocomplete.LDAPSearch()), + ("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/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index febbcac9..9dae05db 100644 --- a/gestioncof/autocomplete.py +++ b/gestioncof/autocomplete.py @@ -1,5 +1,7 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from shared import autocomplete @@ -9,6 +11,7 @@ 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) @@ -18,10 +21,14 @@ class COFMemberSearch(autocomplete.ModelSearch): 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) @@ -31,10 +38,21 @@ class COFOthersSearch(autocomplete.ModelSearch): 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", COFMemberSearch()), ("others", COFOthersSearch()), - ("clippers", autocomplete.LDAPSearch()), + ("clippers", COFLDAPSearch()), ] + + +cof_search = COFSearch() 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 b623b283..0107b6a1 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -16,7 +16,7 @@ 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 +from shared.autocomplete import Clipper, LDAPSearch from shared.tests.mixins import CSVResponseMixin, ICalMixin, MockLDAPMixin 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 a385f460..e399c05f 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -59,7 +59,7 @@ from gestioncof.models import ( SurveyQuestion, SurveyQuestionAnswer, ) -from shared.views import Select2QuerySetView +from shared.views import AutocompleteView, Select2QuerySetView class HomeView(LoginRequiredMixin, TemplateView): @@ -948,14 +948,6 @@ class UserAutocompleteView(BuroRequiredMixin, Select2QuerySetView): search_fields = ("username", "first_name", "last_name") -class RegistrationAutocompleteView(BuroRequiredMixin, TemplateView): +class RegistrationAutocompleteView(BuroRequiredMixin, AutocompleteView): 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(COFSearch().search(q.split())) - return ctx + search_composer = COFSearch() diff --git a/kfet/autocomplete.py b/kfet/autocomplete.py index 04a4be2b..ac2c0b8e 100644 --- a/kfet/autocomplete.py +++ b/kfet/autocomplete.py @@ -1,5 +1,7 @@ from django.contrib.auth import get_user_model from django.db.models import Q +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from shared import autocomplete @@ -14,12 +16,16 @@ 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 @@ -27,6 +33,7 @@ class KfetAccountSearch(autocomplete.ModelSearch): 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) @@ -36,10 +43,14 @@ class COFMemberSearch(autocomplete.ModelSearch): 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) @@ -49,11 +60,25 @@ class OthersSearch(autocomplete.ModelSearch): 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", KfetAccountSearch()), ("users_cof", COFMemberSearch()), ("users_notcof", OthersSearch()), - ("clippers", autocomplete.LDAPSearch()), + ("clippers", KfetLDAPSearch()), ] + + +class KfetAccountOnlyAutocomplete(autocomplete.Compose): + search_units = [("kfet", KfetAccountSearch())] 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/templates/kfet/account_create_autocomplete.html b/kfet/templates/kfet/account_create_autocomplete.html deleted file mode 100644 index 2f04d461..00000000 --- a/kfet/templates/kfet/account_create_autocomplete.html +++ /dev/null @@ -1,50 +0,0 @@ -{% load kfet_tags %} - - diff --git a/kfet/templates/kfet/account_search_autocomplete.html b/kfet/templates/kfet/account_search_autocomplete.html deleted file mode 100644 index e18eb1eb..00000000 --- a/kfet/templates/kfet/account_search_autocomplete.html +++ /dev/null @@ -1,14 +0,0 @@ -{% load kfet_tags %} - - - diff --git a/kfet/templatetags/kfet_tags.py b/kfet/templatetags/kfet_tags.py index 4c26dd17..db4cfbf1 100644 --- a/kfet/templatetags/kfet_tags.py +++ b/kfet/templatetags/kfet_tags.py @@ -1,8 +1,4 @@ -import re - from django import template -from django.utils.html import escape -from django.utils.safestring import mark_safe from ..utils import to_ukf @@ -11,40 +7,14 @@ register = template.Library() register.filter("ukf", to_ukf) -@register.filter() -def highlight_text(text, q): - q2 = "|".join(re.escape(word) for word in q.split()) - pattern = re.compile(r"(?P%s)" % q2, re.IGNORECASE) - regex = r"\g" - return mark_safe(re.sub(pattern, regex, escape(text))) - - -@register.filter(is_safe=True) -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(is_safe=True) -def highlight_clipper(clipper, q): - if clipper.fullname: - text = "%s (%s)" % (clipper.fullname, clipper.clipper) - else: - text = clipper.clipper - return highlight_text(text, q) - - @register.filter() def widget_type(field): return field.field.widget.__class__.__name__ @register.filter() -def slice(l, start, end=None): +def slice(t, start, end=None): if end is None: end = start start = 0 - return l[start:end] + return t[start:end] 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/views.py b/kfet/views.py index 5fd502c0..1f78c60d 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -27,7 +27,7 @@ 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, KfetAutocomplete +from kfet.autocomplete import KfetAccountOnlyAutocomplete, KfetAutocomplete from kfet.config import kfet_config from kfet.decorators import teamkfet_required from kfet.forms import ( @@ -79,6 +79,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 @@ -2594,34 +2595,11 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): # --- -class AccountCreateAutocompleteView(PermissionRequiredMixin, TemplateView): - template_name = "kfet/account_create_autocomplete.html" +class AccountCreateAutocompleteView(PermissionRequiredMixin, AutocompleteView): 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 = KfetAutocomplete().search(q.split()) - ctx["options"] = sum((len(res) for res in results.values())) - ctx.update(results) - return ctx + search_composer = KfetAutocomplete() -class AccountSearchAutocompleteView(PermissionRequiredMixin, TemplateView): - template_name = "kfet/account_search_autocomplete.html" +class AccountSearchAutocompleteView(PermissionRequiredMixin, AutocompleteView): 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 + search_composer = KfetAccountOnlyAutocomplete() diff --git a/shared/autocomplete.py b/shared/autocomplete.py index 02b6b661..564a6da7 100644 --- a/shared/autocomplete.py +++ b/shared/autocomplete.py @@ -3,6 +3,7 @@ from collections import namedtuple 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 @@ -23,6 +24,17 @@ class SearchUnit: 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 @@ -42,6 +54,16 @@ class SearchUnit: # 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 @@ -74,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() @@ -111,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: @@ -157,6 +185,9 @@ 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 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/views.py b/shared/views.py index 8fd28796..31523bad 100644 --- a/shared/views.py +++ b/shared/views.py @@ -1,4 +1,9 @@ +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 @@ -9,3 +14,44 @@ class Select2QuerySetView(ModelSearch, autocomplete.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