Merge branch 'master' of git://github.com/melinath/philo
[philo.git] / philo / contrib / sobol / models.py
1 import datetime
2 import itertools
3
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
13
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
20
21 eventlet = None
22 if getattr(settings, 'SOBOL_USE_EVENTLET', False):
23         try:
24                 import eventlet
25         except:
26                 pass
27
28
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()
33         
34         def __unicode__(self):
35                 return self.string
36         
37         def get_weighted_results(self, threshhold=None):
38                 """
39                 Returns a list of :class:`ResultURL` instances related to the search and ordered by decreasing weight. This will be cached on the instance.
40                 
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).
42                 
43                 """
44                 if not hasattr(self, '_weighted_results'):
45                         result_qs = self.result_urls.all()
46                         
47                         if threshhold is not None:
48                                 result_qs = result_qs.filter(counts__datetime__gte=threshhold)
49                         
50                         results = [result for result in result_qs]
51                         
52                         results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
53                         
54                         self._weighted_results = results
55                 
56                 return self._weighted_results
57         
58         def get_favored_results(self, error=5, threshhold=None):
59                 """
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.
61                 
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`
64                 
65                 """
66                 if not hasattr(self, '_favored_results'):
67                         results = self.get_weighted_results(threshhold)
68                         
69                         grouped_results = SortedDict()
70                         
71                         for result in results:
72                                 grouped_results.setdefault(result.weight, []).append(result)
73                         
74                         self._favored_results = []
75                         
76                         for value, subresults in grouped_results.items():
77                                 cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
78                                 if value > cost:
79                                         self._favored_results += subresults
80                                 else:
81                                         break
82                 return self._favored_results
83         
84         class Meta:
85                 ordering = ['string']
86                 verbose_name_plural = 'searches'
87
88
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()])
95         
96         def __unicode__(self):
97                 return self.url
98         
99         def get_weight(self, threshhold=None):
100                 """
101                 Calculates, caches, and returns the weight of the :class:`ResultURL`.
102                 
103                 :param threshhold: The datetime limit before which :class:`Click`\ s will not contribute to the weight of the :class:`ResultURL`.
104                 
105                 """
106                 if not hasattr(self, '_weight'):
107                         clicks = self.clicks.all()
108                         
109                         if threshhold is not None:
110                                 clicks = clicks.filter(datetime__gte=threshhold)
111                         
112                         self._weight = sum([click.weight for click in clicks])
113                 
114                 return self._weight
115         weight = property(get_weight)
116         
117         class Meta:
118                 ordering = ['url']
119
120
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()
127         
128         def __unicode__(self):
129                 return self.datetime.strftime('%B %d, %Y %H:%M:%S')
130         
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
135                         if days < 0:
136                                 raise ValueError("Click dates must be in the past.")
137                         default = float(default)
138                         if days == 0:
139                                 self._weight = float(default)
140                         else:
141                                 self._weight = weighted(default, days)
142                 return self._weight
143         weight = property(get_weight)
144         
145         def clean(self):
146                 if self.datetime > datetime.datetime.now():
147                         raise ValidationError("Click dates must be in the past.")
148         
149         class Meta:
150                 ordering = ['datetime']
151                 get_latest_by = 'datetime'
152
153
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)
160                         return choices
161                 else:
162                         return self._choices
163         choices = property(_get_choices)
164
165
166 try:
167         from south.modelsinspector import add_introspection_rules
168 except ImportError:
169         pass
170 else:
171         add_introspection_rules([], ["^philo\.contrib\.shipherd\.models\.RegistryChoiceField"])
172
173
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.
181         #:
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.
183         #:
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")
188         
189         #: The form which will be used to validate the input to the search box for this :class:`SearchView`.
190         search_form = SearchForm
191         
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]))
194         
195         def get_reverse_params(self, obj):
196                 raise ViewCanNotProvideSubpath
197         
198         @property
199         def urlpatterns(self):
200                 urlpatterns = patterns('',
201                         url(r'^$', self.results_view, name='results'),
202                 )
203                 if self.enable_ajax_api:
204                         urlpatterns += patterns('',
205                                 url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
206                         )
207                 return urlpatterns
208         
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())
212         
213         def results_view(self, request, extra_context=None):
214                 """
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:
216                 
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.
219                 
220                 """
221                 results = None
222                 
223                 context = self.get_context()
224                 context.update(extra_context or {})
225                 
226                 if SEARCH_ARG_GET_KEY in request.GET:
227                         form = self.search_form(request.GET)
228                         
229                         if form.is_valid():
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)
233                                 
234                                 if url and hash:
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)
241                                         else:
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))
245                                 
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)
250                                         
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)
253                                 
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)
258                                         pool.waitall()
259                                 
260                                 context.update({
261                                         'searches': search_instances
262                                 })
263                 else:
264                         form = SearchForm()
265                 
266                 context.update({
267                         'form': form
268                 })
269                 return self.results_page.render_to_response(request, extra_context=context)
270         
271         def ajax_api_view(self, request, slug, extra_context=None):
272                 """
273                 Returns a JSON string containing two keyed lists.
274                 
275                 results
276                         Contains the results of :meth:`.Result.get_context` for each result.
277                 rendered
278                         Contains the results of :meth:`.Result.render` for each result.
279                 hasMoreResults
280                         ``True`` or ``False`` whether the search has more results according to :meth:`BaseSearch.has_more_results`
281                 moreResultsURL
282                         Contains None or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results.
283                 
284                 """
285                 search_string = request.GET.get(SEARCH_ARG_GET_KEY)
286                 
287                 if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
288                         raise Http404
289                 
290                 search_instance = self.get_search_instance(slug, search_string)
291                 
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")