43b78b4c7b5da5d23f8f2d83954beb5338427003
[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, get_search_instance
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 results_view(self, request, extra_context=None):
210                 """
211                 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:
212                 
213                 * 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.
214                 * 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.
215                 
216                 """
217                 results = None
218                 
219                 context = self.get_context()
220                 context.update(extra_context or {})
221                 
222                 if SEARCH_ARG_GET_KEY in request.GET:
223                         form = self.search_form(request.GET)
224                         
225                         if form.is_valid():
226                                 search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
227                                 url = request.GET.get(URL_REDIRECT_GET_KEY)
228                                 hash = request.GET.get(HASH_REDIRECT_GET_KEY)
229                                 
230                                 if url and hash:
231                                         if check_redirect_hash(hash, search_string, url):
232                                                 # Create the necessary models
233                                                 search = Search.objects.get_or_create(string=search_string)[0]
234                                                 result_url = search.result_urls.get_or_create(url=url)[0]
235                                                 result_url.clicks.create(datetime=datetime.datetime.now())
236                                                 return HttpResponseRedirect(url)
237                                         else:
238                                                 messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
239                                                 # TODO: Should search_string be escaped here?
240                                                 return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
241                                 
242                                 search_instances = []
243                                 for slug in self.searches:
244                                         search_instance = get_search_instance(slug, search_string)
245                                         search_instances.append(search_instance)
246                                         
247                                         if self.enable_ajax_api:
248                                                 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)
249                                 
250                                 if eventlet and not self.enable_ajax_api:
251                                         pool = eventlet.GreenPool()
252                                         for instance in search_instances:
253                                                 pool.spawn_n(lambda x: x.results, search_instance)
254                                         pool.waitall()
255                                 
256                                 context.update({
257                                         'searches': search_instances
258                                 })
259                 else:
260                         form = SearchForm()
261                 
262                 context.update({
263                         'form': form
264                 })
265                 return self.results_page.render_to_response(request, extra_context=context)
266         
267         def ajax_api_view(self, request, slug, extra_context=None):
268                 """
269                 Returns a JSON string containing two keyed lists.
270                 
271                 results
272                         Contains the results of :meth:`.Result.get_context` for each result.
273                 rendered
274                         Contains the results of :meth:`.Result.render` for each result.
275                 hasMoreResults
276                         ``True`` or ``False`` whether the search has more results according to :meth:`BaseSearch.has_more_results`
277                 moreResultsURL
278                         Contains None or a querystring which, once accessed, will note the :class:`Click` and redirect the user to a page containing more results.
279                 
280                 """
281                 search_string = request.GET.get(SEARCH_ARG_GET_KEY)
282                 
283                 if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
284                         raise Http404
285                 
286                 search_instance = get_search_instance(slug, search_string)
287                 
288                 return HttpResponse(json.dumps({
289                         'results': [result.get_context() for result in search_instance.results],
290                         'rendered': [result.render() for result in search_instance.results],
291                         'hasMoreResults': search.has_more_results(),
292                         'moreResultsURL': (u"?%s" % search.more_results_querydict.urlencode()) if search.more_results_querydict else None,
293                 }), mimetype="application/json")