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'
154 class RegistryChoiceField(SlugMultipleChoiceField):
155 def _get_choices(self):
156 if isinstance(self._choices, RegistryIterator):
157 return self._choices.copy()
158 elif hasattr(self._choices, 'next'):
159 choices, self._choices = itertools.tee(self._choices)
163 choices = property(_get_choices)
167 from south.modelsinspector import add_introspection_rules
171 add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"])
174 class SearchView(MultiView):
175 """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."""
176 #: :class:`ForeignKey` to a :class:`.Page` which will be used to render the search results.
177 results_page = models.ForeignKey(Page, related_name='search_results_related')
178 #: A :class:`.SlugMultipleChoiceField` whose choices are the contents of the :class:`.SearchRegistry`
179 searches = RegistryChoiceField(choices=registry.iterchoices())
180 #: A :class:`BooleanField` which controls whether or not the AJAX API is enabled.
182 #: .. 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.
184 #: .. 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.
185 enable_ajax_api = models.BooleanField("Enable AJAX API", default=True)
186 #: 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.
187 placeholder_text = models.CharField(max_length=75, default="Search")
189 #: The form which will be used to validate the input to the search box for this :class:`SearchView`.
190 search_form = SearchForm
192 def __unicode__(self):
193 return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches]))
195 def get_reverse_params(self, obj):
196 raise ViewCanNotProvideSubpath
199 def urlpatterns(self):
200 urlpatterns = patterns('',
201 url(r'^$', self.results_view, name='results'),
203 if self.enable_ajax_api:
204 urlpatterns += patterns('',
205 url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
209 def get_search_instance(self, slug, search_string):
210 """Returns an instance of the :class:`.BaseSearch` subclass corresponding to ``slug`` in the :class:`.SearchRegistry` and instantiated with ``search_string``."""
211 return registry[slug](search_string.lower())
213 def results_view(self, request, extra_context=None):
215 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:
217 * 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.
218 * 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.
223 context = self.get_context()
224 context.update(extra_context or {})
226 if SEARCH_ARG_GET_KEY in request.GET:
227 form = self.search_form(request.GET)
230 search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
231 url = request.GET.get(URL_REDIRECT_GET_KEY)
232 hash = request.GET.get(HASH_REDIRECT_GET_KEY)
235 if check_redirect_hash(hash, search_string, url):
236 # Create the necessary models
237 search = Search.objects.get_or_create(string=search_string)[0]
238 result_url = search.result_urls.get_or_create(url=url)[0]
239 result_url.clicks.create(datetime=datetime.datetime.now())
240 return HttpResponseRedirect(url)
242 messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
243 # TODO: Should search_string be escaped here?
244 return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
246 search_instances = []
247 for slug in self.searches:
248 search_instance = self.get_search_instance(slug, search_string)
249 search_instances.append(search_instance)
251 if self.enable_ajax_api:
252 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)
254 if eventlet and not self.enable_ajax_api:
255 pool = eventlet.GreenPool()
256 for instance in search_instances:
257 pool.spawn_n(lambda x: x.results, search_instance)
261 'searches': search_instances
269 return self.results_page.render_to_response(request, extra_context=context)
271 def ajax_api_view(self, request, slug, extra_context=None):
273 Returns a JSON string containing two keyed lists.
276 Contains the results of :meth:`.Result.get_context` for each result.
278 Contains the results of :meth:`.Result.render` for each result.
280 ``True`` or ``False`` whether the search has more results according to :meth:`BaseSearch.has_more_results`
282 Contains None or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results.
285 search_string = request.GET.get(SEARCH_ARG_GET_KEY)
287 if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
290 search_instance = self.get_search_instance(slug, search_string)
292 return HttpResponse(json.dumps({
293 'results': [result.get_context() for result in search_instance.results],
294 'rendered': [result.render() for result in search_instance.results],
295 'hasMoreResults': search.has_more_results(),
296 'moreResultsURL': (u"?%s" % search.more_results_querydict.urlencode()) if search.more_results_querydict else None,
297 }), mimetype="application/json")