4 from django.conf import settings
5 from django.conf.urls.defaults import patterns, url
6 from django.contrib import messages
7 from django.core.exceptions import ValidationError
8 from django.core.validators import URLValidator
9 from django.db import models
10 from django.http import HttpResponseRedirect, Http404, HttpResponse
11 from django.utils import simplejson as json
12 from django.utils.datastructures import SortedDict
14 from philo.contrib.sobol import registry
15 from philo.contrib.sobol.forms import SearchForm
16 from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash, RegistryIterator
17 from philo.exceptions import ViewCanNotProvideSubpath
18 from philo.models import MultiView, Page
19 from philo.models.fields import SlugMultipleChoiceField
22 if getattr(settings, 'SOBOL_USE_EVENTLET', False):
29 class Search(models.Model):
30 """Represents all attempts to search for a unique string."""
31 #: The string which was searched for.
32 string = models.TextField()
34 def __unicode__(self):
37 def get_weighted_results(self, threshhold=None):
39 Returns a list of :class:`ResultURL` instances related to the search and ordered by decreasing weight. This will be cached on the instance.
41 :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).
44 if not hasattr(self, '_weighted_results'):
45 result_qs = self.result_urls.all()
47 if threshhold is not None:
48 result_qs = result_qs.filter(counts__datetime__gte=threshhold)
50 results = [result for result in result_qs]
52 results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
54 self._weighted_results = results
56 return self._weighted_results
58 def get_favored_results(self, error=5, threshhold=None):
60 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.
62 :param error: An arbitrary number; higher values will cause this method to be more reticent about adding new items to the favored results.
63 :param threshhold: Will be passed directly into :meth:`get_weighted_results`
66 if not hasattr(self, '_favored_results'):
67 results = self.get_weighted_results(threshhold)
69 grouped_results = SortedDict()
71 for result in results:
72 grouped_results.setdefault(result.weight, []).append(result)
74 self._favored_results = []
76 for value, subresults in grouped_results.items():
77 cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
79 self._favored_results += subresults
82 return self._favored_results
86 verbose_name_plural = 'searches'
89 class ResultURL(models.Model):
90 """Represents a URL which has been selected one or more times for a :class:`Search`."""
91 #: A :class:`ForeignKey` to the :class:`Search` which the :class:`ResultURL` is related to.
92 search = models.ForeignKey(Search, related_name='result_urls')
93 #: The URL which was selected.
94 url = models.TextField(validators=[URLValidator()])
96 def __unicode__(self):
99 def get_weight(self, threshhold=None):
101 Calculates, caches, and returns the weight of the :class:`ResultURL`.
103 :param threshhold: The datetime limit before which :class:`Click`\ s will not contribute to the weight of the :class:`ResultURL`.
106 if not hasattr(self, '_weight'):
107 clicks = self.clicks.all()
109 if threshhold is not None:
110 clicks = clicks.filter(datetime__gte=threshhold)
112 self._weight = sum([click.weight for click in clicks])
115 weight = property(get_weight)
121 class Click(models.Model):
122 """Represents a click on a :class:`ResultURL`."""
123 #: A :class:`ForeignKey` to the :class:`ResultURL` which the :class:`Click` is related to.
124 result = models.ForeignKey(ResultURL, related_name='clicks')
125 #: The datetime when the click was registered in the system.
126 datetime = models.DateTimeField()
128 def __unicode__(self):
129 return self.datetime.strftime('%B %d, %Y %H:%M:%S')
131 def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
132 """Calculates and returns the weight of the :class:`Click`."""
133 if not hasattr(self, '_weight'):
134 days = (datetime.datetime.now() - self.datetime).days
136 raise ValueError("Click dates must be in the past.")
137 default = float(default)
139 self._weight = float(default)
141 self._weight = weighted(default, days)
143 weight = property(get_weight)
146 if self.datetime > datetime.datetime.now():
147 raise ValidationError("Click dates must be in the past.")
150 ordering = ['datetime']
151 get_latest_by = 'datetime'
155 from south.modelsinspector import add_introspection_rules
159 add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"])
162 class SearchView(MultiView):
163 """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."""
164 #: :class:`ForeignKey` to a :class:`.Page` which will be used to render the search results.
165 results_page = models.ForeignKey(Page, related_name='search_results_related')
166 #: A :class:`.SlugMultipleChoiceField` whose choices are the contents of :obj:`.sobol.search.registry`
167 searches = SlugMultipleChoiceField(choices=registry.iterchoices())
168 #: A :class:`BooleanField` which controls whether or not the AJAX API is enabled.
170 #: .. 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.
172 #: .. 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.
173 enable_ajax_api = models.BooleanField("Enable AJAX API", default=True)
174 #: 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.
175 placeholder_text = models.CharField(max_length=75, default="Search")
177 #: The form which will be used to validate the input to the search box for this :class:`SearchView`.
178 search_form = SearchForm
180 def __unicode__(self):
181 return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches]))
183 def get_reverse_params(self, obj):
184 raise ViewCanNotProvideSubpath
187 def urlpatterns(self):
188 urlpatterns = patterns('',
189 url(r'^$', self.results_view, name='results'),
191 if self.enable_ajax_api:
192 urlpatterns += patterns('',
193 url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
197 def get_search_instance(self, slug, search_string):
198 """Gets the :class:`.BaseSearch` subclass registered with :obj:`.sobol.search.registry` as ``slug`` and instantiates it with ``search_string``."""
199 return registry[slug](search_string.lower())
201 def results_view(self, request, extra_context=None):
203 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:
205 * 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.
206 * 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.
211 context = self.get_context()
212 context.update(extra_context or {})
214 if SEARCH_ARG_GET_KEY in request.GET:
215 form = self.search_form(request.GET)
218 search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
219 url = request.GET.get(URL_REDIRECT_GET_KEY)
220 hash = request.GET.get(HASH_REDIRECT_GET_KEY)
223 if check_redirect_hash(hash, search_string, url):
224 # Create the necessary models
225 search = Search.objects.get_or_create(string=search_string)[0]
226 result_url = search.result_urls.get_or_create(url=url)[0]
227 result_url.clicks.create(datetime=datetime.datetime.now())
228 return HttpResponseRedirect(url)
230 messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
231 # TODO: Should search_string be escaped here?
232 return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
234 search_instances = []
235 for slug in self.searches:
236 search_instance = self.get_search_instance(slug, search_string)
237 search_instances.append(search_instance)
239 if self.enable_ajax_api:
240 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)
242 if eventlet and not self.enable_ajax_api:
243 pool = eventlet.GreenPool()
244 for instance in search_instances:
245 pool.spawn_n(lambda x: x.results, search_instance)
249 'searches': search_instances
257 return self.results_page.render_to_response(request, extra_context=context)
259 def ajax_api_view(self, request, slug, extra_context=None):
261 Returns a JSON string containing two keyed lists.
264 Contains the results of :meth:`.Result.get_context` for each result.
266 Contains the results of :meth:`.Result.render` for each result.
268 ``True`` or ``False`` whether the search has more results according to :meth:`BaseSearch.has_more_results`
270 Contains None or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results.
273 search_string = request.GET.get(SEARCH_ARG_GET_KEY)
275 if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
278 search_instance = self.get_search_instance(slug, search_string)
280 return HttpResponse(json.dumps({
281 'results': [result.get_context() for result in search_instance.results],
282 'rendered': [result.render() for result in search_instance.results],
283 'hasMoreResults': search.has_more_results(),
284 'moreResultsURL': (u"?%s" % search.more_results_querydict.urlencode()) if search.more_results_querydict else None,
285 }), mimetype="application/json")