diff --git a/readthedocs/donate/admin.py b/readthedocs/donate/admin.py index dcb60641a..569afbe5e 100644 --- a/readthedocs/donate/admin.py +++ b/readthedocs/donate/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Supporter, SupporterPromo +from .models import Supporter, SupporterPromo, SupporterImpressions class SupporterAdmin(admin.ModelAdmin): @@ -9,10 +9,26 @@ class SupporterAdmin(admin.ModelAdmin): list_filter = ('name', 'email', 'dollars', 'public') +class ImpressionInline(admin.TabularInline): + model = SupporterImpressions + readonly_fields = ('date', 'offers', 'views', 'clicks', 'shown') + extra = 0 + can_delete = False + max_num = 15 + + def shown(self, instance): + return instance.shown * 100 + + class SupporterPromoAdmin(admin.ModelAdmin): model = SupporterPromo - list_display = ('name', 'display_type', 'text', 'live') + list_display = ('name', 'display_type', 'text', 'live', 'shown') + readonly_fields = ('shown',) list_filter = ('live', 'display_type') + inlines = [ImpressionInline] + + def shown(self, instance): + return instance.shown() * 100 admin.site.register(Supporter, SupporterAdmin) diff --git a/readthedocs/donate/migrations/0003_add-impressions.py b/readthedocs/donate/migrations/0003_add-impressions.py new file mode 100644 index 000000000..53d01ffa2 --- /dev/null +++ b/readthedocs/donate/migrations/0003_add-impressions.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('donate', '0002_dollar-drop-choices'), + ] + + operations = [ + migrations.CreateModel( + name='SupporterImpressions', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('date', models.DateField(verbose_name='Date')), + ('offers', models.IntegerField(default=0, verbose_name='Offer')), + ('views', models.IntegerField(default=0, verbose_name='View')), + ('clicks', models.IntegerField(default=0, verbose_name='Clicks')), + ('promo', models.ForeignKey(related_name='impressions', blank=True, to='donate.SupporterPromo', null=True)), + ], + options={ + 'ordering': ('-date',), + }, + ), + migrations.AlterUniqueTogether( + name='supporterimpressions', + unique_together=set([('promo', 'date')]), + ), + ] diff --git a/readthedocs/donate/models.py b/readthedocs/donate/models.py index 42af8e481..3eeb01a16 100644 --- a/readthedocs/donate/models.py +++ b/readthedocs/donate/models.py @@ -1,5 +1,10 @@ from django.db import models +from django.utils.crypto import get_random_string from django.utils.translation import ugettext_lazy as _ +from django.core.urlresolvers import reverse + +from readthedocs.donate.utils import get_ad_day + DISPLAY_CHOICES = ( ('doc', 'Documentation Pages'), @@ -7,6 +12,16 @@ DISPLAY_CHOICES = ( ('search', 'Search Pages'), ) +OFFERS = 'offers' +VIEWS = 'views' +CLICKS = 'clicks' + +IMPRESSION_TYPES = ( + OFFERS, + VIEWS, + CLICKS +) + class Supporter(models.Model): pub_date = models.DateTimeField(_('Publication date'), auto_now_add=True) @@ -50,9 +65,63 @@ class SupporterPromo(models.Model): def as_dict(self): "A dict respresentation of this for JSON encoding" + hash = get_random_string() + image_url = reverse( + 'donate_view_proxy', + kwargs={'promo_id': self.pk, 'hash': hash} + ) + # TODO: Store this hash and confirm that a proper hash was sent later + link_url = reverse( + 'donate_click_proxy', + kwargs={'promo_id': self.pk, 'hash': hash} + ) return { 'id': self.analytics_id, 'text': self.text, - 'link': self.link, - 'image': self.image, + 'link': link_url, + 'image': image_url, + 'hash': hash, } + + def cache_key(self, type, hash): + assert type in IMPRESSION_TYPES + return 'promo:{id}:{hash}:{type}'.format(id=self.analytics_id, hash=hash, type=type) + + def incr(self, type): + """Add to the number of times this action has been performed, stored in the DB""" + assert type in IMPRESSION_TYPES + day = get_ad_day() + impression, _ = self.impressions.get_or_create(date=day) + setattr(impression, type, models.F(type) + 1) + impression.save() + + # TODO: Support redis, more info on this PR + # github.com/rtfd/readthedocs.org/pull/2105/files/1b5f8568ae0a7760f7247149bcff481efc000f32#r58253051 + + def shown(self, day=None): + """Return the percentage of times this ad was shown when offered.""" + if not day: + day = get_ad_day() + impression = self.impressions.get(date=day) + return impression.shown + + +class SupporterImpressions(models.Model): + """Track stats around how successful this promo has been. """ + promo = models.ForeignKey(SupporterPromo, related_name='impressions', + blank=True, null=True) + date = models.DateField(_('Date')) + offers = models.IntegerField(_('Offer'), default=0) + views = models.IntegerField(_('View'), default=0) + clicks = models.IntegerField(_('Clicks'), default=0) + + class Meta: + ordering = ('-date',) + unique_together = ('promo', 'date') + + @property + def shown(self): + """Return the percentage of times this ad was shown when offered.""" + if self.views == 0: + return 0 # Don't divide by 0 + return float(self.views) / float(self.offers) diff --git a/readthedocs/donate/tests.py b/readthedocs/donate/tests.py new file mode 100644 index 000000000..48cc23198 --- /dev/null +++ b/readthedocs/donate/tests.py @@ -0,0 +1,117 @@ +import json + +from django.test import TestCase +from django.core.urlresolvers import reverse +from django.core.cache import cache + +from django_dynamic_fixture import get + +from .models import SupporterPromo, CLICKS, VIEWS, OFFERS +from readthedocs.projects.models import Project + + +class PromoTests(TestCase): + + def setUp(self): + self.promo = get(SupporterPromo, + slug='promo-slug', + link='http://example.com', + image='http://media.example.com/img.png') + + def test_clicks(self): + cache.set(self.promo.cache_key(type=CLICKS, hash='random_hash'), 0) + resp = self.client.get('http://testserver/sustainability/click/%s/random_hash/' % self.promo.id) + self.assertEqual(resp._headers['location'][1], 'http://example.com') + promo = SupporterPromo.objects.get(pk=self.promo.pk) + impression = promo.impressions.first() + self.assertEqual(impression.clicks, 1) + + def test_views(self): + cache.set(self.promo.cache_key(type=VIEWS, hash='random_hash'), 0) + resp = self.client.get('http://testserver/sustainability/view/%s/random_hash/' % self.promo.id) + self.assertEqual(resp._headers['location'][1], 'http://media.example.com/img.png') + promo = SupporterPromo.objects.get(pk=self.promo.pk) + impression = promo.impressions.first() + self.assertEqual(impression.views, 1) + + def test_stats(self): + for x in range(5): + self.promo.incr(OFFERS) + for x in range(2): + self.promo.incr(VIEWS) + self.assertEqual(self.promo.shown(), 40) + + def test_multiple_hash_usage(self): + cache.set(self.promo.cache_key(type=VIEWS, hash='random_hash'), 0) + self.client.get('http://testserver/sustainability/view/%s/random_hash/' % self.promo.id) + promo = SupporterPromo.objects.get(pk=self.promo.pk) + impression = promo.impressions.first() + self.assertEqual(impression.views, 1) + + # Don't increment again. + self.client.get('http://testserver/sustainability/view/%s/random_hash/' % self.promo.id) + promo = SupporterPromo.objects.get(pk=self.promo.pk) + impression = promo.impressions.first() + self.assertEqual(impression.views, 1) + + +class FooterTests(TestCase): + + def setUp(self): + self.promo = get(SupporterPromo, + live=True, + slug='promo-slug', + display_type='doc', + link='http://example.com', + image='http://media.example.com/img.png') + self.pip = get(Project, slug='pip') + + def test_footer(self): + r = self.client.get( + '/api/v2/footer_html/?project=pip&version=latest&page=index' + ) + resp = json.loads(r.content) + self.assertEqual(resp['promo_data']['link'], '/sustainability/click/%s/%s/' % (self.promo.pk, resp['promo_data']['hash'])) + impression = self.promo.impressions.first() + self.assertEqual(impression.offers, 1) + + def test_integration(self): + # Get footer promo + r = self.client.get( + '/api/v2/footer_html/?project=pip&version=latest&page=index' + ) + resp = json.loads(r.content) + self.assertEqual( + resp['promo_data']['link'], + '/sustainability/click/%s/%s/' % (self.promo.pk, resp['promo_data']['hash']) + ) + impression = self.promo.impressions.first() + self.assertEqual(impression.offers, 1) + self.assertEqual(impression.views, 0) + self.assertEqual(impression.clicks, 0) + + # Assert view + + r = self.client.get( + reverse( + 'donate_view_proxy', + kwargs={'promo_id': self.promo.pk, 'hash': resp['promo_data']['hash']} + ) + ) + impression = self.promo.impressions.first() + self.assertEqual(impression.offers, 1) + self.assertEqual(impression.views, 1) + self.assertEqual(impression.clicks, 0) + + # Click + + r = self.client.get( + reverse( + 'donate_click_proxy', + kwargs={'promo_id': self.promo.pk, 'hash': resp['promo_data']['hash']} + ) + ) + impression = self.promo.impressions.first() + self.assertEqual(impression.offers, 1) + self.assertEqual(impression.views, 1) + self.assertEqual(impression.clicks, 1) diff --git a/readthedocs/donate/urls.py b/readthedocs/donate/urls.py index 97cc3558c..52f554648 100644 --- a/readthedocs/donate/urls.py +++ b/readthedocs/donate/urls.py @@ -3,6 +3,7 @@ from django.conf.urls import url, patterns, include from .views import DonateCreateView from .views import DonateListView from .views import DonateSuccessView +from .views import click_proxy, view_proxy urlpatterns = patterns( @@ -10,4 +11,6 @@ urlpatterns = patterns( url(r'^$', DonateListView.as_view(), name='donate'), url(r'^contribute/$', DonateCreateView.as_view(), name='donate_add'), url(r'^contribute/thanks$', DonateSuccessView.as_view(), name='donate_success'), + url(r'^view/(?P\d+)/(?P.+)/$', view_proxy, name='donate_view_proxy'), + url(r'^click/(?P\d+)/(?P.+)/$', click_proxy, name='donate_click_proxy'), ) diff --git a/readthedocs/donate/utils.py b/readthedocs/donate/utils.py new file mode 100644 index 000000000..3fe7b18b9 --- /dev/null +++ b/readthedocs/donate/utils.py @@ -0,0 +1,13 @@ +import pytz +import datetime + + +def get_ad_day(): + date = pytz.utc.localize(datetime.datetime.utcnow()) + day = datetime.datetime( + year=date.year, + month=date.month, + day=date.day, + tzinfo=pytz.utc, + ) + return day diff --git a/readthedocs/donate/views.py b/readthedocs/donate/views.py index 123383759..c16c773fc 100644 --- a/readthedocs/donate/views.py +++ b/readthedocs/donate/views.py @@ -5,11 +5,14 @@ import logging from django.views.generic import TemplateView from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ +from django.shortcuts import redirect +from django.core.cache import cache + from vanilla import CreateView, ListView from readthedocs.payments.mixins import StripeMixin -from .models import Supporter +from .models import Supporter, SupporterPromo, CLICKS, VIEWS from .forms import SupporterForm from .mixins import DonateProgressMixin @@ -54,3 +57,31 @@ class DonateListView(DonateProgressMixin, ListView): def get_template_names(self): return [self.template_name] + + +def click_proxy(request, promo_id, hash): + promo = SupporterPromo.objects.get(pk=promo_id) + count = cache.get(promo.cache_key(type=CLICKS, hash=hash), None) + if count is None: + log.warning('Old or nonexistant hash tried on Click.') + elif count == 0: + promo.incr(CLICKS) + cache.incr(promo.cache_key(type=CLICKS, hash=hash)) + else: + log.warning('Duplicate click logged. {count} total clicks tried.'.format(count=count)) + cache.incr(promo.cache_key(type=CLICKS, hash=hash)) + return redirect(promo.link) + + +def view_proxy(request, promo_id, hash): + promo = SupporterPromo.objects.get(pk=promo_id) + count = cache.get(promo.cache_key(type=VIEWS, hash=hash), None) + if count is None: + log.warning('Old or nonexistant hash tried on View.') + elif count == 0: + promo.incr(VIEWS) + cache.incr(promo.cache_key(type=VIEWS, hash=hash)) + else: + log.warning('Duplicate view logged. {count} total clicks tried.'.format(count=count)) + cache.incr(promo.cache_key(type=CLICKS, hash=hash)) + return redirect(promo.image) diff --git a/readthedocs/restapi/views/footer_views.py b/readthedocs/restapi/views/footer_views.py index 9b800964c..ddee2a3f2 100644 --- a/readthedocs/restapi/views/footer_views.py +++ b/readthedocs/restapi/views/footer_views.py @@ -1,6 +1,8 @@ from django.shortcuts import get_object_or_404 from django.template import RequestContext, loader as template_loader from django.conf import settings +from django.core.cache import cache + from rest_framework import decorators, permissions from rest_framework.renderers import JSONPRenderer, JSONRenderer @@ -9,7 +11,7 @@ from rest_framework.response import Response from readthedocs.builds.constants import LATEST from readthedocs.builds.constants import TAG from readthedocs.builds.models import Version -from readthedocs.donate.models import SupporterPromo +from readthedocs.donate.models import SupporterPromo, VIEWS, CLICKS from readthedocs.projects.models import Project from readthedocs.projects.version_handling import highest_version from readthedocs.projects.version_handling import parse_version_failsafe @@ -154,5 +156,14 @@ def footer_html(request): 'promo': show_promo, } if show_promo and promo_obj: - resp_data['promo_data'] = promo_obj.as_dict() + promo_dict = promo_obj.as_dict() + resp_data['promo_data'] = promo_dict + promo_obj.incr('offers') + # Set validation cache + for type in [VIEWS, CLICKS]: + cache.set( + promo_obj.cache_key(type=type, hash=promo_dict['hash']), + 0, # Number of times used. Make this an int so we can detect multiple uses + 60 * 60 # hour + ) return Response(resp_data) diff --git a/readthedocs/settings/test.py b/readthedocs/settings/test.py index 0b20fe077..23cfadb19 100644 --- a/readthedocs/settings/test.py +++ b/readthedocs/settings/test.py @@ -9,6 +9,13 @@ SLUMBER_API_HOST = 'http://localhost:8000' PRODUCTION_DOMAIN = 'readthedocs.org' GROK_API_HOST = 'http://localhost:8888' +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'PREFIX': 'docs', + } +} + if not os.environ.get('DJANGO_SETTINGS_SKIP_LOCAL', False): try: from local_settings import * # noqa