Sell campaigns by CPM.

This commit is contained in:
bsimpson63
2013-05-07 14:14:25 -04:00
committed by Brian Simpson
parent 0d7736ac39
commit a0c4a904e2
13 changed files with 511 additions and 105 deletions

View File

@@ -315,6 +315,7 @@ allowed_pay_countries = United States, United Kingdom, Canada
sponsors =
selfserve_support_email = selfservesupport@mydomain.com
MAX_CAMPAIGNS_PER_LINK = 100
cpm_selfserve = 1.00
# authorize.net credentials (blank authorizenetapi to disable)
authorizenetapi =

View File

@@ -330,7 +330,7 @@ def make_map():
"freebie|promote_note|update_pay|refund|"
"traffic_viewer|rm_traffic_viewer|"
"edit_campaign|delete_campaign|meta_promo|"
"add_roadblock|rm_roadblock")))
"add_roadblock|rm_roadblock|check_inventory")))
mc('/api/:action', controller='apiminimal',
requirements=dict(action="new_captcha"))
mc('/api/:type', controller='api',

View File

@@ -21,6 +21,7 @@
###############################################################################
from datetime import datetime, timedelta
from babel.numbers import format_number
import itertools
import json
import urllib
@@ -29,7 +30,7 @@ from pylons import c, g, request
from pylons.i18n import _
from r2.controllers.listingcontroller import ListingController
from r2.lib import cssfilter, promote
from r2.lib import cssfilter, inventory, promote
from r2.lib.authorize import get_account_info, edit_profile, PROFILE_LIMIT
from r2.lib.db import queries
from r2.lib.errors import errors
@@ -52,8 +53,9 @@ from r2.lib.pages import (
from r2.lib.pages.trafficpages import TrafficViewerList
from r2.lib.pages.things import wrap_links
from r2.lib.system_messages import user_added_messages
from r2.lib.utils import make_offset_date
from r2.lib.utils import make_offset_date, to_date
from r2.lib.validator import (
json_validate,
nop,
noresponse,
VAccountByName,
@@ -85,6 +87,7 @@ from r2.lib.validator import (
VUrl,
)
from r2.models import (
calc_impressions,
Frontpage,
Link,
LiveAdWeights,
@@ -248,6 +251,15 @@ class PromoteController(ListingController):
link = Link._byID(campaign.link_id)
return self.redirect(promote.promo_edit_url(link))
@json_validate(sr=VSubmitSR('sr', promotion=True),
start=VDate('startdate'),
end=VDate('enddate'))
def GET_check_inventory(self, responder, sr, start, end):
sr = sr or Frontpage
available_by_datestr = inventory.get_available_pageviews(sr, start, end,
datestr=True)
return {'inventory': available_by_datestr}
@validate(VSponsor(),
dates=VDateRange(["startdate", "enddate"],
max_range=timedelta(days=28),
@@ -454,6 +466,7 @@ class PromoteController(ListingController):
return
start, end = dates or (None, None)
cpm = g.cpm_selfserve.pennies
if (start and end and not promote.is_accepted(l) and
not c.user_is_sponsor):
@@ -487,20 +500,9 @@ class PromoteController(ListingController):
form.has_errors('title', errors.TOO_MANY_CAMPAIGNS)
return
duration = max((end - start).days, 1)
if form.has_errors('bid', errors.BAD_BID):
return
# minimum bid depends on user privilege and targeting, checked here
# instead of in the validator b/c current duration is needed
if c.user_is_sponsor:
min_daily_bid = 0
elif targeting == 'one':
min_daily_bid = g.min_promote_bid * 1.5
else:
min_daily_bid = g.min_promote_bid
if campaign_id36:
# you cannot edit the bid of a live ad unless it's a freebie
try:
@@ -514,10 +516,11 @@ class PromoteController(ListingController):
except NotFound:
pass
if bid is None or bid / duration < min_daily_bid:
min_bid = 0 if c.user_is_sponsor else g.min_promote_bid
if bid is None or bid < min_bid:
c.errors.add(errors.BAD_BID, field='bid',
msg_params={'min': min_daily_bid,
'max': g.max_promote_bid})
msg_params={'min': min_bid,
'max': g.max_promote_bid})
form.has_errors('bid', errors.BAD_BID)
return
@@ -539,17 +542,31 @@ class PromoteController(ListingController):
if targeting == 'none':
sr = None
# Check inventory
ndays = (to_date(end) - to_date(start)).days
total_request = calc_impressions(bid, cpm)
daily_request = int(total_request / ndays)
oversold = inventory.get_oversold(sr or Frontpage, start, end,
daily_request)
if oversold:
msg_params = {'daily_request': format_number(daily_request,
locale=c.locale)}
c.errors.add(errors.OVERSOLD_DETAIL, field='bid',
msg_params=msg_params)
form.has_errors('bid', errors.OVERSOLD_DETAIL)
return
if campaign_id36 is not None:
campaign = PromoCampaign._byID36(campaign_id36)
promote.edit_campaign(l, campaign, dates, bid, sr)
promote.edit_campaign(l, campaign, dates, bid, cpm, sr)
r = promote.get_renderable_campaigns(l, campaign)
jquery.update_campaign(r.campaign_id36, r.start_date, r.end_date,
r.duration, r.bid, r.sr, r.status)
r.duration, r.bid, r.cpm, r.sr, r.status)
else:
campaign = promote.new_campaign(l, dates, bid, sr)
campaign = promote.new_campaign(l, dates, bid, cpm, sr)
r = promote.get_renderable_campaigns(l, campaign)
jquery.new_campaign(r.campaign_id36, r.start_date, r.end_date,
r.duration, r.bid, r.sr, r.status)
r.duration, r.bid, r.cpm, r.sr, r.status)
@validatedForm(VSponsor('link_id'),
VModhash(),

View File

@@ -214,6 +214,7 @@ class Globals(object):
config_gold_price: [
'gold_month_price',
'gold_year_price',
'cpm_selfserve',
],
}

View File

@@ -59,7 +59,7 @@ error_list = dict((
('INVALID_PREF', "that preference isn't valid"),
('BAD_NUMBER', _("that number isn't in the right range (%(range)s)")),
('BAD_STRING', _("you used a character here that we can't handle")),
('BAD_BID', _("your bid must be at least $%(min)d per day and no more than to $%(max)d in total.")),
('BAD_BID', _("your budget must be at least $%(min)d and no more than $%(max)d.")),
('ALREADY_SUB', _("that link has already been submitted")),
('SUBREDDIT_EXISTS', _('that subreddit already exists')),
('SUBREDDIT_NOEXIST', _('that subreddit doesn\'t exist')),
@@ -79,6 +79,7 @@ error_list = dict((
('NO_EMAILS', _('please enter at least one email address')),
('TOO_MANY_EMAILS', _('please only share to %(num)s emails at a time.')),
('OVERSOLD', _('that subreddit has already been oversold on %(start)s to %(end)s. Please pick another subreddit or date.')),
('OVERSOLD_DETAIL', _('We have insufficient inventory to fulfill your requested budget, target, and dates. Requested %(daily_request)s impressions per day.')),
('BAD_DATE', _('please provide a date of the form mm/dd/yyyy')),
('BAD_DATE_RANGE', _('the dates need to be in order and not identical')),
('DATE_RANGE_TOO_LARGE', _('you must choose a date range of less than %(days)s days')),
@@ -115,7 +116,7 @@ error_list = dict((
('BAD_HASH', _("i don't believe you.")),
('ALREADY_MODERATOR', _('that user is already a moderator')),
('NO_INVITE_FOUND', _('there is no pending invite for that subreddit')),
('BID_LIVE', _('you cannot edit the bid of a live ad')),
('BID_LIVE', _('you cannot edit the budget of a live ad')),
('TOO_MANY_CAMPAIGNS', _('you have too many campaigns for that promotion')),
('BAD_JSONP_CALLBACK', _('that jsonp callback contains invalid characters')),
('INVALID_PERMISSION_TYPE', _("permissions don't apply to that type of user")),

View File

@@ -3339,6 +3339,8 @@ class PromoteLinkForm(Templated):
pay_id=bid.pay_id,
amount_str=format_currency(bid.bid, 'USD',
locale=c.locale),
charge_str=format_currency(bid.charge or bid.bid, 'USD',
locale=c.locale),
)
self.bids.append(row)
@@ -3359,13 +3361,29 @@ class PromoteLinkForm(Templated):
self.mindate = mindate.strftime("%m/%d/%Y")
self.subreddit_selector = SubredditSelector()
# preload some inventory
srnames = []
for title, names in self.subreddit_selector.subreddit_names:
srnames.extend(names)
srs = Subreddit._by_name(srnames).values()
srs.append(Frontpage)
inv_start = startdate
inv_end = startdate + datetime.timedelta(days=14)
sr_inventory = inventory.get_available_pageviews(srs, inv_start,
inv_end, datestr=True)
sr_inventory[''] = sr_inventory[Frontpage.name]
del sr_inventory[Frontpage.name]
self.inventory = sr_inventory
self.link = promote.wrap_promoted(link)
self.listing = listing
campaigns = PromoCampaign._by_link(link._id)
self.campaigns = promote.get_renderable_campaigns(link, campaigns)
self.promotion_log = PromotionLog.get(link)
self.min_daily_bid = 0 if c.user_is_admin else g.min_promote_bid
self.min_bid = 0 if c.user_is_sponsor else g.min_promote_bid
class PromoAdminTool(Reddit):

View File

@@ -65,6 +65,7 @@ from r2.models import (
PromotionLog,
PromotionWeights,
Subreddit,
traffic,
)
from r2.models.keyvalue import NamedGlobals
@@ -172,13 +173,14 @@ def campaign_is_live(link, campaign_index):
# control functions
class RenderableCampaign():
def __init__(self, campaign_id36, start_date, end_date, duration, bid, sr,
status):
def __init__(self, campaign_id36, start_date, end_date, duration, bid,
cpm, sr, status):
self.campaign_id36 = campaign_id36
self.start_date = start_date
self.end_date = end_date
self.duration = duration
self.bid = bid
self.cpm = cpm
self.sr = sr
self.status = status
@@ -197,6 +199,7 @@ class RenderableCampaign():
duration = strings.time_label % dict(num=ndays,
time=ungettext("day", "days", ndays))
bid = "%.2f" % camp.bid
cpm = getattr(camp, 'cpm', g.cpm_selfserve.pennies)
sr = camp.sr_name
status = {'paid': bool(transaction),
'complete': False,
@@ -212,8 +215,8 @@ class RenderableCampaign():
elif transaction.is_charged() or transaction.is_refund():
status['complete'] = True
rc = cls(campaign_id36, start_date, end_date, duration, bid, sr,
status)
rc = cls(campaign_id36, start_date, end_date, duration, bid,
cpm, sr, status)
r.append(rc)
return r
@@ -320,10 +323,10 @@ 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, sr):
def new_campaign(link, dates, bid, cpm, sr):
# empty string for sr_name means target to all
sr_name = sr.name if sr else ""
campaign = PromoCampaign._new(link, sr_name, bid, dates[0], dates[1])
campaign = PromoCampaign._new(link, sr_name, bid, cpm, dates[0], dates[1])
PromotionWeights.add(link, campaign._id, sr_name, dates[0], dates[1], bid)
PromotionLog.add(link, 'campaign %s created' % campaign._id)
author = Account._byID(link.author_id, True)
@@ -334,7 +337,7 @@ def new_campaign(link, dates, bid, sr):
def free_campaign(link, campaign, user):
auth_campaign(link, campaign, user, -1)
def edit_campaign(link, campaign, dates, bid, sr):
def edit_campaign(link, campaign, dates, bid, cpm, sr):
sr_name = sr.name if sr else '' # empty string means target to all
try:
# if the bid amount changed, cancel any pending transactions
@@ -346,7 +349,8 @@ def edit_campaign(link, campaign, dates, bid, sr):
dates[0], dates[1], bid)
# update values in the db
campaign.update(dates[0], dates[1], bid, sr_name, campaign.trans_id, commit=True)
campaign.update(dates[0], dates[1], bid, cpm, sr_name,
campaign.trans_id, commit=True)
# record the transaction
text = 'updated campaign %s. (bid: %0.2f)' % (campaign._id, bid)
@@ -701,6 +705,8 @@ def make_daily_promotions(offset=0, test=False):
else:
print by_srid
finalize_completed_campaigns(daysago=offset+1)
# after launching as many campaigns as possible, raise an exception to
# report any error campaigns. (useful for triggering alerts in irc)
if error_campaigns:
@@ -708,6 +714,66 @@ def make_daily_promotions(offset=0, test=False):
"promotions: %r" % error_campaigns)
def finalize_completed_campaigns(daysago=1):
# PromoCampaign.end_date is utc datetime with year, month, day only
now = datetime.now(g.tz)
date = now - timedelta(days=daysago)
date = date.replace(hour=0, minute=0, second=0, microsecond=0)
q = PromoCampaign._query(PromoCampaign.c.end_date == date,
# exclude no transaction and freebies
PromoCampaign.c.trans_id > 0,
data=True)
campaigns = list(q)
if not campaigns:
return
# check that traffic is up to date
earliest_campaign = min(campaigns, key=lambda camp: camp.start_date)
start, end = get_total_run(earliest_campaign)
missing_traffic = traffic.get_missing_traffic(start.replace(tzinfo=None),
date.replace(tzinfo=None))
if missing_traffic:
raise ValueError("Can't finalize campaigns finished on %s."
"Missing traffic from %s" % (date, missing_traffic))
links = Link._byID([camp.link_id for link in links], data=True)
for camp in campaigns:
if hasattr(camp, 'refund_amount'):
continue
link = links[camp.link_id]
billable_impressions = get_billable_impressions(camp)
billable_amount = get_billable_amount(camp, billable_impressions)
if billable_amount >= camp.bid:
text = ('%s completed with $%s billable (%s impressions @ $%s).'
% (camp, billable_amount, billable_impressions, camp.cpm))
PromotionLog.add(link, text)
refund_amount = 0.
else:
refund_amount = camp.bid - billable_amount
user = Account._byID(link.author_id, data=True)
try:
success = authorize.refund_transaction(user, camp.trans_id,
camp._id, refund_amount)
except authorize.AuthorizeNetException as e:
text = ('%s $%s refund failed' % (camp, refund_amount))
PromotionLog.add(link, text)
g.log.debug(text + ' (response: %s)' % e)
continue
text = ('%s completed with $%s billable (%s impressions @ $%s).'
' %s refunded.' % (camp, billable_amount,
billable_impressions, camp.cpm,
refund_amount))
PromotionLog.add(link, text)
camp.refund_amount = refund_amount
camp._commit()
PromoTuple = namedtuple('PromoTuple', ['link', 'weight', 'campaign'])
@@ -809,6 +875,41 @@ def get_traffic_dates(thing):
return start, end
def get_billable_impressions(campaign):
start, end = get_traffic_dates(campaign)
if start > datetime.now(g.tz):
return 0
traffic_lookup = traffic.TargetedImpressionsByCodename.promotion_history
imps = traffic_lookup(campaign._fullname, start.replace(tzinfo=None),
end.replace(tzinfo=None))
billable_impressions = sum(imp for date, (imp,) in imps)
return billable_impressions
def get_billable_amount(camp, impressions):
if hasattr(camp, 'cpm'):
value_delivered = impressions / 1000. * camp.cpm / 100.
billable_amount = min(camp.bid, value_delivered)
else:
# pre-CPM campaigns are charged in full regardless of impressions
billable_amount = camp.bid
return billable_amount
def get_spent_amount(campaign):
if hasattr(campaign, 'refund_amount'):
# no need to calculate spend if we've already refunded
spent = campaign.bid - campaign.refund_amount
elif not hasattr(campaign, 'cpm'):
# pre-CPM campaign
return campaign.bid
else:
billable_impressions = get_billable_impressions(campaign)
spent = get_billable_amount(campaign, billable_impressions)
return spent
def Run(offset=0, verbose=True):
"""reddit-job-update_promos: Intended to be run hourly to pull in
scheduled changes to ads

View File

@@ -55,6 +55,12 @@ def get_promote_srid(name = 'promos'):
return sr._id
def calc_impressions(bid, cpm_pennies):
# bid is in dollars, cpm_pennies is pennies
# CPM is cost per 1000 impressions
return int(bid / cpm_pennies * 1000 * 100)
NO_TRANSACTION = 0
class PromoCampaign(Thing):
@@ -67,10 +73,11 @@ class PromoCampaign(Thing):
return val
@classmethod
def _new(cls, link, sr_name, bid, start_date, end_date):
def _new(cls, link, sr_name, bid, cpm, start_date, end_date):
pc = PromoCampaign(link_id=link._id,
sr_name=sr_name,
bid=bid,
cpm=cpm,
start_date=start_date,
end_date=end_date,
trans_id=NO_TRANSACTION,
@@ -99,6 +106,13 @@ class PromoCampaign(Thing):
def ndays(self):
return (self.end_date - self.start_date).days
@property
def impressions(self):
# deal with pre-CPM PromoCampaigns
if not hasattr(self, 'cpm'):
return -1
return calc_impressions(self.bid, self.cpm)
def is_freebie(self):
return self.trans_id < 0
@@ -106,10 +120,12 @@ class PromoCampaign(Thing):
now = datetime.now(g.tz)
return self.start_date < now and self.end_date > now
def update(self, start_date, end_date, bid, sr_name, trans_id, commit=True):
def update(self, start_date, end_date, bid, cpm, sr_name, trans_id,
commit=True):
self.start_date = start_date
self.end_date = end_date
self.bid = bid
self.cpm = cpm
self.sr_name = sr_name
self.trans_id = trans_id
if commit:

View File

@@ -4303,12 +4303,15 @@ ul.tabmenu.formtab {
border-bottom: none;
}
.targeting > ul {
.campaign .notes ul {
font-size: x-small;
list-style-type: disc;
margin: 0 20px 10px;
}
.existing-campaigns td > button { margin: 0px 5px 0px 0px; }
.existing-campaigns td > button {
margin: 2px;
padding: 2px 4px;
}
.campaign .bid-info { font-size: x-small; }
.campaign .bid-info.error { color: red; }
@@ -4319,7 +4322,7 @@ ul.tabmenu.formtab {
.campaign #bid {
text-align: right;
}
.campaign .targeting {
.campaign .targeting, .campaign .notes {
margin-left: 25px;
}
.campaign .targeting input{
@@ -4346,6 +4349,13 @@ ul.tabmenu.formtab {
margin: 5px;
}
#campaign td,
#campaign span,
#campaign label,
#campaign li {
font-size: small;
}
/***traffic stuff***/
.traffic-table,
.traffic-tables-side fieldset {
@@ -4961,6 +4971,10 @@ table.lined-table {
font-weight: bold;
}
div #campaign-field {
width: auto;
}
.create-promotion .help {
font-size: x-small;
}
@@ -4977,7 +4991,12 @@ table.lined-table {
}
.create-promo { float: left; width: 520px; margin-right: 20px;}
.create-promo {
float: left;
width: 570px;
margin-right: 20px;
}
.create-promo .infobar {
margin-right: 0;
border-color: red;
@@ -4992,6 +5011,11 @@ table.lined-table {
}
.create-promo .rules { float: left; margin-left: 15px; }
.create-promo textarea,
.create-promo input[type=text] {
width: 98%;
}
.fancy-settings h1, .create-promotion h1 { font-size: 200%; color: #999; margin:10px 5px; }
.fancy-settings h2 { font-size: 200%; font-weight:normal; color: #999; margin:10px 5px; }
.fancy-settings h1 strong { font-weight:bold; color: #666; }

View File

@@ -2,6 +2,199 @@ function update_box(elem) {
$(elem).prevAll('*[type="checkbox"]:first').prop('checked', true);
};
r.sponsored = {
init: function() {
$("#sr-autocomplete").on("sr-changed blur", function() {
r.sponsored.fill_campaign_editor()
})
this.inventory = {}
},
setup: function(inventory_by_sr) {
this.inventory = inventory_by_sr
},
get_dates: function(startdate, enddate) {
var start = $.datepicker.parseDate('mm/dd/yy', startdate),
end = $.datepicker.parseDate('mm/dd/yy', enddate),
ndays = (end - start) / (1000 * 60 * 60 * 24),
dates = []
for (var i=0; i < ndays; i++) {
var d = new Date(start.getTime())
d.setDate(start.getDate() + i)
dates.push(d)
}
return dates
},
get_check_inventory: function(srname, dates) {
var fetch = _.some(dates, function(date) {
var datestr = $.datepicker.formatDate('mm/dd/yy', date)
if (!(this.inventory[srname] && this.inventory[srname][datestr])) {
r.debug('need to fetch ' + datestr + ' for ' + srname)
return true
}
}, this)
if (fetch) {
dates.sort(function(d1,d2){return d1 - d2})
var end = new Date(dates[dates.length-1].getTime())
end.setDate(end.getDate() + 5)
return $.ajax({
type: 'GET',
url: '/api/check_inventory.json',
data: {
sr: srname,
startdate: $.datepicker.formatDate('mm/dd/yy', dates[0]),
enddate: $.datepicker.formatDate('mm/dd/yy', end)
},
success: function(data) {
if (!r.sponsored.inventory[srname]) {
r.sponsored.inventory[srname] = {}
}
for (var datestr in data.inventory) {
r.sponsored.inventory[srname][datestr] = data.inventory[datestr]
}
}
})
} else {
return true
}
},
check_inventory: function($form) {
var bid = this.get_bid($form),
cpm = this.get_cpm($form),
requested = this.calc_impressions(bid, cpm),
startdate = $form.find('*[name="startdate"]').val(),
enddate = $form.find('*[name="enddate"]').val(),
ndays = this.get_duration($form),
daily_request = Math.floor(requested / ndays),
targeted = $form.find('#targeting').is(':checked'),
target = $form.find('*[name="sr"]').val(),
srname = targeted ? target : '',
dates = r.sponsored.get_dates(startdate, enddate)
$.when(r.sponsored.get_check_inventory(srname, dates)).done(
function() {
var oversold = {}
_.each(dates, function(date) {
var datestr = $.datepicker.formatDate('mm/dd/yy', date),
available = r.sponsored.inventory[srname][datestr]
if (available < daily_request) {
oversold[datestr] = available
}
})
if (!_.isEmpty(oversold)) {
var oversold_dates = _.keys(oversold)
var message = r._("We have insufficient inventory to fulfill" +
" your requested budget, target, and dates." +
" Requested %(daily_request)s impressions " +
"per day."
).format({daily_request: r.utils.prettyNumber(daily_request)})
$(".OVERSOLD_DETAIL").text(message).show()
var available_list = $('<ul>').appendTo(".OVERSOLD_DETAIL")
_.each(oversold, function(num, datestr) {
var available_msg = r._("%(num)s available on %(date)s").format({
num: r.utils.prettyNumber(num),
date: datestr
})
available_list.append($('<li>').text(available_msg))
})
r.sponsored.disable_form($form)
} else {
$(".OVERSOLD_DETAIL").hide()
r.sponsored.enable_form($form)
}
}
)
},
get_duration: function($form) {
return Math.round((Date.parse($form.find('*[name="enddate"]').val()) -
Date.parse($form.find('*[name="startdate"]').val())) / (86400*1000))
},
get_bid: function($form) {
return parseFloat($form.find('*[name="bid"]').val())
},
get_cpm: function($form) {
return parseInt($form.find('*[name="cpm"]').val())
},
on_date_change: function() {
this.fill_campaign_editor()
},
on_bid_change: function() {
this.fill_campaign_editor()
},
fill_campaign_editor: function() {
var $form = $("#campaign"),
bid = this.get_bid($form),
cpm = this.get_cpm($form),
ndays = this.get_duration($form),
impressions = this.calc_impressions(bid, cpm);
$(".duration").text(ndays + " " + ((ndays > 1) ? r._("days") : r._("day")))
$(".impression-info").text(r._("%(num)s impressions").format({num: r.utils.prettyNumber(impressions)}))
$(".price-info").text(r._("$%(cpm)s per 1,000 impressions").format({cpm: (cpm/100).toFixed(2)}))
this.check_bid($form)
this.check_inventory($form)
},
disable_form: function($form) {
$form.find('button[name="create"], button[name="save"]')
.prop("disabled", "disabled")
.addClass("disabled");
},
enable_form: function($form) {
$form.find('button[name="create"], button[name="save"]')
.removeProp("disabled")
.removeClass("disabled");
},
targeting_on: function() {
$('.targeting').find('*[name="sr"]').prop("disabled", "").end().slideDown();
this.fill_campaign_editor()
},
targeting_off: function() {
$('.targeting').find('*[name="sr"]').prop("disabled", "disabled").end().slideUp();
this.fill_campaign_editor()
},
check_bid: function($form) {
var bid = this.get_bid($form),
minimum_bid = $("#bid").data("min_bid");
$(".minimum-spend").removeClass("error");
if (bid < minimum_bid) {
$(".minimum-spend").addClass("error");
this.disable_form($form)
} else {
this.enable_form($form)
}
},
calc_impressions: function(bid, cpm_pennies) {
return bid / cpm_pennies * 1000 * 100
}
}
function update_bid(elem) {
var form = $(elem).parents(".campaign");
var is_targeted = $("#targeting").prop("checked");
@@ -98,19 +291,6 @@ function check_enddate(startdate, enddate) {
$("#datepicker-" + enddate.attr("id")).datepicker("destroy");
}
function targeting_on(elem) {
$(elem).parents(".campaign").find(".targeting")
.find('*[name="sr"]').prop("disabled", "").end().slideDown();
update_bid(elem);
}
function targeting_off(elem) {
$(elem).parents(".campaign").find(".targeting")
.find('*[name="sr"]').prop("disabled", "disabled").end().slideUp();
update_bid(elem);
}
(function($) {
@@ -134,14 +314,15 @@ function get_flag_class(flags) {
return css_class
}
$.new_campaign = function(campaign_id36, start_date, end_date, duration,
bid, targeting, flags) {
$.new_campaign = function(campaign_id36, start_date, end_date, duration,
bid, cpm, targeting, flags) {
cancel_edit(function() {
var data =('<input type="hidden" name="startdate" value="' +
start_date +'"/>' +
'<input type="hidden" name="enddate" value="' +
end_date + '"/>' +
'<input type="hidden" name="bid" value="' + bid + '"/>' +
'<input type="hidden" name="cpm" value="' + cpm + '"/>' +
'<input type="hidden" name="targeting" value="' +
(targeting || '') + '"/>' +
'<input type="hidden" name="campaign_id36" value="' + campaign_id36 + '"/>');
@@ -165,8 +346,8 @@ $.new_campaign = function(campaign_id36, start_date, end_date, duration,
return $;
};
$.update_campaign = function(campaign_id36, start_date, end_date,
duration, bid, targeting, flags) {
$.update_campaign = function(campaign_id36, start_date, end_date,
duration, bid, cpm, targeting, flags) {
cancel_edit(function() {
$('.existing-campaigns input[name="campaign_id36"]')
.filter('*[value="' + (campaign_id36 || '0') + '"]')
@@ -182,6 +363,7 @@ $.update_campaign = function(campaign_id36, start_date, end_date,
.find('*[name="enddate"]').val(end_date).end()
.find('*[name="targeting"]').val(targeting).end()
.find('*[name="bid"]').val(bid).end()
.find('*[name="cpm"]').val(cpm).end()
.find("button, span").remove();
$.set_up_campaigns();
});
@@ -199,10 +381,9 @@ $.set_up_campaigns = function() {
var td = $(this).find("td:last");
var bid_td = $(this).find("td:first").next().next().next()
.addClass("bid");
var target_td = $(this).find("td:nth-child(5)")
if(td.length && ! td.children("button, span").length ) {
if(tr.hasClass("live")) {
$(target_td).append($(view).addClass("view")
$(td).append($(view).addClass("view fancybutton")
.click(function() { view_campaign(tr) }));
}
/* once paid, we shouldn't muck around with the campaign */
@@ -313,11 +494,11 @@ function edit_campaign(elem) {
"css_class": "", "cells": [""]}],
tr.rowIndex + 1);
$("#edit-campaign-tr").children('td:first')
.attr("colspan", 6).append(campaign).end()
.attr("colspan", 7).append(campaign).end()
.prev().fadeOut(function() {
var data_tr = $(this);
var c = $("#campaign");
$.map(['startdate', 'enddate', 'bid', 'campaign_id36'],
$.map(['startdate', 'enddate', 'bid', 'cpm', 'campaign_id36'],
function(i) {
i = '*[name="' + i + '"]';
c.find(i).val(data_tr.find(i).val());
@@ -343,7 +524,7 @@ function edit_campaign(elem) {
init_enddate();
c.find('button[name="save"]').show().end()
.find('button[name="create"]').hide().end();
update_bid('*[name="bid"]');
r.sponsored.fill_campaign_editor();
c.fadeIn();
} );
}
@@ -363,11 +544,12 @@ function check_number_of_campaigns(){
}
}
function create_campaign(elem) {
function create_campaign() {
if (check_number_of_campaigns()){
return;
}
cancel_edit(function() {;
var base_cpm = $("#bid").data("base_cpm")
init_startdate();
init_enddate();
$("#campaign")
@@ -379,8 +561,9 @@ function create_campaign(elem) {
.prop("checked", "checked").end()
.find(".targeting").hide().end()
.find('*[name="sr"]').val("").prop("disabled", "disabled").end()
.find('input[name="cpm"]').val(base_cpm).end()
.fadeIn();
update_bid('*[name="bid"]');
r.sponsored.fill_campaign_editor();
});
}

View File

@@ -81,7 +81,18 @@ r.utils = {
return _.escape(str).replace(this._mdLinkRe, function(match, text, url) {
return '<a href="' + url + '">' + text + '</a>'
})
},
prettyNumber: function(number) {
// Add commas to separate every third digit
var numberAsInt = parseInt(number)
if (numberAsInt) {
return numberAsInt.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
} else {
return number
}
}
}
// Nothing is true. Everything is permitted.

View File

@@ -21,6 +21,7 @@
###############################################################################
<%!
from babel.numbers import format_currency
from r2.lib.template_helpers import static
from r2.lib import js
%>
@@ -43,11 +44,8 @@ ${unsafe(js.use('sponsored'))}
<p id="bid-field">
<input type="hidden" name="campaign" value="${thing.campaign.campaign_id36}" />
<input type="hidden" name="link" value="${thing.link._fullname}" />
${unsafe(_("Your current bid is $%(bid)s") % dict(bid=thing.campaign.bid))}
${error_field("BAD_BID", "bid")}
<span class="gray">
&#32;${_('(total for the duration provided)')}
</span>
<% budget=format_currency(float(thing.campaign.bid), 'USD', locale=c.locale) %>
${unsafe(_("Your current budget is %(budget)s") % dict(budget=budget))}
</p>
%if thing.profiles:
<p>
@@ -72,7 +70,8 @@ ${unsafe(js.use('sponsored'))}
</p>
%endif
<p class="info">
${_("NOTE: your card will not be charged until the link has been queued for promotion.")}
${_("NOTE: your card will not be charged until the campaign has been queued "
"for promotion.")}
</p>
<input type="hidden" name="customer_id" value="${thing.customer_id}" />

View File

@@ -25,11 +25,12 @@
from r2.lib.media import thumbnail_url
from r2.lib.template_helpers import static
from r2.lib import promote
from r2.lib.pages import SubredditSelector
from r2.lib.strings import strings
from r2.models import Account
from r2.lib import js
import simplejson
from babel.numbers import format_currency, format_decimal
%>
<%namespace file="utils.html"
@@ -39,13 +40,12 @@
${unsafe(js.use('sponsored'))}
<%def name="javascript_setup()">
<script type="text/javascript">
$(function() { update_bid("*[name=bid]"); });
</script>
<script type="text/javascript">
r.sponsored.init();
r.sponsored.setup(${unsafe(simplejson.dumps(thing.inventory))})
</script>
</%def>
${self.javascript_setup()}
## Create a datepicker for a form. min/maxDateSrc are the id of the
## element containing the min/max date - the '#' is added automatically
## here (as a workaround for babel message extraction not handling it
@@ -109,6 +109,8 @@ ${self.javascript_setup()}
${self.right_panel()}
</div>
${self.javascript_setup()}
<%def name="title_field(link, editable=False)">
<%utils:line_field title="${_('title')}" id="title-field" css_class="rounded">
<textarea name="title" rows="2" cols="1"
@@ -266,7 +268,7 @@ ${self.javascript_setup()}
<table class="preftable">
<tr>
<th>duration</th>
<th>dates</th>
<td class="prefright">
<%
mindate = thing.startdate
@@ -278,14 +280,14 @@ ${self.javascript_setup()}
minDateSrc="date-min" initfuncname="init_startdate">
function(elem) {
check_enddate(elem, $("#enddate"));
update_bid(elem);
r.sponsored.on_date_change();
}
</%self:datepicker>
-
<%self:datepicker name="enddate", value="${thing.enddate}"
minDateSrc="startdate" initfuncname="init_enddate"
min_date_offset="86400000">
function(elem) { update_bid(elem); }
function(elem) { r.sponsored.on_date_change(); }
</%self:datepicker>
${error_field("BAD_DATE", "startdate", "div")}
@@ -297,44 +299,72 @@ ${self.javascript_setup()}
</tr>
<tr>
<th>total bid</th>
<th>duration</th>
<td class="prefright duration">
</td>
</tr>
<tr>
<th>total budget</th>
<td class="prefright">
${error_field("BAD_BID", "bid", "div")}
${error_field("BID_LIVE", "bid", "div")}
${error_field("OVERSOLD_DETAIL", "bid", "div")}
$<input id="bid" name="bid" size="7" type="text"
class="rounded styled-input"
style="width:auto"
onchange="update_bid(this)"
onkeyup="update_bid(this)"
title="Minimum is $${'%.2f' % thing.min_daily_bid} per day, or $${'%.2f' % (thing.min_daily_bid * 1.5)} per day targeted"
value="${'%.2f' % (thing.min_daily_bid * 5)}"
data-min_daily_bid="${thing.min_daily_bid}"/>
<span class="bid-info gray"></span>
onchange="r.sponsored.on_bid_change()"
onkeyup="r.sponsored.on_bid_change()"
title="Minimum is ${format_currency(thing.min_bid, 'USD', locale=c.locale)}"
value="${format_decimal(5 * thing.min_bid, format='.00', locale=c.locale)}"
data-min_bid="${thing.min_bid}"
data-base_cpm="${g.cpm_selfserve.pennies}"/>
<div class="minimum-spend">
${_("$%.2F minimum") % thing.min_bid}
</div>
</td>
</tr>
<tr>
<th>price</th>
<td class="prefright">
<input id="cpm" name="cpm" value="${g.cpm_selfserve.pennies}" type="hidden">
<span class="price-info"></span>
</td>
</tr>
<tr>
<th>impressions</th>
<td class="prefright">
<span class="impression-info"></span>
</td>
</tr>
<tr>
<th>targeting</th>
<td class="prefright">
<input id="no_targeting" class="nomargin"
type="radio" value="none" name="targeting"
onclick="return targeting_off(this)"
checked="checked" />
<label for="no_targeting">no targeting (displays site-wide)</label>
<p id="no_targeting_minimum" class="minimum-spend">minimum $20 / day</p>
<input id="targeting" class="nomargin"
type="radio" value="one" name="targeting"
onclick="return targeting_on(this)" />
<label for="targeting">enable targeting (runs on a specific subreddit)</label>
<p id="targeted_minimum" class="minimum-spend">minimum $30 / day</p>
<label>
<input id="no_targeting" class="nomargin"
type="radio" value="none" name="targeting"
onclick="r.sponsored.targeting_off()"
checked="checked" />
no targeting (displays site-wide)
</label>
<br />
<label>
<input id="targeting" class="nomargin"
type="radio" value="one" name="targeting"
onclick="r.sponsored.targeting_on()" />
enable targeting (runs on a specific subreddit)
</label>
<script type="text/javascript">
$(function() {
var c = $(".campaign input[name=targeting]:checked");
if (c.val() == 'one') {
targeting_on(c);
r.sponsored.targeting_on();
} else {
targeting_off(c);
r.sponsored.targeting_off();
}
})
</script>
@@ -349,18 +379,20 @@ ${self.javascript_setup()}
init_enddate();
$("#campaign").find("button[name=create]").show().end()
.find("button[name=save]").hide().end();
update_bid("*[name=bid]");
})
</script>
<div class="targeting" style="display:none">
${error_field("OVERSOLD", "sr", "div")}
${thing.subreddit_selector}
</div>
<div class="notes">
<ul>
<li>You will only be charged for the portion of your budget that is actually spent. Any unspent portion will be refunded.</li>
<li>By targeting, your ad will only appear in front of users who subscribe to the subreddit that you specify.</li>
<li>Your ad will also appear at the top of the hot listing for that subreddit</li>
<li>You can only target one subreddit per campaign. If you would like to submit to more than one subreddit, add a new campaign (its easy, you just fill this form out again).</li>
</ul>
${error_field("OVERSOLD", "sr", "div")}
${SubredditSelector()}
</div>
<div class="buttons">
@@ -403,13 +435,13 @@ ${self.javascript_setup()}
<th title="${start_title}">start</th>
<th title="${end_title}">end</th>
<th>duration</th>
<th>bid</th>
<th>total budget</th>
<th title="${targeting_title}">targeting</th>
<th style="align:right">
<button class="new-campaign fancybutton"
${'disabled="disabled"' if len(thing.campaigns) >= g.MAX_CAMPAIGNS_PER_LINK else ''}
title="${newcamp_title}"
onclick="return create_campaign(this)">+ add new</button>
onclick="return create_campaign()">+ add new</button>
</th>
</tr>
</table>
@@ -419,7 +451,7 @@ ${self.javascript_setup()}
%for rc in sorted(thing.campaigns, key=lambda rc: rc.start_date):
$.new_campaign(${unsafe(','.join(simplejson.dumps(attr) for attr in
[rc.campaign_id36, rc.start_date, rc.end_date, rc.duration,
rc.bid, rc.sr, rc.status]))});
rc.bid, rc.cpm, rc.sr, rc.status]))});
%endfor
$.set_up_campaigns();
});
@@ -446,7 +478,8 @@ ${self.javascript_setup()}
<th>transaction id</th>
<th>campaign id</th>
<th>pay id</th>
<th>amount</th>
<th>bid</th>
<th>charge</th>
<th>status</th>
</tr>
%for bid in thing.bids:
@@ -457,6 +490,7 @@ ${self.javascript_setup()}
<td>${bid.campaign}</td>
<td>${bid.pay_id}</td>
<td>${bid.amount_str}</td>
<td>${bid.charge_str}</td>
<td class="bid-status">${bid.status}</td>
</tr>
%endfor