Tweaks to sobol to make its use simpler: added a prerendered version of the results...
[philo.git] / philo / contrib / sobol / models.py
1 import datetime
2
3 from django.conf import settings
4 from django.conf.urls.defaults import patterns, url
5 from django.contrib import messages
6 from django.core.exceptions import ValidationError
7 from django.core.validators import URLValidator
8 from django.db import models
9 from django.http import HttpResponseRedirect, Http404, HttpResponse
10 from django.utils import simplejson as json
11 from django.utils.datastructures import SortedDict
12
13 from philo.contrib.sobol import registry
14 from philo.contrib.sobol.forms import SearchForm
15 from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash
16 from philo.exceptions import ViewCanNotProvideSubpath
17 from philo.models import MultiView, Page
18 from philo.models.fields import SlugMultipleChoiceField
19
20 eventlet = None
21 if getattr(settings, 'SOBOL_USE_EVENTLET', False):
22         try:
23                 import eventlet
24         except:
25                 pass
26
27
28 class Search(models.Model):
29         string = models.TextField()
30         
31         def __unicode__(self):
32                 return self.string
33         
34         def get_weighted_results(self, threshhold=None):
35                 "Returns this search's results ordered by decreasing weight."
36                 if not hasattr(self, '_weighted_results'):
37                         result_qs = self.result_urls.all()
38                         
39                         if threshhold is not None:
40                                 result_qs = result_qs.filter(counts__datetime__gte=threshhold)
41                         
42                         results = [result for result in result_qs]
43                         
44                         results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
45                         
46                         self._weighted_results = results
47                 
48                 return self._weighted_results
49         
50         def get_favored_results(self, error=5, threshhold=None):
51                 """
52                 Calculate the set of most-favored results. A higher error
53                 will cause this method to be more reticent about adding new
54                 items.
55                 
56                 The thought is to see whether there are any results which
57                 vastly outstrip the other options. As such, evenly-weighted
58                 results should be grouped together and either added or
59                 excluded as a group.
60                 """
61                 if not hasattr(self, '_favored_results'):
62                         results = self.get_weighted_results(threshhold)
63                         
64                         grouped_results = SortedDict()
65                         
66                         for result in results:
67                                 grouped_results.setdefault(result.weight, []).append(result)
68                         
69                         self._favored_results = []
70                         
71                         for value, subresults in grouped_results.items():
72                                 cost = error * sum([(value - result.weight)**2 for result in self._favored_results])
73                                 if value > cost:
74                                         self._favored_results += subresults
75                                 else:
76                                         break
77                 return self._favored_results
78         
79         class Meta:
80                 ordering = ['string']
81                 verbose_name_plural = 'searches'
82
83
84 class ResultURL(models.Model):
85         search = models.ForeignKey(Search, related_name='result_urls')
86         url = models.TextField(validators=[URLValidator()])
87         
88         def __unicode__(self):
89                 return self.url
90         
91         def get_weight(self, threshhold=None):
92                 if not hasattr(self, '_weight'):
93                         clicks = self.clicks.all()
94                         
95                         if threshhold is not None:
96                                 clicks = clicks.filter(datetime__gte=threshhold)
97                         
98                         self._weight = sum([click.weight for click in clicks])
99                 
100                 return self._weight
101         weight = property(get_weight)
102         
103         class Meta:
104                 ordering = ['url']
105
106
107 class Click(models.Model):
108         result = models.ForeignKey(ResultURL, related_name='clicks')
109         datetime = models.DateTimeField()
110         
111         def __unicode__(self):
112                 return self.datetime.strftime('%B %d, %Y %H:%M:%S')
113         
114         def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
115                 if not hasattr(self, '_weight'):
116                         days = (datetime.datetime.now() - self.datetime).days
117                         if days < 0:
118                                 raise ValueError("Click dates must be in the past.")
119                         default = float(default)
120                         if days == 0:
121                                 self._weight = float(default)
122                         else:
123                                 self._weight = weighted(default, days)
124                 return self._weight
125         weight = property(get_weight)
126         
127         def clean(self):
128                 if self.datetime > datetime.datetime.now():
129                         raise ValidationError("Click dates must be in the past.")
130         
131         class Meta:
132                 ordering = ['datetime']
133                 get_latest_by = 'datetime'
134
135
136 class SearchView(MultiView):
137         results_page = models.ForeignKey(Page, related_name='search_results_related')
138         searches = SlugMultipleChoiceField(choices=registry.iterchoices())
139         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.")
140         placeholder_text = models.CharField(max_length=75, default="Search")
141         
142         search_form = SearchForm
143         
144         def __unicode__(self):
145                 return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices() if slug in self.searches]))
146         
147         def get_reverse_params(self, obj):
148                 raise ViewCanNotProvideSubpath
149         
150         @property
151         def urlpatterns(self):
152                 urlpatterns = patterns('',
153                         url(r'^$', self.results_view, name='results'),
154                 )
155                 if self.enable_ajax_api:
156                         urlpatterns += patterns('',
157                                 url(r'^(?P<slug>[\w-]+)$', self.ajax_api_view, name='ajax_api_view')
158                         )
159                 return urlpatterns
160         
161         def get_search_instance(self, slug, search_string):
162                 return registry[slug](search_string.lower())
163         
164         def results_view(self, request, extra_context=None):
165                 results = None
166                 
167                 context = self.get_context()
168                 context.update(extra_context or {})
169                 
170                 if SEARCH_ARG_GET_KEY in request.GET:
171                         form = self.search_form(request.GET)
172                         
173                         if form.is_valid():
174                                 search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
175                                 url = request.GET.get(URL_REDIRECT_GET_KEY)
176                                 hash = request.GET.get(HASH_REDIRECT_GET_KEY)
177                                 
178                                 if url and hash:
179                                         if check_redirect_hash(hash, search_string, url):
180                                                 # Create the necessary models
181                                                 search = Search.objects.get_or_create(string=search_string)[0]
182                                                 result_url = search.result_urls.get_or_create(url=url)[0]
183                                                 result_url.clicks.create(datetime=datetime.datetime.now())
184                                                 return HttpResponseRedirect(url)
185                                         else:
186                                                 messages.add_message(request, messages.INFO, "The link you followed had been tampered with. Here are all the results for your search term instead!")
187                                                 # TODO: Should search_string be escaped here?
188                                                 return HttpResponseRedirect("%s?%s=%s" % (request.path, SEARCH_ARG_GET_KEY, search_string))
189                                 if not self.enable_ajax_api:
190                                         search_instances = []
191                                         if eventlet:
192                                                 pool = eventlet.GreenPool()
193                                         for slug in self.searches:
194                                                 search_instance = self.get_search_instance(slug, search_string)
195                                                 search_instances.append(search_instance)
196                                                 if eventlet:
197                                                         pool.spawn_n(self.make_result_cache, search_instance)
198                                                 else:
199                                                         self.make_result_cache(search_instance)
200                                         if eventlet:
201                                                 pool.waitall()
202                                         context.update({
203                                                 'searches': search_instances
204                                         })
205                                 else:
206                                         context.update({
207                                                 'searches': [{'verbose_name': verbose_name, 'slug': slug, 'url': "%s?%s=%s" % (self.reverse('ajax_api_view', kwargs={'slug': slug}, node=request.node), SEARCH_ARG_GET_KEY, search_string), 'result_template': registry[slug].result_template} for slug, verbose_name in registry.iterchoices() if slug in self.searches]
208                                         })
209                 else:
210                         form = SearchForm()
211                 
212                 context.update({
213                         'form': form
214                 })
215                 return self.results_page.render_to_response(request, extra_context=context)
216         
217         def make_result_cache(self, search_instance):
218                 search_instance.results
219         
220         def ajax_api_view(self, request, slug, extra_context=None):
221                 search_string = request.GET.get(SEARCH_ARG_GET_KEY)
222                 
223                 if not request.is_ajax() or not self.enable_ajax_api or slug not in self.searches or search_string is None:
224                         raise Http404
225                 
226                 search_instance = self.get_search_instance(slug, search_string)
227                 
228                 return HttpResponse(json.dumps({
229                         'results': [result.get_context() for result in search_instance.results],
230                         'rendered': [result.render() for result in search_instance.results]
231                 }))