Merge pull request #2105 from rtfd/promo-upgrades
Add basic click & view trackingbreak-out-core-urls-views
commit
10a3acb828
|
@ -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)
|
||||
|
|
|
@ -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')]),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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'),
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue