Merge branch 'kerl/factor_autocompletion_views3' into 'master'

Vue et template génériques d'autocomplétion

See merge request klub-dev-ens/gestioCOF!429
This commit is contained in:
Ludovic Stephan 2020-07-18 17:46:04 +02:00
commit c6a6e7fafa
23 changed files with 284 additions and 318 deletions

View file

@ -1,5 +1,6 @@
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 import autocomplete from shared import autocomplete
@ -9,6 +10,7 @@ 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)
@ -18,10 +20,14 @@ class BDSMemberSearch(autocomplete.ModelSearch):
def result_uuid(self, user): def result_uuid(self, user):
return user.username 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)
@ -31,12 +37,20 @@ class BDSOthersSearch(autocomplete.ModelSearch):
def result_uuid(self, user): def result_uuid(self, user):
return user.username 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", BDSMemberSearch()), ("members", BDSMemberSearch()),
("others", BDSOthersSearch()), ("others", BDSOthersSearch()),
("clippers", 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">
<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 %}

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

@ -4,9 +4,8 @@ The settings that are not listed here are imported from .common
""" """
import os import os
from .common import BASE_DIR, INSTALLED_APPS
from .common import * # NOQA from .common import * # NOQA
from .common import BASE_DIR, INSTALLED_APPS
# --- # ---
# BDS-only Django settings # BDS-only Django settings

View file

@ -4,6 +4,7 @@ The settings that are not listed here are imported from .common
""" """
import os import os
from .common import * # NOQA
from .common import ( from .common import (
AUTHENTICATION_BACKENDS, AUTHENTICATION_BACKENDS,
BASE_DIR, BASE_DIR,
@ -13,8 +14,6 @@ from .common import (
import_secret, import_secret,
) )
from .common import * # NOQA
# --- # ---
# COF-specific secrets # COF-specific secrets
# --- # ---

View file

@ -2,9 +2,8 @@
import os import os
from . import bds_prod from . import bds_prod
from .cof_prod import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
from .cof_prod import * # NOQA from .cof_prod import * # NOQA
from .cof_prod import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
# --- # ---
# Merge COF and BDS configs # Merge COF and BDS configs

View file

@ -1,5 +1,7 @@
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.urls import reverse
from django.utils.translation import gettext_lazy as _
from shared import autocomplete from shared import autocomplete
@ -9,6 +11,7 @@ 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)
@ -18,10 +21,14 @@ class COFMemberSearch(autocomplete.ModelSearch):
def result_uuid(self, user): def result_uuid(self, user):
return user.username 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)
@ -31,10 +38,21 @@ class COFOthersSearch(autocomplete.ModelSearch):
def result_uuid(self, user): def result_uuid(self, user):
return user.username return user.username
def result_link(self, user):
return reverse("user-registration", args=(user.username,))
class COFSearch(autocomplete.Compose):
class COFLDAPSearch(autocomplete.LDAPSearch):
def result_link(self, clipper):
return reverse("clipper-registration", args=(clipper.clipper, clipper.fullname))
class COFAutocomplete(autocomplete.Compose):
search_units = [ search_units = [
("members", COFMemberSearch()), ("members", COFMemberSearch()),
("others", COFOthersSearch()), ("others", COFOthersSearch()),
("clippers", autocomplete.LDAPSearch()), ("clippers", COFLDAPSearch()),
] ]
cof_autocomplete = COFAutocomplete()

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 %}">
{{ 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 %}

View file

@ -16,7 +16,7 @@ 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 from shared.autocomplete import Clipper, LDAPSearch
from shared.tests.mixins import CSVResponseMixin, ICalMixin, MockLDAPMixin from shared.tests.mixins import CSVResponseMixin, ICalMixin, MockLDAPMixin
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,7 +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 COFSearch from gestioncof.autocomplete import cof_autocomplete
from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required
from gestioncof.forms import ( from gestioncof.forms import (
CalendarForm, CalendarForm,
@ -59,7 +59,7 @@ from gestioncof.models import (
SurveyQuestion, SurveyQuestion,
SurveyQuestionAnswer, SurveyQuestionAnswer,
) )
from shared.views import Select2QuerySetView from shared.views import AutocompleteView, Select2QuerySetView
class HomeView(LoginRequiredMixin, TemplateView): class HomeView(LoginRequiredMixin, TemplateView):
@ -948,14 +948,6 @@ class UserAutocompleteView(BuroRequiredMixin, Select2QuerySetView):
search_fields = ("username", "first_name", "last_name") search_fields = ("username", "first_name", "last_name")
class RegistrationAutocompleteView(BuroRequiredMixin, TemplateView): class RegistrationAutocompleteView(BuroRequiredMixin, AutocompleteView):
template_name = "gestioncof/search_results.html" template_name = "gestioncof/search_results.html"
search_composer = cof_autocomplete
def get_context_data(self, *args, **kwargs):
ctx = super().get_context_data(*args, **kwargs)
if "q" not in self.request.GET:
raise Http404
q = self.request.GET["q"]
ctx["q"] = q
ctx.update(COFSearch().search(q.split()))
return ctx

View file

@ -1,5 +1,7 @@
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.urls import reverse
from django.utils.translation import gettext_lazy as _
from shared import autocomplete from shared import autocomplete
@ -14,12 +16,16 @@ 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): def result_uuid(self, user):
return user.username return user.username
@ -27,6 +33,7 @@ class KfetAccountSearch(autocomplete.ModelSearch):
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)
@ -36,10 +43,14 @@ class COFMemberSearch(autocomplete.ModelSearch):
def result_uuid(self, user): def result_uuid(self, user):
return user.username 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)
@ -49,11 +60,31 @@ class OthersSearch(autocomplete.ModelSearch):
def result_uuid(self, user): def result_uuid(self, user):
return user.username 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", KfetAccountSearch()), ("kfet", KfetAccountSearch()),
("users_cof", COFMemberSearch()), ("users_cof", COFMemberSearch()),
("users_notcof", OthersSearch()), ("users_notcof", OthersSearch()),
("clippers", autocomplete.LDAPSearch()), ("clippers", KfetLDAPSearch()),
] ]
kfet_autocomplete = KfetAutocomplete()
class KfetAccountOnlyAutocomplete(autocomplete.Compose):
search_units = [("kfet", KfetAccountSearch())]
kfet_account_only_autocomplete = KfetAccountOnlyAutocomplete()

View file

@ -138,7 +138,7 @@
* Specific account create * Specific account create
*/ */
.highlight_autocomplete { .highlight {
font-weight:bold; font-weight:bold;
text-decoration:underline; text-decoration:underline;
} }
@ -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

@ -1,8 +1,4 @@
import re
from django import template from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
from ..utils import to_ukf from ..utils import to_ukf
@ -11,40 +7,14 @@ 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__
@register.filter() @register.filter()
def slice(l, start, end=None): def slice(t, start, end=None):
if end is None: if end is None:
end = start end = start
start = 0 start = 0
return l[start:end] return t[start:end]

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

@ -27,7 +27,7 @@ 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, KfetAutocomplete from kfet.autocomplete import 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 (
@ -79,6 +79,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
@ -2594,34 +2595,11 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
# --- # ---
class AccountCreateAutocompleteView(PermissionRequiredMixin, TemplateView): class AccountCreateAutocompleteView(PermissionRequiredMixin, AutocompleteView):
template_name = "kfet/account_create_autocomplete.html"
permission_required = "kfet.is_team" permission_required = "kfet.is_team"
search_composer = kfet_autocomplete
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "q" not in self.request.GET:
raise Http404
q = self.request.GET["q"]
ctx["q"] = q
results = KfetAutocomplete().search(q.split())
ctx["options"] = sum((len(res) for res in results.values()))
ctx.update(results)
return ctx
class AccountSearchAutocompleteView(PermissionRequiredMixin, TemplateView): class AccountSearchAutocompleteView(PermissionRequiredMixin, AutocompleteView):
template_name = "kfet/account_search_autocomplete.html"
permission_required = "kfet.is_team" permission_required = "kfet.is_team"
search_composer = kfet_account_only_autocomplete
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

@ -3,6 +3,7 @@ from collections import namedtuple
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
@ -23,6 +24,17 @@ class SearchUnit:
It might optionally implement the following methods and attributes: 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 - 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. identifier that is globally unique across search units for this object.
This is used to compare results coming from different search units in the This is used to compare results coming from different search units in the
@ -42,6 +54,16 @@ class SearchUnit:
# Optional attributes and methods # 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): def result_uuid(self, result):
"""A universal unique identifier for the search results.""" """A universal unique identifier for the search results."""
return None return None
@ -74,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()
@ -111,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:
@ -157,6 +185,9 @@ 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): def result_uuid(self, clipper):
return clipper.clipper return clipper.clipper

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

@ -1,4 +1,9 @@
from collections import namedtuple
from dal import autocomplete 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 from shared.autocomplete import ModelSearch
@ -9,3 +14,44 @@ class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView):
def get_queryset(self): def get_queryset(self):
keywords = self.q.split() keywords = self.q.split()
return super().search(keywords) 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