Vue d'autocomplétion générique dans shared.views pour factoriser les 3 autocomplétions (COF + BDS + K-Fêt) #730

Closed
mpepin wants to merge 3 commits from kerl/factor_autocompletion_views into master
24 changed files with 381 additions and 360 deletions

View file

@ -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

View file

@ -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()),
]

View file

@ -1,75 +1,21 @@
{% extends "shared/search_results.html" %}
{% load i18n %}
{% load search_utils %}
<ul>
{% if members %}
<li class="autocomplete-header">
<span class="autocomplete-item">{% trans "Membres" %}</span>
</li>
{% for user in members %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
<a class="autocomplete-item" href="#TODO">
{{ user|highlight_user:q }}
</a>
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">
<span class="autocomplete-item">...</span>
</li>
{% endif %}
{% endfor %}
{% endif %}
{% if others %}
<li class="autocomplete-header">
<span class="autocomplete-item">{% trans "Non-membres" %}</span>
</li>
{% for user in others %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
<a class="autocomplete-item" href="#TODO">
{{ user|highlight_user:q }}
</a>
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">
<span class="autocomplete-item">...</span>
</li>
{% endif %}
{% endfor %}
{% endif %}
{% if clippers %}
<li class="autocomplete-header">
<span class="autocomplete-item">{% trans "Utilisateurs <tt>clipper</tt>" %}</span>
</li>
{% for clipper in clippers %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
<a class="autocomplete-item" href="#TODO">
{{ clipper|highlight_clipper:q }}
</a>
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">
<span class="autocomplete-item">...</span>
</li>
{% endif %}
{% endfor %}
{% endif %}
{% if total %}
<li class="autocomplete-header">
<span class="autocomplete-item">{% trans "Pas dans la liste ?" %}</span>
</li>
{% else %}
<li class="autocomplete-header">
<span class="autocomplete-item">{% trans "Aucune correspondance trouvée" %}</span>
</li>
{% endif %}
<li class="autocomplete-new">
<a class="autocomplete-item" href="#TODO">{% trans "Créer un compte" %}</a>
{% block extra_section %}
<li class="autocomplete-header">
{% if not results %}
<span class="autocomplete-item">
{% trans "Aucune correspondance trouvée" %}
</span>
{% else %}
<span class="autocomplete-item">
{% trans "Pas dans la liste ?" %}
</span>
{% endif %}
</li>
</ul>
<li class="autocomplete-new">
<a class="autocomplete-item" href="#TODO">
{% trans "Créer un compte" %}
</a>
</li>
{% endblock %}

View file

@ -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"),
]

View file

@ -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):

View file

@ -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(

View file

@ -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())

View file

@ -1,56 +1,21 @@
{% load search_utils %}
{% extends "shared/search_results.html" %}
{% load i18n %}
<ul>
{% if members %}
<li class="autocomplete-header">Membres</li>
{% for user in members %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
<a href="{% url "user-registration" user.username %}">
{{ user|highlight_user:q }}
</a>
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">...</li>
{% endif %}
{% endfor %}
{% endif %}
{% if others %}
<li class="autocomplete-header">Non-membres</li>
{% for user in others %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
<a href="{% url "user-registration" user.username %}">
{{ user|highlight_user:q }}
</a>
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">...</li>
{% endif %}
{% endfor %}
{% endif %}
{% if clippers %}
<li class="autocomplete-header">Utilisateurs <tt>clipper</tt></li>
{% for clipper in clippers %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
<a href="{% url "clipper-registration" clipper.clipper clipper.fullname %}">
{{ clipper|highlight_clipper:q }}
</a>
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">...</li>
{% endif %}
{% endfor %}
{% endif %}
{% if total %}
<li class="autocomplete-header">Pas dans la liste ?</li>
{% else %}
<li class="autocomplete-header">Aucune correspondance trouvée</li>
{% endif %}
<li><a href="{% url "empty-registration" %}">Créer un compte</a></li>
</ul>
{% block extra_section %}
<li class="autocomplete-header">
{% if not results %}
<span class="autocomplete-item">
{% trans "Aucune correspondance trouvée" %}
</span>
{% else %}
<span class="autocomplete-item">
{% trans "Pas dans la liste ?" %}
</span>
{% endif %}
</li>
<li class="autocomplete-new">
<a class="autocomplete-item" href="{% url "empty-registration" %}">
{% trans "Créer un compte" %}
</a>
</li>
{% endblock %}

View file

@ -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):

View file

@ -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())

View file

@ -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()

View file

@ -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;
}

View file

@ -1,50 +0,0 @@
{% load kfet_tags %}
<ul>
<li>
<a href="{% url "kfet.account.create.empty" %}">
Créer un compte
</a>
</li>
{% if kfet %}
<li class="user_category"><span class="text">Comptes existants</span></li>
{% for user in kfet %}
<li><span class="text">{{ user.account_kfet.account }} [{{ user|highlight_user:q }}]</span></li>
{% endfor %}
{% endif %}
{% if users_cof %}
<li class="user_category"><span class="text">Membres du COF</span></li>
{% for user in users_cof %}
<li>
<a href="{% url "kfet.account.create.fromuser" user.username %}">
{{ user|highlight_user:q }}
</a>
</li>
{% endfor %}
{% endif %}
{% if users_notcof %}
<li class="user_category"><span class="text">Non-membres du COF</span></li>
{% for user in users_notcof %}
<li>
<a href="{% url "kfet.account.create.fromuser" user.username %}">
{{ user|highlight_user:q }}
</a>
</li>
{% endfor %}
{% endif %}
{% if clippers %}
<li class="user_category"><span class="text">Utilisateurs clipper</span></li>
{% for clipper in clippers %}
<li>
<a href="{% url "kfet.account.create.fromclipper" clipper.clipper clipper.fullname%}">
{{ clipper|highlight_clipper:q }}
</a>
</li>
{% endfor %}
{% endif %}
{% if not q %}
<li class="user_category"><span class="text">Pas de recherche, pas de résultats !</span></li>
{% elif not options %}
<li class="user_category"><span class="text">Aucune correspondance trouvée :-(</span></li>
{% endif %}
</ul>

View file

@ -1,14 +0,0 @@
{% load kfet_tags %}
<ul>
{% if accounts %}
{% for trigramme, user in accounts %}
<li class='choice'>{{ user|highlight_text:q }} (<span class="trigramme">{{ trigramme }}</span>)</li>
{% endfor %}
{% elif not q %}
<li class="user_category"><span class="text">Pas de recherche, pas de résultats !</span></li>
{% else %}
<li class="user_category"><span class="text">Aucune correspondance trouvée :-(</span></li>
{% endif %}
</ul>

View file

@ -11,32 +11,6 @@ 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<filter>%s)" % q2, re.IGNORECASE)
regex = r"<span class='highlight_autocomplete'>\g<filter></span>"
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__

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,17 @@
{% load i18n %}
<ul>
{% for section in results %}
{% include "shared/search_results_section.html" with section=section %}
{% endfor %}
{% block extra_section %}
{% if not results %}
<li class="autocomplete-header">
<span class="autocomplete-item">
{% trans "Aucune correspondance trouvée" %}
</span>
</li>
{% endif %}
{% endblock %}
</ul>

View file

@ -0,0 +1,25 @@
{% load search_utils %}
<li class="autocomplete-header">
<span class="autocomplete-item">{{ section.verbose_name }}</span>
</li>
{% for entry in section.entries %}
{% if forloop.counter < 5 %}
<li class="autocomplete-value">
{% if entry.link %}
<a class="autocomplete-item" href="{{ entry.link }}">
{{ entry.verbose_name | highlight:q }}
</a>
{% else %}
<span class="autocomplete-item">
{{ entry.verbose_name | highlight:q }}
</span>
{% endif %}
</li>
{% elif forloop.counter == 5 %}
<li class="autocomplete-more">
<span class="autocomplete-item">...</span>
</li>
{% endif %}
{% endfor %}

View file

@ -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<filter>%s)" % q2, re.IGNORECASE)
return mark_safe(
re.sub(pattern, r"<span class='highlight'>\g<filter></span>", text)
)
@register.filter
def highlight_user(user, q):
if user.first_name and user.last_name:
text = "%s %s (<tt>%s</tt>)" % (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 (<tt>%s</tt>)" % (clipper.fullname, clipper.clipper)
else:
text = clipper.clipper
return highlight_text(text, q)

View file

@ -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)
@ -115,8 +115,8 @@ class ICalMixin:
return False
return True
def _find_event(self, ev, l):
for i, elt in enumerate(l):
def _find_event(self, ev, rem):
for i, elt in enumerate(rem):
if self._test_event_equal(ev, elt):
return i
return None

57
shared/views.py Normal file
View file

@ -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