Merge pull request #2105 from rtfd/promo-upgrades

Add basic click & view tracking
break-out-core-urls-views
Eric Holscher 2016-04-01 15:18:15 -07:00
commit 10a3acb828
9 changed files with 306 additions and 7 deletions

View File

@ -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)

View File

@ -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')]),
),
]

View File

@ -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)

117
readthedocs/donate/tests.py Normal file
View File

@ -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)

View File

@ -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<promo_id>\d+)/(?P<hash>.+)/$', view_proxy, name='donate_view_proxy'),
url(r'^click/(?P<promo_id>\d+)/(?P<hash>.+)/$', click_proxy, name='donate_click_proxy'),
)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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