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,
|
Tirage,
|
||||||
)
|
)
|
||||||
from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required
|
from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required
|
||||||
from shared.views.autocomplete import Select2QuerySetView
|
from shared.views import Select2QuerySetView
|
||||||
|
|
||||||
|
|
||||||
@cof_required
|
@cof_required
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models import Q
|
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()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -9,28 +10,47 @@ User = get_user_model()
|
||||||
class BDSMemberSearch(autocomplete.ModelSearch):
|
class BDSMemberSearch(autocomplete.ModelSearch):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ["username", "first_name", "last_name"]
|
search_fields = ["username", "first_name", "last_name"]
|
||||||
|
verbose_name = _("Membres du BDS")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(bds__is_member=True)
|
qset_filter &= Q(bds__is_member=True)
|
||||||
return qset_filter
|
return qset_filter
|
||||||
|
|
||||||
|
def result_uuid(self, user):
|
||||||
|
return user.username
|
||||||
|
|
||||||
|
def result_link(self, user):
|
||||||
|
return "#TODO"
|
||||||
|
|
||||||
|
|
||||||
class BDSOthersSearch(autocomplete.ModelSearch):
|
class BDSOthersSearch(autocomplete.ModelSearch):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ["username", "first_name", "last_name"]
|
search_fields = ["username", "first_name", "last_name"]
|
||||||
|
verbose_name = _("Non-membres du BDS")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(bds__isnull=True) | Q(bds__is_member=False)
|
qset_filter &= Q(bds__isnull=True) | Q(bds__is_member=False)
|
||||||
return qset_filter
|
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):
|
class BDSSearch(autocomplete.Compose):
|
||||||
search_units = [
|
search_units = [
|
||||||
("members", "username", BDSMemberSearch),
|
("members", BDSMemberSearch()),
|
||||||
("others", "username", BDSOthersSearch),
|
("others", BDSOthersSearch()),
|
||||||
("clippers", "clipper", autocomplete.LDAPSearch),
|
("clippers", BDSLDAPSearch()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,75 +1,21 @@
|
||||||
|
{% extends "shared/search_results.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load search_utils %}
|
|
||||||
|
|
||||||
<ul>
|
{% block extra_section %}
|
||||||
{% if members %}
|
|
||||||
<li class="autocomplete-header">
|
<li class="autocomplete-header">
|
||||||
<span class="autocomplete-item">{% trans "Membres" %}</span>
|
{% if not results %}
|
||||||
</li>
|
<span class="autocomplete-item">
|
||||||
{% for user in members %}
|
{% trans "Aucune correspondance trouvée" %}
|
||||||
{% if forloop.counter < 5 %}
|
</span>
|
||||||
<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 %}
|
{% else %}
|
||||||
<li class="autocomplete-header">
|
<span class="autocomplete-item">
|
||||||
<span class="autocomplete-item">{% trans "Aucune correspondance trouvée" %}</span>
|
{% trans "Pas dans la liste ?" %}
|
||||||
</li>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<li class="autocomplete-new">
|
|
||||||
<a class="autocomplete-item" href="#TODO">{% trans "Créer un compte" %}</a>
|
|
||||||
</li>
|
</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"
|
app_name = "bds"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.Home.as_view(), name="home"),
|
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 django.views.generic import TemplateView
|
||||||
|
|
||||||
from bds.autocomplete import bds_search
|
from bds.autocomplete import bds_search
|
||||||
from bds.mixins import StaffRequiredMixin
|
from bds.mixins import StaffRequiredMixin
|
||||||
|
from shared.views import AutocompleteView
|
||||||
|
|
||||||
|
|
||||||
class AutocompleteView(StaffRequiredMixin, TemplateView):
|
class BDSAutocompleteView(StaffRequiredMixin, AutocompleteView):
|
||||||
template_name = "bds/search_results.html"
|
template_name = "bds/search_results.html"
|
||||||
|
search_composer = bds_search
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Home(StaffRequiredMixin, TemplateView):
|
class Home(StaffRequiredMixin, TemplateView):
|
||||||
|
|
|
@ -21,7 +21,6 @@ urlpatterns = [
|
||||||
|
|
||||||
if "gestioncof" in settings.INSTALLED_APPS:
|
if "gestioncof" in settings.INSTALLED_APPS:
|
||||||
from gestioncof import csv_views, views as gestioncof_views
|
from gestioncof import csv_views, views as gestioncof_views
|
||||||
from gestioncof.autocomplete import autocomplete
|
|
||||||
from gestioncof.urls import (
|
from gestioncof.urls import (
|
||||||
calendar_patterns,
|
calendar_patterns,
|
||||||
clubs_patterns,
|
clubs_patterns,
|
||||||
|
@ -109,7 +108,7 @@ if "gestioncof" in settings.INSTALLED_APPS:
|
||||||
# Autocompletion
|
# Autocompletion
|
||||||
path(
|
path(
|
||||||
"autocomplete/registration",
|
"autocomplete/registration",
|
||||||
autocomplete,
|
gestioncof_views.registration_autocomplete,
|
||||||
name="cof.registration.autocomplete",
|
name="cof.registration.autocomplete",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.urls import reverse
|
||||||
from django.views.generic import TemplateView
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from gestioncof.decorators import buro_required
|
from shared import autocomplete
|
||||||
from shared.views import autocomplete
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -12,45 +11,48 @@ User = get_user_model()
|
||||||
class COFMemberSearch(autocomplete.ModelSearch):
|
class COFMemberSearch(autocomplete.ModelSearch):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ["username", "first_name", "last_name"]
|
search_fields = ["username", "first_name", "last_name"]
|
||||||
|
verbose_name = _("Membres du COF")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(profile__is_cof=True)
|
qset_filter &= Q(profile__is_cof=True)
|
||||||
return qset_filter
|
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):
|
class COFOthersSearch(autocomplete.ModelSearch):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ["username", "first_name", "last_name"]
|
search_fields = ["username", "first_name", "last_name"]
|
||||||
|
verbose_name = _("Non-membres du COF")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(profile__is_cof=False)
|
qset_filter &= Q(profile__is_cof=False)
|
||||||
return qset_filter
|
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):
|
class COFSearch(autocomplete.Compose):
|
||||||
search_units = [
|
search_units = [
|
||||||
("members", "username", COFMemberSearch),
|
("members", COFMemberSearch()),
|
||||||
("others", "username", COFOthersSearch),
|
("others", COFOthersSearch()),
|
||||||
("clippers", "clipper", autocomplete.LDAPSearch),
|
("clippers", COFLDAPSearch()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
cof_search = COFSearch()
|
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>
|
{% block extra_section %}
|
||||||
{% if members %}
|
<li class="autocomplete-header">
|
||||||
<li class="autocomplete-header">Membres</li>
|
{% if not results %}
|
||||||
{% for user in members %}
|
<span class="autocomplete-item">
|
||||||
{% if forloop.counter < 5 %}
|
{% trans "Aucune correspondance trouvée" %}
|
||||||
<li class="autocomplete-value">
|
</span>
|
||||||
<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 %}
|
{% else %}
|
||||||
<li class="autocomplete-header">Aucune correspondance trouvée</li>
|
<span class="autocomplete-item">
|
||||||
|
{% trans "Pas dans la liste ?" %}
|
||||||
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</li>
|
||||||
<li><a href="{% url "empty-registration" %}">Créer un compte</a></li>
|
<li class="autocomplete-new">
|
||||||
</ul>
|
<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 bda.models import Salle, Tirage
|
||||||
from gestioncof.models import CalendarSubscription, Club, Event, Survey, SurveyAnswer
|
from gestioncof.models import CalendarSubscription, Club, Event, Survey, SurveyAnswer
|
||||||
from gestioncof.tests.mixins import MegaHelperMixin, ViewTestCaseMixin
|
from gestioncof.tests.mixins import MegaHelperMixin, ViewTestCaseMixin
|
||||||
|
from shared.autocomplete import Clipper, LDAPSearch
|
||||||
from shared.tests.mixins import CSVResponseMixin, ICalMixin, MockLDAPMixin
|
from shared.tests.mixins import CSVResponseMixin, ICalMixin, MockLDAPMixin
|
||||||
from shared.views.autocomplete import Clipper
|
|
||||||
|
|
||||||
from .utils import create_member, create_root, create_user
|
from .utils import create_member, create_root, create_user
|
||||||
|
|
||||||
|
@ -290,14 +290,30 @@ class RegistrationAutocompleteViewTests(MockLDAPMixin, ViewTestCaseMixin, TestCa
|
||||||
|
|
||||||
self.assertEqual(r.status_code, 200)
|
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(
|
self.assertQuerysetEqual(
|
||||||
r.context["others"], map(repr, expected_others), ordered=False
|
others, map(str, expected_others), ordered=False, transform=str
|
||||||
)
|
)
|
||||||
self.assertQuerysetEqual(
|
self.assertQuerysetEqual(
|
||||||
r.context["members"], map(repr, expected_members), ordered=False,
|
members, map(str, expected_members), ordered=False, transform=str
|
||||||
)
|
)
|
||||||
self.assertCountEqual(
|
self.assertSetEqual(
|
||||||
map(str, r.context["clippers"]), map(str, expected_clippers)
|
set(clippers), set(map(LDAPSearch().result_verbose_name, expected_clippers))
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_username(self):
|
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 icalendar import Calendar, Event as Vevent
|
||||||
|
|
||||||
from bda.models import Spectacle, Tirage
|
from bda.models import Spectacle, Tirage
|
||||||
|
from gestioncof.autocomplete import cof_search
|
||||||
from gestioncof.decorators import buro_required, cof_required
|
from gestioncof.decorators import buro_required, cof_required
|
||||||
from gestioncof.forms import (
|
from gestioncof.forms import (
|
||||||
CalendarForm,
|
CalendarForm,
|
||||||
|
@ -58,7 +59,7 @@ from gestioncof.models import (
|
||||||
SurveyQuestion,
|
SurveyQuestion,
|
||||||
SurveyQuestionAnswer,
|
SurveyQuestionAnswer,
|
||||||
)
|
)
|
||||||
from shared.views.autocomplete import Select2QuerySetView
|
from shared.views import AutocompleteView, Select2QuerySetView
|
||||||
|
|
||||||
|
|
||||||
class HomeView(LoginRequiredMixin, TemplateView):
|
class HomeView(LoginRequiredMixin, TemplateView):
|
||||||
|
@ -942,9 +943,18 @@ class ConfigUpdate(FormView):
|
||||||
##
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# For the admin site
|
||||||
class UserAutocomplete(Select2QuerySetView):
|
class UserAutocomplete(Select2QuerySetView):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ("username", "first_name", "last_name")
|
search_fields = ("username", "first_name", "last_name")
|
||||||
|
|
||||||
|
|
||||||
user_autocomplete = buro_required(UserAutocomplete.as_view())
|
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 import get_user_model
|
||||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.urls import reverse
|
||||||
from django.views.generic import TemplateView
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from shared.views import autocomplete
|
from shared import autocomplete
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
@ -17,73 +16,75 @@ class KfetAccountSearch(autocomplete.ModelSearch):
|
||||||
"last_name",
|
"last_name",
|
||||||
"profile__account_kfet__trigramme",
|
"profile__account_kfet__trigramme",
|
||||||
]
|
]
|
||||||
|
verbose_name = _("Comptes existants")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(profile__account_kfet__isnull=False)
|
qset_filter &= Q(profile__account_kfet__isnull=False)
|
||||||
return qset_filter
|
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):
|
class COFMemberSearch(autocomplete.ModelSearch):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ["username", "first_name", "last_name"]
|
search_fields = ["username", "first_name", "last_name"]
|
||||||
|
verbose_name = _("Membres du COF")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(profile__account_kfet__isnull=True) & Q(profile__is_cof=True)
|
qset_filter &= Q(profile__account_kfet__isnull=True) & Q(profile__is_cof=True)
|
||||||
return qset_filter
|
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):
|
class OthersSearch(autocomplete.ModelSearch):
|
||||||
model = User
|
model = User
|
||||||
search_fields = ["username", "first_name", "last_name"]
|
search_fields = ["username", "first_name", "last_name"]
|
||||||
|
verbose_name = _("Non-membres du COF")
|
||||||
|
|
||||||
def get_queryset_filter(self, *args, **kwargs):
|
def get_queryset_filter(self, *args, **kwargs):
|
||||||
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
qset_filter = super().get_queryset_filter(*args, **kwargs)
|
||||||
qset_filter &= Q(profile__account_kfet__isnull=True) & Q(profile__is_cof=False)
|
qset_filter &= Q(profile__account_kfet__isnull=True) & Q(profile__is_cof=False)
|
||||||
return qset_filter
|
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):
|
class KfetAutocomplete(autocomplete.Compose):
|
||||||
search_units = [
|
search_units = [
|
||||||
("kfet", "username", KfetAccountSearch),
|
("kfet", KfetAccountSearch()),
|
||||||
("users_cof", "username", COFMemberSearch),
|
("users_cof", COFMemberSearch()),
|
||||||
("users_notcof", "username", OthersSearch),
|
("users_notcof", OthersSearch()),
|
||||||
("clippers", "clipper", autocomplete.LDAPSearch),
|
("clippers", KfetLDAPSearch()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
kfet_autocomplete = KfetAutocomplete()
|
kfet_autocomplete = KfetAutocomplete()
|
||||||
|
|
||||||
|
|
||||||
class AccountCreateAutocompleteView(PermissionRequiredMixin, TemplateView):
|
class KfetAccountOnlyAutocomplete(autocomplete.Compose):
|
||||||
template_name = "kfet/account_create_autocomplete.html"
|
search_units = [("kfet", KfetAccountSearch())]
|
||||||
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 AccountSearchAutocompleteView(PermissionRequiredMixin, TemplateView):
|
kfet_account_only_autocomplete = KfetAccountOnlyAutocomplete()
|
||||||
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
|
|
||||||
|
|
|
@ -159,7 +159,7 @@
|
||||||
background:rgba(255,255,255,0.9);
|
background:rgba(255,255,255,0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#search_results ul li.user_category {
|
#search_results ul li.autocomplete-header {
|
||||||
font-weight:bold;
|
font-weight:bold;
|
||||||
background:#c8102e;
|
background:#c8102e;
|
||||||
color:#fff;
|
color:#fff;
|
||||||
|
@ -178,7 +178,7 @@
|
||||||
text-decoration:none;
|
text-decoration:none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#search_results ul li span.text {
|
#search_results ul li span.autocomplete-item {
|
||||||
display:block;
|
display:block;
|
||||||
padding:5px 20px;
|
padding:5px 20px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -181,9 +181,15 @@ class AccountCreateAutocompleteViewTests(ViewTestCaseMixin, TestCase):
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
r = self.client.get(self.url, {"q": "first"})
|
r = self.client.get(self.url, {"q": "first"})
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
self.assertEqual(len(r.context["users_notcof"]), 0)
|
self.assertEqual(len(r.context["results"]), 1)
|
||||||
self.assertEqual(len(r.context["users_cof"]), 0)
|
(res,) = r.context["results"]
|
||||||
self.assertSetEqual(set(r.context["kfet"]), set([self.users["user"]]))
|
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):
|
class AccountSearchViewTests(ViewTestCaseMixin, TestCase):
|
||||||
|
@ -196,7 +202,12 @@ class AccountSearchViewTests(ViewTestCaseMixin, TestCase):
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
r = self.client.get(self.url, {"q": "first"})
|
r = self.client.get(self.url, {"q": "first"})
|
||||||
self.assertEqual(r.status_code, 200)
|
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):
|
class AccountReadViewTests(ViewTestCaseMixin, TestCase):
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from django.contrib.auth.decorators import permission_required
|
from django.contrib.auth.decorators import permission_required
|
||||||
from django.urls import include, path, register_converter
|
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
|
from kfet.decorators import teamkfet_required
|
||||||
|
|
||||||
register_converter(converters.TrigrammeConverter, "trigramme")
|
register_converter(converters.TrigrammeConverter, "trigramme")
|
||||||
|
@ -38,13 +38,13 @@ urlpatterns = [
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"autocomplete/account_new",
|
"autocomplete/account_new",
|
||||||
autocomplete.AccountCreateAutocompleteView.as_view(),
|
views.AccountCreateAutocompleteView.as_view(),
|
||||||
name="kfet.account.create.autocomplete",
|
name="kfet.account.create.autocomplete",
|
||||||
),
|
),
|
||||||
# Account - Search
|
# Account - Search
|
||||||
path(
|
path(
|
||||||
"autocomplete/account_search",
|
"autocomplete/account_search",
|
||||||
autocomplete.AccountSearchAutocompleteView.as_view(),
|
views.AccountSearchAutocompleteView.as_view(),
|
||||||
name="kfet.account.search.autocomplete",
|
name="kfet.account.search.autocomplete",
|
||||||
),
|
),
|
||||||
# Account - Read
|
# Account - Read
|
||||||
|
|
|
@ -27,6 +27,11 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
from gestioncof.models import CofProfile
|
from gestioncof.models import CofProfile
|
||||||
from kfet import KFET_DELETED_TRIGRAMME, consumers
|
from kfet import KFET_DELETED_TRIGRAMME, consumers
|
||||||
from kfet.auth.decorators import kfet_password_auth
|
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.config import kfet_config
|
||||||
from kfet.decorators import teamkfet_required
|
from kfet.decorators import teamkfet_required
|
||||||
from kfet.forms import (
|
from kfet.forms import (
|
||||||
|
@ -78,6 +83,7 @@ from kfet.models import (
|
||||||
TransferGroup,
|
TransferGroup,
|
||||||
)
|
)
|
||||||
from kfet.statistic import DayScale, MonthScale, ScaleMixin, WeekScale, scale_url_params
|
from kfet.statistic import DayScale, MonthScale, ScaleMixin, WeekScale, scale_url_params
|
||||||
|
from shared.views import AutocompleteView
|
||||||
|
|
||||||
from .auth import KFET_GENERIC_TRIGRAMME
|
from .auth import KFET_GENERIC_TRIGRAMME
|
||||||
from .auth.views import ( # noqa
|
from .auth.views import ( # noqa
|
||||||
|
@ -2586,3 +2592,18 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
return context
|
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
|
import logging
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
from dal import autocomplete
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
if getattr(settings, "LDAP_SERVER_URL", None):
|
if getattr(settings, "LDAP_SERVER_URL", None):
|
||||||
import ldap
|
import ldap
|
||||||
|
@ -21,14 +21,53 @@ class SearchUnit:
|
||||||
|
|
||||||
A search unit should implement a `search` method taking a list of keywords as
|
A search unit should implement a `search` method taking a list of keywords as
|
||||||
argument and returning an iterable of search results.
|
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):
|
def search(self, _keywords):
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Class implementing the SeachUnit interface should implement the search "
|
"Class implementing the SearchUnit interface should implement the search "
|
||||||
"method"
|
"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
|
# Model-based search
|
||||||
|
@ -57,6 +96,10 @@ class ModelSearch(SearchUnit):
|
||||||
model = None
|
model = None
|
||||||
search_fields = []
|
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):
|
def get_queryset_filter(self, keywords):
|
||||||
filter_q = Q()
|
filter_q = Q()
|
||||||
|
|
||||||
|
@ -82,14 +125,6 @@ class ModelSearch(SearchUnit):
|
||||||
return self.model.objects.filter(self.get_queryset_filter(keywords))
|
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
|
# LDAP search
|
||||||
# ---
|
# ---
|
||||||
|
@ -102,6 +137,8 @@ class LDAPSearch(SearchUnit):
|
||||||
domain_component = "dc=spi,dc=ens,dc=fr"
|
domain_component = "dc=spi,dc=ens,dc=fr"
|
||||||
search_fields = ["cn", "uid"]
|
search_fields = ["cn", "uid"]
|
||||||
|
|
||||||
|
verbose_name = _("Comptes clippers")
|
||||||
|
|
||||||
def get_ldap_query(self, keywords):
|
def get_ldap_query(self, keywords):
|
||||||
"""Return a search query with the following semantics:
|
"""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)
|
django_logger.error("An LDAP error occurred", exc_info=err)
|
||||||
return []
|
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
|
# Composition of autocomplete units
|
||||||
|
@ -157,18 +200,13 @@ class LDAPSearch(SearchUnit):
|
||||||
class Compose:
|
class Compose:
|
||||||
"""Search with several units and remove duplicate results.
|
"""Search with several units and remove duplicate results.
|
||||||
|
|
||||||
The `search_units` class attribute should be a list of tuples of the form `(name,
|
The `search_units` class attribute should be a list of pairs of the form `(name,
|
||||||
uniq_key, search_unit)`.
|
search_unit)`.
|
||||||
|
|
||||||
The `search` method produces a dictionary whose keys are the `name`s given in
|
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
|
`search_units` and whose values are iterables produced by the different search
|
||||||
units.
|
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:
|
Typical Example:
|
||||||
|
|
||||||
>>> from django.contrib.auth.models import User
|
>>> from django.contrib.auth.models import User
|
||||||
|
@ -176,11 +214,17 @@ class Compose:
|
||||||
>>> class UserSearch(ModelSearch):
|
>>> class UserSearch(ModelSearch):
|
||||||
... model = User
|
... model = User
|
||||||
... search_fields = ["username", "first_name", "last_name"]
|
... 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):
|
>>> class UserAndClipperSearch(Compose):
|
||||||
... search_units = [
|
... search_units = [
|
||||||
... ("users", "username", UserSearch),
|
... ("users", UserSearch()),
|
||||||
... ("clippers", "clipper", LDAPSearch),
|
... ("clippers", LDAPSearch()),
|
||||||
... ]
|
... ]
|
||||||
|
|
||||||
In this example, clipper accounts that already have an associated user (i.e. with a
|
In this example, clipper accounts that already have an associated user (i.e. with a
|
||||||
|
@ -190,11 +234,15 @@ class Compose:
|
||||||
search_units = []
|
search_units = []
|
||||||
|
|
||||||
def search(self, keywords):
|
def search(self, keywords):
|
||||||
uniq_results = set()
|
seen_uuids = set()
|
||||||
results = {}
|
results = {}
|
||||||
for name, uniq_key, search_unit in self.search_units:
|
for name, search_unit in self.search_units:
|
||||||
res = search_unit().search(keywords)
|
uniq_res = []
|
||||||
res = [r for r in res if getattr(r, uniq_key) not in uniq_results]
|
for r in search_unit.search(keywords):
|
||||||
uniq_results |= set((getattr(r, uniq_key) for r in res))
|
uuid = search_unit.result_uuid(r)
|
||||||
results[name] = res
|
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
|
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()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
def highlight_text(text, q):
|
@register.filter
|
||||||
|
def highlight(text, q):
|
||||||
q2 = "|".join(re.escape(word) for word in q.split())
|
q2 = "|".join(re.escape(word) for word in q.split())
|
||||||
pattern = re.compile(r"(?P<filter>%s)" % q2, re.IGNORECASE)
|
pattern = re.compile(r"(?P<filter>%s)" % q2, re.IGNORECASE)
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
re.sub(pattern, r"<span class='highlight'>\g<filter></span>", text)
|
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 whose `initialize_method` always return the same ldap object.
|
||||||
mock_ldap_module = self.MockLDAPModule(mock_ldap_obj)
|
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()
|
patcher.start()
|
||||||
self.addCleanup(patcher.stop)
|
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