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