Refactored weight code to split the work over Search, ResultURL, and Click models...
authorStephen Burrows <stephen.r.burrows@gmail.com>
Tue, 1 Mar 2011 22:25:45 +0000 (17:25 -0500)
committerStephen Burrows <stephen.r.burrows@gmail.com>
Tue, 1 Mar 2011 22:28:15 +0000 (17:28 -0500)
contrib/sobol/admin.py
contrib/sobol/models.py
contrib/sobol/templates/admin/sobol/search/grappelli_results.html [new file with mode: 0644]
contrib/sobol/templates/admin/sobol/search/results.html [new file with mode: 0644]

index 5407796..1ebbf5e 100644 (file)
@@ -1,12 +1,18 @@
+from django.conf import settings
+from django.conf.urls.defaults import patterns, url
 from django.contrib import admin
+from django.core.urlresolvers import reverse
 from django.db.models import Count
+from django.http import HttpResponseRedirect, Http404
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+from django.utils.functional import update_wrapper
 from philo.admin import EntityAdmin
 from philo.contrib.sobol.models import Search, ResultURL, SearchView
 
 
 class ResultURLInline(admin.TabularInline):
        model = ResultURL
-       template = 'search/admin/chosen_result_inline.html'
        readonly_fields = ('url',)
        can_delete = False
        extra = 0
@@ -18,6 +24,27 @@ class SearchAdmin(admin.ModelAdmin):
        inlines = [ResultURLInline]
        list_display = ['string', 'unique_urls', 'total_clicks']
        search_fields = ['string', 'result_urls__url']
+       actions = ['results_action']
+       if 'grappelli' in settings.INSTALLED_APPS:
+               results_template = 'admin/sobol/search/grappelli_results.html'
+       else:
+               results_template = 'admin/sobol/search/results.html'
+       
+       def get_urls(self):
+               urlpatterns = super(SearchAdmin, self).get_urls()
+               
+               def wrap(view):
+                       def wrapper(*args, **kwargs):
+                               return self.admin_site.admin_view(view)(*args, **kwargs)
+                       return update_wrapper(wrapper, view)
+               
+               info = self.model._meta.app_label, self.model._meta.module_name
+               
+               urlpatterns = patterns('',
+                       url(r'^results/$', wrap(self.results_view), name="%s_%s_selected_results" % info),
+                       url(r'^(.+)/results/$', wrap(self.results_view), name="%s_%s_results" % info)
+               ) + urlpatterns
+               return urlpatterns
        
        def unique_urls(self, obj):
                return obj.unique_urls
@@ -30,6 +57,35 @@ class SearchAdmin(admin.ModelAdmin):
        def queryset(self, request):
                qs = super(SearchAdmin, self).queryset(request)
                return qs.annotate(total_clicks=Count('result_urls__clicks', distinct=True), unique_urls=Count('result_urls', distinct=True))
+       
+       def results_action(self, request, queryset):
+               info = self.model._meta.app_label, self.model._meta.module_name
+               if len(queryset) == 1:
+                       return HttpResponseRedirect(reverse("admin:%s_%s_results" % info, args=(queryset[0].pk,)))
+               else:
+                       url = reverse("admin:%s_%s_selected_results" % info)
+                       return HttpResponseRedirect("%s?ids=%s" % (url, ','.join([str(item.pk) for item in queryset])))
+       results_action.short_description = "View results for selected %(verbose_name_plural)s"
+       
+       def results_view(self, request, object_id=None, extra_context=None):
+               if object_id is not None:
+                       object_ids = [object_id]
+               else:
+                       object_ids = request.GET.get('ids').split(',')
+                       
+                       if object_ids is None:
+                               raise Http404
+               
+               qs = self.queryset(request).filter(pk__in=object_ids)
+               opts = self.model._meta
+               
+               context = {
+                       'queryset': qs,
+                       'opts': opts,
+                       'root_path': self.admin_site.root_path,
+                       'app_label': opts.app_label
+               }
+               return render_to_response(self.results_template, context, context_instance=RequestContext(request))
 
 
 class SearchViewAdmin(EntityAdmin):
index cd9b698..7e11882 100644 (file)
@@ -1,8 +1,10 @@
 from django.conf.urls.defaults import patterns, url
 from django.contrib import messages
+from django.core.exceptions import ValidationError
 from django.db import models
 from django.http import HttpResponseRedirect, Http404
 from django.utils import simplejson as json
+from django.utils.datastructures import SortedDict
 from philo.contrib.sobol import registry
 from philo.contrib.sobol.forms import SearchForm
 from philo.contrib.sobol.utils import HASH_REDIRECT_GET_KEY, URL_REDIRECT_GET_KEY, SEARCH_ARG_GET_KEY, check_redirect_hash
@@ -23,51 +25,50 @@ class Search(models.Model):
        def __unicode__(self):
                return self.string
        
-       def get_favored_results(self, error=5):
-               """Calculate the set of most-favored results. A higher error
-               will cause this method to be more reticent about adding new
-               items."""
-               results = self.result_urls.values_list('pk', 'url',)
-               
-               result_dict = {}
-               for pk, url in results:
-                       result_dict[pk] = {'url': url, 'value': 0}
-               
-               clicks = Click.objects.filter(result__pk__in=result_dict.keys()).values_list('result__pk', 'datetime')
-               
-               now = datetime.datetime.now()
-               
-               def datetime_value(dt):
-                       days = (now - dt).days
-                       if days < 0:
-                               raise ValueError("Click dates must be in the past.")
-                       if days == 0:
-                               value = 1.0
-                       else:
-                               value = 1.0/days**2
-                       return value
-               
-               for pk, dt in clicks:
-                       value = datetime_value(dt)
-                       result_dict[pk]['value'] += value
-               
-               #TODO: is there a reasonable minimum value for consideration?
-               subsets = {}
-               for d in result_dict.values():
-                       subsets.setdefault(d['value'], []).append(d)
-               
-               # Now calculate the result set.
-               results = []
+       def get_weighted_results(self, threshhold=None):
+               "Returns this search's results ordered by decreasing weight."
+               if not hasattr(self, '_weighted_results'):
+                       result_qs = self.result_urls.all()
+                       
+                       if threshhold is not None:
+                               result_qs = result_qs.filter(counts__datetime__gte=threshhold)
+                       
+                       results = [result for result in result_qs]
+                       
+                       results.sort(cmp=lambda x,y: cmp(y.weight, x.weight))
+                       
+                       self._weighted_results = results
                
-               def cost(value):
-                       return error*sum([(value - item['value'])**2 for item in results])
+               return self._weighted_results
+       
+       def get_favored_results(self, error=5, threshhold=None):
+               """
+               Calculate the set of most-favored results. A higher error
+               will cause this method to be more reticent about adding new
+               items.
                
-               for value, subset in sorted(subsets.items(), cmp=lambda x,y: cmp(y[0], x[0])):
-                       if value > cost(value):
-                               results += subset
-                       else:
-                               break
-               return results
+               The thought is to see whether there are any results which
+               vastly outstrip the other options. As such, evenly-weighted
+               results should be grouped together and either added or
+               excluded as a group.
+               """
+               if not hasattr(self, '_favored_results'):
+                       results = self.get_weighted_results(threshhold)
+                       
+                       grouped_results = SortedDict()
+                       
+                       for result in results:
+                               grouped_results.setdefault(result.weight, []).append(result)
+                       
+                       self._favored_results = []
+                       
+                       for value, subresults in grouped_results.items():
+                               cost = error * sum([(value - result.weight)**2 for result in results])
+                               if value > cost:
+                                       self._favored_results += subresults
+                               else:
+                                       break
+               return self._favored_results
        
        class Meta:
                ordering = ['string']
@@ -81,6 +82,18 @@ class ResultURL(models.Model):
        def __unicode__(self):
                return self.url
        
+       def get_weight(self, threshhold=None):
+               if not hasattr(self, '_weight'):
+                       clicks = self.clicks.all()
+                       
+                       if threshhold is not None:
+                               clicks = clicks.filter(datetime__gte=threshhold)
+                       
+                       self._weight = sum([click.weight for click in clicks])
+               
+               return self._weight
+       weight = property(get_weight)
+       
        class Meta:
                ordering = ['url']
 
@@ -92,6 +105,23 @@ class Click(models.Model):
        def __unicode__(self):
                return self.datetime.strftime('%B %d, %Y %H:%M:%S')
        
+       def get_weight(self, default=1, weighted=lambda value, days: value/days**2):
+               if not hasattr(self, '_weight'):
+                       days = (datetime.datetime.now() - self.datetime).days
+                       if days < 0:
+                               raise ValueError("Click dates must be in the past.")
+                       default = float(default)
+                       if days == 0:
+                               self._weight = float(default)
+                       else:
+                               self._weight = weighted(default, days)
+               return self._weight
+       weight = property(get_weight)
+       
+       def clean(self):
+               if self.datetime > datetime.datetime.now():
+                       raise ValidationError("Click dates must be in the past.")
+       
        class Meta:
                ordering = ['datetime']
                get_latest_by = 'datetime'
@@ -103,6 +133,8 @@ class SearchView(MultiView):
        enable_ajax_api = models.BooleanField("Enable AJAX API", default=True)
        placeholder_text = models.CharField(max_length=75, default="Search")
        
+       search_form = SearchForm
+       
        def __unicode__(self):
                return u"%s (%s)" % (self.placeholder_text, u", ".join([display for slug, display in registry.iterchoices()]))
        
@@ -130,7 +162,7 @@ class SearchView(MultiView):
                context.update(extra_context or {})
                
                if SEARCH_ARG_GET_KEY in request.GET:
-                       form = SearchForm(request.GET)
+                       form = self.search_form(request.GET)
                        
                        if form.is_valid():
                                search_string = request.GET[SEARCH_ARG_GET_KEY].lower()
diff --git a/contrib/sobol/templates/admin/sobol/search/grappelli_results.html b/contrib/sobol/templates/admin/sobol/search/grappelli_results.html
new file mode 100644 (file)
index 0000000..28d5af7
--- /dev/null
@@ -0,0 +1,32 @@
+{% extends "admin/base_site.html" %}
+
+<!-- LOADING -->
+{% load i18n %}
+
+<!-- BREADCRUMBS -->
+{% block breadcrumbs %}
+       <div id="breadcrumbs">
+               <a href="../../">{% trans "Home" %}</a> &rsaquo;
+               <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+               <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+               {% trans 'Search results for multiple objects' %}
+       </div>
+{% endblock %}
+
+<!-- CONTENT -->
+{% block content %}
+       <div class="container-grid delete-confirmation">
+               {% for search in queryset %}
+               <h1>{{ search.string }}</h1>
+               <div class="module">
+                       <h2>{% blocktrans %}Results{% endblocktrans %}</h2>{% comment %}For the favored results, add a class?{% endcomment %}
+                       {% for result in search.get_weighted_results %}
+                       <div class="row cell-2">
+                               <div class="cell span-4">{{ result.url }}</div>
+                               <div class="cell span-flexible">{{ result.weight }}</div>
+                       </div>
+                       {% endfor %}
+               </div>
+               {% endfor %}
+       </div>
+{% endblock %}
\ No newline at end of file
diff --git a/contrib/sobol/templates/admin/sobol/search/results.html b/contrib/sobol/templates/admin/sobol/search/results.html
new file mode 100644 (file)
index 0000000..e69de29