Add frequency capping to ads

This commit is contained in:
zeantsoi
2015-07-16 11:29:35 -07:00
parent 409a5d8d46
commit 7b147aac61
12 changed files with 253 additions and 45 deletions

View File

@@ -89,6 +89,7 @@ from r2.lib.validator import (
VDate,
VExistingUname,
VFloat,
VFrequencyCap,
VImageType,
VInt,
VLength,
@@ -992,17 +993,23 @@ class PromoteApiController(ApiController):
bid=VFloat('bid', coerce=False),
target=VPromoTarget(),
campaign_id36=nop("campaign_id36"),
frequency_cap=VFrequencyCap(("frequency_capped",
"frequency_cap",
"frequency_cap_duration"),),
priority=VPriority("priority"),
location=VLocation(),
platform=VOneOf("platform", ("mobile", "desktop", "all"), default="desktop"),
mobile_os=VList("mobile_os", choices=["iOS", "Android"]),
)
def POST_edit_campaign(self, form, jquery, link, campaign_id36,
start, end, bid, target, priority, location,
platform, mobile_os):
start, end, bid, target, frequency_cap,
priority, location, platform, mobile_os):
if not link:
return
if form.has_errors('frequency_cap', errors.INVALID_FREQUENCY_CAP):
return
if platform in ('mobile', 'all') and not mobile_os:
c.errors.add(errors.BAD_PROMO_MOBILE_OS, field='mobile_os')
form.set_error(errors.BAD_PROMO_MOBILE_OS, 'mobile_os')
@@ -1142,9 +1149,11 @@ class PromoteApiController(ApiController):
dates = (start, end)
if campaign:
promote.edit_campaign(link, campaign, dates, bid, cpm, target,
priority, location, platform, mobile_os)
frequency_cap[0], frequency_cap[1], priority,
location, platform, mobile_os)
else:
campaign = promote.new_campaign(link, dates, bid, cpm, target,
frequency_cap[0], frequency_cap[1],
priority, location, platform, mobile_os)
rc = RenderableCampaign.from_campaigns(link, campaign)
jquery.update_campaign(campaign._fullname, rc.render_html())

View File

@@ -152,6 +152,7 @@ error_list = dict((
('JSON_MISSING_KEY', _('JSON missing key: "%(key)s"')),
('NO_CHANGE_KIND', _("can't change post type")),
('INVALID_LOCATION', _("invalid location")),
('INVALID_FREQUENCY_CAP', _("invalid values for frequency cap")),
('BANNED_FROM_SUBREDDIT', _('that user is banned from the subreddit')),
('GOLD_REQUIRED', _('you must have an active reddit gold subscription to do that')),
('INSUFFICIENT_CREDDITS', _("insufficient creddits")),

View File

@@ -443,6 +443,7 @@ module["reddit-init-base"] = LocalizedModule("reddit-init-base.js",
"lib/bootstrap.transition.js",
"lib/bootstrap.tooltip.js",
"lib/reddit-client-lib.js",
"lib/jquery.cookie.js",
"bootstrap.tooltip.extension.js",
"base.js",
"preload.js",
@@ -481,7 +482,6 @@ module["reddit-init"] = LocalizedModule("reddit-init.js",
)
module["reddit"] = LocalizedModule("reddit.js",
"lib/jquery.cookie.js",
"lib/jquery.url.js",
"lib/backbone-1.0.0.js",
"embed/custom-event.js",

View File

@@ -279,10 +279,11 @@ def get_transactions(link, campaigns):
bids_by_campaign = {c._id: bid_dict[(c._id, c.trans_id)] for c in campaigns}
return bids_by_campaign
def new_campaign(link, dates, bid, cpm, target, priority, location,
platform, mobile_os):
def new_campaign(link, dates, bid, cpm, target, frequency_cap, frequency_cap_duration,
priority, location, platform, mobile_os):
campaign = PromoCampaign.create(link, target, bid, cpm, dates[0], dates[1],
priority, location, platform, mobile_os)
frequency_cap, frequency_cap_duration, priority,
location, platform, mobile_os)
PromotionWeights.add(link, campaign)
PromotionLog.add(link, 'campaign %s created' % campaign._id)
@@ -298,8 +299,9 @@ def new_campaign(link, dates, bid, cpm, target, priority, location,
def free_campaign(link, campaign, user):
auth_campaign(link, campaign, user, -1)
def edit_campaign(link, campaign, dates, bid, cpm, target, priority, location,
platform='desktop', mobile_os=None):
def edit_campaign(link, campaign, dates, bid, cpm, target, frequency_cap,
frequency_cap_duration, priority, location, platform='desktop',
mobile_os=None):
changed = {}
if bid != campaign.bid:
# if the bid amount changed, cancel any pending transactions
@@ -320,6 +322,13 @@ def edit_campaign(link, campaign, dates, bid, cpm, target, priority, location,
if target != campaign.target:
changed['target'] = (campaign.target, target)
campaign.target = target
if frequency_cap != campaign.frequency_cap:
changed['frequency_cap'] = (campaign.frequency_cap, frequency_cap)
campaign.frequency_cap = frequency_cap
if frequency_cap_duration != campaign.frequency_cap_duration:
changed['frequency_cap_duration'] = (campaign.frequency_cap_duration,
frequency_cap_duration)
campaign.frequency_cap_duration = frequency_cap_duration
if priority != campaign.priority:
changed['priority'] = (campaign.priority.name, priority.name)
campaign.priority = priority

View File

@@ -2128,6 +2128,22 @@ class VList(Validator):
return {self.param: docs}
class VFrequencyCap(Validator):
def run(self, frequency_capped='false', frequency_cap=None,
frequency_cap_duration=None):
if frequency_capped == 'true':
if frequency_cap and frequency_cap_duration:
try:
return (int(frequency_cap), int(frequency_cap_duration),)
except (ValueError, TypeError):
self.set_error(errors.INVALID_FREQUENCY_CAP, code=400)
else:
self.set_error(errors.INVALID_FREQUENCY_CAP, code=400)
else:
return (None, None)
class VPriority(Validator):
def run(self, val):
if c.user_is_sponsor:

View File

@@ -311,6 +311,8 @@ class PromoCampaign(Thing):
location_code=None,
platform='desktop',
mobile_os_names=None,
frequency_cap=None,
frequency_cap_duration=None,
)
# special attributes that shouldn't set Thing data attributes because they
@@ -376,8 +378,8 @@ class PromoCampaign(Thing):
return target_sr_names, target_name
@classmethod
def create(cls, link, target, bid, cpm, start_date, end_date, priority,
location, platform, mobile_os):
def create(cls, link, target, bid, cpm, start_date, end_date, frequency_cap,
frequency_cap_duration, priority, location, platform, mobile_os):
pc = PromoCampaign(
link_id=link._id,
bid=bid,
@@ -387,6 +389,8 @@ class PromoCampaign(Thing):
trans_id=NO_TRANSACTION,
owner_id=link.author_id,
)
pc.frequency_cap = frequency_cap
pc.frequency_cap_duration = frequency_cap_duration
pc.priority = priority
pc.location = location
pc.target = target

View File

@@ -5614,6 +5614,21 @@ ul.colors {
font-weight: bold;
}
.frequency-cap-inputs {
margin-left: 20px;
}
.frequency-cap-example {
margin: -15px 0 0 20px;
font-style: italic;
font-size: 12px;
}
.frequency-cap-dur-unit {
vertical-align: sub;
margin-left: 5px;
}
.minimum-spend.error {
font-weight: bold;
color: red;
@@ -5705,7 +5720,8 @@ ul.colors {
.campaign td.prefright {
padding: 8px 4px 4px;
}
.campaign #bid, .campaign #impressions {
.campaign #bid, .campaign #impressions,
.campaign #cap, .campaign #duration {
text-align: right;
width: auto;
margin-bottom: 5px;
@@ -6595,7 +6611,8 @@ div #campaign-field {
.lookup-user-field,
.budget-field,
.timing-field {
.timing-field,
.frequency-cap-field {
.group {
display: flex;
margin-bottom: 10px;

View File

@@ -598,6 +598,10 @@ var exports = r.sponsored = {
collapse();
},
toggleFrequency: function() {
$('.frequency-cap-field').toggle('slow');
},
setup_geotargeting: function(regions, metros) {
this.regions = regions
this.metros = metros
@@ -1570,7 +1574,8 @@ function edit_campaign($campaign_row) {
$editRow.append($editCell)
$campaign_row.fadeOut(function() {
/* fill inputs from data in campaign row */
_.each(['startdate', 'enddate', 'bid', 'campaign_id36', 'campaign_name'],
_.each(['startdate', 'enddate', 'bid', 'campaign_id36', 'campaign_name',
'frequency_cap', 'frequency_cap_duration'],
function(input) {
var val = $campaign_row.data(input),
$input = campaign.find('*[name="' + input + '"]')
@@ -1589,6 +1594,12 @@ function edit_campaign($campaign_row) {
});
}
/* show frequency inputs */
if ($campaign_row.data('frequency_cap')) {
$('.frequency-cap-field').show();
$('#frequency_capped_true').prop('checked', 'checked');
}
/* set priority */
var priorities = campaign.find('*[name="priority"]'),
campPriority = $campaign_row.data("priority")
@@ -1693,6 +1704,10 @@ function create_campaign() {
.find('select[name="country"]').val('').end()
.find('select[name="region"]').hide().end()
.find('select[name="metro"]').hide().end()
.find('input[name="frequency_cap"]').val('').end()
.find('input[name="frequency_cap_duration"]').val('').end()
.find('#frequency_capped_false').prop('checked', 'checked').end()
.find('.frequency-cap-field').hide().end()
.slideDown();
r.sponsored.render();
});

View File

@@ -3,15 +3,29 @@
setup: function(organicLinks, interestProb, showPromo, srnames) {
this.organics = [];
this.lineup = [];
this.adWasClicked = false;
this.interestProb = interestProb;
this.showPromo = showPromo;
this.srnames = srnames;
this.lastPromoTimestamp = Date.now();
this.MIN_PROMO_TIME = 1500;
this.loid = $.cookie('loid');
this.lastTabChangeTimestamp = Date.now();
this.MIN_PROMO_TIME = 3000;
this.next = this._advance.bind(this, 1);
this.prev = this._advance.bind(this, -1);
this.$listing = $('.organic-listing');
this.$listing.on('click', function(e) {
var $target = $(e.target);
if ($target.is('.thumbnail, .title')) {
this.adWasClicked = true;
}
}.bind(this));
this.adBlockIsEnabled = $('#siteTable_organic').is(":hidden");
if (this.adBlockIsEnabled) {
this.showPromo = false;
}
organicLinks.forEach(function(name) {
this.organics.push(name);
this.lineup.push({ fullname: name, });
@@ -36,7 +50,8 @@
r.debug('restoring spotlight selection to last click');
selectedThing = { fullname: lastClickFullname, };
} else {
selectedThing = this.chooseRandom();
var shouldForcePromo = this._isDocumentVisible() && this.showPromo;
selectedThing = this.chooseRandom(shouldForcePromo);
}
this.lineup = _.chain(this.lineup)
@@ -50,40 +65,56 @@
this.lineup.pos = 0;
this._advance(0);
if ('hidden' in document) {
this.readyForNewPromo = !document.hidden;
if (!this.showPromo) {
return;
}
// IE 9 and below do not have this prop or work with
// visibilitychange.
if ('hidden' in document ) {
$(document).on('visibilitychange', this._requestOrSaveTimestamp.bind(this));
} else {
$(window).on('focus blur', this._requestOrSaveTimestamp.bind(this));
}
$(document).on('visibilitychange', function(e) {
if (!document.hidden) {
this.requestNewPromo();
}
}.bind(this));
},
_requestOrSaveTimestamp: function() {
if ( this._isDocumentVisible() ) {
this.requestNewPromo();
} else {
this.readyForNewPromo = document.hasFocus();
this.lastTabChangeTimestamp = Date.now();
}
},
$(window).on('focus', this.requestNewPromo.bind(this));
_isDocumentVisible: function () {
if ('hidden' in document) {
return !document.hidden;
} else {
return document.hasFocus();
}
},
requestNewPromo: function() {
// if the page loads in a background tab, this should be false. In that
// case, we don't want to load a new ad, as this will be the first view
if (!this.readyForNewPromo) {
this.readyForNewPromo = true;
return;
}
// the ad will be stored as a promise
if (!this.lineup[this.lineup.pos].promise) {
return;
}
// we don't want to fetch a new ad when the user has clicked so they
// can have a chance to vote or comment on the last ad.
if (this.adWasClicked) {
return;
}
var $promotedLink = this.$listing.find('.promotedlink');
var $clearLeft = $promotedLink.next('.clearleft');
if (!$promotedLink.length || $promotedLink.is(':hidden') ||
$promotedLink.offset().top < window.scrollY ||
Date.now() - this.lastPromoTimestamp < this.MIN_PROMO_TIME) {
if (this.adBlockIsEnabled ||
Date.now() - this.lastTabChangeTimestamp < this.MIN_PROMO_TIME) {
return;
}
if ($promotedLink.length && $promotedLink.offset().top < window.scrollY) {
return;
}
@@ -96,10 +127,22 @@
var $link = $promo.eq(0);
var fullname = $link.data('fullname');
this.organics[this.lineup.pos] = fullname;
this.lineup[this.lineup.pos] = newPromo;
$promotedLink.add($clearLeft).remove();
$promo.show();
if ($promotedLink.length) {
this.organics[this.lineup.pos] = fullname;
this.lineup[this.lineup.pos] = newPromo;
} else {
this.organics[this.lineup.pos + 1] = fullname;
this.lineup[this.lineup.pos + 1] = newPromo;
}
if (!$link.hasClass('adsense-wrap')) {
if ($promotedLink.length) {
$promotedLink.add($clearLeft).remove();
$promo.show();
} else {
this.next()
}
}
// force a redraw to prevent showing duplicate ads
this.$listing.hide().show();
}.bind(this));
@@ -113,27 +156,44 @@
data: {
srnames: this.srnames,
r: r.config.post_site,
loid: this.loid,
},
}).pipe(function(promo) {
var prevPromo = this.$listing.find('.promotedlink')
if (promo) {
this.lastPromoTimestamp = Date.now();
if (this.showPromo) {
$('#siteTable_organic').show('slow');
}
var $item = $(promo);
$item.hide().appendTo(this.$listing);
// adsense will throw error if inserted while hidden
if (!$item.hasClass('adsense-wrap')) {
$item.hide().appendTo(this.$listing);
} else {
var $promotedLink = this.$listing.find('.promotedlink');
$promotedLink.remove()
$item.appendTo(this.$listing);
}
return $item;
} else {
if (!prevPromo.length && !this.organics.length) {
// spotlight box must be hidden when no ad is returned
// and there is no other content.
$('#siteTable_organic').hide();
}
return false;
}
}.bind(this));
},
chooseRandom: function() {
if (this.showPromo) {
chooseRandom: function(forcePromo) {
if (forcePromo) {
return this.requestPromo();
} else if (Math.random() < this.interestProb) {
return '.interestbar';
} else {
var name = this.organics[Math.floor(Math.random() * this.organics.length)];
return { fullname: name, };
var name = this.organics[_.random(this.organics.length)];
return (name) ? { fullname: name, } : null;
}
},
@@ -238,6 +298,8 @@
$help.find('.help-promoted').show();
} else if ($thing.hasClass('interestbar')) {
$help.find('.help-interestbar').show();
} else if ($thing.hasClass('adsense-wrap')) {
$help.find('.help-adserver').show()
} else {
$help.find('.help-organic').show();
}

View File

@@ -426,6 +426,78 @@
%endif
</%def>
<%def name="frequency_cap_field(default_checked='false')">
<%def name="frequency_select_content()">
<div class="radio-group group">
<span class="label">frequency capping</span>
<label class="form-group">
<input id="frequency_capped_false" class="nomargin"
type="radio" value="false" name="frequency_capped"
onclick="r.sponsored.toggleFrequency()"
%if default_checked == 'false':
checked="checked"
%endif
>
<div class="label-group">
<span class="label">${_("no frequency cap")}</span>
</div>
</label>
<label class="form-group">
<input id="frequency_capped_true" class="nomargin"
type="radio" value="true" name="frequency_capped"
onclick="r.sponsored.toggleFrequency()"
%if default_checked == 'true':
checked="checked"
%endif
>
<div class="label-group">
<span class="label">${_("frequency capped")}</span>
</div>
</label>
</div>
</%def>
<%def name="frequency_details_content()">
<div class="group frequency-cap-inputs">
<div>
<div class="form-group">
<span class="label">${_("cap")}</span>
<div class="input-group">
<input id="frequency_cap" name="frequency_cap" size="7" type="text"
class="rounded style-input"
style="width:auto"/>
</div>
</div>
</div>
<div>
<div class="form-group">
<span class="label">${_("duration")}</span>
<div class="input-group">
<input id="frequency_cap_duration" name="frequency_cap_duration"
size="7" type="text" class="rounded styled-input"/>
</div>
<span class="frequency-cap-dur-unit">hours</span>
</div>
</div>
</div>
<div class="frequency-cap-example">
${_('Example: Display this flight 3 times per 24 hours.')}
</div>
${error_field("INVALID_FREQUENCY_CAP", "frequency_cap", "div")}
</%def>
%if c.user_is_sponsor:
<%utils:line_field title="${_('frequency')}" css_class="rounded">
${frequency_select_content()}
<div class="frequency-cap-field hidden">
${frequency_details_content()}
</div>
</%utils:line_field>
%endif
</%def>
<%def name="priority_field()">
<%def name="priority_field_content()">
<div class="radio-group">

View File

@@ -94,6 +94,7 @@ ${pr.javascript_setup()}
${pr.targeting_field()}
${pr.timing_field()}
${pr.platform_field()}
${pr.frequency_cap_field()}
${pr.priority_field()}
${pr.budget_field()}
<%utils:line_field title="${_('confirm')}" css_class="rounded confirmation-field">

View File

@@ -40,6 +40,8 @@
data-cpm="${getattr(thing.campaign, 'cpm', g.cpm_selfserve.pennies)}"
data-campaign_id36="${thing.campaign._id36}"
data-campaign_name="${thing.campaign._fullname}"
data-frequency_cap="${thing.campaign.frequency_cap}"
data-frequency_cap_duration="${thing.campaign.frequency_cap_duration}"
data-priority="${thing.campaign.priority.name}"
data-override="${json.dumps(thing.campaign.priority.inventory_override)}"
data-platform="${thing.platform}"