import logging from collections import namedtuple from django.conf import settings from django.db.models import Q from django.utils.translation import gettext_lazy as _ if getattr(settings, "LDAP_SERVER_URL", None): import ldap else: # shared.tests.testcases.TestCaseMixin.mockLDAP needs # an ldap object to be in the scope ldap = None django_logger = logging.getLogger("django.request") class SearchUnit: """Base class for all the search utilities. A search unit should implement a `search` method taking a list of keywords as argument and returning an iterable of search results. It might optionally implement the following methods and attributes: - verbose_name (attribute): a nice name to refer to the results of this search unit in templates. Examples: "COF Members", "K-FĂȘt accounts", etc. - result_verbose_name (method): a callable that takes one search result as an input and returns a nice name to refer to this particular result in templates. Example: `lambda user: user.get_full_name()` - result_link (method): a callable that takes one search result and returns a url to make this particular search result clickable on the search page. For instance this can be a link to a detail view of the object. - result_uuid (method): a callable that takes one result as an input and returns an identifier that is globally unique across search units for this object. This is used to compare results coming from different search units in the `Compose` class. For instance, if the same user can be returned by the LDAP search and a model search instance, using the clipper login as a UUID in both units avoids this user to be returned twice by `Compose`. Returning `None` means that the object should be considered unique. """ # Mandatory method def search(self, _keywords): raise NotImplementedError( "Class implementing the SearchUnit interface should implement the search " "method" ) # Optional attributes and methods verbose_name = None def result_verbose_name(self, result): """Hook to customize the way results are displayed.""" return str(result) def result_link(self, result): """Hook to add a link on individual results on the search page.""" return None def result_uuid(self, result): """A universal unique identifier for the search results.""" return None # --- # Model-based search # --- class ModelSearch(SearchUnit): """Basic search engine for models based on filtering. The class should be configured through its `model` class attribute: the `search` method will return a queryset of instances of this model. The `search_fields` attributes indicates which fields to search in. Example: >>> from django.contrib.auth.models import User >>> >>> class UserSearch(ModelSearch): ... model = User ... search_fields = ["username", "first_name", "last_name"] >>> >>> user_search = UserSearch() # has type ModelSearch[User] >>> user_search.search(["toto", "foo"]) # returns a queryset of Users """ model = None search_fields = [] def __init__(self): if self.verbose_name is None: self.verbose_name = "{} search".format(self.model._meta.verbose_name) def get_queryset_filter(self, keywords): filter_q = Q() if not keywords: return filter_q for keyword in keywords: kw_filter = Q() for field in self.search_fields: kw_filter |= Q(**{"{}__icontains".format(field): keyword}) filter_q &= kw_filter return filter_q def search(self, keywords): """Returns the queryset of model instances matching all the keywords. The semantic of the search is the following: a model instance appears in the search results iff all of the keywords given as arguments occur in at least one of the search fields. """ return self.model.objects.filter(self.get_queryset_filter(keywords)) # --- # LDAP search # --- Clipper = namedtuple("Clipper", ["clipper", "fullname", "mail"]) class LDAPSearch(SearchUnit): ldap_server_url = getattr(settings, "LDAP_SERVER_URL", None) domain_component = "dc=spi,dc=ens,dc=fr" search_fields = ["cn", "uid"] attr_list = ["cn", "uid", "mail"] verbose_name = _("Comptes clippers") def get_ldap_query(self, keywords): """Return a search query with the following semantics: A Clipper appears in the search results iff all of the keywords given as arguments occur in at least one of the search fields. """ # Dumb but safe keywords = filter(str.isalnum, keywords) ldap_filters = [] for keyword in keywords: ldap_filter = "(|{})".format( "".join( "({}=*{}*)".format(field, keyword) for field in self.search_fields ) ) ldap_filters.append(ldap_filter) return "(&{})".format("".join(ldap_filters)) def search(self, keywords): """Return a list of Clipper objects matching all the keywords.""" query = self.get_ldap_query(keywords) if ldap is None or query == "(&)": return [] try: ldap_obj = ldap.initialize(self.ldap_server_url) res = ldap_obj.search_s( self.domain_component, ldap.SCOPE_SUBTREE, query, self.attr_list ) return [ Clipper( clipper=attrs["uid"][0].decode("utf-8"), fullname=attrs["cn"][0].decode("utf-8"), mail=attrs["mail"][0].decode("utf-8"), ) for (_, attrs) in res if "uid" in attrs # Hack to discard weird accounts like root ] except ldap.LDAPError as err: django_logger.error("An LDAP error occurred", exc_info=err) return [] def result_verbose_name(self, clipper): return "{} ({})".format(clipper.fullname, clipper.clipper) def result_uuid(self, clipper): return clipper.clipper # --- # Composition of autocomplete units # --- class Compose: """Search with several units and remove duplicate results. The `search_units` class attribute should be a list of pairs of the form `(name, search_unit)`. The `search` method produces a dictionary whose keys are the `name`s given in `search_units` and whose values are iterables produced by the different search units. Typical Example: >>> from django.contrib.auth.models import User >>> >>> class UserSearch(ModelSearch): ... model = User ... search_fields = ["username", "first_name", "last_name"] ... ... def result_uuid(self, user): ... # Assuming that `.username` stores the clipper login of already ... # registered users, this avoids showing the same user twice (here and in ... # then ldap unit). ... return user.username >>> >>> class UserAndClipperSearch(Compose): ... search_units = [ ... ("users", UserSearch()), ... ("clippers", LDAPSearch()), ... ] In this example, clipper accounts that already have an associated user (i.e. with a username equal to the clipper login), will not appear in the results. """ search_units = [] def search(self, keywords): seen_uuids = set() results = {} for name, search_unit in self.search_units: uniq_res = [] for r in search_unit.search(keywords): uuid = search_unit.result_uuid(r) if uuid is None or uuid not in seen_uuids: uniq_res.append(r) if uuid is not None: seen_uuids.add(uuid) results[name] = uniq_res return results