Vue d'autocomplétion générique dans shared.views
pour factoriser les 3 autocomplétions (COF + BDS + K-Fêt) #730
21 changed files with 379 additions and 268 deletions
|
@ -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
|
||||
|
|
|
@ -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()),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -1,75 +1,21 @@
|
|||
{% extends "shared/search_results.html" %}
|
||||
{% load i18n %}
|
||||
{% load search_utils %}
|
||||
|
||||
<ul>
|
||||
{% if members %}
|
||||
{% block extra_section %}
|
||||
<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>
|
||||
{% if not results %}
|
||||
<span class="autocomplete-item">
|
||||
{% trans "Aucune correspondance trouvée" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<li class="autocomplete-header">
|
||||
<span class="autocomplete-item">{% trans "Aucune correspondance trouvée" %}</span>
|
||||
</li>
|
||||
<span class="autocomplete-item">
|
||||
{% trans "Pas dans la liste ?" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<li class="autocomplete-new">
|
||||
<a class="autocomplete-item" href="#TODO">{% trans "Créer un compte" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<li class="autocomplete-new">
|
||||
<a class="autocomplete-item" href="#TODO">
|
||||
{% trans "Créer un compte" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
16
bds/views.py
16
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):
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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>
|
||||
{% block extra_section %}
|
||||
<li class="autocomplete-header">
|
||||
{% if not results %}
|
||||
<span class="autocomplete-item">
|
||||
{% trans "Aucune correspondance trouvée" %}
|
||||
</span>
|
||||
{% else %}
|
||||
<li class="autocomplete-header">Aucune correspondance trouvée</li>
|
||||
<span class="autocomplete-item">
|
||||
{% trans "Pas dans la liste ?" %}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<li><a href="{% url "empty-registration" %}">Créer un compte</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="autocomplete-new">
|
||||
<a class="autocomplete-item" href="{% url "empty-registration" %}">
|
||||
{% trans "Créer un compte" %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
17
shared/templates/shared/search_results.html
Normal file
17
shared/templates/shared/search_results.html
Normal 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>
|
25
shared/templates/shared/search_results_section.html
Normal file
25
shared/templates/shared/search_results_section.html
Normal 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 %}
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
57
shared/views.py
Normal file
57
shared/views.py
Normal 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
|
Loading…
Reference in a new issue