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
save_as = True
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')
list_filter = ('live', 'display_type')
list_editable = ('live', 'sold_impressions')

View File

@ -22,3 +22,13 @@ IMPRESSION_TYPES = (
VIEWS,
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.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.constants import PROGRAMMING_LANGUAGES
@ -48,11 +48,15 @@ class SupporterPromo(models.Model):
image = models.URLField(_('Image URL'), max_length=255, blank=True, null=True)
display_type = models.CharField(_('Display Type'), max_length=200,
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_clicks = models.IntegerField(_('Sold Clicks'), default=0)
programming_language = models.CharField(_('Programming Language'), max_length=20,
choices=PROGRAMMING_LANGUAGES, default=None,
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)
class Meta:
@ -148,6 +152,15 @@ class SupporterPromo(models.Model):
(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):
date = models.DateField(_('Date'))
@ -172,24 +185,28 @@ class BaseImpression(models.Model):
def click_ratio(self):
if self.views == 0:
return 0 # Don't divide by 0
return float(
return '%.3f' % float(
float(self.clicks) / float(self.views) * 100
)
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',
blank=True, null=True)
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',
blank=True, null=True)

View File

@ -1,7 +1,11 @@
import random
import logging
from django.dispatch import receiver
from django.conf import settings
from django.core.cache import cache
import redis
from readthedocs.restapi.signals import footer_response
from readthedocs.donate.models import SupporterPromo
@ -9,6 +13,8 @@ from readthedocs.donate.constants import INCLUDE, EXCLUDE
from readthedocs.donate.utils import offer_promo
log = logging.getLogger(__name__)
PROMO_GEO_PATH = getattr(settings, 'PROMO_GEO_PATH', None)
if PROMO_GEO_PATH:
@ -82,7 +88,7 @@ def choose_promo(promo_list):
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.
@ -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')
filtered_promos = []
for obj in promo_queryset:
for promo in promo_queryset:
# 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
# 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
# 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)
@ -140,6 +149,7 @@ def attach_promo_data(sender, **kwargs):
resp_data = kwargs['resp_data']
project = context['project']
theme = context['theme']
# Bail out early if promo's are disabled.
use_promo = getattr(settings, 'USE_PROMOS', True)
@ -181,6 +191,7 @@ def attach_promo_data(sender, **kwargs):
promo_obj = get_promo(
country_code=country_code,
programming_language=project.programming_language,
theme=theme,
gold_project=gold_project,
gold_user=gold_user,
)
@ -195,3 +206,25 @@ def attach_promo_data(sender, **kwargs):
# Set promo object on return JSON
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;
width: 900px;
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 extra_links %}
<link rel="stylesheet" href="{% static 'donate/css/donate.css' %}" />
{% endblock %}
{% block content %}
<h1> Promo Results </h1>
{% if promos %}
{% if promos|length > 1 %}
<p>
Total Clicks for all shown promos: {{ total_clicks }}
<strong>Total Clicks for all shown promos</strong>: {{ total_clicks }}
</p>
{% endif %}
<div id="promo_detail">
{% for promo in promos %}
<h3>
Results for {{ promo.name }} ({{ promo.analytics_id }}) over last {{ days }} days.
</h3>
<div class="example" style="width: 30%;">
<a href="{{ promo.link }}"><img width=120 height=90 src="{{ promo.image }}"></a>
<br>
{{ promo.text|safe }}
<div class="example">
<div class="filters">
{% 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>
<br>
<h5>Promo Data</h5>
<table>
<tr>
<th><strong>Day (UTC)</strong></th>
@ -56,6 +101,7 @@ Results for {{ promo.name }} ({{ promo.analytics_id }}) over last {{ days }} day
</table>
{% endfor %}
</div>
{% else %}

View File

@ -90,7 +90,7 @@ class PromoDetailView(TemplateView):
if promo_slug == 'live' and self.request.user.is_staff:
promos = SupporterPromo.objects.filter(live=True)
elif '*' in promo_slug:
elif promo_slug[-1] == '*' and '-' in promo_slug:
promos = SupporterPromo.objects.filter(
analytics_id__contains=promo_slug.replace('*', '')
)
@ -124,8 +124,14 @@ def click_proxy(request, promo_id, hash):
project = Project.objects.get(slug=project_slug)
promo.incr(CLICKS, project=project)
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))
raise Http404('Invalid click. This has been logged.')
return redirect(promo.link)
@ -147,12 +153,18 @@ def view_proxy(request, promo_id, hash):
project = Project.objects.get(slug=project_slug)
promo.incr(VIEWS, project=project)
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))
raise Http404('Invalid click. This has been logged.')
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_obj = promo_queryset.order_by('?').first()
if promo_obj:
@ -164,7 +176,7 @@ def add_promo_data(display_type):
def promo_500(request, template_name='donate/promo_500.html', **kwargs):
"""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,
context_instance=RequestContext(request),
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):
"""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())
if 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_view_url': version.get_github_url(docroot, page_slug, source_suffix, 'view'),
'bitbucket_url': version.get_bitbucket_url(docroot, page_slug, source_suffix),
'theme': theme,
}
request_context = RequestContext(request, context)
@ -116,7 +117,8 @@ def footer_html(request):
'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)
return Response(resp_data)