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

View file

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

View file

@ -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"> {% if not results %}
<span class="autocomplete-item">{% trans "Membres" %}</span> <span class="autocomplete-item">
</li> {% trans "Aucune correspondance trouvée" %}
{% for user in members %} </span>
{% if forloop.counter < 5 %} {% else %}
<li class="autocomplete-value"> <span class="autocomplete-item">
<a class="autocomplete-item" href="#TODO"> {% trans "Pas dans la liste ?" %}
{{ user|highlight_user:q }} </span>
</a> {% endif %}
</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>
</li> </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" 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"),
] ]

View file

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

View file

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

View file

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

View file

@ -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 %}"> {% else %}
{{ user|highlight_user:q }} <span class="autocomplete-item">
</a> {% trans "Pas dans la liste ?" %}
</li> </span>
{% elif forloop.counter == 5 %} {% endif %}
<li class="autocomplete-more">...</li> </li>
{% endif %} <li class="autocomplete-new">
{% endfor %} <a class="autocomplete-item" href="{% url "empty-registration" %}">
{% endif %} {% trans "Créer un compte" %}
</a>
{% if others %} </li>
<li class="autocomplete-header">Non-membres</li> {% endblock %}
{% 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>

View file

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

View file

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

View file

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

View file

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

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("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() @register.filter()
def widget_type(field): def widget_type(field):
return field.field.widget.__class__.__name__ return field.field.widget.__class__.__name__

View file

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

View file

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

View file

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

View file

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

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

View file

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