Merge branch 'kerl/factor_autocompletion_views1' into 'master'

Mécanisme de dé-duplication des résultats plus souple pour l'autocomplétion

See merge request klub-dev-ens/gestioCOF!427
This commit is contained in:
Ludovic Stephan 2020-07-05 11:25:35 +02:00
commit 68ccd4722f
13 changed files with 87 additions and 39 deletions

View file

@ -63,7 +63,7 @@ linters:
- pip install --upgrade black isort flake8 - pip install --upgrade black isort flake8
script: script:
- black --check . - black --check .
- isort --recursive --check-only --diff bda bds clubs cof events gestioncof kfet petitscours provisioning shared - isort --check --diff .
# Print errors only # Print errors only
- flake8 --exit-zero bda bds clubs cof events gestioncof kfet petitscours provisioning shared - flake8 --exit-zero bda bds clubs cof events gestioncof kfet petitscours provisioning shared
cache: cache:

View file

@ -48,7 +48,7 @@ if type isort &>/dev/null; then
ISORT_OUTPUT="/tmp/gc-isort-output.log" ISORT_OUTPUT="/tmp/gc-isort-output.log"
touch $ISORT_OUTPUT touch $ISORT_OUTPUT
if ! echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort --check-only &>$ISORT_OUTPUT; then if ! echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort --check &>$ISORT_OUTPUT; then
echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort &>$ISORT_OUTPUT echo "$STAGED_PYTHON_FILES" | xargs -d'\n' isort &>$ISORT_OUTPUT
printf "Reformatted.\n" printf "Reformatted.\n"
formatter_updated=1 formatter_updated=1

View file

@ -15,6 +15,9 @@ class BDSMemberSearch(autocomplete.ModelSearch):
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
class BDSOthersSearch(autocomplete.ModelSearch): class BDSOthersSearch(autocomplete.ModelSearch):
model = User model = User
@ -25,12 +28,15 @@ class BDSOthersSearch(autocomplete.ModelSearch):
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
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", autocomplete.LDAPSearch()),
] ]

View file

@ -2,12 +2,12 @@
Django development settings for the cof project. Django development settings for the cof project.
The settings that are not listed here are imported from .common The settings that are not listed here are imported from .common
""" """
import os import os
from .common import * # NOQA
from .common import BASE_DIR, INSTALLED_APPS from .common import BASE_DIR, INSTALLED_APPS
from .common import * # NOQA
# --- # ---
# BDS-only Django settings # BDS-only Django settings
# --- # ---

View file

@ -2,10 +2,8 @@
Django development settings for the cof project. Django development settings for the cof project.
The settings that are not listed here are imported from .common 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,
@ -15,6 +13,8 @@ from .common import (
import_secret, import_secret,
) )
from .common import * # NOQA
# --- # ---
# COF-specific secrets # COF-specific secrets
# --- # ---

View file

@ -1,11 +1,11 @@
"""Django local development settings.""" """Django local development settings."""
import os import os
from . import bds_prod from . import bds_prod
from .cof_prod import * # NOQA
from .cof_prod import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING from .cof_prod import BASE_DIR, INSTALLED_APPS, MIDDLEWARE, TESTING
from .cof_prod import * # NOQA
# --- # ---
# Merge COF and BDS configs # Merge COF and BDS configs
# --- # ---

View file

@ -1,7 +1,6 @@
""" """
Fichier principal de configuration des urls du projet GestioCOF Fichier principal de configuration des urls du projet GestioCOF
""" """
from django.conf import settings from django.conf import settings
from django.conf.urls.i18n import i18n_patterns from django.conf.urls.i18n import i18n_patterns
from django.conf.urls.static import static from django.conf.urls.static import static
@ -20,6 +19,10 @@ urlpatterns = [
] ]
if "gestioncof" in settings.INSTALLED_APPS: if "gestioncof" in settings.INSTALLED_APPS:
from django_js_reverse.views import urls_js
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
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.autocomplete import autocomplete
from gestioncof.urls import ( from gestioncof.urls import (
@ -29,9 +32,6 @@ if "gestioncof" in settings.INSTALLED_APPS:
export_patterns, export_patterns,
surveys_patterns, surveys_patterns,
) )
from django_js_reverse.views import urls_js
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
# Also includes BdA, K-Fêt, etc. # Also includes BdA, K-Fêt, etc.
urlpatterns += [ urlpatterns += [

View file

@ -12,6 +12,7 @@ class GestioncofConfig(AppConfig):
def register_config(self): def register_config(self):
import djconfig import djconfig
from .forms import GestioncofConfigForm from .forms import GestioncofConfigForm
djconfig.register(GestioncofConfigForm) djconfig.register(GestioncofConfigForm)

View file

@ -18,6 +18,9 @@ class COFMemberSearch(autocomplete.ModelSearch):
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
class COFOthersSearch(autocomplete.ModelSearch): class COFOthersSearch(autocomplete.ModelSearch):
model = User model = User
@ -28,12 +31,15 @@ class COFOthersSearch(autocomplete.ModelSearch):
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
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", autocomplete.LDAPSearch()),
] ]

View file

@ -10,6 +10,7 @@ class KFetConfig(AppConfig):
def register_config(self): def register_config(self):
import djconfig import djconfig
from kfet.forms import KFetConfigForm from kfet.forms import KFetConfigForm
djconfig.register(KFetConfigForm) djconfig.register(KFetConfigForm)

View file

@ -23,6 +23,9 @@ class KfetAccountSearch(autocomplete.ModelSearch):
qset_filter &= Q(profile__account_kfet__isnull=False) qset_filter &= Q(profile__account_kfet__isnull=False)
return qset_filter return qset_filter
def result_uuid(self, user):
return user.username
class COFMemberSearch(autocomplete.ModelSearch): class COFMemberSearch(autocomplete.ModelSearch):
model = User model = User
@ -33,6 +36,9 @@ class COFMemberSearch(autocomplete.ModelSearch):
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
class OthersSearch(autocomplete.ModelSearch): class OthersSearch(autocomplete.ModelSearch):
model = User model = User
@ -43,13 +49,16 @@ class OthersSearch(autocomplete.ModelSearch):
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
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", autocomplete.LDAPSearch()),
] ]

View file

@ -39,5 +39,4 @@ known_django = django
known_first_party = bda,bds,clubs,cof,events,gestioncof,kfet,petitscours,shared known_first_party = bda,bds,clubs,cof,events,gestioncof,kfet,petitscours,shared
line_length = 88 line_length = 88
multi_line_output = 3 multi_line_output = 3
not_skip = __init__.py
sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER

View file

@ -21,14 +21,32 @@ 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:
- 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
def result_uuid(self, result):
"""A universal unique identifier for the search results."""
return None
# --- # ---
# Model-based search # Model-based search
@ -148,6 +166,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_uuid(self, clipper):
return clipper.clipper
# --- # ---
# Composition of autocomplete units # Composition of autocomplete units
@ -157,18 +178,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 +192,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 users, this avoids showing the same user twice (here and in
... # then 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 +212,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