Merge pull request #2770 from rtfd/promo-filter-updates

Add a bit more color to the promo display.
tox-dependencies
Eric Holscher 2017-04-17 14:23:55 -07:00 committed by GitHub
commit 817fe496e1
10 changed files with 202 additions and 28 deletions

View File

@ -45,7 +45,7 @@ class SupporterPromoAdmin(admin.ModelAdmin):
model = SupporterPromo model = SupporterPromo
save_as = True save_as = True
prepopulated_fields = {'analytics_id': ('name',)} prepopulated_fields = {'analytics_id': ('name',)}
list_display = ('name', 'live', 'click_ratio', 'sold_impressions', list_display = ('name', 'live', 'total_click_ratio', 'click_ratio', 'sold_impressions',
'total_views', 'total_clicks') 'total_views', 'total_clicks')
list_filter = ('live', 'display_type') list_filter = ('live', 'display_type')
list_editable = ('live', 'sold_impressions') list_editable = ('live', 'sold_impressions')

View File

@ -22,3 +22,13 @@ IMPRESSION_TYPES = (
VIEWS, VIEWS,
CLICKS CLICKS
) )
ANY = 'any'
READTHEDOCS_THEME = 'sphinx_rtd_theme'
ALABASTER_THEME = 'alabaster'
THEMES = (
(ANY, 'Any'),
(ALABASTER_THEME, 'Alabaster Theme'),
(READTHEDOCS_THEME, 'Read the Docs Sphinx Theme'),
)

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.12 on 2017-04-04 13:48
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('donate', '0009_add-error-to-promos'),
]
operations = [
migrations.AddField(
model_name='supporterpromo',
name='sold_clicks',
field=models.IntegerField(default=0, verbose_name='Sold Clicks'),
),
migrations.AlterField(
model_name='supporterpromo',
name='sold_impressions',
field=models.IntegerField(default=1000000, verbose_name='Sold Impressions'),
),
]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.12 on 2017-04-12 13:05
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('donate', '0010_add-sold-clicks'),
]
operations = [
migrations.AddField(
model_name='supporterpromo',
name='theme',
field=models.CharField(blank=True, choices=[(b'any', b'Any'), (b'alabaster', b'Alabaster Theme'), (b'sphinx_rtd_theme', b'Read the Docs Sphinx Theme')], default=b'sphinx_rtd_theme', max_length=40, null=True, verbose_name='Theme'),
),
]

View File

@ -8,7 +8,7 @@ from django_countries.fields import CountryField
from readthedocs.donate.utils import get_ad_day from readthedocs.donate.utils import get_ad_day
from readthedocs.donate.constants import ( from readthedocs.donate.constants import (
DISPLAY_CHOICES, FILTER_CHOICES, IMPRESSION_TYPES DISPLAY_CHOICES, FILTER_CHOICES, IMPRESSION_TYPES, THEMES, READTHEDOCS_THEME
) )
from readthedocs.projects.models import Project from readthedocs.projects.models import Project
from readthedocs.projects.constants import PROGRAMMING_LANGUAGES from readthedocs.projects.constants import PROGRAMMING_LANGUAGES
@ -48,11 +48,15 @@ class SupporterPromo(models.Model):
image = models.URLField(_('Image URL'), max_length=255, blank=True, null=True) image = models.URLField(_('Image URL'), max_length=255, blank=True, null=True)
display_type = models.CharField(_('Display Type'), max_length=200, display_type = models.CharField(_('Display Type'), max_length=200,
choices=DISPLAY_CHOICES, default='doc') choices=DISPLAY_CHOICES, default='doc')
sold_impressions = models.IntegerField(_('Sold Impressions'), default=1000) sold_impressions = models.IntegerField(_('Sold Impressions'), default=1000000)
sold_days = models.IntegerField(_('Sold Days'), default=30) sold_days = models.IntegerField(_('Sold Days'), default=30)
sold_clicks = models.IntegerField(_('Sold Clicks'), default=0)
programming_language = models.CharField(_('Programming Language'), max_length=20, programming_language = models.CharField(_('Programming Language'), max_length=20,
choices=PROGRAMMING_LANGUAGES, default=None, choices=PROGRAMMING_LANGUAGES, default=None,
blank=True, null=True) blank=True, null=True)
theme = models.CharField(_('Theme'), max_length=40,
choices=THEMES, default=READTHEDOCS_THEME,
blank=True, null=True)
live = models.BooleanField(_('Live'), default=False) live = models.BooleanField(_('Live'), default=False)
class Meta: class Meta:
@ -148,6 +152,15 @@ class SupporterPromo(models.Model):
(float(self.total_clicks()) / float(self.total_views())) * 100 (float(self.total_clicks()) / float(self.total_views())) * 100
) )
def report_html_text(self):
"""
Include the link in the html text.
Only used for reporting,
doesn't include any click fruad protection!
"""
return self.text.replace('<a>', "<a href='%s'>" % self.link)
class BaseImpression(models.Model): class BaseImpression(models.Model):
date = models.DateField(_('Date')) date = models.DateField(_('Date'))
@ -172,24 +185,28 @@ class BaseImpression(models.Model):
def click_ratio(self): def click_ratio(self):
if self.views == 0: if self.views == 0:
return 0 # Don't divide by 0 return 0 # Don't divide by 0
return float( return '%.3f' % float(
float(self.clicks) / float(self.views) * 100 float(self.clicks) / float(self.views) * 100
) )
class PromoImpressions(BaseImpression): class PromoImpressions(BaseImpression):
"""Track stats around how successful this promo has been. """
Track stats around how successful this promo has been.
Indexed one per promo per day.""" Indexed one per promo per day.
"""
promo = models.ForeignKey(SupporterPromo, related_name='impressions', promo = models.ForeignKey(SupporterPromo, related_name='impressions',
blank=True, null=True) blank=True, null=True)
class ProjectImpressions(BaseImpression): class ProjectImpressions(BaseImpression):
"""Track stats for a specific project and promo. """
Track stats for a specific project and promo.
Indexed one per project per promo per day""" Indexed one per project per promo per day
"""
promo = models.ForeignKey(SupporterPromo, related_name='project_impressions', promo = models.ForeignKey(SupporterPromo, related_name='project_impressions',
blank=True, null=True) blank=True, null=True)

View File

@ -1,7 +1,11 @@
import random import random
import logging
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
from django.core.cache import cache
import redis
from readthedocs.restapi.signals import footer_response from readthedocs.restapi.signals import footer_response
from readthedocs.donate.models import SupporterPromo from readthedocs.donate.models import SupporterPromo
@ -9,6 +13,8 @@ from readthedocs.donate.constants import INCLUDE, EXCLUDE
from readthedocs.donate.utils import offer_promo from readthedocs.donate.utils import offer_promo
log = logging.getLogger(__name__)
PROMO_GEO_PATH = getattr(settings, 'PROMO_GEO_PATH', None) PROMO_GEO_PATH = getattr(settings, 'PROMO_GEO_PATH', None)
if PROMO_GEO_PATH: if PROMO_GEO_PATH:
@ -82,7 +88,7 @@ def choose_promo(promo_list):
return None return None
def get_promo(country_code, programming_language, gold_project=False, gold_user=False): def get_promo(country_code, programming_language, theme, gold_project=False, gold_user=False):
""" """
Get a proper promo. Get a proper promo.
@ -97,15 +103,18 @@ def get_promo(country_code, programming_language, gold_project=False, gold_user=
promo_queryset = SupporterPromo.objects.filter(live=True, display_type='doc') promo_queryset = SupporterPromo.objects.filter(live=True, display_type='doc')
filtered_promos = [] filtered_promos = []
for obj in promo_queryset: for promo in promo_queryset:
# Break out if we aren't meant to show to this language # Break out if we aren't meant to show to this language
if obj.programming_language and not show_to_programming_language(obj, programming_language): if promo.programming_language and not show_to_programming_language(promo, programming_language): # noqa
continue continue
# Break out if we aren't meant to show to this country # Break out if we aren't meant to show to this country
if country_code and not show_to_geo(obj, country_code): if country_code and not show_to_geo(promo, country_code):
continue
# Don't show if the theme doesn't match
if promo.theme not in ['any', theme]:
continue continue
# If we haven't bailed because of language or country, possibly show the promo # If we haven't bailed because of language or country, possibly show the promo
filtered_promos.append(obj) filtered_promos.append(promo)
promo_obj = choose_promo(filtered_promos) promo_obj = choose_promo(filtered_promos)
@ -140,6 +149,7 @@ def attach_promo_data(sender, **kwargs):
resp_data = kwargs['resp_data'] resp_data = kwargs['resp_data']
project = context['project'] project = context['project']
theme = context['theme']
# Bail out early if promo's are disabled. # Bail out early if promo's are disabled.
use_promo = getattr(settings, 'USE_PROMOS', True) use_promo = getattr(settings, 'USE_PROMOS', True)
@ -181,6 +191,7 @@ def attach_promo_data(sender, **kwargs):
promo_obj = get_promo( promo_obj = get_promo(
country_code=country_code, country_code=country_code,
programming_language=project.programming_language, programming_language=project.programming_language,
theme=theme,
gold_project=gold_project, gold_project=gold_project,
gold_user=gold_user, gold_user=gold_user,
) )
@ -195,3 +206,25 @@ def attach_promo_data(sender, **kwargs):
# Set promo object on return JSON # Set promo object on return JSON
resp_data['promo'] = show_promo resp_data['promo'] = show_promo
@receiver(footer_response)
def index_theme_data(sender, **kwargs):
"""
Keep track of which projects are using which theme.
This is primarily used so we can send email to folks using alabaster,
and other themes we might want to display ads on.
This will allow us to give people fair warning before we put ads on their docs.
"""
context = kwargs['context']
project = context['project']
theme = context['theme']
try:
redis_client = cache.get_client(None)
redis_client.sadd("readthedocs:v1:index:themes:%s" % theme, project)
except (AttributeError, redis.exceptions.ConnectionError):
log.warning('Redis theme indexing error: %s', exc_info=True)

View File

@ -3,4 +3,13 @@
margin-right: auto; margin-right: auto;
width: 900px; width: 900px;
text-align: center text-align: center
.promo {
margin-top: 1em;
margin-bottom: 1em;
width: 240px;
}
.filters dt {
font-weight: bold;
} }

View File

@ -5,31 +5,76 @@
{% block title %}{% trans "Promo Detail" %}{% endblock %} {% block title %}{% trans "Promo Detail" %}{% endblock %}
{% block extra_links %}
<link rel="stylesheet" href="{% static 'donate/css/donate.css' %}" />
{% endblock %}
{% block content %} {% block content %}
<h1> Promo Results </h1> <h1> Promo Results </h1>
{% if promos %} {% if promos %}
{% if promos|length > 1 %}
<p> <p>
Total Clicks for all shown promos: {{ total_clicks }} <strong>Total Clicks for all shown promos</strong>: {{ total_clicks }}
</p> </p>
{% endif %}
<div id="promo_detail">
{% for promo in promos %} {% for promo in promos %}
<h3> <h3>
Results for {{ promo.name }} ({{ promo.analytics_id }}) over last {{ days }} days. Results for {{ promo.name }} ({{ promo.analytics_id }}) over last {{ days }} days.
</h3> </h3>
<div class="example" style="width: 30%;"> <div class="example">
<a href="{{ promo.link }}"><img width=120 height=90 src="{{ promo.image }}"></a>
<br> <div class="filters">
{{ promo.text|safe }} {% if promo.programming_language %}
<dl>
<dt>Filtered Language</dt>
<dd>{{ promo.programming_language }}</dd>
</dl>
{% endif %}
{% if promo.geo_filters.count %}
<dl>
<dt>Filtered Geos</dt>
{% for geo in promo.geo_filters.all %}
<dd>
{{ geo.get_filter_type_display }}: {{ geo.countries.all|join:", " }}
</dd>
{% endfor %}
</dl>
{% endif %}
{% if promo.sold_clicks %}
<dl>
<dt>Total Clicks Sold</dt>
<dd>
{{ promo.sold_clicks }}
</dd>
</dl>
{% endif %}
</div>
<div class="promo">
<div id="promo_image">
<a href="{{ promo.link }}">
<img width=120 height=90 src="{{ promo.image }}">
</a>
</div>
<div id="promo_text">
{{ promo.report_html_text|safe }}
</div>
</div>
</div> </div>
<br> <h5>Promo Data</h5>
<table> <table>
<tr> <tr>
<th><strong>Day (UTC)</strong></th> <th><strong>Day (UTC)</strong></th>
@ -56,6 +101,7 @@ Results for {{ promo.name }} ({{ promo.analytics_id }}) over last {{ days }} day
</table> </table>
{% endfor %} {% endfor %}
</div>
{% else %} {% else %}

View File

@ -90,7 +90,7 @@ class PromoDetailView(TemplateView):
if promo_slug == 'live' and self.request.user.is_staff: if promo_slug == 'live' and self.request.user.is_staff:
promos = SupporterPromo.objects.filter(live=True) promos = SupporterPromo.objects.filter(live=True)
elif '*' in promo_slug: elif promo_slug[-1] == '*' and '-' in promo_slug:
promos = SupporterPromo.objects.filter( promos = SupporterPromo.objects.filter(
analytics_id__contains=promo_slug.replace('*', '') analytics_id__contains=promo_slug.replace('*', '')
) )
@ -124,8 +124,14 @@ def click_proxy(request, promo_id, hash):
project = Project.objects.get(slug=project_slug) project = Project.objects.get(slug=project_slug)
promo.incr(CLICKS, project=project) promo.incr(CLICKS, project=project)
else: else:
log.warning('Duplicate click logged. {count} total clicks tried.'.format(count=count)) agent = request.META.get('HTTP_USER_AGENT', 'Unknown')
log.warning(
'Duplicate click logged. {count} total clicks tried. User Agent: [{agent}]'.format(
count=count, agent=agent
)
)
cache.incr(promo.cache_key(type=CLICKS, hash=hash)) cache.incr(promo.cache_key(type=CLICKS, hash=hash))
raise Http404('Invalid click. This has been logged.')
return redirect(promo.link) return redirect(promo.link)
@ -147,12 +153,18 @@ def view_proxy(request, promo_id, hash):
project = Project.objects.get(slug=project_slug) project = Project.objects.get(slug=project_slug)
promo.incr(VIEWS, project=project) promo.incr(VIEWS, project=project)
else: else:
log.warning('Duplicate view logged. {count} total clicks tried.'.format(count=count)) agent = request.META.get('HTTP_USER_AGENT', 'Unknown')
log.warning(
'Duplicate view logged. {count} total views tried. User Agent: [{agent}]'.format(
count=count, agent=agent
)
)
cache.incr(promo.cache_key(type=VIEWS, hash=hash)) cache.incr(promo.cache_key(type=VIEWS, hash=hash))
raise Http404('Invalid click. This has been logged.')
return redirect(promo.image) return redirect(promo.image)
def add_promo_data(display_type): def _add_promo_data(display_type):
promo_queryset = SupporterPromo.objects.filter(live=True, display_type=display_type) promo_queryset = SupporterPromo.objects.filter(live=True, display_type=display_type)
promo_obj = promo_queryset.order_by('?').first() promo_obj = promo_queryset.order_by('?').first()
if promo_obj: if promo_obj:
@ -164,7 +176,7 @@ def add_promo_data(display_type):
def promo_500(request, template_name='donate/promo_500.html', **kwargs): def promo_500(request, template_name='donate/promo_500.html', **kwargs):
"""A simple 500 handler so we get media""" """A simple 500 handler so we get media"""
promo_dict = add_promo_data(display_type='error') promo_dict = _add_promo_data(display_type='error')
r = render_to_response(template_name, r = render_to_response(template_name,
context_instance=RequestContext(request), context_instance=RequestContext(request),
context={ context={
@ -176,7 +188,7 @@ def promo_500(request, template_name='donate/promo_500.html', **kwargs):
def promo_404(request, template_name='donate/promo_404.html', **kwargs): def promo_404(request, template_name='donate/promo_404.html', **kwargs):
"""A simple 404 handler so we get media""" """A simple 404 handler so we get media"""
promo_dict = add_promo_data(display_type='error') promo_dict = _add_promo_data(display_type='error')
response = get_redirect_response(request, path=request.get_full_path()) response = get_redirect_response(request, path=request.get_full_path())
if response: if response:
return response return response

View File

@ -105,6 +105,7 @@ def footer_html(request):
'github_edit_url': version.get_github_url(docroot, page_slug, source_suffix, 'edit'), 'github_edit_url': version.get_github_url(docroot, page_slug, source_suffix, 'edit'),
'github_view_url': version.get_github_url(docroot, page_slug, source_suffix, 'view'), 'github_view_url': version.get_github_url(docroot, page_slug, source_suffix, 'view'),
'bitbucket_url': version.get_bitbucket_url(docroot, page_slug, source_suffix), 'bitbucket_url': version.get_bitbucket_url(docroot, page_slug, source_suffix),
'theme': theme,
} }
request_context = RequestContext(request, context) request_context = RequestContext(request, context)
@ -116,7 +117,8 @@ def footer_html(request):
'version_supported': version.supported, 'version_supported': version.supported,
} }
# Allow folks to hook onto the footer response for various information usage. # Allow folks to hook onto the footer response for various information collection,
# or to modify the resp_data.
footer_response.send(sender=None, request=request, context=context, resp_data=resp_data) footer_response.send(sender=None, request=request, context=context, resp_data=resp_data)
return Response(resp_data) return Response(resp_data)