managers.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  1. from __future__ import unicode_literals
  2. import django
  3. from future.builtins import int, zip
  4. from functools import reduce
  5. from operator import ior, iand
  6. from string import punctuation
  7. from django.apps import apps
  8. from django.core.exceptions import ImproperlyConfigured
  9. from django.db.models import Manager, Q, CharField, TextField
  10. from django.db.models.manager import ManagerDescriptor
  11. from django.db.models.query import QuerySet
  12. from django.contrib.sites.managers import CurrentSiteManager as DjangoCSM
  13. from django.utils.timezone import now
  14. from django.utils.translation import ugettext_lazy as _
  15. from mezzanine.conf import settings
  16. from mezzanine.utils.sites import current_site_id
  17. from mezzanine.utils.urls import home_slug
  18. if django.VERSION >= (1, 10):
  19. class ManagerDescriptor(ManagerDescriptor):
  20. """
  21. This class exists purely to skip the abstract model check
  22. in the __get__ method of Django's ManagerDescriptor.
  23. """
  24. def __get__(self, instance, cls=None):
  25. if instance is not None:
  26. raise AttributeError(
  27. "Manager isn't accessible via %s instances" % cls.__name__
  28. )
  29. # In ManagerDescriptor.__get__, an exception is raised here
  30. # if cls is abstract
  31. if cls._meta.swapped:
  32. raise AttributeError(
  33. "Manager isn't available; "
  34. "'%s.%s' has been swapped for '%s'" % (
  35. cls._meta.app_label,
  36. cls._meta.object_name,
  37. cls._meta.swapped,
  38. )
  39. )
  40. return cls._meta.managers_map[self.manager.name]
  41. class PublishedManager(Manager):
  42. """
  43. Provides filter for restricting items returned by status and
  44. publish date when the given user is not a staff member.
  45. """
  46. def published(self, for_user=None):
  47. """
  48. For non-staff users, return items with a published status and
  49. whose publish and expiry dates fall before and after the
  50. current date when specified.
  51. """
  52. from mezzanine.core.models import CONTENT_STATUS_PUBLISHED
  53. if for_user is not None and for_user.is_staff:
  54. return self.all()
  55. return self.filter(
  56. Q(publish_date__lte=now()) | Q(publish_date__isnull=True),
  57. Q(expiry_date__gte=now()) | Q(expiry_date__isnull=True),
  58. Q(status=CONTENT_STATUS_PUBLISHED))
  59. def get_by_natural_key(self, slug):
  60. return self.get(slug=slug)
  61. def search_fields_to_dict(fields):
  62. """
  63. In ``SearchableQuerySet`` and ``SearchableManager``, search fields
  64. can either be a sequence, or a dict of fields mapped to weights.
  65. This function converts sequences to a dict mapped to even weights,
  66. so that we're consistently dealing with a dict of fields mapped to
  67. weights, eg: ("title", "content") -> {"title": 1, "content": 1}
  68. """
  69. if not fields:
  70. return {}
  71. try:
  72. int(list(dict(fields).values())[0])
  73. except (TypeError, ValueError):
  74. fields = dict(zip(fields, [1] * len(fields)))
  75. return fields
  76. class SearchableQuerySet(QuerySet):
  77. """
  78. QuerySet providing main search functionality for
  79. ``SearchableManager``.
  80. """
  81. def __init__(self, *args, **kwargs):
  82. self._search_ordered = False
  83. self._search_terms = set()
  84. self._search_fields = kwargs.pop("search_fields", {})
  85. super(SearchableQuerySet, self).__init__(*args, **kwargs)
  86. def search(self, query, search_fields=None):
  87. """
  88. Build a queryset matching words in the given search query,
  89. treating quoted terms as exact phrases and taking into
  90. account + and - symbols as modifiers controlling which terms
  91. to require and exclude.
  92. """
  93. # ### DETERMINE FIELDS TO SEARCH ###
  94. # Use search_fields arg if given, otherwise use search_fields
  95. # initially configured by the manager class.
  96. if search_fields:
  97. self._search_fields = search_fields_to_dict(search_fields)
  98. if not self._search_fields:
  99. return self.none()
  100. # ### BUILD LIST OF TERMS TO SEARCH FOR ###
  101. # Remove extra spaces, put modifiers inside quoted terms.
  102. terms = " ".join(query.split()).replace("+ ", "+") \
  103. .replace('+"', '"+') \
  104. .replace("- ", "-") \
  105. .replace('-"', '"-') \
  106. .split('"')
  107. # Strip punctuation other than modifiers from terms and create
  108. # terms list, first from quoted terms and then remaining words.
  109. terms = [("" if t[0:1] not in "+-" else t[0:1]) + t.strip(punctuation)
  110. for t in terms[1::2] + "".join(terms[::2]).split()]
  111. # Remove stop words from terms that aren't quoted or use
  112. # modifiers, since words with these are an explicit part of
  113. # the search query. If doing so ends up with an empty term
  114. # list, then keep the stop words.
  115. terms_no_stopwords = [t for t in terms if t.lower() not in
  116. settings.STOP_WORDS]
  117. get_positive_terms = lambda terms: [t.lower().strip(punctuation)
  118. for t in terms if t[0:1] != "-"]
  119. positive_terms = get_positive_terms(terms_no_stopwords)
  120. if positive_terms:
  121. terms = terms_no_stopwords
  122. else:
  123. positive_terms = get_positive_terms(terms)
  124. # Append positive terms (those without the negative modifier)
  125. # to the internal list for sorting when results are iterated.
  126. if not positive_terms:
  127. return self.none()
  128. else:
  129. self._search_terms.update(positive_terms)
  130. # ### BUILD QUERYSET FILTER ###
  131. # Create the queryset combining each set of terms.
  132. excluded = [reduce(iand, [~Q(**{"%s__icontains" % f: t[1:]}) for f in
  133. self._search_fields.keys()]) for t in terms if t[0:1] == "-"]
  134. required = [reduce(ior, [Q(**{"%s__icontains" % f: t[1:]}) for f in
  135. self._search_fields.keys()]) for t in terms if t[0:1] == "+"]
  136. optional = [reduce(ior, [Q(**{"%s__icontains" % f: t}) for f in
  137. self._search_fields.keys()]) for t in terms if t[0:1] not in "+-"]
  138. queryset = self
  139. if excluded:
  140. queryset = queryset.filter(reduce(iand, excluded))
  141. if required:
  142. queryset = queryset.filter(reduce(iand, required))
  143. # Optional terms aren't relevant to the filter if there are
  144. # terms that are explicitly required.
  145. elif optional:
  146. queryset = queryset.filter(reduce(ior, optional))
  147. return queryset.distinct()
  148. def _clone(self, *args, **kwargs):
  149. """
  150. Ensure attributes are copied to subsequent queries.
  151. """
  152. for attr in ("_search_terms", "_search_fields", "_search_ordered"):
  153. kwargs[attr] = getattr(self, attr)
  154. return super(SearchableQuerySet, self)._clone(*args, **kwargs)
  155. def order_by(self, *field_names):
  156. """
  157. Mark the filter as being ordered if search has occurred.
  158. """
  159. if not self._search_ordered:
  160. self._search_ordered = len(self._search_terms) > 0
  161. return super(SearchableQuerySet, self).order_by(*field_names)
  162. def iterator(self):
  163. """
  164. If search has occurred and no ordering has occurred, decorate
  165. each result with the number of search terms so that it can be
  166. sorted by the number of occurrence of terms.
  167. In the case of search fields that span model relationships, we
  168. cannot accurately match occurrences without some very
  169. complicated traversal code, which we won't attempt. So in this
  170. case, namely when there are no matches for a result (count=0),
  171. and search fields contain relationships (double underscores),
  172. we assume one match for one of the fields, and use the average
  173. weight of all search fields with relationships.
  174. """
  175. results = super(SearchableQuerySet, self).iterator()
  176. if self._search_terms and not self._search_ordered:
  177. results = list(results)
  178. for i, result in enumerate(results):
  179. count = 0
  180. related_weights = []
  181. for (field, weight) in self._search_fields.items():
  182. if "__" in field:
  183. related_weights.append(weight)
  184. for term in self._search_terms:
  185. field_value = getattr(result, field, None)
  186. if field_value:
  187. count += field_value.lower().count(term) * weight
  188. if not count and related_weights:
  189. count = int(sum(related_weights) / len(related_weights))
  190. results[i].result_count = count
  191. return iter(results)
  192. return results
  193. class SearchableManager(Manager):
  194. """
  195. Manager providing a chainable queryset.
  196. Adapted from http://www.djangosnippets.org/snippets/562/
  197. search method supports spanning across models that subclass the
  198. model being used to search.
  199. """
  200. def __init__(self, *args, **kwargs):
  201. self._search_fields = kwargs.pop("search_fields", {})
  202. super(SearchableManager, self).__init__(*args, **kwargs)
  203. def get_search_fields(self):
  204. """
  205. Returns the search field names mapped to weights as a dict.
  206. Used in ``get_queryset`` below to tell ``SearchableQuerySet``
  207. which search fields to use. Also used by ``DisplayableAdmin``
  208. to populate Django admin's ``search_fields`` attribute.
  209. Search fields can be populated via
  210. ``SearchableManager.__init__``, which then get stored in
  211. ``SearchableManager._search_fields``, which serves as an
  212. approach for defining an explicit set of fields to be used.
  213. Alternatively and more commonly, ``search_fields`` can be
  214. defined on models themselves. In this case, we look at the
  215. model and all its base classes, and build up the search
  216. fields from all of those, so the search fields are implicitly
  217. built up from the inheritence chain.
  218. Finally if no search fields have been defined at all, we
  219. fall back to any fields that are ``CharField`` or ``TextField``
  220. instances.
  221. """
  222. search_fields = self._search_fields.copy()
  223. if not search_fields:
  224. for cls in reversed(self.model.__mro__):
  225. super_fields = getattr(cls, "search_fields", {})
  226. search_fields.update(search_fields_to_dict(super_fields))
  227. if not search_fields:
  228. search_fields = []
  229. for f in self.model._meta.fields:
  230. if isinstance(f, (CharField, TextField)):
  231. search_fields.append(f.name)
  232. search_fields = search_fields_to_dict(search_fields)
  233. return search_fields
  234. def get_queryset(self):
  235. search_fields = self.get_search_fields()
  236. return SearchableQuerySet(self.model, search_fields=search_fields)
  237. def contribute_to_class(self, model, name):
  238. """
  239. Newer versions of Django explicitly prevent managers being
  240. accessed from abstract classes, which is behaviour the search
  241. API has always relied on. Here we reinstate it.
  242. """
  243. super(SearchableManager, self).contribute_to_class(model, name)
  244. setattr(model, name, ManagerDescriptor(self))
  245. def search(self, *args, **kwargs):
  246. """
  247. Proxy to queryset's search method for the manager's model and
  248. any models that subclass from this manager's model if the
  249. model is abstract.
  250. """
  251. if not settings.SEARCH_MODEL_CHOICES:
  252. # No choices defined - build a list of leaf models (those
  253. # without subclasses) that inherit from Displayable.
  254. models = [m for m in apps.get_models()
  255. if issubclass(m, self.model)]
  256. parents = reduce(ior, [set(m._meta.get_parent_list())
  257. for m in models])
  258. models = [m for m in models if m not in parents]
  259. elif getattr(self.model._meta, "abstract", False):
  260. # When we're combining model subclasses for an abstract
  261. # model (eg Displayable), we only want to use models that
  262. # are represented by the ``SEARCH_MODEL_CHOICES`` setting.
  263. # Now this setting won't contain an exact list of models
  264. # we should use, since it can define superclass models such
  265. # as ``Page``, so we check the parent class list of each
  266. # model when determining whether a model falls within the
  267. # ``SEARCH_MODEL_CHOICES`` setting.
  268. search_choices = set()
  269. models = set()
  270. parents = set()
  271. errors = []
  272. for name in settings.SEARCH_MODEL_CHOICES:
  273. try:
  274. model = apps.get_model(*name.split(".", 1))
  275. except LookupError:
  276. errors.append(name)
  277. else:
  278. search_choices.add(model)
  279. if errors:
  280. raise ImproperlyConfigured("Could not load the model(s) "
  281. "%s defined in the 'SEARCH_MODEL_CHOICES' setting."
  282. % ", ".join(errors))
  283. for model in apps.get_models():
  284. # Model is actually a subclasses of what we're
  285. # searching (eg Displayabale)
  286. is_subclass = issubclass(model, self.model)
  287. # Model satisfies the search choices list - either
  288. # there are no search choices, model is directly in
  289. # search choices, or its parent is.
  290. this_parents = set(model._meta.get_parent_list())
  291. in_choices = not search_choices or model in search_choices
  292. in_choices = in_choices or this_parents & search_choices
  293. if is_subclass and (in_choices or not search_choices):
  294. # Add to models we'll seach. Also maintain a parent
  295. # set, used below for further refinement of models
  296. # list to search.
  297. models.add(model)
  298. parents.update(this_parents)
  299. # Strip out any models that are superclasses of models,
  300. # specifically the Page model which will generally be the
  301. # superclass for all custom content types, since if we
  302. # query the Page model as well, we will get duplicate
  303. # results.
  304. models -= parents
  305. else:
  306. models = [self.model]
  307. all_results = []
  308. user = kwargs.pop("for_user", None)
  309. for model in models:
  310. try:
  311. queryset = model.objects.published(for_user=user)
  312. except AttributeError:
  313. queryset = model.objects.get_queryset()
  314. all_results.extend(queryset.search(*args, **kwargs))
  315. return sorted(all_results, key=lambda r: r.result_count, reverse=True)
  316. class CurrentSiteManager(DjangoCSM):
  317. """
  318. Extends Django's site manager to first look up site by ID stored in
  319. the request, the session, then domain for the current request
  320. (accessible via threadlocals in ``mezzanine.core.request``), the
  321. environment variable ``MEZZANINE_SITE_ID`` (which can be used by
  322. management commands with the ``--site`` arg, finally falling back
  323. to ``settings.SITE_ID`` if none of those match a site.
  324. """
  325. use_in_migrations = False
  326. def __init__(self, field_name=None, *args, **kwargs):
  327. super(DjangoCSM, self).__init__(*args, **kwargs)
  328. self.__field_name = field_name
  329. self.__is_validated = False
  330. def get_queryset(self):
  331. if not self.__is_validated:
  332. self._get_field_name()
  333. lookup = {self.__field_name + "__id__exact": current_site_id()}
  334. return super(DjangoCSM, self).get_queryset().filter(**lookup)
  335. class DisplayableManager(CurrentSiteManager, PublishedManager,
  336. SearchableManager):
  337. """
  338. Manually combines ``CurrentSiteManager``, ``PublishedManager``
  339. and ``SearchableManager`` for the ``Displayable`` model.
  340. """
  341. def url_map(self, for_user=None, **kwargs):
  342. """
  343. Returns a dictionary of urls mapped to Displayable subclass
  344. instances, including a fake homepage instance if none exists.
  345. Used in ``mezzanine.core.sitemaps``.
  346. """
  347. class Home:
  348. title = _("Home")
  349. home = Home()
  350. setattr(home, "get_absolute_url", home_slug)
  351. items = {home.get_absolute_url(): home}
  352. for model in apps.get_models():
  353. if issubclass(model, self.model):
  354. for item in (model.objects.published(for_user=for_user)
  355. .filter(**kwargs)
  356. .exclude(slug__startswith="http://")
  357. .exclude(slug__startswith="https://")):
  358. items[item.get_absolute_url()] = item
  359. return items