Updated the ExtJS and Fugue Icons submodules to point to the latest release of each.
[philo.git] / philo / contrib / sobol / search.py
index b117eaa..a79030a 100644 (file)
@@ -10,9 +10,10 @@ from django.utils import simplejson as json
 from django.utils.http import urlquote_plus
 from django.utils.safestring import mark_safe
 from django.utils.text import capfirst
 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):
 
 
 if getattr(settings, 'SOBOL_USE_EVENTLET', False):
@@ -25,82 +26,16 @@ else:
 
 
 __all__ = (
 
 
 __all__ = (
-       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'SearchRegistry', 'registry', 'get_search_instance'
+       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry', 'get_search_instance'
 )
 
 
 SEARCH_CACHE_SEED = 'philo_sobol_search_results'
 )
 
 
 SEARCH_CACHE_SEED = 'philo_sobol_search_results'
-USE_CACHE = getattr(settings, 'SOBOL_USE_SEARCH', True)
+USE_CACHE = getattr(settings, 'SOBOL_USE_CACHE', 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):
 
 
 def _make_cache_key(search, search_arg):
@@ -116,8 +51,9 @@ def get_search_instance(slug, search_arg):
                cached = cache.get(key)
                if cached:
                        return cached
                cached = cache.get(key)
                if cached:
                        return cached
-       return search(search_arg)
-       
+       instance = search(search_arg)
+       instance.slug = slug
+       return instance
 
 
 class Result(object):
 
 
 class Result(object):
@@ -137,39 +73,41 @@ class Result(object):
                return self.search.get_result_title(self.result)
        
        def get_url(self):
                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):
        
        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)
        
                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):
                """
        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`
                
                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`."""
        
        def render(self):
                """Returns the template from :meth:`get_template` rendered with the context from :meth:`get_context`."""
@@ -203,8 +141,12 @@ class BaseSearch(object):
        result_limit = 5
        #: How long the items for the search should be cached (in minutes). Default: 48 hours.
        _cache_timeout = 60*48
        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.
-       result_template = "sobol/search/basesearch.html"
+       #: 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/<slug>/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/<slug>/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/<slug>/content.html`` and ``sobol/search/content.html``.
+       content_template = None
        
        def __init__(self, search_arg):
                self.search_arg = search_arg
        
        def __init__(self, search_arg):
                self.search_arg = search_arg
@@ -229,6 +171,8 @@ class BaseSearch(object):
                        self._results = results
                        
                        if USE_CACHE:
                        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)
                
                                key = _make_cache_key(self, self.search_arg)
                                cache.set(key, self, self._cache_timeout)
                
@@ -249,47 +193,74 @@ class BaseSearch(object):
                """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
        
                """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."""
                """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)
        
                if url is None:
                        return None
                return make_tracking_querydict(self.search_arg, url)
        
-       def get_result_template(self, result):
-               """Returns the template to be used for rendering the ``result``."""
-               return loader.get_template(self.result_template)
+       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_extra_context(self, result):
-               """Returns any extra context to be used when rendering the ``result``."""
-               return {}
+       def get_result_title(self, result):
+               """Returns the title of the ``result``. By default, renders ``sobol/search/<slug>/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/<slug>/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``. 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)
+               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 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. By default, simply returns ``None``."""
+       def get_actual_more_results_url(self):
+               """Returns the actual url for more results. By default, simply returns ``None``."""
                return 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."""
                """Returns a :class:`QueryDict` for tracking whether people click on a 'more results' link."""
-               url = self.more_results_url
+               url = self.get_actual_more_results_url()
                if url:
                        return make_tracking_querydict(self.search_arg, url)
                return None
        
                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
 
        def __unicode__(self):
                return self.verbose_name
 
@@ -323,9 +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)
        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):
                return self.url
        
        def parse_response(self, response, limit=None):
@@ -347,7 +317,6 @@ class GoogleSearch(JSONSearch):
        search_url = "http://ajax.googleapis.com/ajax/services/search/web"
        _cache_timeout = 60
        verbose_name = "Google search (current site)"
        search_url = "http://ajax.googleapis.com/ajax/services/search/web"
        _cache_timeout = 60
        verbose_name = "Google search (current site)"
-       result_template = "sobol/search/googlesearch.html"
        _more_results_url = None
        
        @property
        _more_results_url = None
        
        @property
@@ -387,15 +356,17 @@ class GoogleSearch(JSONSearch):
                        return True
                return False
        
                        return True
                return False
        
-       @property
-       def more_results_url(self):
+       def get_actual_more_results_url(self):
                return self._more_results_url
        
                return self._more_results_url
        
+       def get_actual_result_url(self, result):
+               return result['unescapedUrl']
+       
        def get_result_title(self, result):
        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)
 
 
 registry.register(GoogleSearch)