Merge branch 'release/0.9.1'
[philo.git] / philo / contrib / sobol / models.py
index 2cb1651..ffe5871 100644 (file)
@@ -1,21 +1,22 @@
 import datetime
 import datetime
+import itertools
 
 from django.conf import settings
 from django.conf.urls.defaults import patterns, url
 from django.contrib import messages
 from django.core.exceptions import ValidationError
 
 from django.conf import settings
 from django.conf.urls.defaults import patterns, url
 from django.contrib import messages
 from django.core.exceptions import ValidationError
+from django.core.validators import URLValidator
 from django.db import models
 from django.http import HttpResponseRedirect, Http404, HttpResponse
 from django.utils import simplejson as json
 from django.utils.datastructures import SortedDict
 
 from django.db import models
 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.forms import SearchForm
-from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash
+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
 from philo.models import MultiView, Page
 from philo.models.fields import SlugMultipleChoiceField
 from philo.exceptions import ViewCanNotProvideSubpath
 from philo.models import MultiView, Page
 from philo.models.fields import SlugMultipleChoiceField
-from philo.validators import RedirectValidator
 
 eventlet = None
 if getattr(settings, 'SOBOL_USE_EVENTLET', False):
 
 eventlet = None
 if getattr(settings, 'SOBOL_USE_EVENTLET', False):
@@ -26,13 +27,20 @@ if getattr(settings, 'SOBOL_USE_EVENTLET', False):
 
 
 class Search(models.Model):
 
 
 class Search(models.Model):
+       """Represents all attempts to search for a unique string."""
+       #: The string which was searched for.
        string = models.TextField()
        
        def __unicode__(self):
                return self.string
        
        def get_weighted_results(self, threshhold=None):
        string = models.TextField()
        
        def __unicode__(self):
                return self.string
        
        def get_weighted_results(self, threshhold=None):
-               "Returns this search's results ordered by decreasing weight."
+               """
+               Returns a list of :class:`ResultURL` instances related to the search and ordered by decreasing weight. This will be cached on the instance.
+               
+               :param threshhold: The earliest datetime that a :class:`Click` can have been made on a related :class:`ResultURL` in order to be included in the weighted results (or ``None`` to include all :class:`Click`\ s and :class:`ResultURL`\ s).
+               
+               """
                if not hasattr(self, '_weighted_results'):
                        result_qs = self.result_urls.all()
                        
                if not hasattr(self, '_weighted_results'):
                        result_qs = self.result_urls.all()
                        
@@ -49,14 +57,11 @@ class Search(models.Model):
        
        def get_favored_results(self, error=5, threshhold=None):
                """
        
        def get_favored_results(self, error=5, threshhold=None):
                """
-               Calculate the set of most-favored results. A higher error
-               will cause this method to be more reticent about adding new
-               items.
-               
-               The thought is to see whether there are any results which
-               vastly outstrip the other options. As such, evenly-weighted
-               results should be grouped together and either added or
-               excluded as a group.
+               Calculates the set of most-favored results based on their weight. Evenly-weighted results will be grouped together and either added or excluded as a group.
+               
+               :param error: An arbitrary number; higher values will cause this method to be more reticent about adding new items to the favored results.
+               :param threshhold: Will be passed directly into :meth:`get_weighted_results`
+               
                """
                if not hasattr(self, '_favored_results'):
                        results = self.get_weighted_results(threshhold)
                """
                if not hasattr(self, '_favored_results'):
                        results = self.get_weighted_results(threshhold)
@@ -74,6 +79,8 @@ class Search(models.Model):
                                        self._favored_results += subresults
                                else:
                                        break
                                        self._favored_results += subresults
                                else:
                                        break
+                       if len(self._favored_results) == len(results):
+                               self._favored_results = []
                return self._favored_results
        
        class Meta:
                return self._favored_results
        
        class Meta:
@@ -82,13 +89,22 @@ class Search(models.Model):
 
 
 class ResultURL(models.Model):
 
 
 class ResultURL(models.Model):
+       """Represents a URL which has been selected one or more times for a :class:`Search`."""
+       #: A :class:`ForeignKey` to the :class:`Search` which the :class:`ResultURL` is related to.
        search = models.ForeignKey(Search, related_name='result_urls')
        search = models.ForeignKey(Search, related_name='result_urls')
-       url = models.TextField(validators=[RedirectValidator()])
+       #: The URL which was selected.
+       url = models.TextField(validators=[URLValidator()])
        
        def __unicode__(self):
                return self.url
        
        def get_weight(self, threshhold=None):
        
        def __unicode__(self):
                return self.url
        
        def get_weight(self, threshhold=None):
+               """
+               Calculates, caches, and returns the weight of the :class:`ResultURL`.
+               
+               :param threshhold: The datetime limit before which :class:`Click`\ s will not contribute to the weight of the :class:`ResultURL`.
+               
+               """
                if not hasattr(self, '_weight'):
                        clicks = self.clicks.all()
                        
                if not hasattr(self, '_weight'):
                        clicks = self.clicks.all()
                        
@@ -105,13 +121,17 @@ class ResultURL(models.Model):
 
 
 class Click(models.Model):
 
 
 class Click(models.Model):
+       """Represents a click on a :class:`ResultURL`."""
+       #: A :class:`ForeignKey` to the :class:`ResultURL` which the :class:`Click` is related to.
        result = models.ForeignKey(ResultURL, related_name='clicks')
        result = models.ForeignKey(ResultURL, related_name='clicks')
+       #: The datetime when the click was registered in the system.
        datetime = models.DateTimeField()
        
        def __unicode__(self):
                return self.datetime.strftime('%B %d, %Y %H:%M:%S')
        
        def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
        datetime = models.DateTimeField()
        
        def __unicode__(self):
                return self.datetime.strftime('%B %d, %Y %H:%M:%S')
        
        def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
+               """Calculates and returns the weight of the :class:`Click`."""
                if not hasattr(self, '_weight'):
                        days = (datetime.datetime.now() - self.datetime).days
                        if days < 0:
                if not hasattr(self, '_weight'):
                        days = (datetime.datetime.now() - self.datetime).days
                        if days < 0:
@@ -133,12 +153,30 @@ class Click(models.Model):
                get_latest_by = 'datetime'
 
 
                get_latest_by = 'datetime'
 
 
+try:
+       from south.modelsinspector import add_introspection_rules
+except ImportError:
+       pass
+else:
+       add_introspection_rules([], ["^philo\.contrib\.sobol\.models\.RegistryChoiceField"])
+
+
 class SearchView(MultiView):
 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')
        results_page = models.ForeignKey(Page, related_name='search_results_related')
+       #: A :class:`.SlugMultipleChoiceField` whose choices are the contents of :obj:`.sobol.search.registry`
        searches = SlugMultipleChoiceField(choices=registry.iterchoices())
        searches = SlugMultipleChoiceField(choices=registry.iterchoices())
-       enable_ajax_api = models.BooleanField("Enable AJAX API", default=True, help_text="Search results will be available <i>only</i> by AJAX, not as template variables.")
+       #: 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.
+       #:
+       #: .. note:: Be careful not to access :attr:`search_instance.results <.BaseSearch.results>` if the AJAX API is enabled - otherwise the search will be run immediately rather than on the AJAX request.
+       enable_ajax_api = models.BooleanField("Enable AJAX API", default=True)
+       #: A :class:`CharField` containing the placeholder text which is intended to be used for the search box for the :class:`SearchView`. It is the template author's responsibility to make use of this information.
        placeholder_text = models.CharField(max_length=75, default="Search")
        
        placeholder_text = models.CharField(max_length=75, default="Search")
        
+       #: The form which will be used to validate the input to the search box for this :class:`SearchView`.
        search_form = SearchForm
        
        def __unicode__(self):
        search_form = SearchForm
        
        def __unicode__(self):
@@ -158,10 +196,14 @@ class SearchView(MultiView):
                        )
                return urlpatterns
        
                        )
                return urlpatterns
        
-       def get_search_instance(self, slug, search_string):
-               return registry[slug](search_string.lower())
-       
        def results_view(self, request, extra_context=None):
        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:
+               
+               * A search has been initiated. In this case, a list of search instances will be added to the context as ``searches``. If :attr:`enable_ajax_api` is enabled, each instance will have an ``ajax_api_url`` attribute containing the url needed to make an AJAX request for the search results.
+               * A link has been chosen. In this case, corresponding :class:`Search`, :class:`ResultURL`, and :class:`Click` instances will be created and the user will be redirected to the link's actual url.
+               
+               """
                results = None
                
                context = self.get_context()
                results = None
                
                context = self.get_context()
@@ -186,26 +228,33 @@ class SearchView(MultiView):
                                                messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
                                                # TODO: Should search_string be escaped here?
                                                return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
                                                messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
                                                # TODO: Should search_string be escaped here?
                                                return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
-                               if not self.enable_ajax_api:
-                                       search_instances = []
-                                       if eventlet:
-                                               pool = eventlet.GreenPool()
-                                       for slug in self.searches:
-                                               search_instance = self.get_search_instance(slug, search_string)
+                               
+                               search_instances = []
+                               for slug in self.searches:
+                                       if slug in registry:
+                                               search_instance = get_search_instance(slug, search_string)
                                                search_instances.append(search_instance)
                                                search_instances.append(search_instance)
-                                               if eventlet:
-                                                       pool.spawn_n(self.make_result_cache, search_instance)
-                                               else:
-                                                       self.make_result_cache(search_instance)
-                                       if eventlet:
-                                               pool.waitall()
-                                       context.update({
-                                               'searches': search_instances
-                                       })
+                                       
+                                               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()
+                                       for instance in search_instances:
+                                               pool.spawn_n(lambda x: x.results, search_instance)
+                                       pool.waitall()
+                               
+                               context.update({
+                                       'searches': search_instances,
+                                       'favored_results': []
+                               })
+                               
+                               try:
+                                       search = Search.objects.get(string=search_string)
+                               except Search.DoesNotExist:
+                                       pass
                                else:
                                else:
-                                       context.update({
-                                               'searches': [{'verbose_name': verbose_name, 'slug': slug, 'url': self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), 'result_template': registry[slug].result_template} for slug, verbose_name in registry.iterchoices() if slug in self.searches]
-                                       })
+                                       context['favored_results'] = [r.url for r in search.get_favored_results()]
                else:
                        form = SearchForm()
                
                else:
                        form = SearchForm()
                
@@ -214,17 +263,32 @@ class SearchView(MultiView):
                })
                return self.results_page.render_to_response(request, extra_context=context)
        
                })
                return self.results_page.render_to_response(request, extra_context=context)
        
-       def make_result_cache(self, search_instance):
-               search_instance.results
-       
        def ajax_api_view(self, request, slug, extra_context=None):
        def ajax_api_view(self, request, slug, extra_context=None):
+               """
+               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
+                       Contains the results of :meth:`.Result.render` for each result.
+               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.
+               
+               """
                search_string = request.GET.get(SEARCH_ARG_GET_KEY)
                
                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
                
                        raise Http404
                
-               search_instance = self.get_search_instance(slug, search_string)
-               response = HttpResponse(json.dumps({
+               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],
                        'results': [result.get_context() for result in search_instance.results],
-               }))
-               return response
\ No newline at end of file
+                       'hasMoreResults': search_instance.has_more_results,
+                       'moreResultsURL': search_instance.more_results_url,
+               }), mimetype="application/json")
\ No newline at end of file