From: Stephen Burrows Date: Fri, 10 Jun 2011 18:52:48 +0000 (-0400) Subject: Merge branch 'release' into develop X-Git-Tag: philo-0.9.1^2~8^2~4 X-Git-Url: http://git.ithinksw.org/philo.git/commitdiff_plain/87170ecd588c3472ee2d1ef30b568fb9fb756e70?ds=inline;hp=-c Merge branch 'release' into develop Conflicts: philo/contrib/sobol/models.py philo/contrib/sobol/search.py --- 87170ecd588c3472ee2d1ef30b568fb9fb756e70 diff --combined philo/contrib/sobol/models.py index ee5f62e,b35133e..ffe5871 --- a/philo/contrib/sobol/models.py +++ b/philo/contrib/sobol/models.py @@@ -11,7 -11,7 +11,7 @@@ from django.http import HttpResponseRed from django.utils import simplejson as json from django.utils.datastructures import SortedDict - from philo.contrib.sobol import registry + from philo.contrib.sobol import registry, get_search_instance from philo.contrib.sobol.forms import SearchForm from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash, RegistryIterator from philo.exceptions import ViewCanNotProvideSubpath @@@ -79,6 -79,8 +79,8 @@@ class Search(models.Model) self._favored_results += subresults else: break + if len(self._favored_results) == len(results): + self._favored_results = [] return self._favored_results class Meta: @@@ -151,20 -153,32 +153,20 @@@ class Click(models.Model) get_latest_by = 'datetime' -class RegistryChoiceField(SlugMultipleChoiceField): - def _get_choices(self): - if isinstance(self._choices, RegistryIterator): - return self._choices.copy() - elif hasattr(self._choices, 'next'): - choices, self._choices = itertools.tee(self._choices) - return choices - else: - return self._choices - choices = property(_get_choices) - - try: from south.modelsinspector import add_introspection_rules except ImportError: pass else: - add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"]) + add_introspection_rules([], ["^philo\.contrib\.sobol\.models\.RegistryChoiceField"]) class SearchView(MultiView): """Handles a view for the results of a search, anonymously tracks the selections made by end users, and provides an AJAX API for asynchronous search result loading. This can be particularly useful if some searches are slow.""" #: :class:`ForeignKey` to a :class:`.Page` which will be used to render the search results. results_page = models.ForeignKey(Page, related_name='search_results_related') - #: A :class:`.SlugMultipleChoiceField` whose choices are the contents of the :class:`.SearchRegistry` - searches = RegistryChoiceField(choices=registry.iterchoices()) + #: A :class:`.SlugMultipleChoiceField` whose choices are the contents of :obj:`.sobol.search.registry` + searches = SlugMultipleChoiceField(choices=registry.iterchoices()) #: A :class:`BooleanField` which controls whether or not the AJAX API is enabled. #: #: .. note:: If the AJAX API is enabled, a ``ajax_api_url`` attribute will be added to each search instance containing the url and get parameters for an AJAX request to retrieve results for that search. @@@ -194,10 -208,6 +196,6 @@@ ) return urlpatterns - def get_search_instance(self, slug, search_string): - """Gets the :class:`.BaseSearch` subclass registered with :obj:`.sobol.search.registry` as ``slug`` and instantiates it with ``search_string``.""" - return registry[slug](search_string.lower()) - def results_view(self, request, extra_context=None): """ Renders :attr:`results_page` with a context containing an instance of :attr:`search_form`. If the form was submitted and was valid, then one of two things has happened: @@@ -233,11 -243,12 +231,12 @@@ search_instances = [] for slug in self.searches: - search_instance = self.get_search_instance(slug, search_string) - search_instances.append(search_instance) + if slug in registry: + search_instance = get_search_instance(slug, search_string) + search_instances.append(search_instance) - if self.enable_ajax_api: - search_instance.ajax_api_url = "%s?%s=%s" % (self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), SEARCH_ARG_GET_KEY, search_string) + if self.enable_ajax_api: + search_instance.ajax_api_url = "%s?%s=%s" % (self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), SEARCH_ARG_GET_KEY, search_string) if eventlet and not self.enable_ajax_api: pool = eventlet.GreenPool() @@@ -246,8 -257,16 +245,16 @@@ pool.waitall() context.update({ - 'searches': search_instances + 'searches': search_instances, + 'favored_results': [] }) + + try: + search = Search.objects.get(string=search_string) + except Search.DoesNotExist: + pass + else: + context['favored_results'] = [r.url for r in search.get_favored_results()] else: form = SearchForm() @@@ -258,8 -277,10 +265,10 @@@ def ajax_api_view(self, request, slug, extra_context=None): """ - Returns a JSON string containing two keyed lists. + Returns a JSON object containing the following variables: + search + Contains the slug for the search. results Contains the results of :meth:`.Result.get_context` for each result. rendered @@@ -267,19 -288,19 +276,19 @@@ hasMoreResults ``True`` or ``False`` whether the search has more results according to :meth:`BaseSearch.has_more_results` moreResultsURL - Contains None or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results. + Contains ``None`` or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results. """ search_string = request.GET.get(SEARCH_ARG_GET_KEY) - if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None: + if not request.is_ajax() or not self.enable_ajax_api or slug not in registry or slug not in self.searches or search_string is None: raise Http404 - search_instance = self.get_search_instance(slug, search_string) + search_instance = get_search_instance(slug, search_string) return HttpResponse(json.dumps({ + 'search': search_instance.slug, 'results': [result.get_context() for result in search_instance.results], - 'rendered': [result.render() for result in search_instance.results], - 'hasMoreResults': search.has_more_results(), - 'moreResultsURL': (u"?%s" % search.more_results_querydict.urlencode()) if search.more_results_querydict else None, + 'hasMoreResults': search_instance.has_more_results, + 'moreResultsURL': search_instance.more_results_url, }), mimetype="application/json") diff --combined philo/contrib/sobol/search.py index b0dca84,5f5fddc..3e30c27 --- a/philo/contrib/sobol/search.py +++ b/philo/contrib/sobol/search.py @@@ -1,5 -1,6 +1,6 @@@ #encoding: utf-8 import datetime + from hashlib import sha1 from django.conf import settings from django.contrib.sites.models import Site @@@ -9,10 -10,9 +10,10 @@@ from django.utils import simplejson as from django.utils.http import urlquote_plus from django.utils.safestring import mark_safe from django.utils.text import capfirst - from django.template import loader, Context, Template + from django.template import loader, Context, Template, TemplateDoesNotExist -from philo.contrib.sobol.utils import make_tracking_querydict, RegistryIterator +from philo.contrib.sobol.utils import make_tracking_querydict +from philo.utils.registry import Registry if getattr(settings, 'SOBOL_USE_EVENTLET', False): @@@ -25,22 -25,103 +26,36 @@@ else __all__ = ( - 'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry' - 'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'SearchRegistry', 'registry', 'get_search_instance' ++ 'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry', 'get_search_instance' ) - SEARCH_CACHE_KEY = 'philo_sobol_search_results' - DEFAULT_RESULT_TEMPLATE_STRING = "{% if url %}{% endif %}{{ title }}{% if url %}{% endif %}" - DEFAULT_RESULT_TEMPLATE = Template(DEFAULT_RESULT_TEMPLATE_STRING) - - # Determines the timeout on the entire result cache. - MAX_CACHE_TIMEOUT = 60*24*7 + SEARCH_CACHE_SEED = 'philo_sobol_search_results' + USE_CACHE = getattr(settings, 'SOBOL_USE_SEARCH', True) -class RegistrationError(Exception): - """Raised if there is a problem registering a search with a :class:`SearchRegistry`""" - pass - - -class SearchRegistry(object): - """Holds a registry of search types by slug.""" - - def __init__(self): - self._registry = {} - - def register(self, search, slug=None): - """ - Register a search with the registry. - - :param search: The search class to register - generally a subclass of :class:`BaseSearch` - :param slug: The slug which will be used to register the search class. If ``slug`` is ``None``, the search's default slug will be used. - :raises: :class:`RegistrationError` if a different search is already registered with ``slug``. - - """ - slug = slug or search.slug - if slug in self._registry: - registered = self._registry[slug] - if registered.__module__ != search.__module__: - raise RegistrationError("A different search is already registered as `%s`" % slug) - else: - self._registry[slug] = search - - def unregister(self, search, slug=None): - """ - Unregister a search from the registry. - - :param search: The search class to unregister - generally a subclass of :class:`BaseSearch` - :param slug: If provided, the search will only be removed if it was registered with ``slug``. If not provided, the search class will be unregistered no matter what slug it was registered with. - :raises: :class:`RegistrationError` if a slug is provided but the search registered with that slug is not ``search``. - - """ - if slug is not None: - if slug in self._registry and self._registry[slug] == search: - del self._registry[slug] - raise RegistrationError("`%s` is not registered as `%s`" % (search, slug)) - else: - for slug, search in self._registry.items(): - if search == search: - del self._registry[slug] - - def items(self): - """Returns a list of (slug, search) items in the registry.""" - return self._registry.items() - - def iteritems(self): - """Returns an iterator over the (slug, search) pairs in the registry.""" - return RegistryIterator(self._registry, 'iteritems') - - def iterchoices(self): - """Returns an iterator over (slug, search.verbose_name) pairs for the registry.""" - return RegistryIterator(self._registry, 'iteritems', lambda x: (x[0], x[1].verbose_name)) - - def __getitem__(self, key): - """Returns the search registered with ``key``.""" - return self._registry[key] - - def __iter__(self): - """Returns an iterator over the keys in the registry.""" - return self._registry.__iter__() - - -registry = SearchRegistry() +#: A registry for :class:`BaseSearch` subclasses that should be available in the admin. +registry = Registry() + def _make_cache_key(search, search_arg): + return sha1(SEARCH_CACHE_SEED + search.slug + search_arg).hexdigest() + + + def get_search_instance(slug, search_arg): + """Returns a search instance for the given slug, either from the cache or newly-instantiated.""" + search = registry[slug] + search_arg = search_arg.lower() + if USE_CACHE: + key = _make_cache_key(search, search_arg) + cached = cache.get(key) + if cached: + return cached + instance = search(search_arg) + instance.slug = slug + return instance - + + class Result(object): """ :class:`Result` is a helper class that, given a search and a result of that search, is able to correctly render itself with a template defined by the search. Every :class:`Result` will pass a ``title``, a ``url`` (if applicable), and the raw ``result`` returned by the search into the template context when rendering. @@@ -58,39 -139,41 +73,41 @@@ return self.search.get_result_title(self.result) def get_url(self): - """Returns the url of the result or an empty string by calling :meth:`BaseSearch.get_result_querydict` on the raw result and then encoding the querydict returned.""" - qd = self.search.get_result_querydict(self.result) - if qd is None: - return "" - return "?%s" % qd.urlencode() + """Returns the url of the result or ``None`` by calling :meth:`BaseSearch.get_result_url` on the raw result. This url will contain a querystring which, if used, will track a :class:`.Click` for the actual url.""" + return self.search.get_result_url(self.result) + + def get_actual_url(self): + """Returns the actual url of the result by calling :meth:`BaseSearch.get_actual_result_url` on the raw result.""" + return self.search.get_actual_result_url(self.result) + + def get_content(self): + """Returns the content of the result by calling :meth:`BaseSearch.get_result_content` on the raw result.""" + return self.search.get_result_content(self.result) def get_template(self): - """Returns the template for the result by calling :meth:`BaseSearch.get_result_template` on the raw result.""" + """Returns the template which will be used to render the :class:`Result` by calling :meth:`BaseSearch.get_result_template` on the raw result.""" return self.search.get_result_template(self.result) - def get_extra_context(self): - """Returns any extra context for the result by calling :meth:`BaseSearch.get_result_extra_context` on the raw result.""" - return self.search.get_result_extra_context(self.result) - def get_context(self): """ - Returns the context dictionary for the result. This is used both in rendering the result and in the AJAX return value for :meth:`.SearchView.ajax_api_view`. The context will contain everything from :meth:`get_extra_context` as well as the following keys: + Returns the context dictionary for the result. This is used both in rendering the result and in the AJAX return value for :meth:`.SearchView.ajax_api_view`. The context will contain the following keys: title The result of calling :meth:`get_title` url The result of calling :meth:`get_url` - result - The raw result which the :class:`Result` was instantiated with. + content + The result of calling :meth:`get_content` """ - context = self.get_extra_context() - context.update({ - 'title': self.get_title(), - 'url': self.get_url(), - 'result': self.result - }) - return context + if not hasattr(self, '_context'): + self._context = { + 'title': self.get_title(), + 'url': self.get_url(), + 'actual_url': self.get_actual_url(), + 'content': self.get_content() + } + return self._context def render(self): """Returns the template from :meth:`get_template` rendered with the context from :meth:`get_context`.""" @@@ -108,7 -191,7 +125,7 @@@ class BaseSearchMetaclass(type) if 'verbose_name' not in attrs: attrs['verbose_name'] = capfirst(' '.join(convert_camelcase(name).rsplit(' ', 1)[:-1])) if 'slug' not in attrs: - attrs['slug'] = name.lower() + attrs['slug'] = name[:-6].lower() if name.endswith("Search") else name.lower() return super(BaseSearchMetaclass, cls).__new__(cls, name, bases, attrs) @@@ -120,54 -203,44 +137,44 @@@ class BaseSearch(object) """ __metaclass__ = BaseSearchMetaclass - #: The number of results to return from the complete list. Default: 10 - result_limit = 10 + #: The number of results to return from the complete list. Default: 5 + result_limit = 5 #: How long the items for the search should be cached (in minutes). Default: 48 hours. _cache_timeout = 60*48 + #: The path to the template which will be used to render the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search//result.html`` and ``sobol/search/result.html``. + result_template = None + #: The path to the template which will be used to generate the title of the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search//title.html`` and ``sobol/search/title.html``. + title_template = None + #: The path to the template which will be used to generate the content of the :class:`Result`\ s for this search. If this is ``None``, then the framework will try ``sobol/search//content.html`` and ``sobol/search/content.html``. + content_template = None def __init__(self, search_arg): self.search_arg = search_arg - def _get_cached_results(self): - """Return the cached results if the results haven't timed out. Otherwise return None.""" - result_cache = cache.get(SEARCH_CACHE_KEY) - if result_cache and self.__class__ in result_cache and self.search_arg.lower() in result_cache[self.__class__]: - cached = result_cache[self.__class__][self.search_arg.lower()] - if cached['timeout'] >= datetime.datetime.now(): - return cached['results'] - return None - - def _set_cached_results(self, results, timeout): - """Sets the results to the cache for minutes.""" - result_cache = cache.get(SEARCH_CACHE_KEY) or {} - cached = result_cache.setdefault(self.__class__, {}).setdefault(self.search_arg.lower(), {}) - cached.update({ - 'results': results, - 'timeout': datetime.datetime.now() + datetime.timedelta(minutes=timeout) - }) - cache.set(SEARCH_CACHE_KEY, result_cache, MAX_CACHE_TIMEOUT) - @property def results(self): """Retrieves cached results or initiates a new search via :meth:`get_results` and caches the results.""" if not hasattr(self, '_results'): - results = self._get_cached_results() - if results is None: - try: - # Cache one extra result so we can see if there are - # more results to be had. - limit = self.result_limit - if limit is not None: - limit += 1 - results = self.get_results(limit) - except: - if settings.DEBUG: - raise - # On exceptions, don't set any cache; just return. - return [] + try: + # Cache one extra result so we can see if there are + # more results to be had. + limit = self.result_limit + if limit is not None: + limit += 1 + results = self.get_results(limit) + except: + if settings.DEBUG: + raise + # On exceptions, don't set any cache; just return. + return [] - self._set_cached_results(results, self._cache_timeout) self._results = results + + if USE_CACHE: + for result in results: + result.get_context() + key = _make_cache_key(self, self.search_arg) + cache.set(key, self, self._cache_timeout) return self._results @@@ -186,46 -259,73 +193,73 @@@ """Returns an iterable of up to ``limit`` results. The :meth:`get_result_title`, :meth:`get_result_url`, :meth:`get_result_template`, and :meth:`get_result_extra_context` methods will be used to interpret the individual items that this function returns, so the result can be an object with attributes as easily as a dictionary with keys. However, keep in mind that the raw results will be stored with django's caching mechanisms and will be converted to JSON.""" raise NotImplementedError - def get_result_title(self, result): - """Returns the title of the ``result``. Must be implemented by subclasses.""" - raise NotImplementedError - - def get_result_url(self, result): + def get_actual_result_url(self, result): """Returns the actual URL for the ``result`` or ``None`` if there is no URL. Must be implemented by subclasses.""" raise NotImplementedError def get_result_querydict(self, result): """Returns a querydict for tracking selection of the result, or ``None`` if there is no URL for the result.""" - url = self.get_result_url(result) + url = self.get_actual_result_url(result) if url is None: return None return make_tracking_querydict(self.search_arg, url) + def get_result_url(self, result): + """Returns ``None`` or a url which, when accessed, will register a :class:`.Click` for that url.""" + qd = self.get_result_querydict(result) + if qd is None: + return None + return "?%s" % qd.urlencode() + + def get_result_title(self, result): + """Returns the title of the ``result``. By default, renders ``sobol/search//title.html`` or ``sobol/search/title.html`` with the result in the context. This can be overridden by setting :attr:`title_template` or simply overriding :meth:`get_result_title`. If no template can be found, this will raise :exc:`TemplateDoesNotExist`.""" + return loader.render_to_string(self.title_template or [ + 'sobol/search/%s/title.html' % self.slug, + 'sobol/search/title.html' + ], {'result': result}) + + def get_result_content(self, result): + """Returns the content for the ``result``. By default, renders ``sobol/search//content.html`` or ``sobol/search/content.html`` with the result in the context. This can be overridden by setting :attr:`content_template` or simply overriding :meth:`get_result_content`. If no template is found, this will return an empty string.""" + try: + return loader.render_to_string(self.content_template or [ + 'sobol/search/%s/content.html' % self.slug, + 'sobol/search/content.html' + ], {'result': result}) + except TemplateDoesNotExist: + return "" + def get_result_template(self, result): - """Returns the template to be used for rendering the ``result``.""" - if hasattr(self, 'result_template'): + """Returns the template to be used for rendering the ``result``. For a search with slug ``google``, this would first try ``sobol/search/google/result.html``, then fall back on ``sobol/search/result.html``. Subclasses can override this by setting :attr:`result_template` to the path of another template.""" + if self.result_template: return loader.get_template(self.result_template) - if not hasattr(self, '_result_template'): - self._result_template = DEFAULT_RESULT_TEMPLATE - return self._result_template - - def get_result_extra_context(self, result): - """Returns any extra context to be used when rendering the ``result``.""" - return {} + return loader.select_template([ + 'sobol/search/%s/result.html' % self.slug, + 'sobol/search/result.html' + ]) + @property def has_more_results(self): """Returns ``True`` if there are more results than :attr:`result_limit` and ``False`` otherwise.""" return len(self.results) > self.result_limit - @property - def more_results_url(self): - """Returns the actual url for more results. This should be accessed through :attr:`more_results_querydict` in the template so that the click can be tracked.""" - raise NotImplementedError + def get_actual_more_results_url(self): + """Returns the actual url for more results. By default, simply returns ``None``.""" + return None - @property - def more_results_querydict(self): + def get_more_results_querydict(self): """Returns a :class:`QueryDict` for tracking whether people click on a 'more results' link.""" - return make_tracking_querydict(self.search_arg, self.more_results_url) + url = self.get_actual_more_results_url() + if url: + return make_tracking_querydict(self.search_arg, url) + return None + + @property + def more_results_url(self): + """Returns a URL which consists of a querystring which, when accessed, will log a :class:`.Click` for the actual URL.""" + qd = self.get_more_results_querydict() + if qd is None: + return None + return "?%s" % qd.urlencode() def __unicode__(self): return self.verbose_name @@@ -260,9 -360,8 +294,8 @@@ class URLSearch(BaseSearch) def url(self): """The URL where the search gets its results. Composed from :attr:`search_url` and :attr:`query_format_str`.""" return self.search_url + self.query_format_str % urlquote_plus(self.search_arg) - - @property - def more_results_url(self): + + def get_actual_more_results_url(self): return self.url def parse_response(self, response, limit=None): @@@ -282,9 -381,9 +315,9 @@@ class JSONSearch(URLSearch) class GoogleSearch(JSONSearch): """An example implementation of a :class:`JSONSearch`.""" search_url = "http://ajax.googleapis.com/ajax/services/search/web" - result_template = 'search/googlesearch.html' _cache_timeout = 60 verbose_name = "Google search (current site)" + _more_results_url = None @property def query_format_str(self): @@@ -323,15 -422,17 +356,17 @@@ return True return False - @property - def more_results_url(self): + def get_actual_more_results_url(self): return self._more_results_url + def get_actual_result_url(self, result): + return result['unescapedUrl'] + def get_result_title(self, result): - return result['titleNoFormatting'] + return mark_safe(result['titleNoFormatting']) - def get_result_url(self, result): - return result['unescapedUrl'] + def get_result_content(self, result): + return mark_safe(result['content']) registry.register(GoogleSearch)