Merge branch 'release' into develop
authorStephen Burrows <stephen.r.burrows@gmail.com>
Fri, 10 Jun 2011 18:52:48 +0000 (14:52 -0400)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Fri, 10 Jun 2011 18:57:58 +0000 (14:57 -0400)
Conflicts:
philo/contrib/sobol/models.py
philo/contrib/sobol/search.py

15 files changed:
docs/what.rst
philo/contrib/sobol/__init__.py
philo/contrib/sobol/admin.py
philo/contrib/sobol/models.py
philo/contrib/sobol/search.py
philo/contrib/sobol/static/sobol/ajax_search.js [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/change_form.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/change_list.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html [new file with mode: 0644]
philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html [deleted file]
philo/contrib/sobol/templates/admin/sobol/search/results.html [deleted file]
philo/contrib/sobol/templates/search/googlesearch.html [deleted file]
philo/contrib/sobol/templates/sobol/search/_list.html [new file with mode: 0644]
philo/contrib/sobol/templates/sobol/search/content.html [new file with mode: 0644]
philo/contrib/sobol/templates/sobol/search/result.html [new file with mode: 0644]

index ac44619..efa8537 100644 (file)
@@ -9,3 +9,17 @@ Philo allows the creation of site structures using Django's built-in admin inter
 * :class:`.Attribute`\ s are arbitrary key/value pairs which can be attached to most of the models that Philo provides. Attributes of a :class:`.Node` will be inherited by all of the :class:`.Node`'s descendants and will be available in the template's context.
 
 The :ttag:`~philo.templatetags.containers.container` template tag that Philo provides makes it easy to mark areas in a template which need to be editable page-by-page; every :class:`.Page` will have an additional field in the admin for each :ttag:`~philo.templatetags.containers.container` in the template it uses.
+
+How's that different than other CMSes?
+++++++++++++++++++++++++++++++++++++++
+
+Philo developed according to principles that grew out of the observation of the limitations and practices of other content management systems. For example, Philo believes that:
+
+* Designers are in charge of how content is displayed, not end users. For example, users should be able to embed images in blog entries -- but the display of the image, even the presence or absence of a wrapping ``<figure>`` element, should depend on the template used to render the entry, not the HTML5 knowledge of the user.
+       .. seealso:: :ttag:`~philo.templatetags.embed.embed`
+* Interpretation of content (as a django template, as markdown, as textile, etc.) is the responsibility of the template designer, not of code developers or the framework.
+       .. seealso:: :ttag:`~philo.templatetags.include_string.include_string`
+* Page content should be simple -- not reorderable. Each piece of content should only be related to one page. Any other system will cause more trouble than it's worth.
+       .. seealso:: :class:`.Contentlet`, :class:`.ContentReference`
+* Some pieces of information may be shared by an entire site, used in disparate places, and changed frequently enough that it is far too difficult to track down every use. These pieces of information should be stored separately from the content that contains them.
+       .. seealso:: :class:`.Attribute`
index cd75f13..0458a83 100644 (file)
@@ -1,5 +1,21 @@
 """
-Sobol implements a generic search interface, which can be used to search databases or websites. No assumptions are made about the search method, and the results are cached using django's caching.
+Sobol implements a generic search interface, which can be used to search databases or websites. No assumptions are made about the search method. If SOBOL_USE_CACHE is ``True`` (default), the results will be cached using django's cache framework. Be aware that this may use a large number of cache entries, as a unique entry will be made for each search string for each type of search.
+
+Settings
+--------
+
+:setting:`SOBOL_USE_CACHE`
+       Whether sobol will use django's cache framework. Defaults to ``True``; this may cause a lot of entries in the cache.
+
+:setting:`SOBOL_USE_EVENTLET`
+       If :mod:`eventlet` is installed and this setting is ``True``, sobol web searches will use :mod:`eventlet.green.urllib2` instead of the built-in :mod:`urllib2` module. Default: ``False``.
+
+Templates
+---------
+
+For convenience, :mod:`.sobol` provides a template at ``sobol/search/_list.html`` which can be used with an ``{% include %}`` tag inside a full search page template to list the search results. The ``_list.html`` template also uses a basic jQuery script (``static/sobol/ajax_search.js``) to handle AJAX search result loading if the AJAX API of the current :class:`.SearchView` is enabled. If you want to use ``_list.html``, but want to provide your own version of jQuery or your own AJAX loading script, or if you want to include the basic script somewhere else (like inside the ``<head>``) simply do the following::
+
+       {% include "sobol/search/_list.html" with suppress_scripts=1 %}
 
 """
 
index f4636e7..6af7e4d 100644 (file)
@@ -29,25 +29,7 @@ class SearchAdmin(admin.ModelAdmin):
        search_fields = ['string', 'result_urls__url']
        actions = ['results_action']
        if 'grappelli' in settings.INSTALLED_APPS:
-               results_template = 'admin/sobol/search/grappelli_results.html'
-       else:
-               results_template = 'admin/sobol/search/results.html'
-       
-       def get_urls(self):
-               urlpatterns = super(SearchAdmin, self).get_urls()
-               
-               def wrap(view):
-                       def wrapper(*args, **kwargs):
-                               return self.admin_site.admin_view(view)(*args, **kwargs)
-                       return update_wrapper(wrapper, view)
-               
-               info = self.model._meta.app_label, self.model._meta.module_name
-               
-               urlpatterns = patterns('',
-                       url(r'^results/$', wrap(self.results_view), name="%s_%s_selected_results" % info),
-                       url(r'^(.+)/results/$', wrap(self.results_view), name="%s_%s_results" % info)
-               ) + urlpatterns
-               return urlpatterns
+               change_form_template = 'admin/sobol/search/grappelli_change_form.html'
        
        def unique_urls(self, obj):
                return obj.unique_urls
@@ -60,41 +42,6 @@ class SearchAdmin(admin.ModelAdmin):
        def queryset(self, request):
                qs = super(SearchAdmin, self).queryset(request)
                return qs.annotate(total_clicks=Count('result_urls__clicks', distinct=True), unique_urls=Count('result_urls', distinct=True))
-       
-       def results_action(self, request, queryset):
-               info = self.model._meta.app_label, self.model._meta.module_name
-               if len(queryset) == 1:
-                       return HttpResponseRedirect(reverse("admin:%s_%s_results" % info, args=(queryset[0].pk,)))
-               else:
-                       url = reverse("admin:%s_%s_selected_results" % info)
-                       return HttpResponseRedirect("%s?ids=%s" % (url, ','.join([str(item.pk) for item in queryset])))
-       results_action.short_description = "View results for selected %(verbose_name_plural)s"
-       
-       def results_view(self, request, object_id=None, extra_context=None):
-               if object_id is not None:
-                       object_ids = [object_id]
-               else:
-                       object_ids = request.GET.get('ids').split(',')
-                       
-                       if object_ids is None:
-                               raise Http404
-               
-               qs = self.queryset(request).filter(pk__in=object_ids)
-               opts = self.model._meta
-               
-               if len(object_ids) == 1:
-                       title = _(u"Search results for %s" % qs[0])
-               else:
-                       title = _(u"Search results for multiple objects")
-               
-               context = {
-                       'title': title,
-                       'queryset': qs,
-                       'opts': opts,
-                       'root_path': self.admin_site.root_path,
-                       'app_label': opts.app_label
-               }
-               return render_to_response(self.results_template, context, context_instance=RequestContext(request))
 
 
 class SearchViewAdmin(EntityAdmin):
index ee5f62e..ffe5871 100644 (file)
@@ -11,7 +11,7 @@ from django.http import HttpResponseRedirect, Http404, HttpResponse
 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 @@ 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:
@@ -156,7 +158,7 @@ try:
 except ImportError:
        pass
 else:
-       add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"])
+       add_introspection_rules([], ["^philo\.contrib\.sobol\.models\.RegistryChoiceField"])
 
 
 class SearchView(MultiView):
@@ -194,10 +196,6 @@ class SearchView(MultiView):
                        )
                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 +231,12 @@ class SearchView(MultiView):
                                
                                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 +245,16 @@ class SearchView(MultiView):
                                        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 +265,10 @@ class SearchView(MultiView):
        
        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 +276,19 @@ class SearchView(MultiView):
                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")
\ No newline at end of file
index b0dca84..3e30c27 100644 (file)
@@ -1,5 +1,6 @@
 #encoding: utf-8
 import datetime
+from hashlib import sha1
 
 from django.conf import settings
 from django.contrib.sites.models import Site
@@ -9,7 +10,7 @@ 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.template import loader, Context, Template
+from django.template import loader, Context, Template, TemplateDoesNotExist
 
 from philo.contrib.sobol.utils import make_tracking_querydict
 from philo.utils.registry import Registry
@@ -25,22 +26,36 @@ else:
 
 
 __all__ = (
-       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry'
+       'Result', 'BaseSearch', 'DatabaseSearch', 'URLSearch', 'JSONSearch', 'GoogleSearch', 'registry', 'get_search_instance'
 )
 
 
-SEARCH_CACHE_KEY = 'philo_sobol_search_results'
-DEFAULT_RESULT_TEMPLATE_STRING = "{% if url %}<a href='{{ url }}'>{% endif %}{{ title }}{% if url %}</a>{% 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)
 
 
 #: 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 +73,41 @@ class Result(object):
                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 +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 +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/<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 _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 <timeout> 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 +193,73 @@ 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
        
-       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/<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``."""
-               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 +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 +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 +356,17 @@ class GoogleSearch(JSONSearch):
                        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)
diff --git a/philo/contrib/sobol/static/sobol/ajax_search.js b/philo/contrib/sobol/static/sobol/ajax_search.js
new file mode 100644 (file)
index 0000000..b2ef413
--- /dev/null
@@ -0,0 +1,81 @@
+(function($){
+       var sobol = window.sobol = {};
+       sobol.favoredResults = []
+       sobol.favoredResultSearch = null;
+       sobol.search = function(){
+               var searches = sobol.searches = $('article.search');
+               if(sobol.favoredResults.length) sobol.favoredResultSearch = searches.eq(0);
+               for (var i=sobol.favoredResults.length ? 1 : 0;i<searches.length;i++) {
+                       (function(){
+                               var s = searches[i];
+                               $.ajax({
+                                       url: s.getAttribute('data-url'),
+                                       dataType: 'json',
+                                       success: function(data){
+                                               sobol.onSuccess($(s), data);
+                                       },
+                                       error: function(data, textStatus, errorThrown){
+                                               sobol.onError($(s), textStatus, errorThrown);
+                                       }
+                               });
+                       }());
+               };
+       }
+       sobol.renderResult = function(result){
+               // Returns the result rendered as a string. Override this to provide custom rendering.
+               var url = result['url'],
+                       title = result['title'],
+                       content = result['content'],
+                       rendered = '';
+               
+               if(url){
+                       rendered += "<dt><a href='" + url + "'>" + title + "</a></dt>";
+               } else {
+                       rendered += "<dt>" + title + "</dt>";
+               }
+               if(content && content != ''){
+                       rendered += "<dd>" + content + "</dd>"
+               }
+               return rendered
+       }
+       sobol.addFavoredResult = function(result) {
+               var dl = sobol.favoredResultSearch.find('dl');
+               if(!dl.length){
+                       dl = $('<dl>');
+                       dl.appendTo(sobol.favoredResultSearch);
+                       sobol.favoredResultSearch.removeClass('loading');
+               }
+               dl[0].innerHTML += sobol.renderResult(result)
+       }
+       sobol.onSuccess = function(ele, data){
+               // hook for success!
+               ele.removeClass('loading');
+               if (data['results'].length) {
+                       ele[0].innerHTML += "<dl>";
+                       $.each(data['results'], function(i, v){
+                               ele[0].innerHTML += sobol.renderResult(v);
+                       })
+                       ele[0].innerHTML += "</dl>";
+                       if(data['hasMoreResults'] && data['moreResultsURL']) ele[0].innerHTML += "<footer><p><a href='" + data['moreResultsURL'] + "'>See more results</a></p></footer>";
+               } else {
+                       ele.addClass('empty');
+                       ele[0].innerHTML += "<p>No results found.</p>";
+                       ele.slideUp();
+               }
+               if (sobol.favoredResultSearch){
+                       for (var i=0;i<data['results'].length;i++){
+                               var r = data['results'][i];
+                               if ($.inArray(r['actual_url'], sobol.favoredResults) != -1){
+                                       sobol.addFavoredResult(r);
+                               }
+                       }
+               }
+       };
+       sobol.onError = function(ele, textStatus, errorThrown){
+               // Hook for error...
+               ele.removeClass('loading');
+               text = errorThrown ? errorThrown : textStatus ? textStatus : "Error occurred.";
+               ele[0].innerHTML += "<p>" + text + "</p>";
+       };
+       $(sobol.search);
+}(jQuery));
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/change_form.html b/philo/contrib/sobol/templates/admin/sobol/search/change_form.html
new file mode 100644 (file)
index 0000000..8dfba08
--- /dev/null
@@ -0,0 +1,43 @@
+{% extends 'admin/change_form.html' %}
+{% load i18n %}
+
+{% block javascripts %}{% endblock %}
+{% block object-tools %}{% endblock %}
+{% block title %}Results for "{{ original.string }}" | {% trans 'Django site admin' %}{% endblock %}
+{% block content_title %}<h1>Results for "{{ original.string }}"</h1>{% endblock %}
+{% block extrastyle %}
+       <style type="text/css">
+               .favored td{
+                       font-weight:bold;
+               }
+               #changelist table{
+                       width:100%;
+               }
+       </style>
+{% endblock %}
+
+{% block content %}
+       <div class="module" id="changelist">
+               <table>
+                       <thead>
+                               <tr>
+                                       <th>Weight</th>
+                                       <th>URL</th>
+                               </tr>
+                       </thead>
+                       <tbody>
+                               {% for result in original.get_weighted_results %}
+                               <tr class="{% cycle 'row1' 'row2' %}{% if result in original.get_favored_results %} favored{% endif %}">
+                                       <td>{{ result.weight }}</td>
+                                       <td>{{ result.url }}</td>
+                               </tr>
+                               {% endfor %}
+                       </tbody>
+               </table>
+       </div>
+       {% block submit_row %}
+       <div class="submit-row">
+               {% if not is_popup and has_delete_permission %}{% if change or show_delete %}<p class="deletelink-box"><a href="delete/" class="deletelink">{% trans "Delete" %}</a></p>{% endif %}{% endif %}
+       </div>
+       {% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/change_list.html b/philo/contrib/sobol/templates/admin/sobol/search/change_list.html
new file mode 100644 (file)
index 0000000..9b01661
--- /dev/null
@@ -0,0 +1,3 @@
+{% extends 'admin/change_list.html' %}
+
+{% block object-tools %}{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_change_form.html
new file mode 100644 (file)
index 0000000..c89f748
--- /dev/null
@@ -0,0 +1,23 @@
+{% extends 'admin/sobol/search/change_form.html' %}
+{% load i18n %}
+
+{% block extrastyle %}
+       <style type="text/css">
+               .favored td{
+                       font-weight:bold;
+               }
+               #changelist{
+                       border:none;
+                       background:none;
+               }
+               thead th{color:#444;font-weight:bold;}
+       </style>
+{% endblock %}
+
+{% block submit_row %}
+       <div class="module footer">
+               <ul class="submit-row">
+                       {% if not is_popup and has_delete_permission %}{% if change or show_delete %}<li class="left delete-link-container"><a href="delete/" class="delete-link">{% trans "Delete" %}</a></li>{% endif %}{% endif %}
+               </ul>
+       </div>
+{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html b/philo/contrib/sobol/templates/admin/sobol/search/grappelli_results.html
deleted file mode 100644 (file)
index f01eb88..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-{% extends "admin/base_site.html" %}
-
-<!-- LOADING -->
-{% load i18n %}
-
-<!-- EXTRASTYLES -->
-{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
-
-<!-- BREADCRUMBS -->
-{% block breadcrumbs %}
-       <div id="breadcrumbs">
-               {% if queryset|length > 1 %}
-               <a href="../../">{% trans "Home" %}</a> &rsaquo;
-               <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
-               <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-               {% trans 'Search results for multiple objects' %}
-               {% else %}
-               <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
-               <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo;
-               <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-               <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
-               {% trans 'Results' %}
-               {% endif %}
-       </div>
-{% endblock %}
-
-<!-- CONTENT -->
-{% block content %}
-       <div class="container-grid delete-confirmation">
-               {% for search in queryset %}
-               <div class="group tabular">
-                       <h2>{{ search_string }}</h2>
-                       <div class="module table">
-                               <div class="module thead">
-                                       <div class="tr">
-                                               <div class="th">Weight</div>
-                                               <div class="th">URL</div>
-                                       </div>
-                               </div>
-                               <div class="module tbody">
-                                       {% for result in search.get_weighted_results %}
-                                       <div class="tr{% if result in search.get_favored_results %} favored{% endif %}">
-                                               <div class="td">{{ result.weight }}</div>
-                                               <div class="td">{{ result.url }}</div>
-                                       </div>
-                                       {% endfor %}
-                               </div>
-                       </div>
-               </div>
-               {% endfor %}
-       </div>
-{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/admin/sobol/search/results.html b/philo/contrib/sobol/templates/admin/sobol/search/results.html
deleted file mode 100644 (file)
index 24442c7..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-{% extends "admin/base_site.html" %}
-{% load i18n %}
-
-{% block extrastyle %}<style type="text/css">.favored{font-weight:bold;}</style>{% endblock %}
-
-{% block breadcrumbs %}
-<div class="breadcrumbs">
-       {% if queryset|length > 1 %}
-       <a href="../../">{% trans "Home" %}</a> &rsaquo;
-       <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
-       <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-       {% trans 'Search results for multiple objects' %}
-       {% else %}
-       <a href="../../../../">{% trans "Home" %}</a> &rsaquo;
-       <a href="../../../">{{ app_label|capfirst }}</a> &rsaquo; 
-       <a href="../../">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
-       <a href="../">{{ queryset|first|truncatewords:"18" }}</a> &rsaquo;
-       {% trans 'Results' %}
-       {% endif %}
-</div>
-{% endblock %}
-
-
-{% block content %}
-               {% for search in queryset %}
-                       <fieldset class="module">
-                               <h2>{{ search.string }}</h2>
-                               <table>
-                                       <thead>
-                                               <tr>
-                                                       <th>Weight</th>
-                                                       <th>URL</th>
-                                               </tr>
-                                       </thead>
-                                       <tbody>
-                                               {% for result in search.get_weighted_results %}
-                                               <tr{% if result in search.favored_results %} class="favored"{% endif %}>
-                                                       <td>{{ result.weight }}</td>
-                                                       <td>{{ result.url }}</td>
-                                               </tr>
-                                               {% endfor %}
-                                       </tbody>
-                               </table>
-                       </fieldset>
-               {% endfor %}
-{% endblock %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/search/googlesearch.html b/philo/contrib/sobol/templates/search/googlesearch.html
deleted file mode 100644 (file)
index 1b22388..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<article>
-       <h1><a href="{{ url }}">{{ title|safe }}</a></h1>
-       <p>{{ content|safe }}</p>
-</article>
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/sobol/search/_list.html b/philo/contrib/sobol/templates/sobol/search/_list.html
new file mode 100644 (file)
index 0000000..99db761
--- /dev/null
@@ -0,0 +1,56 @@
+{% with node.view.enable_ajax_api as ajax %}
+{% if ajax %}
+       {% if not suppress_scripts %}<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js"></script><script type="text/javascript" src="{{ STATIC_URL }}sobol/ajax_search.js"></script>{% endif %}
+       <script type="text/javascript">
+               (function($){
+                       var sobol = window.sobol;
+                       sobol.favoredResults = [{% for r in favored_results %}"{{ r }}"{% if not forloop.last %}, {% endif %}{% endfor %}];
+               }(jQuery));
+       </script>
+{% endif %}
+{% if favored_results %}
+       <article class="search favored{% if ajax %} loading{% endif %}">
+               <header>
+                       <h1>Favored results</h1>
+               </header>
+               {% if not ajax %}
+               <dl>
+                       {% for search in searches %}
+                       {% for result in search.results %}
+                               {% if result.get_actual_url in favored_results %}
+                               {{ result }}
+                               {% endif %}
+                       {% endfor %}
+                       {% endfor %}
+                       {% if search.get_actual_more_results_url in favored_results %}
+                               <dt><a href="{{ search.more_results_url }}">More results for {{ search }}</a></dt>
+                       {% endif %}
+               </dl>
+               {% endif %}
+       </article>
+{% endif %}
+{% for search in searches %}
+<article {% if ajax %}class="search loading {{ search.slug }}" data-url="{{ search.ajax_api_url }}"{% else %}class="search {{ search.slug }}{% if not search.results %} empty{% endif %}"{% endif %}>
+       <header>
+               <a name='{{ search.slug }}'></a>
+               <h1>{{ search }}</h1>
+       </header>
+       {% if not ajax %}
+               {% if search.results %}
+                       <dl>
+                       {% for result in search.results %}
+                               {{ result }}
+                       {% endfor %}
+                       </dl>
+                       {% if search.has_more_results and search.more_results_url %}
+                       <footer>
+                               <p><a href="{{ search.more_results_url }}">See more results</a></p>
+                       </footer>
+                       {% endif %}
+               {% else %}
+                       <p>No results found.</p>
+               {% endif %}
+       {% endif %}
+</article>
+{% endfor %}
+{% endwith %}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/sobol/search/content.html b/philo/contrib/sobol/templates/sobol/search/content.html
new file mode 100644 (file)
index 0000000..82088ec
--- /dev/null
@@ -0,0 +1 @@
+{{ result.content|truncatewords_html:20 }}
\ No newline at end of file
diff --git a/philo/contrib/sobol/templates/sobol/search/result.html b/philo/contrib/sobol/templates/sobol/search/result.html
new file mode 100644 (file)
index 0000000..c5a906a
--- /dev/null
@@ -0,0 +1,2 @@
+<dt>{% if url %}<a href="{{ url }}">{% endif %}{{ title }}{% if url %}</a>{% endif %}</dt>
+{% if content %}<dd>{{ content }}</dd>{% endif %}
\ No newline at end of file